File size: 3,171 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
"""Feature engineering.

Form-string parsing follows the FormFav convention:
- digits 1..9 are finishing positions (1st..9th)
- '0' means 10th or worse
- 'x' (or 'X') means a spell / no result (NOT a finishing position)
- '-' / '.' separators are ignored
Recent results weigh more than old ones. Quality improves/declines tracked via
slope of recent positions.
"""
from __future__ import annotations

import math
import re
from typing import Optional

# Map a finishing token to a numeric score (higher = better). x/spell = None.
_FINISH_SCORE = {1: 100, 2: 80, 3: 65, 4: 50, 5: 40, 6: 32, 7: 26, 8: 20, 9: 15, 0: 8}


def parse_form_positions(form: str) -> list[Optional[int]]:
    """Return finishing positions oldest->newest; x/spell -> None."""
    if not form:
        return []
    tokens = re.findall(r"[0-9xX]", form)
    out: list[Optional[int]] = []
    for t in tokens:
        if t in ("x", "X"):
            out.append(None)  # spell / no result
        else:
            out.append(int(t))
    return out


def form_quality_score(form: str, max_len: int = 12) -> tuple[float, dict]:
    """Return (0..100 score, diagnostics). Recent results weighted exponentially."""
    positions = parse_form_positions(form)
    # normalise to latest `max_len`
    recent = positions[-max_len:]
    if not recent:
        return 0.0, {"reason": "no form data", "recent": []}

    # weight recency: newest gets highest weight
    n = len(recent)
    total_w = 0.0
    weighted = 0.0
    for i, pos in enumerate(recent):
        w = 2 ** (i)  # newest (last) has largest exponent
        if pos is None:
            # spell: mildly negative but not catastrophic; treat as 0-score
            total_w += w
            continue
        total_w += w
        weighted += _FINISH_SCORE.get(pos, 8) * w
    base = (weighted / total_w) if total_w else 0.0

    # consistency: fraction of recent runs that placed (1..3) — ignoring spells
    placed = [p for p in recent if p is not None and p <= 3]
    consistency = len(placed) / max(1, len([p for p in recent if p is not None]))

    # trend: compare mean of last 3 (non-spell) vs prior 3 (non-spell)
    valid = [p for p in recent if p is not None]
    trend = 0.0
    if len(valid) >= 4:
        last3 = valid[-3:]
        prev3 = valid[-6:-3]
        if prev3:
            m_last = sum(last3) / len(last3)
            m_prev = sum(prev3) / len(prev3)
            # lower position number = better; improvement => positive
            trend = max(-1.0, min(1.0, (m_prev - m_last) / 3.0))

    score = 0.75 * base + 20 * consistency + 15 * max(0.0, trend)
    score = max(0.0, min(100.0, score))
    return round(score, 1), {
        "recent": recent,
        "consistency": round(consistency, 2),
        "trend": round(trend, 2),
        "base": round(base, 1),
    }


def spell_count(form: str) -> int:
    """Number of spell markers (x) — indicates layoffs."""
    return len(re.findall(r"[xX]", form or ""))


def is_fresh_after_spell(form: str) -> bool:
    """True if the most recent result is a spell (just resumed)."""
    positions = parse_form_positions(form)
    return bool(positions) and positions[-1] is None