File size: 4,989 Bytes
e23172f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
"""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"),
    }