Skip to main content

fforge_core/match_engine/
calibrate.rs

1//! Calibration telemetry (`MATCH_MODEL.md` §7-8): a passive accumulator over
2//! `MatchOutcome` traces, deliberately **not** wired into the fold — the
3//! event-sourced `GameState` only ever folds the score
4//! (`Event::MatchPlayed`), and `SeasonTelemetry` (`observer.rs`) is the
5//! consumer that sees that folded stream. Shots, on-target rate, conversion,
6//! and goal-source mix live only in the discarded `MatchOutcome.stream`, so
7//! the calibration harness must consume it directly — exactly what §7
8//! sanctions ("calibration re-runs the engine freely").
9//!
10//! This module is exploratory-harness plumbing, not simulation logic: it
11//! never feeds back into `Knobs` or the presence tables by itself.
12
13use super::MatchOutcome;
14use super::stream::{MatchEventKind, ShotKind, ShotOutcome, ShotSource, Side};
15use std::collections::BTreeMap;
16
17/// Per-formation usage seen by `StreamTelemetry::record` — one increment per
18/// side per match (a formation used by both home and away in the same match
19/// counts twice), keyed by `Lineup::formation` in the caller.
20#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
21pub struct FormationStats {
22    /// Number of side-uses of this formation (not matches).
23    pub uses: u32,
24    pub goals: u32,
25    pub shots: u32,
26}
27
28impl FormationStats {
29    pub fn goals_per_match(&self) -> f64 {
30        if self.uses == 0 {
31            return 0.0;
32        }
33        self.goals as f64 / self.uses as f64
34    }
35
36    pub fn shots_per_match(&self) -> f64 {
37        if self.uses == 0 {
38            return 0.0;
39        }
40        self.shots as f64 / self.uses as f64
41    }
42}
43
44/// Accumulates match aggregates directly from `MatchOutcome` traces —
45/// everything `SeasonTelemetry` can't see because the fold discards the
46/// stream. Mirrors the notebook's `report()` fields.
47#[derive(Debug, Default, Clone)]
48pub struct StreamTelemetry {
49    pub matches: u32,
50    pub home_wins: u32,
51    pub draws: u32,
52    pub away_wins: u32,
53    pub goals: u32,
54    pub shots: u32,
55    pub shots_on_target: u32,
56    pub goals_by_kind: BTreeMap<ShotKind, u32>,
57    /// Goals keyed by arrival route (`MATCH_MODEL.md` §5) — what makes the
58    /// wide-origin-goal-share target (cross + cutback, §8) computable,
59    /// distinct from `goals_by_kind`'s coarser Finish/Header/LongShot split.
60    pub goals_by_source: BTreeMap<ShotSource, u32>,
61    /// A crude possession proxy: total stream events attributed to each
62    /// side (more events ⇒ more time on the ball / advancing it).
63    pub home_events: u32,
64    pub away_events: u32,
65    /// Keyed by `Lineup::formation` index into `FORMATIONS`.
66    pub by_formation: BTreeMap<u8, FormationStats>,
67}
68
69impl StreamTelemetry {
70    /// Fold one match's trace in. `home_formation`/`away_formation` are the
71    /// `Lineup::formation` index each side fielded, for the per-formation
72    /// breakdown (`MATCH_MODEL.md` §10 item 1's diagnostic).
73    pub fn record(&mut self, outcome: &MatchOutcome, home_formation: u8, away_formation: u8) {
74        self.matches += 1;
75        self.goals += outcome.home_goals as u32 + outcome.away_goals as u32;
76        match outcome.home_goals.cmp(&outcome.away_goals) {
77            std::cmp::Ordering::Greater => self.home_wins += 1,
78            std::cmp::Ordering::Equal => self.draws += 1,
79            std::cmp::Ordering::Less => self.away_wins += 1,
80        }
81
82        let mut home_shots = 0u32;
83        let mut away_shots = 0u32;
84        let mut home_goals = 0u32;
85        let mut away_goals = 0u32;
86
87        for event in &outcome.stream {
88            match event.side {
89                Side::Home => self.home_events += 1,
90                Side::Away => self.away_events += 1,
91            }
92            if let MatchEventKind::Shot {
93                kind,
94                source,
95                outcome: shot_outcome,
96            } = event.kind
97            {
98                self.shots += 1;
99                match event.side {
100                    Side::Home => home_shots += 1,
101                    Side::Away => away_shots += 1,
102                }
103                if matches!(shot_outcome, ShotOutcome::Goal | ShotOutcome::Saved) {
104                    self.shots_on_target += 1;
105                }
106                if shot_outcome == ShotOutcome::Goal {
107                    *self.goals_by_kind.entry(kind).or_default() += 1;
108                    *self.goals_by_source.entry(source).or_default() += 1;
109                    match event.side {
110                        Side::Home => home_goals += 1,
111                        Side::Away => away_goals += 1,
112                    }
113                }
114            }
115        }
116
117        let home_stats = self.by_formation.entry(home_formation).or_default();
118        home_stats.uses += 1;
119        home_stats.goals += home_goals;
120        home_stats.shots += home_shots;
121
122        let away_stats = self.by_formation.entry(away_formation).or_default();
123        away_stats.uses += 1;
124        away_stats.goals += away_goals;
125        away_stats.shots += away_shots;
126    }
127
128    pub fn goals_per_match(&self) -> f64 {
129        if self.matches == 0 {
130            return 0.0;
131        }
132        self.goals as f64 / self.matches as f64
133    }
134
135    pub fn shots_per_match(&self) -> f64 {
136        if self.matches == 0 {
137            return 0.0;
138        }
139        self.shots as f64 / self.matches as f64
140    }
141
142    pub fn home_win_rate(&self) -> f64 {
143        self.rate(self.home_wins)
144    }
145
146    pub fn draw_rate(&self) -> f64 {
147        self.rate(self.draws)
148    }
149
150    pub fn away_win_rate(&self) -> f64 {
151        self.rate(self.away_wins)
152    }
153
154    fn rate(&self, n: u32) -> f64 {
155        if self.matches == 0 {
156            return 0.0;
157        }
158        n as f64 / self.matches as f64
159    }
160
161    pub fn shot_on_target_rate(&self) -> f64 {
162        if self.shots == 0 {
163            return 0.0;
164        }
165        self.shots_on_target as f64 / self.shots as f64
166    }
167
168    pub fn conversion_rate(&self) -> f64 {
169        if self.shots == 0 {
170            return 0.0;
171        }
172        self.goals as f64 / self.shots as f64
173    }
174
175    /// Share of goals scored via `ShotKind::Header` — the headed-goal-share
176    /// calibration target (`MATCH_MODEL.md` §8).
177    pub fn headed_goal_share(&self) -> f64 {
178        if self.goals == 0 {
179            return 0.0;
180        }
181        *self.goals_by_kind.get(&ShotKind::Header).unwrap_or(&0) as f64 / self.goals as f64
182    }
183
184    /// Share of goals arriving via `ShotSource::Cross` or `Cutback` — the
185    /// wide-origin-goal-share calibration target (`MATCH_MODEL.md` §8:
186    /// "cross + cutback"), 25-35%.
187    pub fn wide_origin_goal_share(&self) -> f64 {
188        if self.goals == 0 {
189            return 0.0;
190        }
191        let cross = *self.goals_by_source.get(&ShotSource::Cross).unwrap_or(&0);
192        let cutback = *self.goals_by_source.get(&ShotSource::Cutback).unwrap_or(&0);
193        (cross + cutback) as f64 / self.goals as f64
194    }
195
196    /// Home share of the possession-proxy event count.
197    pub fn home_possession_share(&self) -> f64 {
198        let total = self.home_events + self.away_events;
199        if total == 0 {
200            return 0.0;
201        }
202        self.home_events as f64 / total as f64
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::match_engine::Zone;
210    use crate::match_engine::stream::MatchEvent;
211
212    fn shot(side: Side, kind: ShotKind, source: ShotSource, outcome: ShotOutcome) -> MatchEvent {
213        MatchEvent {
214            minute: 10,
215            side,
216            zone: Zone::Box,
217            kind: MatchEventKind::Shot {
218                kind,
219                source,
220                outcome,
221            },
222        }
223    }
224
225    #[test]
226    fn record_reproduces_hand_counted_aggregates() {
227        // Hand-built trace: home score once (through-ball Finish), miss
228        // once (Off), home scores a Header (from a Cross); away score once
229        // (LongShot), get one Saved.
230        let stream = vec![
231            shot(
232                Side::Home,
233                ShotKind::Finish,
234                ShotSource::Through,
235                ShotOutcome::Goal,
236            ),
237            shot(
238                Side::Home,
239                ShotKind::Finish,
240                ShotSource::Cutback,
241                ShotOutcome::Off,
242            ),
243            shot(
244                Side::Home,
245                ShotKind::Header,
246                ShotSource::Cross,
247                ShotOutcome::Goal,
248            ),
249            shot(
250                Side::Away,
251                ShotKind::LongShot,
252                ShotSource::Long,
253                ShotOutcome::Goal,
254            ),
255            shot(
256                Side::Away,
257                ShotKind::LongShot,
258                ShotSource::Long,
259                ShotOutcome::Saved,
260            ),
261            MatchEvent {
262                minute: 20,
263                side: Side::Home,
264                zone: Zone::Mid,
265                kind: MatchEventKind::Pass { success: true },
266            },
267            MatchEvent {
268                minute: 21,
269                side: Side::Away,
270                zone: Zone::Mid,
271                kind: MatchEventKind::Pass { success: true },
272            },
273        ];
274        let outcome = MatchOutcome {
275            home_goals: 2,
276            away_goals: 1,
277            stream,
278        };
279
280        let mut telemetry = StreamTelemetry::default();
281        telemetry.record(&outcome, 0, 2); // home 4-4-2, away 4-2-3-1
282
283        assert_eq!(telemetry.matches, 1);
284        assert_eq!(telemetry.goals, 3);
285        assert_eq!(telemetry.home_wins, 1);
286        assert_eq!(telemetry.draws, 0);
287        assert_eq!(telemetry.away_wins, 0);
288        assert_eq!(telemetry.shots, 5);
289        assert_eq!(telemetry.shots_on_target, 4); // 3 goals + 1 saved
290        assert_eq!(telemetry.goals_by_kind.get(&ShotKind::Finish), Some(&1));
291        assert_eq!(telemetry.goals_by_kind.get(&ShotKind::Header), Some(&1));
292        assert_eq!(telemetry.goals_by_kind.get(&ShotKind::LongShot), Some(&1));
293        assert_eq!(
294            telemetry.goals_by_source.get(&ShotSource::Through),
295            Some(&1)
296        );
297        assert_eq!(telemetry.goals_by_source.get(&ShotSource::Cross), Some(&1));
298        assert_eq!(telemetry.goals_by_source.get(&ShotSource::Long), Some(&1));
299        assert_eq!(telemetry.goals_by_source.get(&ShotSource::Cutback), None); // the Cutback shot was Off, not a goal
300        assert_eq!(telemetry.home_events, 4); // 3 home shots + 1 home pass
301        assert_eq!(telemetry.away_events, 3); // 2 away shots + 1 away pass
302
303        let home_formation = telemetry.by_formation.get(&0).unwrap();
304        assert_eq!(home_formation.uses, 1);
305        assert_eq!(home_formation.goals, 2);
306        assert_eq!(home_formation.shots, 3);
307
308        let away_formation = telemetry.by_formation.get(&2).unwrap();
309        assert_eq!(away_formation.uses, 1);
310        assert_eq!(away_formation.goals, 1);
311        assert_eq!(away_formation.shots, 2);
312
313        assert_eq!(telemetry.goals_per_match(), 3.0);
314        assert_eq!(telemetry.shots_per_match(), 5.0);
315        assert!((telemetry.shot_on_target_rate() - 0.8).abs() < 1e-9);
316        assert!((telemetry.conversion_rate() - 0.6).abs() < 1e-9);
317        assert!((telemetry.headed_goal_share() - (1.0 / 3.0)).abs() < 1e-9);
318        assert!((telemetry.wide_origin_goal_share() - (1.0 / 3.0)).abs() < 1e-9); // the Cross goal only
319        assert!((telemetry.home_possession_share() - (4.0 / 7.0)).abs() < 1e-9);
320    }
321}