"""Probability, trifecta generation, and confidence. Deterministic / statistical only — no LLM involvement. Probabilities are model estimates (softmax over scores), explicitly NOT bookmaker odds, and are labelled as such in all output. """ from __future__ import annotations import math from collections import OrderedDict from typing import Optional def softmax_probabilities(scores: list[float], temperature: float = 1.0) -> list[float]: if not scores: return [] mx = max(scores) exps = [math.exp((s - mx) / temperature) for s in scores] total = sum(exps) or 1.0 return [e / total for e in exps] def joint_prob(first: int, second: int, third: int, runner_probs: dict[int, float], order: list[int]) -> float: """Probability that `order` (a permutation of first/second/third) lands, under a simple first-order model: P(1st) * P(2nd | not 1st) * P(3rd | not 1,2).""" nums = [first, second, third] perm = list(order) p = 1.0 excluded = set() for pos, pick in enumerate(perm): avail = [n for n in nums if n not in excluded] # P(pick | available) proportional to its standalone prob among available avail_p = {n: runner_probs.get(n, 0.0) for n in avail} s = sum(avail_p.values()) or 1.0 p *= avail_p.get(pick, 0.0) / s excluded.add(pick) return p def generate_trifecta(ranked: list[dict], weights_cfg: dict) -> dict: """ranked: list of {number, score, win_est, place_est} sorted desc by score. Returns primary, top3, alternatives (savers), and probabilities.""" if len(ranked) < 3: return {"skippable": True, "reason": "fewer than 3 active runners"} # standalone per-runner win probabilities (softmax over scores) nums = [r["number"] for r in ranked] scores = [r["score"] for r in ranked] probs = softmax_probabilities(scores, temperature=12.0) runner_probs = {n: p for n, p in zip(nums, probs)} top3 = [ranked[0]["number"], ranked[1]["number"], ranked[2]["number"]] primary = list(top3) # enumerate saver permutations of the top-K (K=4) and score them k = min(4, len(ranked)) pool = [r["number"] for r in ranked[:k]] from itertools import permutations candidates = [] for order in permutations(pool): if order == tuple(primary): continue pr = joint_prob(primary[0], primary[1], primary[2], runner_probs, order) candidates.append((list(order), pr)) candidates.sort(key=lambda x: x[1], reverse=True) primary_prob = joint_prob(primary[0], primary[1], primary[2], runner_probs, tuple(primary)) max_ratio = weights_cfg.get("min_saver_prob_ratio", 0.18) max_savers = weights_cfg.get("max_savers", 6) savers = [c for c in candidates if c[1] >= primary_prob * max_ratio][:max_savers] return { "primary": primary, "top3": top3, "alternatives": [c[0] for c in savers], "primary_prob": round(primary_prob, 4), "saver_probs": [round(c[1], 4) for c in savers], "runner_probs": {str(k): round(v, 4) for k, v in runner_probs.items()}, } def compute_confidence(ranked: list[dict], race_completeness: float, pace: dict, weights_cfg: dict) -> dict: if len(ranked) < 3: return {"category": "AVOID", "score": 0, "reason": "Insufficient active runners for a trifecta."} conf = weights_cfg leader = ranked[0]["score"] if leader < weights_cfg.get("min_leader_score_for_prediction", 30.0): return {"category": "AVOID", "score": int(leader), "reason": "Leader score below threshold — data too weak to predict."} sep = ranked[0]["score"] - ranked[1]["score"] # competitive runners within `competitive_band` of leader band = conf.get("competitive_band", 8.0) competitive = sum(1 for r in ranked if leader - r["score"] <= band) if (sep >= conf.get("high_separation", 18.0) and race_completeness >= conf.get("high_min_completeness", 0.85) and competitive <= conf.get("max_competitive_runners_for_high", 3)): cat, score = "HIGH", min(100, 70 + int(sep) + int(race_completeness * 15)) elif (sep >= conf.get("medium_separation", 9.0) and race_completeness >= conf.get("medium_min_completeness", 0.6) and competitive <= conf.get("max_competitive_runners_for_medium", 6)): cat, score = "MEDIUM", min(75, 50 + int(sep) + int(race_completeness * 10)) elif sep < 4.0 or race_completeness < 0.35: cat, score = "AVOID", min(35, int(leader)) else: cat, score = "LOW", min(50, 30 + int(sep)) score = max(0, min(100, score)) return { "category": cat, "score": score, "leader_score": round(leader, 1), "separation": round(sep, 1), "competitive_runners": competitive, "data_completeness": round(race_completeness, 2), "pace": pace.get("label"), }