diff --git a/bakkesmod/subtr-actor/rust/src/lib.rs b/bakkesmod/subtr-actor/rust/src/lib.rs index d6f45e76a..34917dd73 100644 --- a/bakkesmod/subtr-actor/rust/src/lib.rs +++ b/bakkesmod/subtr-actor/rust/src/lib.rs @@ -23,6 +23,8 @@ use subtr_actor::BoostPickupPadZone; use subtr_actor::EventLifecycle; #[cfg(test)] use subtr_actor::ReplayFrameInputBuilder; +#[cfg(test)] +use subtr_actor::default_stats_module_names; use subtr_actor::{ BackboardBounceEvent, BoostPickupEvent, BumpEvent, CorePlayerScoreboardEvent, DemolitionEvent, Event, EventPayload, EventTiming, FiftyFiftyEvent, FrameInput, GoalBuildupKind, diff --git a/bakkesmod/subtr-actor/rust/src/lib_tests/live_graph_events.rs b/bakkesmod/subtr-actor/rust/src/lib_tests/live_graph_events.rs index df5102873..d97edefec 100644 --- a/bakkesmod/subtr-actor/rust/src/lib_tests/live_graph_events.rs +++ b/bakkesmod/subtr-actor/rust/src/lib_tests/live_graph_events.rs @@ -101,8 +101,8 @@ fn exposes_stats_collector_module_json_after_processing_frame() { let module_names = value["module_names"] .as_array() .expect("module names should be an array"); - assert_eq!(module_names.len(), builtin_stats_module_names().len()); - for module_name in builtin_stats_module_names() { + assert_eq!(module_names.len(), default_stats_module_names().len()); + for module_name in default_stats_module_names() { assert!( module_names.iter().any(|name| name == module_name), "stats json should expose stats module {module_name}" @@ -127,7 +127,7 @@ fn exposes_stats_collector_module_json_after_processing_frame() { let frame_modules = value["frame"]["modules"] .as_object() .expect("frame modules should be an object"); - for module_name in builtin_stats_module_names() { + for module_name in default_stats_module_names() { assert!( frame_modules.contains_key(*module_name), "stats frame should include module payload for {module_name}" diff --git a/bakkesmod/subtr-actor/rust/src/lib_tests/live_graph_exports.rs b/bakkesmod/subtr-actor/rust/src/lib_tests/live_graph_exports.rs index c45c80d11..1cf0a49f9 100644 --- a/bakkesmod/subtr-actor/rust/src/lib_tests/live_graph_exports.rs +++ b/bakkesmod/subtr-actor/rust/src/lib_tests/live_graph_exports.rs @@ -1,5 +1,5 @@ #[test] -fn live_abi_exposes_every_builtin_stats_module_frame_and_config_by_name() { +fn live_abi_exposes_every_default_stats_module_frame_and_config_by_name() { let engine = subtr_actor_bakkesmod_engine_create(); let touches = [SaTouchEvent { timing: SaEventTiming::default(), @@ -56,7 +56,7 @@ fn live_abi_exposes_every_builtin_stats_module_frame_and_config_by_name() { let frame_modules = stats["frame"]["modules"] .as_object() .expect("stats json should expose frame modules"); - for module_name in builtin_stats_module_names() { + for module_name in default_stats_module_names() { assert_eq!( live_stats_module_frame_json_value(engine, module_name), frame_modules diff --git a/bakkesmod/subtr-actor/rust/src/lib_tests/live_graph_json.rs b/bakkesmod/subtr-actor/rust/src/lib_tests/live_graph_json.rs index 358bed51c..7b345dce6 100644 --- a/bakkesmod/subtr-actor/rust/src/lib_tests/live_graph_json.rs +++ b/bakkesmod/subtr-actor/rust/src/lib_tests/live_graph_json.rs @@ -807,7 +807,7 @@ fn live_abi_stats_json_matches_direct_full_graph_across_finish() { } #[test] -fn live_abi_exposes_every_builtin_stats_module_by_name() { +fn live_abi_exposes_every_default_stats_module_by_name() { let engine = subtr_actor_bakkesmod_engine_create(); let touches = [SaTouchEvent { timing: SaEventTiming::default(), @@ -861,7 +861,7 @@ fn live_abi_exposes_every_builtin_stats_module_by_name() { let modules = stats["modules"] .as_object() .expect("stats json should expose a modules object"); - for module_name in builtin_stats_module_names() { + for module_name in default_stats_module_names() { assert_eq!( live_stats_module_json_value(engine, module_name), modules diff --git a/bakkesmod/subtr-actor/verify-plugin-source.py b/bakkesmod/subtr-actor/verify-plugin-source.py index 60ecd2cb5..1fe30aed1 100755 --- a/bakkesmod/subtr-actor/verify-plugin-source.py +++ b/bakkesmod/subtr-actor/verify-plugin-source.py @@ -2078,8 +2078,8 @@ def main() -> int: ) require_contains( web_player_main_source, - 'renderModuleSummaryGroup("Timeline ranges", rangeToggles)', - "stats evaluation player launcher module summary timeline range group", + 'renderModuleSummaryGroup("Timeline views", rangeToggles)', + "stats evaluation player launcher module summary timeline view group", errors, ) require_contains( diff --git a/crates/subtr-actor-tools/src/bin/threat_dataset_dump.rs b/crates/subtr-actor-tools/src/bin/threat_dataset_dump.rs new file mode 100644 index 000000000..ef357619f --- /dev/null +++ b/crates/subtr-actor-tools/src/bin/threat_dataset_dump.rs @@ -0,0 +1,404 @@ +//! Dump a threat-model training dataset from a manifest of replays. +//! +//! For each replay, samples both teams' attacking-normalized +//! `ThreatFeatures` rows at `--sample-hz` during live play (through the same +//! ndarray threat feature adder the runtime model consumes -- the feature +//! computation is shared, never reimplemented) and joins each row with the +//! replay-time distance to the next goal for/against that side plus the time +//! to replay end, so the Python training pipeline can compute +//! scored-within-tau labels with censoring downstream. +//! +//! Manifest rows are JSON objects, one per line: +//! `{"path": ..., "ballchasing_id": ..., "playlist": ..., "date": ..., +//! "min_rank_tier": ..., "max_rank_tier": ..., "median_rank_tier": ..., +//! "team_size": ..., ...}`. +//! Unknown keys are ignored. + +use std::io::{BufRead, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::mpsc; + +use anyhow::Context; +use clap::Parser; +use serde::Deserialize; +use subtr_actor::{ + Collector, ExpectedGoalsCalculator, LiveThreatSampleFilter, NDArrayCollector, ThreatFeatures, + ThreatGoalRecord, +}; + +#[derive(Debug, Parser)] +#[command(about = "Dump per-frame threat features and goal-time labels for threat-model training.")] +struct Args { + /// JSONL manifest of replays to process. + #[arg(long)] + manifest: PathBuf, + /// Output CSV path. + #[arg(long)] + out: PathBuf, + /// Live-play sampling rate in rows-per-second (per team). + #[arg(long, default_value_t = 4.0)] + sample_hz: f32, + /// Process at most this many manifest rows. + #[arg(long)] + limit: Option, + /// Worker threads (defaults to available parallelism). + #[arg(long)] + threads: Option, + /// When set, also write one CSV row per (replay, team) summarizing the + /// episode state machine's output against actual goals. Columns: + /// `replay_id,is_team0,episode_count,episode_xg_sum,full_integral_xg,peak_sum,incident_peak_sum,incident_xg_sum,goals`. + /// `episode_xg_sum` sums the episode xG time integrals exactly as emitted + /// by `ExpectedGoalsCalculator`'s episode events; `full_integral_xg` is + /// the team's full-match `sum(V * dt) / tau` (the same state the stats + /// accumulator reads); `peak_sum` sums episode peak V (the uncalibrated + /// legacy estimator, kept for comparison); `incident_peak_sum` sums the + /// raw hysteresis-delimited, goal-touch-censored incident peaks and + /// `incident_xg_sum` includes their count calibration. + #[arg(long)] + episode_summary: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct ManifestRow { + path: String, + #[serde(default)] + ballchasing_id: Option, + #[serde(default)] + playlist: Option, + #[serde(default)] + date: Option, + #[serde(default)] + min_rank_tier: Option, + #[serde(default)] + max_rank_tier: Option, + #[serde(default)] + median_rank_tier: Option, + #[serde(default)] + team_size: Option, +} + +impl ManifestRow { + fn replay_id(&self) -> String { + self.ballchasing_id.clone().unwrap_or_else(|| { + Path::new(&self.path) + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_else(|| self.path.clone()) + }) + } +} + +struct ReplayRows { + replay_id: String, + lines: Vec, + value_min: f32, + value_max: f32, + goal_count: usize, + /// One summary row per team, in `episode_summary_header` column order. + episode_summary_lines: Vec, +} + +fn main() -> anyhow::Result<()> { + let args = Args::parse(); + anyhow::ensure!(args.sample_hz > 0.0, "--sample-hz must be positive"); + + let manifest_file = std::fs::File::open(&args.manifest) + .with_context(|| format!("failed to open manifest {}", args.manifest.display()))?; + let mut rows: Vec = Vec::new(); + for (line_number, line) in std::io::BufReader::new(manifest_file).lines().enumerate() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let row: ManifestRow = serde_json::from_str(&line) + .with_context(|| format!("bad manifest row on line {}", line_number + 1))?; + rows.push(row); + } + if let Some(limit) = args.limit { + rows.truncate(limit); + } + + let mut out = std::io::BufWriter::new( + std::fs::File::create(&args.out) + .with_context(|| format!("failed to create {}", args.out.display()))?, + ); + writeln!(out, "{}", header())?; + + let mut episode_summary_out = args + .episode_summary + .as_ref() + .map(|path| -> anyhow::Result<_> { + let mut writer = std::io::BufWriter::new( + std::fs::File::create(path) + .with_context(|| format!("failed to create {}", path.display()))?, + ); + writeln!(writer, "{}", episode_summary_header())?; + Ok(writer) + }) + .transpose()?; + + let sample_interval = 1.0 / args.sample_hz; + let threads = args + .threads + .unwrap_or_else(|| { + std::thread::available_parallelism() + .map(std::num::NonZero::get) + .unwrap_or(1) + }) + .max(1); + + let next_index = AtomicUsize::new(0); + let (sender, receiver) = mpsc::channel::>(); + + let mut processed = 0usize; + let mut skipped = 0usize; + let mut total_rows = 0usize; + std::thread::scope(|scope| -> anyhow::Result<()> { + for _ in 0..threads.min(rows.len().max(1)) { + let sender = sender.clone(); + let rows = &rows; + let next_index = &next_index; + scope.spawn(move || { + loop { + let index = next_index.fetch_add(1, Ordering::Relaxed); + let Some(row) = rows.get(index) else { + break; + }; + let result = process_replay(row, sample_interval) + .map_err(|error| (row.replay_id(), format!("{error:#}"))); + if sender.send(result).is_err() { + break; + } + } + }); + } + drop(sender); + + for result in receiver { + match result { + Ok(replay_rows) => { + processed += 1; + total_rows += replay_rows.lines.len(); + for line in &replay_rows.lines { + writeln!(out, "{line}")?; + } + if let Some(writer) = episode_summary_out.as_mut() { + for line in &replay_rows.episode_summary_lines { + writeln!(writer, "{line}")?; + } + } + eprintln!( + "ok {}: {} rows, {} goals, V in [{:.4}, {:.4}]", + replay_rows.replay_id, + replay_rows.lines.len(), + replay_rows.goal_count, + replay_rows.value_min, + replay_rows.value_max, + ); + } + Err((replay_id, error)) => { + skipped += 1; + eprintln!("skip {replay_id}: {error}"); + } + } + } + Ok(()) + })?; + out.flush()?; + if let Some(writer) = episode_summary_out.as_mut() { + writer.flush()?; + } + + eprintln!( + "done: {processed} replays processed, {skipped} skipped, {total_rows} rows -> {}", + args.out.display() + ); + Ok(()) +} + +fn header() -> String { + let mut columns = vec![ + "replay_id", + "playlist", + "date", + "min_rank_tier", + "max_rank_tier", + "median_rank_tier", + "team_size", + "is_team0", + "time", + ]; + columns.extend(ThreatFeatures::FEATURE_NAMES); + columns.extend([ + "time_to_next_goal_for", + "time_to_next_goal_against", + "time_to_replay_end", + ]); + columns.join(",") +} + +fn episode_summary_header() -> &'static str { + "replay_id,is_team0,episode_count,episode_xg_sum,full_integral_xg,peak_sum,incident_peak_sum,incident_xg_sum,goals" +} + +fn process_replay(row: &ManifestRow, sample_interval: f32) -> anyhow::Result { + anyhow::ensure!( + row.team_size.is_none_or(|team_size| team_size == 2), + "threat model only supports 2v2 replays" + ); + let replay = parse_replay(&row.path)?; + let collector = NDArrayCollector::::from_strings( + &["CurrentTime", "ThreatFeatures", "ThreatModelValues"], + &[], + ) + .map_err(|error| anyhow::anyhow!("failed to configure threat ndarray: {error:?}"))? + .with_analysis_frame_filter(LiveThreatSampleFilter::new(sample_interval)) + .process_replay(&replay) + .map_err(|error| anyhow::anyhow!("failed to process replay: {error:?}"))?; + let calculator = collector + .analysis_state::() + .context("expected_goals state missing from graph")?; + let goals = calculator.goal_records().to_vec(); + let episodes = calculator.episode_events().to_vec(); + let full_integrals = calculator.team_xg_integrals(); + let replay_end_time = calculator.last_frame_time().unwrap_or(0.0); + let (_, matrix) = collector + .get_meta_and_ndarray() + .map_err(|error| anyhow::anyhow!("failed to build threat ndarray: {error:?}"))?; + anyhow::ensure!( + matrix.nrows() > 0, + "replay did not produce any valid 2v2 live-play threat rows" + ); + let replay_id = row.replay_id(); + let metadata_prefix = format!( + "{},{},{},{},{},{},{}", + csv_field(&replay_id), + csv_field(row.playlist.as_deref().unwrap_or("")), + csv_field(row.date.as_deref().unwrap_or("")), + optional_int(row.min_rank_tier), + optional_int(row.max_rank_tier), + row.median_rank_tier + .map(|value| value.to_string()) + .unwrap_or_default(), + row.team_size + .map(|value| value.to_string()) + .unwrap_or_default(), + ); + + let mut lines = Vec::with_capacity(matrix.nrows() * 2); + let mut value_min = f32::INFINITY; + let mut value_max = f32::NEG_INFINITY; + for matrix_row in matrix.rows() { + let sample_time = matrix_row[0]; + for (team_index, is_team_0) in [true, false].into_iter().enumerate() { + let feature_start = 1 + team_index * subtr_actor::THREAT_FEATURE_COUNT; + let feature_end = feature_start + subtr_actor::THREAT_FEATURE_COUNT; + let threat_value = matrix_row[1 + 2 * subtr_actor::THREAT_FEATURE_COUNT + team_index]; + value_min = value_min.min(threat_value); + value_max = value_max.max(threat_value); + let mut line = format!("{metadata_prefix},{},{sample_time:.4}", u8::from(is_team_0),); + for value in matrix_row.slice(ndarray::s![feature_start..feature_end]) { + line.push_str(&format!(",{value:.6}")); + } + line.push_str(&format!( + ",{},{},{:.4}", + time_to_next_goal(&goals, sample_time, is_team_0), + time_to_next_goal(&goals, sample_time, !is_team_0), + (replay_end_time - sample_time).max(0.0), + )); + lines.push(line); + } + } + + // Per-team episode summary through the exact shipping episode state + // machine: counts, episode xG integrals, and peak sums come straight off + // the calculator's emitted episode events (and its full-match integral + // state), never recomputed here. + let episode_summary_lines = [true, false] + .into_iter() + .map(|is_team_0| { + let episodes = episodes.iter() + .filter(|episode| episode.team_is_team_0 == is_team_0); + let (episode_count, episode_xg_sum, peak_sum, incident_peak_sum, incident_xg_sum) = episodes.fold( + (0usize, 0.0f64, 0.0f64, 0.0f64, 0.0f64), + |(count, xg_sum, peak_sum, incident_peak_sum, incident_xg_sum), episode| { + ( + count + 1, + xg_sum + f64::from(episode.xg), + peak_sum + f64::from(episode.peak_value), + incident_peak_sum + f64::from(episode.incident_peak_value), + incident_xg_sum + f64::from(episode.incident_xg), + ) + }, + ); + let full_integral_xg = full_integrals[usize::from(!is_team_0)]; + let goal_count = goals + .iter() + .filter(|goal| goal.scoring_team_is_team_0 == is_team_0) + .count(); + format!( + "{},{},{episode_count},{episode_xg_sum:.6},{full_integral_xg:.6},{peak_sum:.6},{incident_peak_sum:.6},{incident_xg_sum:.6},{goal_count}", + csv_field(&replay_id), + u8::from(is_team_0), + ) + }) + .collect(); + + Ok(ReplayRows { + replay_id, + lines, + value_min: if value_min.is_finite() { + value_min + } else { + 0.0 + }, + value_max: if value_max.is_finite() { + value_max + } else { + 0.0 + }, + goal_count: goals.len(), + episode_summary_lines, + }) +} + +/// Seconds from `time` to the next goal scored by `scoring_team_is_team_0`, +/// or empty when that side never scores again (censored downstream). +fn time_to_next_goal( + goals: &[ThreatGoalRecord], + time: f32, + scoring_team_is_team_0: bool, +) -> String { + goals + .iter() + .filter(|goal| goal.scoring_team_is_team_0 == scoring_team_is_team_0 && goal.time >= time) + .map(|goal| goal.time - time) + .fold(None::, |best, candidate| { + Some(best.map_or(candidate, |best| best.min(candidate))) + }) + .map(|seconds| format!("{seconds:.4}")) + .unwrap_or_default() +} + +fn optional_int(value: Option) -> String { + value.map(|value| value.to_string()).unwrap_or_default() +} + +fn csv_field(value: &str) -> String { + if value.contains(',') || value.contains('"') || value.contains('\n') { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + value.to_owned() + } +} + +fn parse_replay(path: &str) -> anyhow::Result { + let bytes = std::fs::read(path) + .with_context(|| format!("failed to read {}", Path::new(path).display()))?; + boxcars::ParserBuilder::new(&bytes) + .always_check_crc() + .must_parse_network_data() + .parse() + .with_context(|| format!("failed to parse {}", Path::new(path).display())) +} diff --git a/docs/event-definitions.html b/docs/event-definitions.html index 82002d563..90c687589 100644 --- a/docs/event-definitions.html +++ b/docs/event-definitions.html @@ -93,7 +93,7 @@

Stat & Event Definitions

-

Generated from static Rust metadata — do not edit by hand. 48 events

+

Generated from static Rust metadata — do not edit by hand. 50 events

Contents

Basic
@@ -147,6 +147,8 @@

Contents

PossessionpossessionDefinition pending. RushrushA quick possession transition where the attacking team has numbers moving out of its defensive half. Territorial Pressureterritorial_pressureDefinition pending. +Threat Episodethreat_episodeA contiguous span where one team's continuous threat value exceeds the episode threshold; its xG is the goal-calibrated time integral of V over the span (peak V is kept separately for intensity), credited to the attacking toucher associated with the peak. +Threat Touch Deltathreat_touchThe positive detection-frame change in the touching team's continuous threat value (expected-goals state value), not a causal estimate of the touch's multi-frame impulse. WhiffwhiffA committed attempt near the ball that resolves as a clear miss.
Positioning
@@ -1097,6 +1099,57 @@

Events

  • territorial_pressure via TerritorialPressureNode / TerritorialPressureCalculator
  • +
    +

    Threat Episode

    threat_episodeOtherhidden from reviewtop ↑
    +

    A contiguous span where one team's continuous threat value exceeds the episode threshold; its xG is the goal-calibrated time integral of V over the span (peak V is kept separately for intensity), credited to the attacking toucher associated with the peak.

    + +
    +Approach Unknown +True positive Not Evaluated +False positive Not Evaluated +False negative Not Evaluated +Testing Untested +
    + +
      +
    • Open an episode when a team's threat value V rises above the episode threshold during live play, accumulating the time integral sum(V * dt) / tau alongside the peak V and the most recent attacking toucher when that peak was established.
    • +
    • Close on V dropping back under the threshold, a goal for the team (always a goal-outcome close), or a stoppage.
    • +
    • Hold stoppage-closed episodes pending until the goal that caused the stoppage is attributed (or the next kickoff/grace window passes), so goal outcomes are not lost to attribution lag.
    • +
    + +

    None documented.

    + +

    None documented.

    + +
      +
    • expected_goals via ExpectedGoalsNode / ExpectedGoalsCalculator
    • +
    +
    +
    +

    Threat Touch Delta

    threat_touchOtherhidden from reviewtop ↑
    +

    The positive detection-frame change in the touching team's continuous threat value (expected-goals state value), not a causal estimate of the touch's multi-frame impulse.

    + +
    +Approach Unknown +True positive Not Evaluated +False positive Not Evaluated +False negative Not Evaluated +Testing Untested +
    + +
      +
    • Evaluate the versioned compact nonlinear threat model V(state) for both teams on every live-play frame from full ball and player physics state.
    • +
    • On each attributed touch, emit the toucher's team's V on the preceding live frame and on the detection frame; positive deltas contribute to threat_added.
    • +
    + +

    None documented.

    + +

    None documented.

    + +
      +
    • expected_goals via ExpectedGoalsNode / ExpectedGoalsCalculator
    • +
    +

    Whiff

    whiffOthertop ↑

    A committed attempt near the ball that resolves as a clear miss.

    diff --git a/docs/event-definitions.md b/docs/event-definitions.md index 0cb9add5e..f7e63237d 100644 --- a/docs/event-definitions.md +++ b/docs/event-definitions.md @@ -1227,6 +1227,65 @@ _None documented._ _None documented._ +### Threat Episode (`threat_episode`) + +- Category: `other` +- Confidence: + - Approach: `unknown` + - True positive evidence: `not_evaluated` + - False positive evidence: `not_evaluated` + - False negative evidence: `not_evaluated` + - Testing: `untested` +- Producers: + - `expected_goals` via `ExpectedGoalsNode` / `ExpectedGoalsCalculator` + +**Summary** + +A contiguous span where one team's continuous threat value exceeds the episode threshold; its xG is the goal-calibrated time integral of V over the span (peak V is kept separately for intensity), credited to the attacking toucher associated with the peak. + +**Approach** + +- Open an episode when a team's threat value V rises above the episode threshold during live play, accumulating the time integral sum(V * dt) / tau alongside the peak V and the most recent attacking toucher when that peak was established. +- Close on V dropping back under the threshold, a goal for the team (always a goal-outcome close), or a stoppage. +- Hold stoppage-closed episodes pending until the goal that caused the stoppage is attributed (or the next kickoff/grace window passes), so goal outcomes are not lost to attribution lag. + +**Limitations** + +_None documented._ + +**Known Issues** + +_None documented._ + +### Threat Touch Delta (`threat_touch`) + +- Category: `other` +- Confidence: + - Approach: `unknown` + - True positive evidence: `not_evaluated` + - False positive evidence: `not_evaluated` + - False negative evidence: `not_evaluated` + - Testing: `untested` +- Producers: + - `expected_goals` via `ExpectedGoalsNode` / `ExpectedGoalsCalculator` + +**Summary** + +The positive detection-frame change in the touching team's continuous threat value (expected-goals state value), not a causal estimate of the touch's multi-frame impulse. + +**Approach** + +- Evaluate the versioned compact nonlinear threat model V(state) for both teams on every live-play frame from full ball and player physics state. +- On each attributed touch, emit the toucher's team's V on the preceding live frame and on the detection frame; positive deltas contribute to threat_added. + +**Limitations** + +_None documented._ + +**Known Issues** + +_None documented._ + ### Replay Timeline Event (`timeline`) - Category: `core` diff --git a/flake.nix b/flake.nix index 4d23d62cb..d6e74fffd 100644 --- a/flake.nix +++ b/flake.nix @@ -41,7 +41,10 @@ pyproject-build-systems, }: let - workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./python; }; + pythonWorkspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./python; }; + threatModelWorkspace = uv2nix.lib.workspace.loadWorkspace { + workspaceRoot = ./scripts/threat_model; + }; in flake-utils.lib.eachDefaultSystem ( system: @@ -74,14 +77,14 @@ pythonBase = pkgs.callPackage pyproject-nix.build.packages { python = pkgs.python312; }; - overlay = workspace.mkPyprojectOverlay { + pythonOverlay = pythonWorkspace.mkPyprojectOverlay { sourcePreference = "wheel"; }; pythonSet = ( pythonBase.overrideScope ( pkgs.lib.composeManyExtensions [ pyproject-build-systems.overlays.wheel - overlay + pythonOverlay ] ) ); @@ -90,6 +93,28 @@ pytest = [ ]; wheel = [ ]; }; + threatModelOverlay = threatModelWorkspace.mkPyprojectOverlay { + sourcePreference = "wheel"; + }; + threatModelPythonSet = ( + pythonBase.overrideScope ( + pkgs.lib.composeManyExtensions [ + pyproject-build-systems.overlays.wheel + threatModelOverlay + ] + ) + ); + threatModelEnv = threatModelPythonSet.mkVirtualEnv "subtr-actor-threat-model-env" + threatModelWorkspace.deps.default; + threatModelDevEnv = threatModelPythonSet.mkVirtualEnv "subtr-actor-threat-model-dev-env" + threatModelWorkspace.deps.all; + trainThreatModel = pkgs.writeShellApplication { + name = "train-threat-model"; + runtimeInputs = [ threatModelEnv ]; + text = '' + exec python ${./scripts/threat_model/train_threat_model.py} "$@" + ''; + }; projectVersion = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).workspace.package.version; # Build identification for the replay-to-training plugin: the nix # sandbox has no .git, so the hash/dirty/date come from the flake's @@ -152,6 +177,12 @@ in { packages.python-env = pythonEnv; + packages.threat-model-env = threatModelEnv; + packages.train-threat-model = trainThreatModel; + apps.train-threat-model = { + type = "app"; + program = "${trainThreatModel}/bin/train-threat-model"; + }; packages.js-web-wasm = rustPlatform.buildRustPackage { pname = "subtr-actor-js-web-wasm"; version = projectVersion; @@ -440,6 +471,21 @@ export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath shellPackages}:${pkgs.stdenv.cc.cc.lib.outPath}/lib:/run/opengl-driver/lib/:''${LD_LIBRARY_PATH:-}" ''; }; + devShells.threat-model = pkgs.mkShell { + packages = [ + threatModelDevEnv + pkgs.uv + ]; + + env = { + UV_PYTHON_DOWNLOADS = "never"; + }; + + shellHook = '' + unset PYTHONPATH + echo "subtr-actor threat-model shell (from scripts/threat_model/uv.lock)" + ''; + }; devShells.bakkesmod = pkgs.mkShell { packages = shellPackages ++ [ mingw.stdenv.cc diff --git a/js/player/src/lib.ts b/js/player/src/lib.ts index e573c0554..b62697c6f 100644 --- a/js/player/src/lib.ts +++ b/js/player/src/lib.ts @@ -266,6 +266,13 @@ export type { ReplayTimelineEvent, ReplayTimelineEventKind, ReplayTimelineEventSource, + ReplayTimelineGraph, + ReplayTimelineGraphHighlight, + ReplayTimelineGraphMarker, + ReplayTimelineGraphPoint, + ReplayTimelineGraphReference, + ReplayTimelineGraphSeries, + ReplayTimelineGraphSource, ReplayTimelineRange, ReplayTimelineRangeSource, ResolvedPlaybackBound, diff --git a/js/player/src/timeline-overlay-style.ts b/js/player/src/timeline-overlay-style.ts index 4facc1b93..29f8dd5df 100644 --- a/js/player/src/timeline-overlay-style.ts +++ b/js/player/src/timeline-overlay-style.ts @@ -146,6 +146,127 @@ export function ensureTimelineOverlayStyles(): void { margin-bottom: 0; } + .sap-tl-graphs { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: 0.34rem; + margin-bottom: 0.34rem; + } + + .sap-tl-graph-lane { + position: relative; + display: grid; + grid-template-columns: var(--sap-tl-gutter-width) minmax(0, 1fr); + column-gap: var(--sap-tl-gutter-gap); + align-items: center; + } + + .sap-tl-graph-lane-track { + position: relative; + grid-column: 2; + height: 4.6rem; + margin: 0 calc(var(--sap-tl-thumb-size) / 2); + overflow: hidden; + border: 1px solid rgba(184, 214, 236, 0.13); + border-radius: 0.48rem; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.015)); + cursor: crosshair; + } + + .sap-tl-graph-lane-track:focus-visible { + outline: 2px solid rgba(123, 180, 255, 0.9); + outline-offset: 2px; + } + + .sap-tl-graph-lane-label { + display: block; + max-width: 100%; + padding: 0.08rem 0.38rem; + border: 1px solid rgba(184, 214, 236, 0.18); + border-radius: 999px; + background: rgba(10, 16, 23, 0.82); + color: #c8d7e4; + font-size: 0.54rem; + font-weight: 800; + letter-spacing: 0.04em; + line-height: 1.2; + text-transform: uppercase; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .sap-tl-graph-svg { + display: block; + width: 100%; + height: 100%; + overflow: visible; + } + + .sap-tl-graph-series { + fill: none; + stroke-width: 2.2; + vector-effect: non-scaling-stroke; + filter: drop-shadow(0 0 2px currentColor); + } + + .sap-tl-graph-reference { + stroke-width: 1; + stroke-dasharray: 5 4; + vector-effect: non-scaling-stroke; + opacity: 0.88; + } + + .sap-tl-graph-reference-label { + font-size: 7px; + font-weight: 800; + letter-spacing: 0.04em; + paint-order: stroke; + stroke: rgba(7, 12, 18, 0.94); + stroke-width: 2px; + } + + .sap-tl-graph-highlight { + opacity: 0.13; + } + + .sap-tl-graph-highlight.excluded { + opacity: 0.3; + stroke: rgba(255, 255, 255, 0.46); + stroke-width: 1; + stroke-dasharray: 4 3; + vector-effect: non-scaling-stroke; + } + + .sap-tl-graph-marker { + stroke: rgba(6, 12, 18, 0.94); + stroke-width: 2; + vector-effect: non-scaling-stroke; + filter: drop-shadow(0 0 3px rgba(255, 255, 255, 0.45)); + } + + .sap-tl-graph-marker.selected { + stroke: #ffffff; + stroke-width: 2.5; + } + + .sap-tl-graph-marker.excluded-peak { + fill: transparent; + stroke: #ef4444; + stroke-width: 2.5; + stroke-dasharray: 3 2; + } + + .sap-tl-graph-playhead { + stroke: rgba(245, 251, 255, 0.88); + stroke-width: 1; + vector-effect: non-scaling-stroke; + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9)); + pointer-events: none; + } + .sap-tl-event-lanes { grid-column: 1 / -1; display: flex; @@ -232,7 +353,8 @@ export function ensureTimelineOverlayStyles(): void { } .sap-tl-event-lane[data-label]::after, - .sap-tl-range-lane[data-label]::after { + .sap-tl-range-lane[data-label]::after, + .sap-tl-graph-lane[data-label]::after { content: attr(data-label); position: absolute; left: calc(var(--sap-tl-gutter-width) + var(--sap-tl-gutter-gap) + calc(var(--sap-tl-thumb-size) / 2)); @@ -262,7 +384,9 @@ export function ensureTimelineOverlayStyles(): void { .sap-tl-event-lane[data-label]:hover::after, .sap-tl-event-lane[data-label]:focus-within::after, .sap-tl-range-lane[data-label]:hover::after, - .sap-tl-range-lane[data-label]:focus-within::after { + .sap-tl-range-lane[data-label]:focus-within::after, + .sap-tl-graph-lane[data-label]:hover::after, + .sap-tl-graph-lane[data-label]:focus-within::after { opacity: 1; transform: translateY(0); } diff --git a/js/player/src/timeline-overlay.ts b/js/player/src/timeline-overlay.ts index 7eb861a5f..401849131 100644 --- a/js/player/src/timeline-overlay.ts +++ b/js/player/src/timeline-overlay.ts @@ -6,6 +6,9 @@ import type { ReplayTimelineEvent, ReplayTimelineEventKind, ReplayTimelineEventSource, + ReplayTimelineGraph, + ReplayTimelineGraphPoint, + ReplayTimelineGraphSource, ReplayTimelineRange, ReplayTimelineRangeSource, } from "./types"; @@ -20,6 +23,7 @@ export interface TimelineOverlayPluginOptions { eventsLabel?: string; events?: ReplayTimelineEventSource; ranges?: ReplayTimelineRangeSource; + graphs?: ReplayTimelineGraphSource; } export interface TimelineOverlayEventSourceOptions { @@ -37,6 +41,9 @@ export interface TimelineOverlayPlugin extends ReplayPlayerPlugin { addRangeSource(source: ReplayTimelineRangeSource): () => void; removeRangeSource(source: ReplayTimelineRangeSource): boolean; refreshRanges(): void; + addGraphSource(source: ReplayTimelineGraphSource): () => void; + removeGraphSource(source: ReplayTimelineGraphSource): boolean; + refreshGraphs(): void; } interface TimelineEventBucket { @@ -74,6 +81,11 @@ interface TimelineRangeLanePlayhead { element: HTMLDivElement; } +interface TimelineGraphPlayhead { + element: SVGLineElement; + track: HTMLDivElement; +} + interface TimelineEventLanePlayhead { element: HTMLDivElement; } @@ -339,6 +351,36 @@ function resolveRangeSources( return ranges; } +function resolveCustomGraphs( + source: ReplayTimelineGraphSource | undefined, + context: ReplayPlayerPluginContext, +): ReplayTimelineGraph[] { + if (!source) { + return []; + } + return typeof source === "function" ? source(context) : source; +} + +function resolveGraphSources( + sources: Iterable, + context: ReplayPlayerPluginContext, +): ReplayTimelineGraph[] { + const graphIds = new Set(); + const graphs: ReplayTimelineGraph[] = []; + for (const source of sources) { + for (const graph of resolveCustomGraphs(source, context)) { + if (graph.id !== undefined) { + if (graphIds.has(graph.id)) { + continue; + } + graphIds.add(graph.id); + } + graphs.push(graph); + } + } + return graphs; +} + function groupRanges(ranges: ReplayTimelineRange[]): TimelineRangeLane[] { const lanes = new Map(); for (const range of ranges) { @@ -410,6 +452,41 @@ function markerLeftPercent(timelineTime: number, duration: number): string { return `${(timelineTime / Math.max(duration, 0.0001)) * 100}%`; } +const TIMELINE_GRAPH_WIDTH = 1000; +const TIMELINE_GRAPH_HEIGHT = 100; + +function timelineGraphY(value: number, minValue: number, maxValue: number): number { + const span = Math.max(maxValue - minValue, Number.EPSILON); + const normalized = Math.max(0, Math.min(1, (value - minValue) / span)); + return (1 - normalized) * TIMELINE_GRAPH_HEIGHT; +} + +/** Build an SVG path for timeline-projected points, preserving null gaps. */ +export function buildTimelineGraphPath( + points: readonly ReplayTimelineGraphPoint[], + duration: number, + minValue: number, + maxValue: number, +): string { + const safeDuration = Math.max(duration, 0.0001); + let needsMove = true; + const commands: string[] = []; + for (const point of points) { + if (point.value === null || !Number.isFinite(point.time) || !Number.isFinite(point.value)) { + needsMove = true; + continue; + } + const x = Math.max( + 0, + Math.min(TIMELINE_GRAPH_WIDTH, (point.time / safeDuration) * TIMELINE_GRAPH_WIDTH), + ); + const y = timelineGraphY(point.value, minValue, maxValue); + commands.push(`${needsMove ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`); + needsMove = false; + } + return commands.join(" "); +} + export function projectedRangeTimelineBounds( startProjection: ReplayPlayerTimelineProjection, endProjection: ReplayPlayerTimelineProjection, @@ -451,10 +528,12 @@ export function createTimelineOverlayPlugin( ] : []; const extraRangeSources = options.ranges ? [options.ranges] : []; + const extraGraphSources = options.graphs ? [options.graphs] : []; let root: HTMLDivElement | null = null; let shell: HTMLDivElement | null = null; let rangesRoot: HTMLDivElement | null = null; + let graphsRoot: HTMLDivElement | null = null; let range: HTMLInputElement | null = null; let toggleButton: HTMLButtonElement | null = null; let toggleButtonIcon: HTMLSpanElement | null = null; @@ -476,6 +555,7 @@ export function createTimelineOverlayPlugin( const timelineMarkers: TimelineMarkerRecord[] = []; const rangeElements: TimelineRangeRecord[] = []; const rangeLanePlayheads: TimelineRangeLanePlayhead[] = []; + const graphPlayheads: TimelineGraphPlayhead[] = []; const eventLanePlayheads: TimelineEventLanePlayhead[] = []; let passedMarkerEndIndex = 0; let activeMarkers = new Set(); @@ -504,6 +584,17 @@ export function createTimelineOverlayPlugin( }); } + function refreshGraphLanes(): void { + if (!playerContext) { + return; + } + buildGraphs(playerContext); + syncState({ + ...playerContext, + state: playerContext.player.getState(), + }); + } + function syncState(context: ReplayPlayerPluginStateContext): void { if ( !range || @@ -527,6 +618,7 @@ export function createTimelineOverlayPlugin( if (projectionCacheKey !== nextProjectionCacheKey) { buildMarkers(context); buildRanges(context); + buildGraphs(context); projectionCacheKey = nextProjectionCacheKey; } range.min = "0"; @@ -566,6 +658,13 @@ export function createTimelineOverlayPlugin( for (const playhead of rangeLanePlayheads) { playhead.element.style.left = playheadLeft; } + const graphPlayheadX = `${(Math.min(currentTime, duration) / Math.max(duration, 0.0001)) * TIMELINE_GRAPH_WIDTH}`; + for (const playhead of graphPlayheads) { + playhead.element.setAttribute("x1", graphPlayheadX); + playhead.element.setAttribute("x2", graphPlayheadX); + playhead.track.setAttribute("aria-valuenow", `${Math.min(currentTime, duration)}`); + playhead.track.setAttribute("aria-valuetext", formatPlaybackTime(currentTime)); + } } function upperBoundMarkerTime(time: number): number { @@ -843,6 +942,194 @@ export function createTimelineOverlayPlugin( } } + function buildGraphs(context: ReplayPlayerPluginContext): void { + if (!graphsRoot) { + return; + } + + graphsRoot.replaceChildren(); + graphPlayheads.splice(0, graphPlayheads.length); + const graphs = resolveGraphSources(extraGraphSources, context); + if (graphs.length === 0) { + graphsRoot.hidden = true; + return; + } + + graphsRoot.hidden = false; + const duration = Math.max(context.player.getTimelineDuration(), 0.0001); + const svgNamespace = "http://www.w3.org/2000/svg"; + + for (const graph of graphs) { + const minValue = graph.minValue ?? 0; + const maxValue = graph.maxValue ?? 1; + if (!Number.isFinite(minValue) || !Number.isFinite(maxValue) || maxValue <= minValue) { + continue; + } + + const lane = document.createElement("div"); + lane.className = "sap-tl-graph-lane"; + lane.dataset.label = graph.label; + + const label = document.createElement("span"); + label.className = "sap-tl-graph-lane-label"; + label.textContent = graph.label; + label.setAttribute("aria-label", graph.label); + + const track = document.createElement("div"); + track.className = "sap-tl-graph-lane-track"; + track.tabIndex = 0; + track.setAttribute("role", "slider"); + track.setAttribute("aria-label", `${graph.label} timeline; click or drag to seek`); + track.setAttribute("aria-valuemin", "0"); + track.setAttribute("aria-valuemax", `${duration}`); + + const svg = document.createElementNS(svgNamespace, "svg"); + svg.classList.add("sap-tl-graph-svg"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("viewBox", `0 0 ${TIMELINE_GRAPH_WIDTH} ${TIMELINE_GRAPH_HEIGHT}`); + svg.setAttribute("preserveAspectRatio", "none"); + + for (const highlight of graph.highlights ?? []) { + if ( + !Number.isFinite(highlight.startTime) || + !Number.isFinite(highlight.endTime) || + highlight.endTime <= highlight.startTime + ) { + continue; + } + const bounds = projectedRangeTimelineBounds( + context.player.projectReplayTimeToTimeline(highlight.startTime), + context.player.projectReplayTimeToTimeline(highlight.endTime), + duration, + ); + const rect = document.createElementNS(svgNamespace, "rect"); + rect.classList.add("sap-tl-graph-highlight"); + if (highlight.className) rect.classList.add(highlight.className); + rect.setAttribute("x", `${(bounds.startTimelineTime / duration) * TIMELINE_GRAPH_WIDTH}`); + rect.setAttribute("y", "0"); + rect.setAttribute( + "width", + `${Math.max(0, ((bounds.endTimelineTime - bounds.startTimelineTime) / duration) * TIMELINE_GRAPH_WIDTH)}`, + ); + rect.setAttribute("height", `${TIMELINE_GRAPH_HEIGHT}`); + rect.setAttribute("fill", highlight.color ?? "rgba(255,255,255,0.08)"); + if (highlight.label) { + const title = document.createElementNS(svgNamespace, "title"); + title.textContent = highlight.label; + rect.append(title); + } + svg.append(rect); + } + + for (const reference of graph.references ?? []) { + if (!Number.isFinite(reference.value)) continue; + const y = timelineGraphY(reference.value, minValue, maxValue); + const line = document.createElementNS(svgNamespace, "line"); + line.classList.add("sap-tl-graph-reference"); + if (reference.className) line.classList.add(reference.className); + line.setAttribute("x1", "0"); + line.setAttribute("x2", `${TIMELINE_GRAPH_WIDTH}`); + line.setAttribute("y1", `${y}`); + line.setAttribute("y2", `${y}`); + line.setAttribute("stroke", reference.color ?? "rgba(255,255,255,0.32)"); + svg.append(line); + if (reference.label) { + const text = document.createElementNS(svgNamespace, "text"); + text.classList.add("sap-tl-graph-reference-label"); + text.setAttribute("x", "6"); + text.setAttribute("y", `${Math.max(8, y - 3)}`); + text.setAttribute("fill", reference.color ?? "rgba(255,255,255,0.62)"); + text.textContent = reference.label; + svg.append(text); + } + } + + for (const series of graph.series) { + const projectedPoints = series.points.map((point) => ({ + time: context.player.projectReplayTimeToTimeline(point.time).timelineTime, + value: point.value, + })); + const path = document.createElementNS(svgNamespace, "path"); + path.classList.add("sap-tl-graph-series"); + path.setAttribute( + "d", + buildTimelineGraphPath(projectedPoints, duration, minValue, maxValue), + ); + path.setAttribute("stroke", series.color); + if (series.label) { + const title = document.createElementNS(svgNamespace, "title"); + title.textContent = series.label; + path.append(title); + } + svg.append(path); + } + + for (const marker of graph.markers ?? []) { + if (!Number.isFinite(marker.time) || !Number.isFinite(marker.value)) continue; + const projection = context.player.projectReplayTimeToTimeline(marker.time); + const circle = document.createElementNS(svgNamespace, "circle"); + circle.classList.add("sap-tl-graph-marker"); + if (marker.className) circle.classList.add(marker.className); + circle.setAttribute("cx", `${(projection.timelineTime / duration) * TIMELINE_GRAPH_WIDTH}`); + circle.setAttribute("cy", `${timelineGraphY(marker.value, minValue, maxValue)}`); + circle.setAttribute("r", "5"); + circle.setAttribute("fill", marker.color ?? "#ffffff"); + if (marker.label) { + const title = document.createElementNS(svgNamespace, "title"); + title.textContent = marker.label; + circle.append(title); + } + svg.append(circle); + } + + const playhead = document.createElementNS(svgNamespace, "line"); + playhead.classList.add("sap-tl-graph-playhead"); + playhead.setAttribute("x1", "0"); + playhead.setAttribute("x2", "0"); + playhead.setAttribute("y1", "0"); + playhead.setAttribute("y2", `${TIMELINE_GRAPH_HEIGHT}`); + svg.append(playhead); + graphPlayheads.push({ element: playhead, track }); + + const seekFromPointer = (event: PointerEvent): void => { + const rect = track.getBoundingClientRect(); + const ratio = Math.max( + 0, + Math.min(1, (event.clientX - rect.left) / Math.max(rect.width, 1)), + ); + context.player.seek(context.player.projectTimelineTimeToReplay(ratio * duration)); + }; + track.addEventListener("pointerdown", (event) => { + track.setPointerCapture(event.pointerId); + seekFromPointer(event); + }); + track.addEventListener("pointermove", (event) => { + if (track.hasPointerCapture(event.pointerId)) seekFromPointer(event); + }); + track.addEventListener("keydown", (event) => { + const current = context.player.getTimelineCurrentTime(); + const increments: Partial> = { + ArrowLeft: -1, + ArrowRight: 1, + PageDown: -10, + PageUp: 10, + }; + let next = increments[event.key] === undefined ? null : current + increments[event.key]!; + if (event.key === "Home") next = 0; + if (event.key === "End") next = duration; + if (next === null) return; + event.preventDefault(); + context.player.seek( + context.player.projectTimelineTimeToReplay(Math.max(0, Math.min(duration, next))), + ); + }); + + track.append(svg); + lane.append(label, track); + graphsRoot.append(lane); + } + } + function endScrub(): void { if (!scrubbing) { return; @@ -924,6 +1211,25 @@ export function createTimelineOverlayPlugin( refreshRanges(): void { refreshRangeLanes(); }, + addGraphSource(source): () => void { + extraGraphSources.push(source); + refreshGraphLanes(); + return () => { + this.removeGraphSource(source); + }; + }, + removeGraphSource(source): boolean { + const index = extraGraphSources.indexOf(source); + if (index < 0) { + return false; + } + extraGraphSources.splice(index, 1); + refreshGraphLanes(); + return true; + }, + refreshGraphs(): void { + refreshGraphLanes(); + }, setup(context): void { playerContext = context; ensureTimelineOverlayStyles(); @@ -979,6 +1285,10 @@ export function createTimelineOverlayPlugin( rangesRoot.className = "sap-tl-ranges"; rangesRoot.hidden = true; + graphsRoot = document.createElement("div"); + graphsRoot.className = "sap-tl-graphs"; + graphsRoot.hidden = true; + eventLanesRoot = document.createElement("div"); eventLanesRoot.className = "sap-tl-event-lanes"; eventLanesRoot.hidden = true; @@ -1028,12 +1338,13 @@ export function createTimelineOverlayPlugin( }; trackRail.append(mainRail, markers, range); - trackWrap.append(rangesRoot, eventLanesRoot, toggleButton, trackRail); + trackWrap.append(graphsRoot, rangesRoot, eventLanesRoot, toggleButton, trackRail); shell.append(topLine, trackWrap); root.append(shell); context.container.append(root); buildMarkers(context); buildRanges(context); + buildGraphs(context); syncState({ ...context, state: context.player.getState(), @@ -1051,6 +1362,7 @@ export function createTimelineOverlayPlugin( root = null; shell = null; rangesRoot = null; + graphsRoot = null; eventLanesRoot = null; range = null; toggleButton = null; @@ -1067,6 +1379,7 @@ export function createTimelineOverlayPlugin( timelineMarkers.splice(0, timelineMarkers.length); rangeElements.splice(0, rangeElements.length); rangeLanePlayheads.splice(0, rangeLanePlayheads.length); + graphPlayheads.splice(0, graphPlayheads.length); eventLanePlayheads.splice(0, eventLanePlayheads.length); passedMarkerEndIndex = 0; activeMarkers = new Set(); diff --git a/js/player/src/types.ts b/js/player/src/types.ts index 309a7a413..223d3586a 100644 --- a/js/player/src/types.ts +++ b/js/player/src/types.ts @@ -195,6 +195,53 @@ export interface ReplayTimelineRange { className?: string; } +export interface ReplayTimelineGraphPoint { + time: number; + value: number | null; +} + +export interface ReplayTimelineGraphSeries { + id?: string; + label?: string; + color: string; + points: ReplayTimelineGraphPoint[]; +} + +export interface ReplayTimelineGraphReference { + value: number; + label?: string; + color?: string; + className?: string; +} + +export interface ReplayTimelineGraphHighlight { + startTime: number; + endTime: number; + label?: string; + color?: string; + className?: string; +} + +export interface ReplayTimelineGraphMarker { + time: number; + value: number; + label?: string; + color?: string; + className?: string; +} + +/** A numeric graph lane aligned to the replay timeline and shared playhead. */ +export interface ReplayTimelineGraph { + id?: string; + label: string; + minValue?: number; + maxValue?: number; + series: ReplayTimelineGraphSeries[]; + references?: ReplayTimelineGraphReference[]; + highlights?: ReplayTimelineGraphHighlight[]; + markers?: ReplayTimelineGraphMarker[]; +} + export interface ReplayPlayerTimelineSegment { startTime: number; endTime: number; @@ -438,6 +485,10 @@ export type ReplayTimelineRangeSource = | ReplayTimelineRange[] | ((context: ReplayPlayerPluginContext) => ReplayTimelineRange[]); +export type ReplayTimelineGraphSource = + | ReplayTimelineGraph[] + | ((context: ReplayPlayerPluginContext) => ReplayTimelineGraph[]); + export interface ReplayPlayerOptions { autoplay?: boolean; fieldScale?: number; diff --git a/js/player/test/timeline-overlay.test.ts b/js/player/test/timeline-overlay.test.ts index 989e59b3a..792bda8a8 100644 --- a/js/player/test/timeline-overlay.test.ts +++ b/js/player/test/timeline-overlay.test.ts @@ -1,7 +1,11 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { projectedRangeTimelineBounds, timelineEventSeekTime } from "../src/timeline-overlay"; +import { + buildTimelineGraphPath, + projectedRangeTimelineBounds, + timelineEventSeekTime, +} from "../src/timeline-overlay"; import type { ReplayPlayerTimelineProjection } from "../src/types"; function projection(timelineTime: number, hiddenBySkip: boolean): ReplayPlayerTimelineProjection { @@ -39,3 +43,20 @@ test("timeline event seek targets include lead-in while preserving event timesta test("timeline event seek targets honor explicit seek times", () => { assert.equal(timelineEventSeekTime({ kind: "shot", time: 12, seekTime: 6.5 }), 6.5); }); + +test("timeline graph paths preserve stoppage gaps and clamp probabilities", () => { + assert.equal( + buildTimelineGraphPath( + [ + { time: 0, value: 0 }, + { time: 5, value: 0.5 }, + { time: 6, value: null }, + { time: 10, value: 2 }, + ], + 10, + 0, + 1, + ), + "M0.00,100.00 L500.00,50.00 M1000.00,0.00", + ); +}); diff --git a/js/src/lib.rs b/js/src/lib.rs index f3122de0c..80d5cd3dd 100644 --- a/js/src/lib.rs +++ b/js/src/lib.rs @@ -154,12 +154,18 @@ fn collect_replay_data_with_optional_progress( fn collect_replay_bundle_with_optional_progress( replay: &boxcars::Replay, progress: Option<(&Function, usize)>, + include_expected_goals: bool, ) -> Result<(ReplayData, subtr_actor::ReplayStatsTimelineScaffold), JsValue> { let total_frames = get_total_frames(replay)?; let mut processor = ReplayProcessor::new(replay) .map_err(|e| JsValue::from_str(&format!("Failed to initialize replay processor: {e:?}")))?; let mut replay_data_collector = ReplayDataCollector::new(); - let mut stats_collector = StatsTimelineEventCollector::new(); + let stats_collector = StatsTimelineEventCollector::new(); + let mut stats_collector = if include_expected_goals { + stats_collector.with_expected_goals() + } else { + stats_collector + }; let mut last_reported_frames = 0usize; let mut progress_collector = progress .map(|(callback, frame_interval)| { @@ -251,6 +257,11 @@ fn stats_timeline_json_parts( set_json_bytes(&result, "activitySummary", &timeline.activity_summary)?; set_json_bytes(&result, "positioningSummary", &timeline.positioning_summary)?; set_json_bytes(&result, "accumulationTracks", &timeline.accumulation_tracks)?; + set_json_bytes( + &result, + "expectedGoalsTracks", + &timeline.expected_goals_tracks, + )?; if let Some((callback, _, start, end)) = progress { emit_stats_timeline_progress(callback, start + ((end - start) * 0.15)) .map_err(|error| JsValue::from_str(&format!("Failed to emit progress: {error:?}")))?; @@ -443,11 +454,13 @@ pub fn get_replay_bundle_json_with_progress( data: &[u8], callback: Function, report_every_n_frames: Option, + include_expected_goals: Option, ) -> Result { let replay = parse_replay_from_data(data)?; let (replay_data, stats_timeline) = collect_replay_bundle_with_optional_progress( &replay, Some((&callback, report_every_n_frames.unwrap_or(1000))), + include_expected_goals.unwrap_or(false), )?; let result = Object::new(); @@ -488,11 +501,13 @@ pub fn get_replay_bundle_json_parts_with_progress( callback: Function, report_every_n_frames: Option, max_frame_chunk_bytes: Option, + include_expected_goals: Option, ) -> Result { let replay = parse_replay_from_data(data)?; let (replay_data, stats_timeline) = collect_replay_bundle_with_optional_progress( &replay, Some((&callback, report_every_n_frames.unwrap_or(1000))), + include_expected_goals.unwrap_or(false), )?; let result = Object::new(); @@ -529,10 +544,19 @@ pub fn get_replay_bundle_json_parts_with_progress( /// Get compact event-backed stats frames for each replay sample. #[wasm_bindgen] -pub fn get_stats_timeline(data: &[u8]) -> Result { +pub fn get_stats_timeline( + data: &[u8], + include_expected_goals: Option, +) -> Result { let replay = parse_replay_from_data(data)?; - let stats_timeline = StatsTimelineEventCollector::new() + let collector = StatsTimelineEventCollector::new(); + let collector = if include_expected_goals.unwrap_or(false) { + collector.with_expected_goals() + } else { + collector + }; + let stats_timeline = collector .get_replay_stats_timeline_scaffold(&replay) .map_err(|e| JsValue::from_str(&format!("Failed to process replay stats: {e:?}")))?; @@ -541,10 +565,19 @@ pub fn get_stats_timeline(data: &[u8]) -> Result { } #[wasm_bindgen] -pub fn get_stats_timeline_json(data: &[u8]) -> Result, JsValue> { +pub fn get_stats_timeline_json( + data: &[u8], + include_expected_goals: Option, +) -> Result, JsValue> { let replay = parse_replay_from_data(data)?; - let stats_timeline = StatsTimelineEventCollector::new() + let collector = StatsTimelineEventCollector::new(); + let collector = if include_expected_goals.unwrap_or(false) { + collector.with_expected_goals() + } else { + collector + }; + let stats_timeline = collector .get_replay_stats_timeline_scaffold(&replay) .map_err(|e| JsValue::from_str(&format!("Failed to process replay stats: {e:?}")))?; @@ -568,10 +601,17 @@ pub fn get_legacy_stats_timeline_json(data: &[u8]) -> Result, JsValue> { pub fn get_stats_timeline_json_parts( data: &[u8], max_frame_chunk_bytes: Option, + include_expected_goals: Option, ) -> Result { let replay = parse_replay_from_data(data)?; - let stats_timeline = StatsTimelineEventCollector::new() + let collector = StatsTimelineEventCollector::new(); + let collector = if include_expected_goals.unwrap_or(false) { + collector.with_expected_goals() + } else { + collector + }; + let stats_timeline = collector .get_replay_stats_timeline_scaffold(&replay) .map_err(|e| JsValue::from_str(&format!("Failed to process replay stats: {e:?}")))?; diff --git a/js/stat-evaluation-player/src/activeModulesRuntime.ts b/js/stat-evaluation-player/src/activeModulesRuntime.ts index 37a41c348..82a29d615 100644 --- a/js/stat-evaluation-player/src/activeModulesRuntime.ts +++ b/js/stat-evaluation-player/src/activeModulesRuntime.ts @@ -29,6 +29,7 @@ export class ActiveModulesRuntime { private removeRenderHook: (() => void) | null = null; private readonly timelineSourceRemovers = new Map void>(); private readonly timelineRangeSourceRemovers = new Map void>(); + private readonly timelineGraphSourceRemovers = new Map void>(); constructor(private readonly options: ActiveModulesRuntimeOptions) {} @@ -194,6 +195,10 @@ export class ActiveModulesRuntime { removeSource(); } this.timelineRangeSourceRemovers.clear(); + for (const removeSource of this.timelineGraphSourceRemovers.values()) { + removeSource(); + } + this.timelineGraphSourceRemovers.clear(); } clearStandalonePlugins(): void { @@ -249,14 +254,21 @@ export class ActiveModulesRuntime { } for (const mod of this.activeModules) { - if (!this.activeTimelineRangeModuleIds.has(mod.id) || !mod.getTimelineRanges) { + if (!this.activeTimelineRangeModuleIds.has(mod.id)) { continue; } - - this.timelineRangeSourceRemovers.set( - mod.id, - timelineOverlay.addRangeSource(() => mod.getTimelineRanges?.(ctx) ?? []), - ); + if (mod.getTimelineRanges) { + this.timelineRangeSourceRemovers.set( + mod.id, + timelineOverlay.addRangeSource(() => mod.getTimelineRanges?.(ctx) ?? []), + ); + } + if (mod.getTimelineGraphs) { + this.timelineGraphSourceRemovers.set( + mod.id, + timelineOverlay.addGraphSource(() => mod.getTimelineGraphs?.(ctx) ?? []), + ); + } } for (const source of this.options.getEventTimelineSources(ctx)) { @@ -272,6 +284,7 @@ export class ActiveModulesRuntime { } timelineOverlay.refreshRanges(); + timelineOverlay.refreshGraphs(); } private getActiveModuleIds(): Set { diff --git a/js/stat-evaluation-player/src/env.d.ts b/js/stat-evaluation-player/src/env.d.ts index 4b8515ecd..18926d662 100644 --- a/js/stat-evaluation-player/src/env.d.ts +++ b/js/stat-evaluation-player/src/env.d.ts @@ -22,6 +22,7 @@ declare module "@rlrml/subtr-actor" { data: Uint8Array, callback: (progress: unknown) => void, reportEveryNFrames?: number, + includeExpectedGoals?: boolean, ): { rawReplayData: Uint8Array; /** Compact event-backed stats timeline JSON bytes. */ @@ -32,6 +33,7 @@ declare module "@rlrml/subtr-actor" { callback: (progress: unknown) => void, reportEveryNFrames?: number, maxFrameChunkBytes?: number, + includeExpectedGoals?: boolean, ): { rawReplayData: Uint8Array; statsTimelineParts: { @@ -41,6 +43,7 @@ declare module "@rlrml/subtr-actor" { activitySummary: Uint8Array; positioningSummary: Uint8Array; accumulationTracks: Uint8Array; + expectedGoalsTracks: Uint8Array; frameChunks: Uint8Array[]; }; }; @@ -48,14 +51,19 @@ declare module "@rlrml/subtr-actor" { /** Returns the compact event-backed timeline with scaffold frames. */ export function get_stats_timeline( data: Uint8Array, + includeExpectedGoals?: boolean, ): import("./generated/ReplayStatsTimelineScaffold.ts").ReplayStatsTimelineScaffold; /** Returns the compact event-backed timeline as JSON bytes. */ - export function get_stats_timeline_json(data: Uint8Array): Uint8Array; + export function get_stats_timeline_json( + data: Uint8Array, + includeExpectedGoals?: boolean, + ): Uint8Array; /** Returns the legacy full partial-sum timeline as JSON bytes. */ export function get_legacy_stats_timeline_json(data: Uint8Array): Uint8Array; export function get_stats_timeline_json_parts( data: Uint8Array, maxFrameChunkBytes?: number, + includeExpectedGoals?: boolean, ): { config: Uint8Array; replayMeta: Uint8Array; @@ -63,6 +71,7 @@ declare module "@rlrml/subtr-actor" { activitySummary: Uint8Array; positioningSummary: Uint8Array; accumulationTracks: Uint8Array; + expectedGoalsTracks: Uint8Array; frameChunks: Uint8Array[]; }; } diff --git a/js/stat-evaluation-player/src/eventCatalogParity.test.ts b/js/stat-evaluation-player/src/eventCatalogParity.test.ts index 7ecdfb2af..712540b50 100644 --- a/js/stat-evaluation-player/src/eventCatalogParity.test.ts +++ b/js/stat-evaluation-player/src/eventCatalogParity.test.ts @@ -38,6 +38,12 @@ test("every registry event is represented in the player (or explicitly excepted) ]); const missing = EVENT_DEFINITION_CATALOG.filter((entry) => { + // Registry entries hidden from the review picker are, by declaration, + // not surfaced as player streams; the Rust `hidden` flag is the single + // source of truth, so they need no per-key exception here. + if (entry.hidden_from_review) { + return false; + } if (CORE_AGGREGATED.has(entry.key) || NOT_SURFACED.has(entry.key)) { return false; } diff --git a/js/stat-evaluation-player/src/expectedGoalsTimelineGraph.test.ts b/js/stat-evaluation-player/src/expectedGoalsTimelineGraph.test.ts new file mode 100644 index 000000000..167ee5fc4 --- /dev/null +++ b/js/stat-evaluation-player/src/expectedGoalsTimelineGraph.test.ts @@ -0,0 +1,90 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildExpectedGoalsTimelineGraphs } from "./expectedGoalsTimelineGraph.ts"; +import type { StatsTimeline } from "./statsTimeline.ts"; + +test("instantaneous xG graph explains incident thresholds, selection, and exclusion", () => { + const timeline = { + frames: [ + { frame_number: 0, time: 0 }, + { frame_number: 10, time: 1 }, + { frame_number: 20, time: 2 }, + { frame_number: 30, time: 3 }, + ], + expected_goals_tracks: { + config: { + episode_threshold: 0.15, + episode_end_threshold: 0.05, + goal_touch_exclusion_seconds: 0.5, + incident_xg_calibration_factor: 0.583503, + }, + teams: [ + { + is_team_0: true, + points: [ + { + frame: 10, + stats: { + current_threat: 0.2, + incident_xg: 0, + xg: 0.02, + episode_count: 0, + goal_episode_count: 0, + }, + }, + { + frame: 20, + stats: { + current_threat: 0.7, + incident_xg: 0, + xg: 0.1, + episode_count: 0, + goal_episode_count: 0, + }, + }, + ], + }, + { is_team_0: false, points: [] }, + ], + players: [], + episodes: [ + { + start_time: 1, + start_frame: 10, + end_time: 3, + end_frame: 30, + team_is_team_0: true, + xg: 0.12, + peak_value: 0.7, + peak_frame: 20, + peak_time: 2, + incident_peak_value: 0.2, + incident_xg: 0.125895, + incident_xg_frame: 10, + incident_xg_time: 1, + goal_exclusion_start_time: 1.5, + credited_player: null, + ended_in_goal: true, + end_reason: "goal", + }, + ], + }, + } as unknown as StatsTimeline; + + const [graph] = buildExpectedGoalsTimelineGraphs(timeline); + assert.ok(graph); + assert.deepEqual( + graph.references?.map((reference) => reference.value), + [0.15, 0.05], + ); + assert.equal(graph.highlights?.length, 2); + assert.equal(graph.highlights?.[1]?.className, "excluded"); + assert.equal(graph.highlights?.[1]?.startTime, 1.5); + assert.equal(graph.markers?.length, 2); + assert.equal(graph.markers?.[0]?.className, "selected"); + assert.equal(graph.markers?.[0]?.time, 1); + assert.match(graph.markers?.[0]?.label ?? "", /contributes 0\.126 xG/); + assert.equal(graph.markers?.[1]?.className, "excluded-peak"); + assert.equal(graph.markers?.[1]?.time, 2); +}); diff --git a/js/stat-evaluation-player/src/expectedGoalsTimelineGraph.ts b/js/stat-evaluation-player/src/expectedGoalsTimelineGraph.ts new file mode 100644 index 000000000..0ff2f4aa4 --- /dev/null +++ b/js/stat-evaluation-player/src/expectedGoalsTimelineGraph.ts @@ -0,0 +1,168 @@ +import type { + ReplayTimelineGraph, + ReplayTimelineGraphHighlight, + ReplayTimelineGraphMarker, +} from "@rlrml/player"; + +import type { ExpectedGoalsTimelineTracks } from "./generated/ExpectedGoalsTimelineTracks.ts"; +import type { ExpectedGoalsTeamStats } from "./generated/ExpectedGoalsTeamStats.ts"; +import type { ThreatEpisodeEvent } from "./generated/ThreatEpisodeEvent.ts"; +import type { StatsTimeline } from "./statsTimeline.ts"; + +const TEAM_ZERO_COLOR = "#3b82f6"; +const TEAM_ONE_COLOR = "#f59e0b"; +const DEFAULT_OPEN_THRESHOLD = 0.15; +const DEFAULT_END_THRESHOLD = 0.05; + +interface ExpectedGoalsTrackPayload { + expected_goals_tracks?: Partial; +} + +function formatPercent(value: number): string { + return `${(value * 100).toFixed(1)}%`; +} + +function teamLabel(isTeamZero: boolean): string { + return isTeamZero ? "Blue" : "Orange"; +} + +function teamColor(isTeamZero: boolean): string { + return isTeamZero ? TEAM_ZERO_COLOR : TEAM_ONE_COLOR; +} + +function frameTimes(timeline: StatsTimeline): Map { + return new Map(timeline.frames.map((frame) => [frame.frame_number, frame.time])); +} + +function compactTracks(timeline: StatsTimeline): Partial | null { + return (timeline as StatsTimeline & ExpectedGoalsTrackPayload).expected_goals_tracks ?? null; +} + +function buildSeries(timeline: StatsTimeline, tracks: Partial | null) { + if (!tracks?.teams) { + return [true, false].map((isTeamZero) => ({ + id: isTeamZero ? "team-zero" : "team-one", + label: teamLabel(isTeamZero), + color: teamColor(isTeamZero), + points: timeline.frames.map((frame) => { + const team = (isTeamZero ? frame.team_zero : frame.team_one) as { + expected_goals?: Partial; + }; + return { + time: frame.time, + value: team.expected_goals?.current_threat ?? null, + }; + }), + })); + } + + const times = frameTimes(timeline); + return [true, false].map((isTeamZero) => { + const track = tracks.teams?.find((candidate) => candidate.is_team_0 === isTeamZero); + return { + id: isTeamZero ? "team-zero" : "team-one", + label: teamLabel(isTeamZero), + color: teamColor(isTeamZero), + points: (track?.points ?? []).flatMap((point) => { + const time = times.get(point.frame); + return time === undefined ? [] : [{ time, value: point.stats.current_threat ?? null }]; + }), + }; + }); +} + +function incidentHighlights(episodes: readonly ThreatEpisodeEvent[]) { + return episodes.flatMap((episode) => { + const label = teamLabel(episode.team_is_team_0); + const highlights: ReplayTimelineGraphHighlight[] = [ + { + startTime: episode.start_time, + endTime: episode.end_time, + color: teamColor(episode.team_is_team_0), + label: `${label} incident · selected ${formatPercent(episode.incident_peak_value)} · +${episode.incident_xg.toFixed(3)} xG`, + }, + ]; + if ( + episode.goal_exclusion_start_time !== null && + episode.goal_exclusion_start_time < episode.end_time + ) { + highlights.push({ + startTime: Math.max(episode.start_time, episode.goal_exclusion_start_time), + endTime: episode.end_time, + color: "#ef4444", + className: "excluded", + label: `${label} goal-result window excluded from incident xG`, + }); + } + return highlights; + }); +} + +function incidentMarkers(episodes: readonly ThreatEpisodeEvent[]) { + return episodes.flatMap((episode) => { + const color = teamColor(episode.team_is_team_0); + const label = teamLabel(episode.team_is_team_0); + const markers: ReplayTimelineGraphMarker[] = []; + if (episode.incident_xg_time !== null && episode.incident_peak_value > 0) { + markers.push({ + time: episode.incident_xg_time, + value: episode.incident_peak_value, + color, + className: "selected", + label: `${label} selected peak ${formatPercent(episode.incident_peak_value)} · contributes ${episode.incident_xg.toFixed(3)} xG`, + }); + } + if ( + episode.goal_exclusion_start_time !== null && + episode.peak_time >= episode.goal_exclusion_start_time && + episode.peak_time !== episode.incident_xg_time + ) { + markers.push({ + time: episode.peak_time, + value: episode.peak_value, + color, + className: "excluded-peak", + label: `${label} observed peak ${formatPercent(episode.peak_value)} excluded after the final-touch cutoff`, + }); + } + return markers; + }); +} + +export function buildExpectedGoalsTimelineGraphs(timeline: StatsTimeline): ReplayTimelineGraph[] { + const tracks = compactTracks(timeline); + const series = buildSeries(timeline, tracks); + if (!series.some((team) => team.points.some((point) => point.value !== null))) { + return []; + } + + const episodes = tracks?.episodes ?? []; + const startThreshold = tracks?.config?.episode_threshold ?? DEFAULT_OPEN_THRESHOLD; + const endThreshold = tracks?.config?.episode_end_threshold ?? DEFAULT_END_THRESHOLD; + + return [ + { + id: "instantaneous-xg", + label: "Instantaneous xG (5s)", + minValue: 0, + maxValue: 1, + series, + references: [ + { + value: startThreshold, + label: `open ${formatPercent(startThreshold)}`, + color: "#d8ebff", + className: "incident-open", + }, + { + value: endThreshold, + label: `end ${formatPercent(endThreshold)}`, + color: "#9aaabd", + className: "incident-end", + }, + ], + highlights: incidentHighlights(episodes), + markers: incidentMarkers(episodes), + }, + ]; +} diff --git a/js/stat-evaluation-player/src/expectedGoalsTrackDerivation.test.ts b/js/stat-evaluation-player/src/expectedGoalsTrackDerivation.test.ts new file mode 100644 index 000000000..c0a298d9f --- /dev/null +++ b/js/stat-evaluation-player/src/expectedGoalsTrackDerivation.test.ts @@ -0,0 +1,147 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import type { ExpectedGoalsTimelineTracks } from "./generated/ExpectedGoalsTimelineTracks.ts"; +import { + applyExpectedGoalsTrackDerivedStats, + createExpectedGoalsTrackDerivedStatsAccumulator, +} from "./expectedGoalsTrackDerivation.ts"; +import { createStatsTimeline } from "./testStatsTimeline.ts"; + +const playerId = { Steam: "expected-goals-player" }; + +function fixture() { + const timeline = createStatsTimeline({ + frames: [0, 10, 20].map((frame) => ({ + frame_number: frame, + time: frame / 10, + dt: 1, + players: [{ player_id: playerId, name: "Player", is_team_0: true }], + })), + }); + ( + timeline as typeof timeline & { + expected_goals_tracks: ExpectedGoalsTimelineTracks; + } + ).expected_goals_tracks = { + config: { + episode_threshold: 0.15, + episode_end_threshold: 0.05, + goal_touch_exclusion_seconds: 0.5, + incident_xg_calibration_factor: 0.583503, + }, + teams: [ + { + is_team_0: true, + points: [ + { + frame: 10, + stats: { + current_threat: 0.35, + incident_xg: 0.19, + xg: 0.25, + episode_count: 1, + goal_episode_count: 0, + }, + }, + { + frame: 20, + stats: { + current_threat: null, + incident_xg: 0.19, + xg: 0.6, + episode_count: 1, + goal_episode_count: 1, + }, + }, + ], + }, + { + is_team_0: false, + points: [ + { + frame: 20, + stats: { + current_threat: 0.08, + incident_xg: 0, + xg: 0.1, + episode_count: 0, + goal_episode_count: 0, + }, + }, + ], + }, + ], + episodes: [], + players: [ + { + player_id: playerId, + is_team_0: true, + points: [ + { + frame: 10, + stats: { + threat_added: 0.4, + xg: 0.2, + credited_episode_count: 1, + credited_goal_episode_count: 0, + }, + }, + { + frame: 20, + stats: { + threat_added: 0.4, + xg: 0.2, + credited_episode_count: 1, + credited_goal_episode_count: 1, + }, + }, + ], + }, + ], + }; + return timeline; +} + +function assertHydrated(timeline: ReturnType): void { + assert.equal(timeline.frames[0]?.team_zero.expected_goals.xg, 0); + assert.equal(timeline.frames[0]?.players[0]?.expected_goals.threat_added, 0); + + assert.equal(timeline.frames[1]?.team_zero.expected_goals.xg, 0.25); + assert.equal(timeline.frames[1]?.team_zero.expected_goals.current_threat, 0.35); + assert.equal(timeline.frames[1]?.team_zero.expected_goals.incident_xg, 0.19); + assert.equal(timeline.frames[1]?.team_zero.expected_goals.episode_count, 1); + assert.equal(timeline.frames[1]?.players[0]?.expected_goals.threat_added, 0.4); + assert.equal(timeline.frames[1]?.players[0]?.expected_goals.xg, 0.2); + + assert.equal(timeline.frames[2]?.team_zero.expected_goals.xg, 0.6); + assert.equal(timeline.frames[2]?.team_zero.expected_goals.current_threat, null); + assert.equal(timeline.frames[2]?.team_zero.expected_goals.incident_xg, 0.19); + assert.equal(timeline.frames[2]?.team_zero.expected_goals.goal_episode_count, 1); + assert.equal(timeline.frames[2]?.team_one.expected_goals.xg, 0.1); + assert.equal(timeline.frames[2]?.team_one.expected_goals.current_threat, 0.08); + assert.equal(timeline.frames[2]?.players[0]?.expected_goals.credited_goal_episode_count, 1); +} + +test("expected-goals tracks hydrate full and sparse player/team snapshots", () => { + const timeline = fixture(); + applyExpectedGoalsTrackDerivedStats(timeline); + assertHydrated(timeline); +}); + +test("expected-goals tracks hydrate incrementally with held change-point values", () => { + const timeline = fixture(); + const accumulator = createExpectedGoalsTrackDerivedStatsAccumulator(timeline); + for (const frame of timeline.frames) accumulator.applyFrame(frame); + assertHydrated(timeline); +}); + +test("missing expected-goals tracks preserve zero defaults for older compact payloads", () => { + const timeline = createStatsTimeline({ + frames: [{ frame_number: 1, players: [{ player_id: playerId, is_team_0: true }] }], + }); + applyExpectedGoalsTrackDerivedStats(timeline); + assert.equal(timeline.frames[0]?.team_zero.expected_goals.xg, 0); + assert.equal(timeline.frames[0]?.team_zero.expected_goals.current_threat, null); + assert.equal(timeline.frames[0]?.players[0]?.expected_goals.xg, 0); +}); diff --git a/js/stat-evaluation-player/src/expectedGoalsTrackDerivation.ts b/js/stat-evaluation-player/src/expectedGoalsTrackDerivation.ts new file mode 100644 index 000000000..cc373d3f1 --- /dev/null +++ b/js/stat-evaluation-player/src/expectedGoalsTrackDerivation.ts @@ -0,0 +1,99 @@ +import type { ExpectedGoalsPlayerStats } from "./generated/ExpectedGoalsPlayerStats.ts"; +import type { ExpectedGoalsPlayerTimelinePoint } from "./generated/ExpectedGoalsPlayerTimelinePoint.ts"; +import type { ExpectedGoalsTeamStats } from "./generated/ExpectedGoalsTeamStats.ts"; +import type { ExpectedGoalsTeamTimelinePoint } from "./generated/ExpectedGoalsTeamTimelinePoint.ts"; +import type { ExpectedGoalsTimelineTracks } from "./generated/ExpectedGoalsTimelineTracks.ts"; +import type { MaterializedStatsTimeline, StatsFrame } from "./statsTimeline.ts"; + +function remoteIdKey(playerId: unknown): string { + return JSON.stringify(playerId); +} + +class SnapshotCursor { + private index = 0; + + constructor(private readonly points: readonly T[]) {} + + sample(frameNumber: number): T["stats"] | undefined { + while ( + this.index + 1 < this.points.length && + this.points[this.index + 1]!.frame <= frameNumber + ) { + this.index += 1; + } + const point = this.points[this.index]; + return point && point.frame <= frameNumber ? point.stats : undefined; + } +} + +function expectedGoalsTracks(timeline: MaterializedStatsTimeline): ExpectedGoalsTimelineTracks { + return ( + ( + timeline as MaterializedStatsTimeline & { + expected_goals_tracks?: ExpectedGoalsTimelineTracks; + } + ).expected_goals_tracks ?? { + config: { + episode_threshold: 0.15, + episode_end_threshold: 0.05, + goal_touch_exclusion_seconds: 0.5, + incident_xg_calibration_factor: 1, + }, + teams: [], + players: [], + episodes: [], + } + ); +} + +export function createExpectedGoalsTrackDerivedStatsAccumulator( + timeline: MaterializedStatsTimeline, +): { applyFrame(frame: StatsFrame): void } { + const tracks = expectedGoalsTracks(timeline); + const teamCursors = new Map( + tracks.teams.map( + (track) => + [ + track.is_team_0, + new SnapshotCursor(track.points), + ] as const, + ), + ); + const playerCursors = new Map( + tracks.players.map( + (track) => + [ + remoteIdKey(track.player_id), + new SnapshotCursor(track.points), + ] as const, + ), + ); + + return { + applyFrame(frame): void { + const teamZero = teamCursors.get(true)?.sample(frame.frame_number) as + | ExpectedGoalsTeamStats + | undefined; + const teamOne = teamCursors.get(false)?.sample(frame.frame_number) as + | ExpectedGoalsTeamStats + | undefined; + if (teamZero) Object.assign(frame.team_zero.expected_goals, teamZero); + if (teamOne) Object.assign(frame.team_one.expected_goals, teamOne); + + for (const player of frame.players) { + const stats = playerCursors + .get(remoteIdKey(player.player_id)) + ?.sample(frame.frame_number) as ExpectedGoalsPlayerStats | undefined; + if (stats) Object.assign(player.expected_goals, stats); + } + }, + }; +} + +export function applyExpectedGoalsTrackDerivedStats( + timeline: MaterializedStatsTimeline, +): MaterializedStatsTimeline { + const accumulator = createExpectedGoalsTrackDerivedStatsAccumulator(timeline); + for (const frame of timeline.frames) accumulator.applyFrame(frame); + return timeline; +} diff --git a/js/stat-evaluation-player/src/generated/ExpectedGoalsCalculatorConfig.ts b/js/stat-evaluation-player/src/generated/ExpectedGoalsCalculatorConfig.ts new file mode 100644 index 000000000..c9bcf7cd1 --- /dev/null +++ b/js/stat-evaluation-player/src/generated/ExpectedGoalsCalculatorConfig.ts @@ -0,0 +1,24 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Configuration for [`ExpectedGoalsCalculator`]. + */ +export type ExpectedGoalsCalculatorConfig = { +/** + * V required to open an incident. + */ +episode_threshold: number, +/** + * V at or below which an open incident closes. This is deliberately + * lower than `episode_threshold` to avoid splitting on small dips. + */ +episode_end_threshold: number, +/** + * Seconds before the scoring team's final touch at which a goal-ending + * incident stops being eligible for incident xG. + */ +goal_touch_exclusion_seconds: number, +/** + * Count-scale calibration applied to the selected incident peak. + */ +incident_xg_calibration_factor: number, }; diff --git a/js/stat-evaluation-player/src/generated/ExpectedGoalsPlayerStats.ts b/js/stat-evaluation-player/src/generated/ExpectedGoalsPlayerStats.ts new file mode 100644 index 000000000..128b62e5b --- /dev/null +++ b/js/stat-evaluation-player/src/generated/ExpectedGoalsPlayerStats.ts @@ -0,0 +1,21 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LabeledFloatSums } from "./LabeledFloatSums.ts"; + +/** + * Per-player accumulated threat stats. The labeled sums are the canonical + * record (`metric=threat_added` / `metric=xg`); the plain fields are + * convenience projections kept in sync. + */ +export type ExpectedGoalsPlayerStats = { +/** + * Sum of positive detection-frame threat deltas (detection-frame V minus + * preceding-live-frame V, from the toucher's team's perspective) over the + * player's touches. This is an observed one-frame delta, not a causal + * estimate of each touch's multi-frame impulse. + */ +threat_added: number, +/** + * Sum of episode xG time integrals (`sum(V * dt) / tau` per episode) + * over episodes credited to this player. + */ +xg: number, credited_episode_count: number, credited_goal_episode_count: number, labeled_sums?: LabeledFloatSums, }; diff --git a/js/stat-evaluation-player/src/generated/ExpectedGoalsPlayerTimelinePoint.ts b/js/stat-evaluation-player/src/generated/ExpectedGoalsPlayerTimelinePoint.ts new file mode 100644 index 000000000..b4cd03034 --- /dev/null +++ b/js/stat-evaluation-player/src/generated/ExpectedGoalsPlayerTimelinePoint.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ExpectedGoalsPlayerStats } from "./ExpectedGoalsPlayerStats.ts"; + +export type ExpectedGoalsPlayerTimelinePoint = { frame: number, stats: ExpectedGoalsPlayerStats, }; diff --git a/js/stat-evaluation-player/src/generated/ExpectedGoalsPlayerTimelineTrack.ts b/js/stat-evaluation-player/src/generated/ExpectedGoalsPlayerTimelineTrack.ts new file mode 100644 index 000000000..50e21467c --- /dev/null +++ b/js/stat-evaluation-player/src/generated/ExpectedGoalsPlayerTimelineTrack.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ExpectedGoalsPlayerTimelinePoint } from "./ExpectedGoalsPlayerTimelinePoint.ts"; +import type { RemoteIdTs } from "./RemoteIdTs.ts"; + +export type ExpectedGoalsPlayerTimelineTrack = { player_id: RemoteIdTs, is_team_0: boolean, points: Array, }; diff --git a/js/stat-evaluation-player/src/generated/ExpectedGoalsTeamStats.ts b/js/stat-evaluation-player/src/generated/ExpectedGoalsTeamStats.ts new file mode 100644 index 000000000..004902446 --- /dev/null +++ b/js/stat-evaluation-player/src/generated/ExpectedGoalsTeamStats.ts @@ -0,0 +1,29 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Per-team accumulated threat stats. + */ +export type ExpectedGoalsTeamStats = { +/** + * Instantaneous probability that this team scores within the model's + * prediction horizon on the current live-play frame. `None` outside live + * play or when the replay is not a supported 2v2 match. + */ +current_threat: number | null, +/** + * Sum of count-calibrated, one-peak contributions from threshold-delimited + * incidents. For an incident ending in a goal, samples from shortly before + * the scoring team's final touch onward are excluded to avoid outcome + * leakage. Raw selected probabilities remain available on the incident + * events. + */ +incident_xg: number, +/** + * The team's full-match xG time integral (`sum(V * dt) / tau` over every + * evaluated live frame, sub-threshold frames included), fed from + * [`ExpectedGoalsCalculator::team_xg_integrals`]. NOT a sum of episode + * xG: per-player `xg` sums to LESS than this, because diffuse + * sub-threshold threat is not attributed to any player (empirically only + * ~62% of the integral falls inside above-threshold episodes). + */ +xg: number, episode_count: number, goal_episode_count: number, }; diff --git a/js/stat-evaluation-player/src/generated/ExpectedGoalsTeamTimelinePoint.ts b/js/stat-evaluation-player/src/generated/ExpectedGoalsTeamTimelinePoint.ts new file mode 100644 index 000000000..60eb864f7 --- /dev/null +++ b/js/stat-evaluation-player/src/generated/ExpectedGoalsTeamTimelinePoint.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ExpectedGoalsTeamStats } from "./ExpectedGoalsTeamStats.ts"; + +export type ExpectedGoalsTeamTimelinePoint = { frame: number, stats: ExpectedGoalsTeamStats, }; diff --git a/js/stat-evaluation-player/src/generated/ExpectedGoalsTeamTimelineTrack.ts b/js/stat-evaluation-player/src/generated/ExpectedGoalsTeamTimelineTrack.ts new file mode 100644 index 000000000..9e7dda01d --- /dev/null +++ b/js/stat-evaluation-player/src/generated/ExpectedGoalsTeamTimelineTrack.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ExpectedGoalsTeamTimelinePoint } from "./ExpectedGoalsTeamTimelinePoint.ts"; + +export type ExpectedGoalsTeamTimelineTrack = { is_team_0: boolean, points: Array, }; diff --git a/js/stat-evaluation-player/src/generated/ExpectedGoalsTimelineTracks.ts b/js/stat-evaluation-player/src/generated/ExpectedGoalsTimelineTracks.ts new file mode 100644 index 000000000..3880c9a31 --- /dev/null +++ b/js/stat-evaluation-player/src/generated/ExpectedGoalsTimelineTracks.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ExpectedGoalsCalculatorConfig } from "./ExpectedGoalsCalculatorConfig.ts"; +import type { ExpectedGoalsPlayerTimelineTrack } from "./ExpectedGoalsPlayerTimelineTrack.ts"; +import type { ExpectedGoalsTeamTimelineTrack } from "./ExpectedGoalsTeamTimelineTrack.ts"; +import type { ThreatEpisodeEvent } from "./ThreatEpisodeEvent.ts"; + +export type ExpectedGoalsTimelineTracks = { config: ExpectedGoalsCalculatorConfig, teams: Array, players: Array, +/** + * Finalized threshold-delimited incidents, including peak timing and any + * scoring-touch exclusion applied to incident xG. + */ +episodes: Array, }; diff --git a/js/stat-evaluation-player/src/generated/PlayerStatsSnapshot.ts b/js/stat-evaluation-player/src/generated/PlayerStatsSnapshot.ts index d2341a336..2bd6d706a 100644 --- a/js/stat-evaluation-player/src/generated/PlayerStatsSnapshot.ts +++ b/js/stat-evaluation-player/src/generated/PlayerStatsSnapshot.ts @@ -10,6 +10,7 @@ import type { CorePlayerStats } from "./CorePlayerStats.ts"; import type { DemoPlayerStats } from "./DemoPlayerStats.ts"; import type { DodgeResetStats } from "./DodgeResetStats.ts"; import type { DoubleTapPlayerStats } from "./DoubleTapPlayerStats.ts"; +import type { ExpectedGoalsPlayerStats } from "./ExpectedGoalsPlayerStats.ts"; import type { FiftyFiftyPlayerStats } from "./FiftyFiftyPlayerStats.ts"; import type { FlickStats } from "./FlickStats.ts"; import type { FlipResetStats } from "./FlipResetStats.ts"; @@ -36,4 +37,4 @@ import type { WhiffStats } from "./WhiffStats.ts"; * Like `TeamStatsSnapshot`, this is a serialization/client DTO. It should not * be used as an upstream data dependency between analysis nodes. */ -export type PlayerStatsSnapshot = { player_id: RemoteIdTs, name: string, is_team_0: boolean, core: CorePlayerStats, backboard: BackboardPlayerStats, ceiling_shot: CeilingShotStats, wall_aerial: WallAerialStats, wall_aerial_shot: WallAerialShotStats, double_tap: DoubleTapPlayerStats, one_timer: OneTimerPlayerStats, pass: PassPlayerStats, fifty_fifty: FiftyFiftyPlayerStats, kickoff: KickoffPlayerStats, speed_flip: SpeedFlipStats, half_flip: HalfFlipStats, half_volley: HalfVolleyPlayerStats, wavedash: WavedashStats, touch: TouchStats, whiff: WhiffStats, flick: FlickStats, dodge_reset: DodgeResetStats, flip_reset: FlipResetStats, ball_carry: BallCarryStats, controlled_play: ControlledPlayStats, air_dribble: AirDribbleStats, boost: BoostStats, bump: BumpPlayerStats, movement: MovementStats, positioning: PositioningStats, rotation: RotationPlayerStats, powerslide: PowerslideStats, demo: DemoPlayerStats, }; +export type PlayerStatsSnapshot = { player_id: RemoteIdTs, name: string, is_team_0: boolean, core: CorePlayerStats, backboard: BackboardPlayerStats, ceiling_shot: CeilingShotStats, wall_aerial: WallAerialStats, wall_aerial_shot: WallAerialShotStats, double_tap: DoubleTapPlayerStats, one_timer: OneTimerPlayerStats, pass: PassPlayerStats, fifty_fifty: FiftyFiftyPlayerStats, kickoff: KickoffPlayerStats, speed_flip: SpeedFlipStats, half_flip: HalfFlipStats, half_volley: HalfVolleyPlayerStats, wavedash: WavedashStats, touch: TouchStats, whiff: WhiffStats, flick: FlickStats, dodge_reset: DodgeResetStats, flip_reset: FlipResetStats, ball_carry: BallCarryStats, controlled_play: ControlledPlayStats, air_dribble: AirDribbleStats, boost: BoostStats, bump: BumpPlayerStats, movement: MovementStats, positioning: PositioningStats, rotation: RotationPlayerStats, powerslide: PowerslideStats, demo: DemoPlayerStats, expected_goals: ExpectedGoalsPlayerStats, }; diff --git a/js/stat-evaluation-player/src/generated/ReplayStatsTimelineScaffold.ts b/js/stat-evaluation-player/src/generated/ReplayStatsTimelineScaffold.ts index 63cdc6179..55c4616ad 100644 --- a/js/stat-evaluation-player/src/generated/ReplayStatsTimelineScaffold.ts +++ b/js/stat-evaluation-player/src/generated/ReplayStatsTimelineScaffold.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AccumulationTrack } from "./AccumulationTrack.ts"; +import type { ExpectedGoalsTimelineTracks } from "./ExpectedGoalsTimelineTracks.ts"; import type { ReplayMeta } from "./ReplayMeta.ts"; import type { ReplayStatsActivitySummary } from "./ReplayStatsActivitySummary.ts"; import type { ReplayStatsFrameScaffold } from "./ReplayStatsFrameScaffold.ts"; @@ -27,4 +28,11 @@ positioning_summary: Array, * alongside the event-only frames so the player can show a value growing during playback * without re-deriving it from events. See [`AccumulationTrack`]. */ -accumulation_tracks: Array, }; +accumulation_tracks: Array, +/** + * Change-point-compressed expected-goals snapshots. Team xG is a + * continuous full-play integral and therefore cannot be reconstructed + * from the discrete threat episodes alone; these tracks preserve that + * value together with the sparse player and episode counters. + */ +expected_goals_tracks: ExpectedGoalsTimelineTracks, }; diff --git a/js/stat-evaluation-player/src/generated/TeamStatsSnapshot.ts b/js/stat-evaluation-player/src/generated/TeamStatsSnapshot.ts index 506846114..e7f2e8ae4 100644 --- a/js/stat-evaluation-player/src/generated/TeamStatsSnapshot.ts +++ b/js/stat-evaluation-player/src/generated/TeamStatsSnapshot.ts @@ -10,6 +10,7 @@ import type { ControlledPlayStats } from "./ControlledPlayStats.ts"; import type { CoreTeamStats } from "./CoreTeamStats.ts"; import type { DemoTeamStats } from "./DemoTeamStats.ts"; import type { DoubleTapTeamStats } from "./DoubleTapTeamStats.ts"; +import type { ExpectedGoalsTeamStats } from "./ExpectedGoalsTeamStats.ts"; import type { FiftyFiftyTeamStats } from "./FiftyFiftyTeamStats.ts"; import type { HalfVolleyTeamStats } from "./HalfVolleyTeamStats.ts"; import type { KickoffTeamStats } from "./KickoffTeamStats.ts"; @@ -35,4 +36,4 @@ import type { TerritorialPressureTeamStats } from "./TerritorialPressureTeamStat * timelines. It is not the authoritative registry of team analysis outputs; * use the module-keyed stats/graph surfaces when callers need discoverability. */ -export type TeamStatsSnapshot = { fifty_fifty: FiftyFiftyTeamStats, possession: PossessionTeamStats, ball_half: BallHalfTeamStats, ball_third: BallThirdTeamStats, territorial_pressure: TerritorialPressureTeamStats, rotation: RotationTeamStats, rush: RushTeamStats, core: CoreTeamStats, backboard: BackboardTeamStats, double_tap: DoubleTapTeamStats, one_timer: OneTimerTeamStats, pass: PassTeamStats, kickoff: KickoffTeamStats, ball_carry: BallCarryStats, controlled_play: ControlledPlayStats, air_dribble: AirDribbleStats, boost: BoostStats, bump: BumpTeamStats, half_volley: HalfVolleyTeamStats, movement: MovementStats, positioning: PositioningTeamStats, powerslide: PowerslideStats, demo: DemoTeamStats, }; +export type TeamStatsSnapshot = { fifty_fifty: FiftyFiftyTeamStats, possession: PossessionTeamStats, ball_half: BallHalfTeamStats, ball_third: BallThirdTeamStats, territorial_pressure: TerritorialPressureTeamStats, rotation: RotationTeamStats, rush: RushTeamStats, core: CoreTeamStats, backboard: BackboardTeamStats, double_tap: DoubleTapTeamStats, one_timer: OneTimerTeamStats, pass: PassTeamStats, kickoff: KickoffTeamStats, ball_carry: BallCarryStats, controlled_play: ControlledPlayStats, air_dribble: AirDribbleStats, boost: BoostStats, bump: BumpTeamStats, half_volley: HalfVolleyTeamStats, movement: MovementStats, positioning: PositioningTeamStats, powerslide: PowerslideStats, demo: DemoTeamStats, expected_goals: ExpectedGoalsTeamStats, }; diff --git a/js/stat-evaluation-player/src/generated/ThreatEpisodeEndReason.ts b/js/stat-evaluation-player/src/generated/ThreatEpisodeEndReason.ts new file mode 100644 index 000000000..3f907d6a2 --- /dev/null +++ b/js/stat-evaluation-player/src/generated/ThreatEpisodeEndReason.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Why a threat episode closed. + */ +export type ThreatEpisodeEndReason = "value_dropped" | "goal" | "stoppage" | "replay_end"; diff --git a/js/stat-evaluation-player/src/generated/ThreatEpisodeEvent.ts b/js/stat-evaluation-player/src/generated/ThreatEpisodeEvent.ts new file mode 100644 index 000000000..eb3d966a9 --- /dev/null +++ b/js/stat-evaluation-player/src/generated/ThreatEpisodeEvent.ts @@ -0,0 +1,54 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RemoteIdTs } from "./RemoteIdTs.ts"; +import type { ThreatEpisodeEndReason } from "./ThreatEpisodeEndReason.ts"; + +/** + * A contiguous span where one team's V exceeded the episode threshold. + * `credited_player` is the attacking team's most recent toucher when the + * episode reached `peak_value`, `None` when the team had not touched during + * the live stretch by that point -- team-only credit. Later touches at lower + * V do not take credit for threat accumulated before them. + */ +export type ThreatEpisodeEvent = { start_time: number, start_frame: number, end_time: number, end_frame: number, team_is_team_0: boolean, +/** + * The episode's continuous threat integral: `sum(V * dt) / tau` over the + * span, where `tau` is + * [`THREAT_HORIZON_SECONDS`](super::expected_goals_model::THREAT_HORIZON_SECONDS). + * Frames that count: every evaluated live-play frame from the frame that + * opens the episode through the frame that closes it (for value-drop + * closes the final sub-threshold frame contributes too; stoppage / + * replay-end closes end at the last evaluated live frame). This is kept + * for attribution and comparison with the full-match integral; the + * incident-based goal-count estimator is [`Self::incident_xg`]. + */ +xg: number, +/** + * Peak V over the span, kept for display and intensity ranking. + */ +peak_value: number, +/** + * Frame/time where [`Self::peak_value`] occurred. + */ +peak_frame: number, peak_time: number, +/** + * One peak probability contributed to the team's incident-based xG. + * For ordinary incidents this equals `peak_value`. For a goal-ending + * incident it is the largest value strictly before + * `goal_exclusion_start_time`, or zero when the incident only became + * dangerous inside the excluded window. + */ +incident_peak_value: number, +/** + * Count-calibrated contribution derived from `incident_peak_value`. + */ +incident_xg: number, +/** + * Frame/time of the sample selected for [`Self::incident_xg`]. `None` + * when a goal-ending incident has no eligible pre-touch sample. + */ +incident_xg_frame: number | null, incident_xg_time: number | null, +/** + * Start of the excluded goal-result window. `None` for non-goal + * incidents or when no scoring-team touch was available. + */ +goal_exclusion_start_time: number | null, credited_player: RemoteIdTs | null, ended_in_goal: boolean, end_reason: ThreatEpisodeEndReason, }; diff --git a/js/stat-evaluation-player/src/generated/eventDefinitionCatalog.generated.ts b/js/stat-evaluation-player/src/generated/eventDefinitionCatalog.generated.ts index 850e89815..b12ba03af 100644 --- a/js/stat-evaluation-player/src/generated/eventDefinitionCatalog.generated.ts +++ b/js/stat-evaluation-player/src/generated/eventDefinitionCatalog.generated.ts @@ -344,6 +344,20 @@ export const EVENT_DEFINITION_CATALOG: EventDefinitionCatalogEntry[] = [ "hidden_from_review": false, "variants": [] }, + { + "key": "threat_episode", + "label": "Threat Episode", + "category": "other", + "hidden_from_review": true, + "variants": [] + }, + { + "key": "threat_touch", + "label": "Threat Touch Delta", + "category": "other", + "hidden_from_review": true, + "variants": [] + }, { "key": "timeline", "label": "Replay Timeline Event", diff --git a/js/stat-evaluation-player/src/lib.ts b/js/stat-evaluation-player/src/lib.ts index 02041ee9a..265bafb1f 100644 --- a/js/stat-evaluation-player/src/lib.ts +++ b/js/stat-evaluation-player/src/lib.ts @@ -9,6 +9,7 @@ export * from "./ceilingShotOverlay.ts"; export * from "./ceilingShotEventDerivation.ts"; export * from "./coreEventDerivation.ts"; export * from "./demoEventDerivation.ts"; +export * from "./expectedGoalsTrackDerivation.ts"; export * from "./dodgeResetEventDerivation.ts"; export * from "./doubleTapEventDerivation.ts"; export * from "./fiftyFiftyFormatting.ts"; diff --git a/js/stat-evaluation-player/src/moduleControls.ts b/js/stat-evaluation-player/src/moduleControls.ts index 9ed94b119..c8e411002 100644 --- a/js/stat-evaluation-player/src/moduleControls.ts +++ b/js/stat-evaluation-player/src/moduleControls.ts @@ -59,7 +59,12 @@ export class ModuleControlsController { for (const mod of this.options.modules) { const hasRenderEffect = RENDER_EFFECT_MODULE_IDS.has(mod.id); - if (!mod.getTimelineEvents && !mod.getTimelineRanges && !hasRenderEffect) { + if ( + !mod.getTimelineEvents && + !mod.getTimelineRanges && + !mod.getTimelineGraphs && + !hasRenderEffect + ) { continue; } @@ -68,13 +73,16 @@ export class ModuleControlsController { this.renderCapabilityToggle(mod.id, getCapabilityLabel(mod, "events"), "events"), ); } - if (mod.getTimelineRanges) { + if (mod.getTimelineRanges || mod.getTimelineGraphs) { + const timelineViewCount = ctx + ? (mod.getTimelineRanges?.(ctx).length ?? 0) + (mod.getTimelineGraphs?.(ctx).length ?? 0) + : undefined; rangeToggles.push( this.renderCapabilityToggle( mod.id, getCapabilityLabel(mod, "ranges"), "ranges", - ctx ? mod.getTimelineRanges(ctx).length : undefined, + timelineViewCount, ), ); } @@ -89,7 +97,7 @@ export class ModuleControlsController { summary.append( renderModuleSummaryGroup("Timeline markers", markerToggles), - renderModuleSummaryGroup("Timeline ranges", rangeToggles), + renderModuleSummaryGroup("Timeline views", rangeToggles), renderModuleSummaryGroup("In-game visualizations", inGameVisualizationToggles), ); } @@ -253,6 +261,7 @@ function getCapabilityLabel(mod: StatModule, kind: ModuleCapabilityKind): string "fifty-fifty:events": "50/50", "fifty-fifty:ranges": "50/50", "dodge:events": "Dodge", + "expected_goals:ranges": "Instantaneous xG graph", "half-flip:events": "Half flip", "possession:ranges": "Possession", "powerslide:events": "Powerslide", diff --git a/js/stat-evaluation-player/src/replayFormatFixtureLoadChild.test-helper.ts b/js/stat-evaluation-player/src/replayFormatFixtureLoadChild.test-helper.ts index 14df8e4ef..6fd877d44 100644 --- a/js/stat-evaluation-player/src/replayFormatFixtureLoadChild.test-helper.ts +++ b/js/stat-evaluation-player/src/replayFormatFixtureLoadChild.test-helper.ts @@ -68,6 +68,10 @@ function parseStatsTimelineParts( decoder, parts.accumulationTracks, ), + expected_goals_tracks: parseJsonBuffer( + decoder, + parts.expectedGoalsTracks, + ), } satisfies StatsTimeline; const statsFrameLookup = createStatsFrameLookup(statsTimeline, undefined, { materializationChunkSize: Math.max(1, statsTimeline.frames.length), @@ -361,7 +365,7 @@ async function main(): Promise { progressStages.push("stats-timeline"); const statsTimelineStartedAt = process.hrtime.bigint(); logProgress(`${fixture}: loading compact stats timeline`); - const statsTimelineParts = get_stats_timeline_json_parts(bytes, 32 * 1024 * 1024); + const statsTimelineParts = get_stats_timeline_json_parts(bytes, 32 * 1024 * 1024, true); logProgress( `${fixture}: compact stats timeline loaded in ${elapsedMs(statsTimelineStartedAt)}ms`, ); diff --git a/js/stat-evaluation-player/src/replayLoader.ts b/js/stat-evaluation-player/src/replayLoader.ts index 800ce41b0..6b4236a10 100644 --- a/js/stat-evaluation-player/src/replayLoader.ts +++ b/js/stat-evaluation-player/src/replayLoader.ts @@ -54,6 +54,7 @@ interface TransferableStatsTimelineParts { activitySummaryBuffer: ArrayBuffer; positioningSummaryBuffer: ArrayBuffer; accumulationTracksBuffer: ArrayBuffer; + expectedGoalsTracksBuffer: ArrayBuffer; frameChunkBuffers: ArrayBuffer[]; } @@ -92,6 +93,10 @@ async function parseStatsTimelineParts( decoder, parts.accumulationTracksBuffer, ); + const expectedGoalsTracks = parseJsonBuffer( + decoder, + parts.expectedGoalsTracksBuffer, + ); onProgress?.({ stage: "decoding-stats", progress: 0.15 }); await waitForNextPaint(); @@ -121,6 +126,7 @@ async function parseStatsTimelineParts( frames, positioning_summary: positioningSummary, accumulation_tracks: accumulationTracks, + expected_goals_tracks: expectedGoalsTracks, }; } diff --git a/js/stat-evaluation-player/src/replayLoader.worker.ts b/js/stat-evaluation-player/src/replayLoader.worker.ts index fee924d91..3d7fd3e4c 100644 --- a/js/stat-evaluation-player/src/replayLoader.worker.ts +++ b/js/stat-evaluation-player/src/replayLoader.worker.ts @@ -103,6 +103,7 @@ self.onmessage = async (event: MessageEvent) => { }, event.data.reportEveryNFrames, 32 * 1024 * 1024, + true, ); postMessageToMain({ @@ -149,6 +150,7 @@ self.onmessage = async (event: MessageEvent) => { activitySummaryBuffer: replayBundle.statsTimelineParts.activitySummary.buffer, positioningSummaryBuffer: replayBundle.statsTimelineParts.positioningSummary.buffer, accumulationTracksBuffer: replayBundle.statsTimelineParts.accumulationTracks.buffer, + expectedGoalsTracksBuffer: replayBundle.statsTimelineParts.expectedGoalsTracks.buffer, frameChunkBuffers, }, }, @@ -161,6 +163,7 @@ self.onmessage = async (event: MessageEvent) => { replayBundle.statsTimelineParts.activitySummary.buffer, replayBundle.statsTimelineParts.positioningSummary.buffer, replayBundle.statsTimelineParts.accumulationTracks.buffer, + replayBundle.statsTimelineParts.expectedGoalsTracks.buffer, ...frameChunkBuffers, ], ); diff --git a/js/stat-evaluation-player/src/scoreboardWindow.test.ts b/js/stat-evaluation-player/src/scoreboardWindow.test.ts new file mode 100644 index 000000000..62be687a5 --- /dev/null +++ b/js/stat-evaluation-player/src/scoreboardWindow.test.ts @@ -0,0 +1,27 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + formatIncidentXg, + formatThreatProbability, + normalizeThreatProbability, +} from "./scoreboardWindow.ts"; + +test("live threat probabilities render as bounded percentages", () => { + assert.equal(formatThreatProbability(0.1264), "12.6%"); + assert.equal(formatThreatProbability(0), "0.0%"); + assert.equal(formatThreatProbability(1), "100.0%"); + assert.equal(formatThreatProbability(null), "--"); + assert.equal(formatThreatProbability(Number.NaN), "--"); + + assert.equal(normalizeThreatProbability(-0.2), 0); + assert.equal(normalizeThreatProbability(1.4), 1); + assert.equal(normalizeThreatProbability(undefined), null); +}); + +test("incident xG renders as a goal-count total", () => { + assert.equal(formatIncidentXg(2.345), "2.35"); + assert.equal(formatIncidentXg(0), "0.00"); + assert.equal(formatIncidentXg(null), "--"); + assert.equal(formatIncidentXg(Number.NaN), "--"); +}); diff --git a/js/stat-evaluation-player/src/scoreboardWindow.ts b/js/stat-evaluation-player/src/scoreboardWindow.ts index a574ac785..8ee50d0dd 100644 --- a/js/stat-evaluation-player/src/scoreboardWindow.ts +++ b/js/stat-evaluation-player/src/scoreboardWindow.ts @@ -2,6 +2,18 @@ import { getTeamClass } from "./statModules.ts"; import { getStatsFrameForReplayFrame, type StatsFrameLookup } from "./statsTimeline.ts"; import type { StatsReplayPlayer } from "./statsReplayPlayer.ts"; +export function normalizeThreatProbability(value: number | null | undefined): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) { + return null; + } + return Math.max(0, Math.min(1, value)); +} + +export function formatThreatProbability(value: number | null | undefined): string { + const normalized = normalizeThreatProbability(value); + return normalized === null ? "--" : `${(normalized * 100).toFixed(1)}%`; +} + export interface ScoreboardWindowOptions { readonly body: HTMLDivElement; getReplayPlayer(): StatsReplayPlayer | null; @@ -36,9 +48,106 @@ export class ScoreboardWindowController { ); body.append(header); + if (replay.players.length === 4) { + body.append( + createThreatReadout( + frame.team_zero?.expected_goals.current_threat, + frame.team_one?.expected_goals.current_threat, + frame.team_zero?.expected_goals.incident_xg, + frame.team_one?.expected_goals.incident_xg, + ), + ); + } } } +function createThreatReadout( + teamZeroThreat: number | null | undefined, + teamOneThreat: number | null | undefined, + teamZeroIncidentXg: number | null | undefined, + teamOneIncidentXg: number | null | undefined, +): HTMLElement { + const readout = document.createElement("section"); + readout.className = "scoreboard-threat"; + readout.title = + "Current values are each team's probability of scoring within five seconds. " + + "Incident xG counts one calibrated peak per dangerous incident and excludes the " + + "goal-result window beginning shortly before the scoring team's final touch."; + + const values = document.createElement("div"); + values.className = "scoreboard-threat-values"; + values.append( + createThreatValue(teamZeroThreat, true), + createThreatLabel(), + createThreatValue(teamOneThreat, false), + ); + + const meter = document.createElement("div"); + meter.className = "scoreboard-threat-meter"; + meter.setAttribute("aria-hidden", "true"); + meter.append( + createThreatMeterHalf(teamZeroThreat, true), + createThreatMeterHalf(teamOneThreat, false), + ); + + const accumulated = document.createElement("div"); + accumulated.className = "scoreboard-threat-accumulated"; + accumulated.append( + createIncidentXgValue(teamZeroIncidentXg, true), + createIncidentXgLabel(), + createIncidentXgValue(teamOneIncidentXg, false), + ); + + readout.append(values, meter, accumulated); + return readout; +} + +function createThreatValue(value: number | null | undefined, isTeamZero: boolean): HTMLElement { + const output = document.createElement("strong"); + output.className = `scoreboard-threat-value ${getTeamClass(isTeamZero)}`; + output.textContent = formatThreatProbability(value); + output.setAttribute( + "aria-label", + `${isTeamZero ? "Blue" : "Orange"} goal probability ${output.textContent}`, + ); + return output; +} + +function createThreatLabel(): HTMLElement { + const label = document.createElement("span"); + label.className = "scoreboard-threat-label"; + label.textContent = "Goal in 5s"; + return label; +} + +function createThreatMeterHalf(value: number | null | undefined, isTeamZero: boolean): HTMLElement { + const half = document.createElement("span"); + half.className = `scoreboard-threat-meter-half ${getTeamClass(isTeamZero)}`; + const fill = document.createElement("span"); + fill.className = "scoreboard-threat-meter-fill"; + fill.style.setProperty("--threat-value", `${normalizeThreatProbability(value) ?? 0}`); + half.append(fill); + return half; +} + +function createIncidentXgLabel(): HTMLElement { + const label = document.createElement("span"); + label.className = "scoreboard-threat-accumulated-label"; + label.textContent = "Incident xG"; + return label; +} + +function createIncidentXgValue(value: number | null | undefined, isTeamZero: boolean): HTMLElement { + const output = document.createElement("span"); + output.className = `scoreboard-threat-accumulated-value ${getTeamClass(isTeamZero)}`; + output.textContent = formatIncidentXg(value); + return output; +} + +export function formatIncidentXg(value: number | null | undefined): string { + return typeof value === "number" && Number.isFinite(value) ? value.toFixed(2) : "--"; +} + function formatScoreboardInteger(value: number | null | undefined): string { return typeof value === "number" && Number.isFinite(value) ? `${Math.round(value)}` : "--"; } diff --git a/js/stat-evaluation-player/src/stat-modules/expectedGoalsModule.ts b/js/stat-evaluation-player/src/stat-modules/expectedGoalsModule.ts new file mode 100644 index 000000000..db3c65f19 --- /dev/null +++ b/js/stat-evaluation-player/src/stat-modules/expectedGoalsModule.ts @@ -0,0 +1,25 @@ +import { buildExpectedGoalsTimelineGraphs } from "../expectedGoalsTimelineGraph.ts"; +import type { StatModule } from "./types.ts"; + +export function createExpectedGoalsModule(): StatModule { + return { + id: "expected_goals", + label: "Expected goals", + + setup() {}, + teardown() {}, + onBeforeRender() {}, + + getTimelineGraphs(ctx) { + return buildExpectedGoalsTimelineGraphs(ctx.statsTimeline); + }, + + renderStats() { + return ""; + }, + + renderFocusedPlayerStats() { + return ""; + }, + }; +} diff --git a/js/stat-evaluation-player/src/stat-modules/index.ts b/js/stat-evaluation-player/src/stat-modules/index.ts index d62e34e5e..5d0f84edb 100644 --- a/js/stat-evaluation-player/src/stat-modules/index.ts +++ b/js/stat-evaluation-player/src/stat-modules/index.ts @@ -37,6 +37,7 @@ import { createRelativePositioningModule, } from "./positioningModules.ts"; import type { BoostPickupFilterController } from "../boostPickupFilters.ts"; +import { createExpectedGoalsModule } from "./expectedGoalsModule.ts"; export { hasBoostPickupAnimationTimelineMatch } from "../boostPickupFilters.ts"; export { @@ -68,6 +69,7 @@ export function createStatModules( createBallHalfModule(), createBallThirdModule(), createRushModule(), + createExpectedGoalsModule(), createRelativePositioningModule(), createAbsolutePositioningModule(), createRotationModule(), diff --git a/js/stat-evaluation-player/src/stat-modules/types.ts b/js/stat-evaluation-player/src/stat-modules/types.ts index cdbf9749e..f4b5a4dbd 100644 --- a/js/stat-evaluation-player/src/stat-modules/types.ts +++ b/js/stat-evaluation-player/src/stat-modules/types.ts @@ -4,6 +4,7 @@ import type { BoostPickupAnimationPickup, ReplayModel, ReplayTimelineEvent, + ReplayTimelineGraph, ReplayTimelineRange, } from "@rlrml/player"; import { getStatsFrameForReplayFrame } from "../statsTimeline.ts"; @@ -26,6 +27,7 @@ export interface StatModule { onBeforeRender(info: FrameRenderInfo): void; getTimelineEvents?(ctx: StatModuleContext): ReplayTimelineEvent[]; getTimelineRanges?(ctx: StatModuleContext): ReplayTimelineRange[]; + getTimelineGraphs?(ctx: StatModuleContext): ReplayTimelineGraph[]; getConfig?(): unknown; applyConfig?(config: unknown): void; includeBoostPickupAnimationPickup?(pickup: BoostPickupAnimationPickup): boolean; diff --git a/js/stat-evaluation-player/src/statsSnapshotFactories.ts b/js/stat-evaluation-player/src/statsSnapshotFactories.ts index 4ab3ad613..1f399bb45 100644 --- a/js/stat-evaluation-player/src/statsSnapshotFactories.ts +++ b/js/stat-evaluation-player/src/statsSnapshotFactories.ts @@ -260,6 +260,13 @@ export function createTeamStatsSnapshot( bumps_inflicted: 0, team_bumps_inflicted: 0, }, + expected_goals: { + current_threat: null, + incident_xg: 0, + xg: 0, + episode_count: 0, + goal_episode_count: 0, + }, }, overrides, ); @@ -625,6 +632,12 @@ export function createPlayerStatsSnapshot( max_bump_strength: 0, cumulative_bump_strength: 0, }, + expected_goals: { + threat_added: 0, + xg: 0, + credited_episode_count: 0, + credited_goal_episode_count: 0, + }, }, overrides, ); diff --git a/js/stat-evaluation-player/src/statsTimelineDerivation.test.ts b/js/stat-evaluation-player/src/statsTimelineDerivation.test.ts index 8c91953e6..8547214cd 100644 --- a/js/stat-evaluation-player/src/statsTimelineDerivation.test.ts +++ b/js/stat-evaluation-player/src/statsTimelineDerivation.test.ts @@ -215,6 +215,7 @@ test("event-derived stats frame lookup applies converted modules incrementally", (applier) => applier.createFrameAccumulator, ).map((applier) => applier.id); assert.deepEqual(incrementalApplierIds, [ + "expected-goals", "event-counts", "boost-track", "core", diff --git a/js/stat-evaluation-player/src/statsTimelineDerivation.ts b/js/stat-evaluation-player/src/statsTimelineDerivation.ts index 71ade32f0..bc8752230 100644 --- a/js/stat-evaluation-player/src/statsTimelineDerivation.ts +++ b/js/stat-evaluation-player/src/statsTimelineDerivation.ts @@ -42,6 +42,10 @@ import { applyDemoEventDerivedStats, createDemoEventDerivedStatsAccumulator, } from "./demoEventDerivation.ts"; +import { + applyExpectedGoalsTrackDerivedStats, + createExpectedGoalsTrackDerivedStatsAccumulator, +} from "./expectedGoalsTrackDerivation.ts"; import { applyFiftyFiftyEventDerivedStats, createFiftyFiftyEventDerivedStatsAccumulator, @@ -157,6 +161,13 @@ const DEFAULT_STATS_FRAME_MAX_MATERIALIZATION_CHUNK_SIZE = 1_200; const STATS_FRAME_MATERIALIZATION_CHUNK_GROWTH_FACTOR = 2; export const STATS_TIMELINE_EVENT_DERIVED_APPLIERS: readonly StatsTimelineEventDerivedApplier[] = [ + { + id: "expected-goals", + playerModules: ["expected_goals"], + teamModules: ["expected_goals"], + apply: applyExpectedGoalsTrackDerivedStats, + createFrameAccumulator: createExpectedGoalsTrackDerivedStatsAccumulator, + }, { id: "event-counts", playerModules: ["event_counts"], diff --git a/js/stat-evaluation-player/src/styles/base.css b/js/stat-evaluation-player/src/styles/base.css index 2826a6e34..20c9d69c3 100644 --- a/js/stat-evaluation-player/src/styles/base.css +++ b/js/stat-evaluation-player/src/styles/base.css @@ -271,6 +271,10 @@ input { border-radius: 999px; } +.scoreboard-window:has(.scoreboard-threat) { + min-width: 15rem; +} + .scoreboard-window-body { display: grid; gap: 0.42rem; @@ -308,6 +312,82 @@ input { text-align: center; } +.scoreboard-threat { + display: grid; + gap: 0.25rem; + min-width: 13.6rem; + padding-top: 0.34rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.scoreboard-threat-values, +.scoreboard-threat-accumulated { + display: grid; + grid-template-columns: minmax(3.3rem, 1fr) auto minmax(3.3rem, 1fr); + align-items: baseline; + gap: 0.45rem; + font-variant-numeric: tabular-nums; +} + +.scoreboard-threat-value { + color: var(--team-accent); + font-size: 0.88rem; + font-weight: 850; + line-height: 1; +} + +.scoreboard-threat-value:first-child, +.scoreboard-threat-accumulated-value:first-child { + text-align: left; +} + +.scoreboard-threat-value:last-child, +.scoreboard-threat-accumulated-value:last-child { + text-align: right; +} + +.scoreboard-threat-label, +.scoreboard-threat-accumulated-label { + color: var(--muted); + font-size: 0.56rem; + font-weight: 800; + letter-spacing: 0.08em; + text-align: center; + text-transform: uppercase; +} + +.scoreboard-threat-meter { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.18rem; +} + +.scoreboard-threat-meter-half { + display: flex; + height: 0.28rem; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); +} + +.scoreboard-threat-meter-half:first-child { + justify-content: flex-end; +} + +.scoreboard-threat-meter-fill { + width: calc(var(--threat-value, 0) * 100%); + border-radius: inherit; + background: var(--team-accent); + box-shadow: 0 0 0.5rem color-mix(in srgb, var(--team-accent) 60%, transparent); + transition: width 100ms linear; +} + +.scoreboard-threat-accumulated-value { + color: color-mix(in srgb, var(--team-accent) 72%, #ffffff); + font-size: 0.65rem; + font-weight: 750; +} + @media (max-width: 900px) { .scoreboard-window { top: 3.25rem; @@ -320,6 +400,10 @@ input { padding: 0.32rem 0.56rem; } + .scoreboard-window:has(.scoreboard-threat) { + min-width: 13.8rem; + } + .scoreboard-scoreline { gap: 0.24rem; } @@ -327,4 +411,8 @@ input { .scoreboard-goal-value { font-size: 0.92rem; } + + .scoreboard-threat { + min-width: 12.4rem; + } } diff --git a/justfile b/justfile index 9af4aa59c..01dd04b66 100644 --- a/justfile +++ b/justfile @@ -79,6 +79,14 @@ ballchasing-fixture replay_id name: test-python: {{nix_shell_bash}} 'cd python && pytest' +# Recreate the threat-model virtual environment exactly from its lockfile. +threat-model-sync: + nix develop .#threat-model -c uv sync --locked --project scripts/threat_model + +# Train using the Nix-built uv lock environment. Pass training arguments after `--`. +threat-model-train *args: + nix run .#train-threat-model -- {{args}} + # Publish main Rust crate to crates.io publish-rust: {{nix_develop}} cargo publish -p subtr-actor @@ -134,7 +142,7 @@ check-readme: # --------------------------------------------------------------------------- # Fast quality gate: Rust quality + JS style. Run this clean before every commit. -check: check-rust check-style +check: check-rust check-style check-threat-model # Rust quality gate (mirrors CI "Rust quality" job, minus tests/release build) check-rust: @@ -148,6 +156,10 @@ check-rust: check-style: cd js && npm run check:style +# Offline threat-model tooling: lock drift and Python style/lint. +check-threat-model: + nix develop .#threat-model -c bash -lc 'cd scripts/threat_model && uv lock --check && ruff check . && ruff format --check .' + # Heavy JS/TS gate: ts-rs binding drift + typecheck. Run when you change JS/TS or an exported (ts-rs) Rust type. Needs built wasm pkg + JS deps. check-types: cd js/player && npm run check diff --git a/python/PYTHON-README.md b/python/PYTHON-README.md index 9939f0565..7c679889e 100644 --- a/python/PYTHON-README.md +++ b/python/PYTHON-README.md @@ -106,7 +106,7 @@ Get header information for the configured ndarray layout. Get structured frame-by-frame game state data with no FPS resampling. -### `get_stats_events(filepath, frame_step_seconds=None) -> dict` +### `get_stats_events(filepath, frame_step_seconds=None, include_expected_goals=False) -> dict` Get the compact modern stats event streams for a replay. @@ -116,6 +116,8 @@ Parameters: - `frame_step_seconds`: optional positive sampling interval in seconds for the accompanying timeline collector. Events are still emitted as compact stat change streams. +- `include_expected_goals`: opt in to the model-backed expected-goals event + streams. It is disabled by default. ### `get_summed_stats(filepath, module_names=None) -> dict` @@ -124,8 +126,9 @@ Get aggregate summed stats for the selected builtin modules. Parameters: - `filepath`: path to the replay file -- `module_names`: optional list of builtin stats module names. By default all - builtin modules are included. +- `module_names`: optional list of builtin stats module names. By default the + non-model-backed modules are included; select `expected_goals` explicitly + to enable it. ### `get_stats_module_names() -> list[str]` @@ -143,12 +146,13 @@ Get the raw stats snapshot payload produced by `StatsCollector`, including: Parameters: - `filepath`: path to the replay file -- `module_names`: optional list of builtin stats module names. By default all - builtin modules are included. +- `module_names`: optional list of builtin stats module names. By default the + non-model-backed modules are included; select `expected_goals` explicitly + to enable it. - `frame_step_seconds`: optional positive sampling interval in seconds. By default every replay frame is captured. -### `get_stats_timeline(filepath, frame_step_seconds=None) -> dict` +### `get_stats_timeline(filepath, frame_step_seconds=None, include_expected_goals=False) -> dict` Get the compact event-backed stats timeline for each replay sample. @@ -161,6 +165,8 @@ Parameters: - `filepath`: path to the replay file - `frame_step_seconds`: optional positive sampling interval in seconds. By default every replay frame is captured. +- `include_expected_goals`: opt in to the model-backed expected-goals tracks. + It is disabled by default. `module_names` filtering is not supported for compact event timelines. Passing it raises `ValueError`; use `get_legacy_stats_timeline` if filtered full diff --git a/python/src/lib.rs b/python/src/lib.rs index e734961b6..21a9a9ba5 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -169,9 +169,15 @@ fn build_stats_collector( fn build_stats_timeline_event_collector( frame_step_seconds: Option, + include_expected_goals: bool, ) -> PyResult { - Ok(subtr_actor::StatsTimelineEventCollector::new() - .with_frame_resolution(parse_stats_frame_resolution(frame_step_seconds)?)) + let collector = subtr_actor::StatsTimelineEventCollector::new(); + let collector = if include_expected_goals { + collector.with_expected_goals() + } else { + collector + }; + Ok(collector.with_frame_resolution(parse_stats_frame_resolution(frame_step_seconds)?)) } fn get_ndarray_with_info_for_type<'p, F>( @@ -396,16 +402,18 @@ fn get_stats_module_names<'p>(py: Python<'p>) -> PyResult> { #[allow(clippy::useless_conversion)] #[pyfunction] -#[pyo3(signature = (filepath, frame_step_seconds=None))] +#[pyo3(signature = (filepath, frame_step_seconds=None, include_expected_goals=false))] fn get_stats_events<'p>( py: Python<'p>, filepath: PathBuf, frame_step_seconds: Option, + include_expected_goals: bool, ) -> PyResult> { let replay = replay_from_filepath(&filepath)?; - let timeline = build_stats_timeline_event_collector(frame_step_seconds)? - .get_replay_stats_timeline_scaffold(&replay) - .map_err(handle_subtr_actor_exception)?; + let timeline = + build_stats_timeline_event_collector(frame_step_seconds, include_expected_goals)? + .get_replay_stats_timeline_scaffold(&replay) + .map_err(handle_subtr_actor_exception)?; let events = serde_json::to_value(timeline.events).map_err(to_py_error)?; Ok(convert_to_py(py, &events)) @@ -452,12 +460,13 @@ fn get_stats_snapshot_data<'p>( #[allow(clippy::useless_conversion)] #[pyfunction] -#[pyo3(signature = (filepath, module_names=None, frame_step_seconds=None))] +#[pyo3(signature = (filepath, module_names=None, frame_step_seconds=None, include_expected_goals=false))] fn get_stats_timeline<'p>( py: Python<'p>, filepath: PathBuf, module_names: Option>, frame_step_seconds: Option, + include_expected_goals: bool, ) -> PyResult> { if module_names.is_some() { return Err(PyErr::new::( @@ -466,9 +475,10 @@ fn get_stats_timeline<'p>( } let replay = replay_from_filepath(&filepath)?; - let timeline = build_stats_timeline_event_collector(frame_step_seconds)? - .get_replay_stats_timeline_scaffold(&replay) - .map_err(handle_subtr_actor_exception)?; + let timeline = + build_stats_timeline_event_collector(frame_step_seconds, include_expected_goals)? + .get_replay_stats_timeline_scaffold(&replay) + .map_err(handle_subtr_actor_exception)?; Ok(convert_to_py( py, diff --git a/python/tests/test_filepath_functions.py b/python/tests/test_filepath_functions.py index ea693efa7..dcda5d0b9 100644 --- a/python/tests/test_filepath_functions.py +++ b/python/tests/test_filepath_functions.py @@ -31,9 +31,8 @@ def test_get_stats_module_names(): def test_get_stats_events(): events = subtr_actor.get_stats_events(REPLAY_PATH) - assert "timeline" in events - assert "boost_ledger" in events - assert "core_player" in events + assert events["events"] + assert all("meta" in event and "payload" in event for event in events["events"]) def test_get_summed_stats_with_module_selection(): @@ -78,9 +77,7 @@ def test_get_stats_timeline_with_sampling(): assert frame["team_one"] == {} for player in frame["players"]: assert set(player) == {"player_id", "name", "is_team_0"} - assert "timeline" in sampled_timeline["events"] - assert "boost_ledger" in sampled_timeline["events"] - assert "core_player" in sampled_timeline["events"] + assert sampled_timeline["events"]["events"] def test_get_legacy_stats_timeline_with_module_filtering(): diff --git a/scripts/threat_model/README.md b/scripts/threat_model/README.md new file mode 100644 index 000000000..dd953baa8 --- /dev/null +++ b/scripts/threat_model/README.md @@ -0,0 +1,132 @@ +# Threat model training pipeline + +Offline pipeline that fits the expected-goals threat model embedded in +`src/stats/calculators/expected_goals_model.rs`. The deployed model is a compact +eight-hidden-unit tanh MLP that estimates the probability that the attacking +team scores within `THREAT_HORIZON_SECONDS` (5s), evaluated per frame on the +`ThreatFeatures` vector. Feature extraction lives only in the Rust ndarray +feature layer (`ThreatFeatures` / `ThreatModelValues`); the runtime model and +training exporter consume that same analysis-backed row state, so train and +inference cannot diverge. A logistic model remains the transparent baseline, +and a gradient-boosted tree remains the nonlinear reference ceiling. + +## Steps + +1. **Fetch a corpus** (optional — a ranked-doubles manifest of local replays works): + + ```sh + python3 fetch_corpus.py --seed 7 # stdlib only + ``` + + Downloads a rank-stratified sample of processed replays from a + rocket-sense instance into a local cache and writes `manifest.jsonl` + there. Configured entirely by environment variables (all optional): + `ROCKET_SENSE_API_TOKEN` (or `ROCKET_SENSE_TOKEN_COMMAND`, defaulting to + `pass show rocket-sense/token`), `ROCKET_SENSE_BASE_URL`, + `THREAT_CORPUS_CACHE` (default `~/.cache/subtr-actor-threat-corpus`), and + `PER_STRATUM` (default 150 per playlist × rank tier), + `THREAT_CORPUS_SEED` (default 7), and `THREAT_CORPUS_PLAYLISTS` (fixed to + `ranked-doubles`). The model and exporter intentionally reject 1v1 and 3v3; + team formats are not mixed into one coefficient set. Selection shuffles + each rank stratum with the recorded seed instead + of biasing toward low replay IDs. The fetcher writes + `manifest.provenance.json` with the seed, playlist set, and SHA-256 hashes of + both the cached listing and resulting manifest. + +2. **Export the dataset** through `NDArrayCollector`: + + ```sh + cargo run --release -p subtr-actor-tools --bin threat_dataset_dump -- \ + --manifest ~/.cache/subtr-actor-threat-corpus/manifest.jsonl \ + --out threat_dataset.csv --sample-hz 4 + ``` + + The collector evaluates its analysis graph on every replay frame, then an + analysis-aware filter materializes one matrix row at the requested cadence + during live play. Each matrix row contains both teams' attacking-normalized + 72-value feature vectors and their streaming model values; the exporter + splits it into one CSV row per team and joins τ-agnostic goal-time columns + for downstream labeling/censoring. The feature row has eight ball/shot + values followed by permutation-invariant summaries of the perspective's + own-team and opponent-team player sets. Every player first receives the + same 16 position, velocity, facing, ball/goal distance, boost, + goal-side/net, dodge-available, and demo inputs. Each two-player set is then + represented by the component-wise mean and absolute spread, so swapping + either pair cannot change the row and no near/far player role exists. + + This dataset path is separate from normal stats collection. Expected goals + is an opt-in stats/timeline module and is not evaluated by default. + +3. **Train and evaluate** (from the repository root): + + ```sh + nix run .#train-threat-model -- scripts/threat_model/threat_dataset.csv \ + --tau 5.0 --gbt \ + --manifest ~/.cache/subtr-actor-threat-corpus/manifest.jsonl \ + --out-dir scripts/threat_model/threat_model_out + ``` + + Dependencies (numpy/pandas/scikit-learn) are isolated from the published + Python bindings in this directory's `pyproject.toml` and pinned by + `uv.lock`. The repository flake builds the same lock as + `packages.threat-model-env` and exposes it through the command above and + `nix develop .#threat-model`. Without Nix, run `uv sync --locked` followed + by `uv run --locked train_threat_model.py ...` from this directory. + + The newest 20% of replays are held out by replay date for evaluation; after + metrics are frozen, the publishable coefficients are refit on the complete + corpus. `metrics.txt` reports log-loss, Brier score, AUC, equal-frequency + calibration, feature-family knockouts, per-rank calibration, and integrated + xG versus actual goals per held-out replay/team. This combination measures + probability accuracy, ranking, forward generalization, feature usefulness, + and count-scale behavior rather than relying on one headline score. + The command also writes + `training_provenance.json` (dataset/manifest and split hashes, seed, Python + and package versions), `coefficients.json`, `model_coefficients.rs`, and + `parity_fixture.rs`. The script evaluates the logistic baseline and smooth + nonlinear model, then refits the MLP on the full corpus and emits its folded + raw-feature weights. `--gbt` also fits a gradient-boosted reference ceiling. + +4. **Embed**: replace + `src/stats/calculators/expected_goals_model_weights.rs` with + `model_coefficients.rs`, bump `THREAT_MODEL_VERSION` (`trained-v`), + refresh the provenance comment and the parity fixture in + `expected_goals_model_tests.rs` from `parity_fixture.rs`, and run + `cargo test --lib expected_goals`. + +## trained-v5 + +Fit 2026-07-15 on 5.22M team rows from 2,544 rank-stratified ranked-doubles +replays (rocket-sense production, tiers 3–22, ~150 per tier where available). +Every player receives the same 16 inputs, including boost, inferred dodge +availability, and demo state; each team pair is aggregated without ordering. +On the newest 509 replays (1.04M rows), the eight-unit tanh MLP reaches 0.13005 +log-loss, 0.03456 Brier score, 0.8936 AUC, and 0.00129 15-bin expected +calibration error. The logistic baseline is 0.13548 log-loss and the GBT +reference ceiling is 0.12816, so the compact smooth model captures roughly +three quarters of the measured nonlinear gap. At 4 Hz it crosses the 15% +incident threshold 6.740 times per live minute versus 6.544 for logistic. +On 1,018 held-out replay/team outcomes, the time-integrated MLP averages 2.795 +xG against 2.834 goals (0.986 ratio), with 0.626 per-team-game correlation +versus 0.594 for logistic. Individual match totals remain noisy and should not +be treated as precise forecasts. + +## xG aggregation + +`V` is calibrated per overlapping 5s window. Summing frame or episode peaks +would repeatedly count the same sustained chance, so the count-scale estimator +is the time integral `Σ V·dt/τ`. `ThreatEpisodeEvent.xg` is the within-episode +integral, team xg is the full-match integral, and the peak remains available as +`peak_value` for display/intensity. + +The calculator also exposes an incident-based team total. An incident opens +above 15% V, remains open until V falls to 5%, and contributes one selected +peak. For goal-ending incidents, samples from 0.5 seconds before the scoring +team's final touch onward are excluded so nearly determined ball trajectories +do not leak into the total. The selected raw peak is multiplied by 0.583503, +fit on the oldest 80% of the ranked-doubles corpus. On the newest 509 held-out +replays (1,018 team-games), incident xG averages 2.891 against 2.797 goals +(3.4% high), with 0.356 per-team-game correlation. The continuous integral +averages 2.827 xG with 0.641 correlation on the same full-replay evaluation. +Both remain available rather than conflating their semantics. Revalidate with +`threat_dataset_dump --episode-summary`. diff --git a/scripts/threat_model/fetch_corpus.py b/scripts/threat_model/fetch_corpus.py new file mode 100644 index 000000000..e052ce695 --- /dev/null +++ b/scripts/threat_model/fetch_corpus.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""Fetch a rank-stratified replay corpus from a rocket-sense instance. + +Phase 1: paginate /api/v1/replays (processed) and cache the listing. +Phase 2: stratified sample by (playlist, median rank tier), download files. +Writes manifest.jsonl compatible with threat_dataset_dump. + +Configuration (command-line flags, with environment-variable defaults): + ROCKET_SENSE_API_TOKEN bearer token for the API + ROCKET_SENSE_TOKEN_COMMAND shell command printing the token on stdout + (default: `pass show rocket-sense/token`) + ROCKET_SENSE_BASE_URL API base (default the production instance) + THREAT_CORPUS_CACHE cache directory + (default ~/.cache/subtr-actor-threat-corpus) + PER_STRATUM replays per (playlist, tier) stratum (150) + THREAT_CORPUS_SEED deterministic sampling seed (7) + THREAT_CORPUS_PLAYLISTS comma-separated playlists + (ranked-doubles) +""" + +import argparse +import concurrent.futures +import hashlib +import json +import os +import pathlib +import random +import statistics +import subprocess +import sys +import urllib.request + +BASE = os.environ.get("ROCKET_SENSE_BASE_URL", "https://rocket-sense.duckdns.org/api/v1") +CACHE = pathlib.Path( + os.environ.get("THREAT_CORPUS_CACHE", pathlib.Path.home() / ".cache/subtr-actor-threat-corpus") +) +LISTING = CACHE / "listing.jsonl" +MANIFEST = CACHE / "manifest.jsonl" +REPLAYS = CACHE / "replays" +DEFAULT_PLAYLISTS = "ranked-doubles" +ALLOWED_PLAYLISTS = {"ranked-doubles"} + + +def resolve_token() -> str: + token = os.environ.get("ROCKET_SENSE_API_TOKEN") + if token: + return token.strip() + command = os.environ.get("ROCKET_SENSE_TOKEN_COMMAND", "pass show rocket-sense/token") + result = subprocess.run(command, shell=True, capture_output=True, text=True, check=True) + token = result.stdout.splitlines()[0].strip() if result.stdout else "" + if not token: + raise SystemExit( + "no API token: set ROCKET_SENSE_API_TOKEN or make ROCKET_SENSE_TOKEN_COMMAND print one" + ) + return token + + +TOKEN: str | None = None + + +def api(path): + req = urllib.request.Request(BASE + path, headers={"Authorization": f"Bearer {TOKEN}"}) + with urllib.request.urlopen(req, timeout=120) as resp: + return json.load(resp) + + +def fetch_listing(): + if LISTING.exists(): + rows = [json.loads(line) for line in LISTING.read_text().splitlines()] + print(f"listing cache: {len(rows)} rows", file=sys.stderr) + return rows + rows = [] + offset = 0 + while True: + page = api(f"/replays?status=processed&count=200&offset={offset}") + for r in page["replays"]: + tiers = [ + p.get("rank_tier") for p in r.get("players") or [] if p.get("rank_tier") is not None + ] + rows.append( + { + "id": r["id"], + "sha256": r.get("file_sha256"), + "playlist": r.get("playlist"), + "replay_date": r.get("replay_date"), + "team_size": (r.get("playlist_metadata") or {}).get("team_size"), + "min_rank_tier": min(tiers) if tiers else None, + "max_rank_tier": max(tiers) if tiers else None, + "median_rank_tier": statistics.median(tiers) if tiers else None, + } + ) + offset = page.get("next_offset") + print(f"listed {len(rows)}/{page['total']}", file=sys.stderr) + if offset is None or len(rows) >= page["total"]: + break + with open(LISTING, "w") as f: + for r in rows: + f.write(json.dumps(r) + "\n") + return rows + + +def stratify(rows, playlists, per_stratum, seed): + seen_sha = set() + strata = {} + for r in rows: + if r["playlist"] not in playlists or r["median_rank_tier"] is None: + continue + replay_identity = r["sha256"] or f"id:{r['id']}" + if replay_identity in seen_sha: + continue + seen_sha.add(replay_identity) + key = (r["playlist"], int(round(r["median_rank_tier"]))) + strata.setdefault(key, []).append(r) + picked = [] + for key in sorted(strata): + bucket = sorted(strata[key], key=lambda r: r["id"]) + seed_material = f"{seed}:{key[0]}:{key[1]}".encode() + stratum_seed = int.from_bytes(hashlib.sha256(seed_material).digest()[:8], "big") + rng = random.Random(stratum_seed) + rng.shuffle(bucket) + take = bucket[:per_stratum] + picked.extend(take) + print(f"stratum {key}: {len(take)}/{len(bucket)}", file=sys.stderr) + return picked + + +def download(r): + dest = REPLAYS / f"{r['id']}.replay" + if dest.exists() and dest.stat().st_size > 0: + return "cached" + try: + req = urllib.request.Request( + f"{BASE}/replays/{r['id']}/file", + headers={"Authorization": f"Bearer {TOKEN}"}, + ) + with urllib.request.urlopen(req, timeout=300) as resp: + data = resp.read() + tmp = dest.with_suffix(".part") + tmp.write_bytes(data) + tmp.rename(dest) + return "ok" + except Exception as e: # noqa: BLE001 + return f"fail: {e}" + + +def parse_args(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--playlist", + action="append", + dest="playlists", + choices=sorted(ALLOWED_PLAYLISTS), + help="playlist to include (fixed to ranked doubles)", + ) + parser.add_argument( + "--per-stratum", type=int, default=int(os.environ.get("PER_STRATUM", "150")) + ) + parser.add_argument("--seed", type=int, default=int(os.environ.get("THREAT_CORPUS_SEED", "7"))) + args = parser.parse_args() + if args.playlists is None: + args.playlists = [ + value.strip() + for value in os.environ.get("THREAT_CORPUS_PLAYLISTS", DEFAULT_PLAYLISTS).split(",") + if value.strip() + ] + unknown = set(args.playlists) - ALLOWED_PLAYLISTS + if unknown: + parser.error(f"unknown playlists: {', '.join(sorted(unknown))}") + if not args.playlists: + parser.error("select at least one playlist") + if args.per_stratum <= 0: + parser.error("--per-stratum must be positive") + return args + + +def file_sha256(path): + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def main(): + global TOKEN + args = parse_args() + TOKEN = resolve_token() + REPLAYS.mkdir(parents=True, exist_ok=True) + rows = fetch_listing() + picked = stratify(rows, set(args.playlists), args.per_stratum, args.seed) + print(f"selected {len(picked)} replays", file=sys.stderr) + + results = {} + with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex: + for r, res in zip(picked, ex.map(download, picked)): + results[r["id"]] = res + fails = {k: v for k, v in results.items() if v.startswith("fail")} + print( + f"downloaded ok/cached={len(results) - len(fails)} failed={len(fails)}", + file=sys.stderr, + ) + for k, v in list(fails.items())[:10]: + print(f" {k}: {v}", file=sys.stderr) + + with open(MANIFEST, "w") as f: + for r in picked: + if results.get(r["id"], "").startswith("fail"): + continue + f.write( + json.dumps( + { + "path": str(REPLAYS / f"{r['id']}.replay"), + "ballchasing_id": r["id"], + "playlist": r["playlist"], + "team_size": r["team_size"], + "min_rank_tier": r["min_rank_tier"], + "max_rank_tier": r["max_rank_tier"], + "median_rank_tier": r["median_rank_tier"], + "date": r["replay_date"], + } + ) + + "\n" + ) + provenance = { + "base_url": BASE, + "listing": str(LISTING), + "listing_sha256": file_sha256(LISTING), + "manifest": str(MANIFEST), + "manifest_sha256": file_sha256(MANIFEST), + "seed": args.seed, + "sampling_algorithm": "sha256-derived per-stratum Python random shuffle v1", + "per_stratum": args.per_stratum, + "playlists": sorted(set(args.playlists)), + "selected_replays": sum( + not results.get(row["id"], "").startswith("fail") for row in picked + ), + } + provenance_path = MANIFEST.with_suffix(".provenance.json") + provenance_path.write_text(json.dumps(provenance, indent=2) + "\n") + print(f"manifest: {MANIFEST}", file=sys.stderr) + print(f"provenance: {provenance_path}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/scripts/threat_model/pyproject.toml b/scripts/threat_model/pyproject.toml new file mode 100644 index 000000000..dd19b5259 --- /dev/null +++ b/scripts/threat_model/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "subtr-actor-threat-model" +version = "0.1.0" +description = "Reproducible offline environment for training the subtr-actor threat model" +requires-python = ">=3.12,<3.13" +dependencies = [ + "numpy>=2.0,<3", + "pandas>=2.2,<4", + "scikit-learn>=1.5,<2", +] + +[dependency-groups] +dev = ["ruff>=0.15,<0.16"] + +[tool.uv] +package = false + +[tool.ruff] +target-version = "py312" +line-length = 100 diff --git a/scripts/threat_model/train_threat_model.py b/scripts/threat_model/train_threat_model.py new file mode 100644 index 000000000..832901259 --- /dev/null +++ b/scripts/threat_model/train_threat_model.py @@ -0,0 +1,516 @@ +#!/usr/bin/env python3 +"""Train the subtr-actor expected-goals threat model. + +Run from this directory with `uv run --locked train_threat_model.py ...`, or +from the repository root with `nix run .#train-threat-model -- ...`. + +Input: CSV from `threat_dataset_dump` with columns + replay_id, playlist, date, min_rank_tier, max_rank_tier, median_rank_tier, + team_size, is_team0, time, + , time_to_next_goal_for, time_to_next_goal_against, + time_to_replay_end + +Label: this team scores within TAU seconds. Rows with no future goal and less +than TAU seconds of remaining observation are censored (dropped). + +Outputs (to --out-dir): + - metrics.txt: log-loss/Brier/AUC vs baseline, overall + per rank tier calibration + - model_coefficients.rs: generated Rust coefficients for the selected model + - parity_fixture.rs: feature rows + expected V for a Rust parity test +""" + +import argparse +import gc +import hashlib +import importlib.metadata +import json +import pathlib +import platform + +import numpy as np +import pandas as pd +from sklearn.ensemble import HistGradientBoostingClassifier +from sklearn.linear_model import LogisticRegression +from sklearn.metrics import brier_score_loss, log_loss, roc_auc_score +from sklearn.neural_network import MLPClassifier +from sklearn.preprocessing import StandardScaler + +META_COLS = [ + "replay_id", + "playlist", + "date", + "min_rank_tier", + "max_rank_tier", + "median_rank_tier", + "team_size", + "is_team0", + "time", + "time_to_next_goal_for", + "time_to_next_goal_against", + "time_to_replay_end", +] + +parser = argparse.ArgumentParser() +parser.add_argument("csv") +parser.add_argument("--tau", type=float, default=5.0) +parser.add_argument("--sample-hz", type=float, default=4.0) +parser.add_argument("--out-dir", default="threat_model_out") +parser.add_argument("--test-frac", type=float, default=0.2) +parser.add_argument("--seed", type=int, default=7) +parser.add_argument( + "--manifest", + help="source replay manifest; its SHA-256 is recorded in training provenance", +) +parser.add_argument("--gbt", action="store_true", help="also fit a GBT ceiling reference") +parser.add_argument( + "--mlp-hidden-units", + type=int, + default=8, + help="hidden width of the smooth model published for Rust", +) +parser.add_argument( + "--mlp-epochs", + type=int, + default=16, + help="fixed MLP training budget (also acts as regularization)", +) +args = parser.parse_args() +if args.sample_hz <= 0: + parser.error("--sample-hz must be positive") +if args.mlp_hidden_units <= 0: + parser.error("--mlp-hidden-units must be positive") +if args.mlp_epochs <= 0: + parser.error("--mlp-epochs must be positive") + + +def sha256_file(path: pathlib.Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +out_dir = pathlib.Path(args.out_dir) +out_dir.mkdir(parents=True, exist_ok=True) + +csv_path = pathlib.Path(args.csv) +df = pd.read_csv(csv_path) +feature_cols = [c for c in df.columns if c not in META_COLS] +print(f"rows={len(df)} features={feature_cols}") + +if set(df["playlist"].dropna().unique()) != {"ranked-doubles"}: + raise ValueError("threat model training is restricted to ranked-doubles") +if not (df["team_size"].dropna() == 2).all(): + raise ValueError("threat model training requires team_size=2 for every row") + +ball_fields = [ + "ball_forward_y", + "ball_dist_to_goal", + "ball_height", + "ball_speed", + "ball_speed_toward_goal", + "goal_open_angle", + "on_target", + "time_to_goal_line", +] +player_fields = [ + "position_x", + "position_y", + "position_z", + "velocity_x", + "velocity_y", + "velocity_z", + "forward_x", + "forward_y", + "forward_z", + "distance_to_ball", + "distance_to_goal", + "boost", + "is_goalside", + "in_net", + "dodge_available", + "demoed", +] +team_sets = ("own_team", "opponent_team") +aggregates = ("mean", "spread") +expected_feature_cols = ball_fields + [ + f"{team}_{aggregate}_{field}" + for team in team_sets + for aggregate in aggregates + for field in player_fields +] +if feature_cols != expected_feature_cols: + raise ValueError( + "unexpected or out-of-order threat feature schema:\n" + f"expected={expected_feature_cols}\nactual={feature_cols}" + ) + +for team in team_sets: + for aggregate in aggregates: + prefix = f"{team}_{aggregate}_" + actual = [name.removeprefix(prefix) for name in feature_cols if name.startswith(prefix)] + if actual != player_fields: + raise ValueError( + f"asymmetric or out-of-order player feature schema for {team}/{aggregate}: {actual}" + ) + +for col in feature_cols: + df[col] = df[col].astype(np.float32) + +# Label + censoring +tau = args.tau +has_goal = df["time_to_next_goal_for"].notna() +label = has_goal & (df["time_to_next_goal_for"] <= tau) +censored = ~has_goal & (df["time_to_replay_end"] < tau) +keep = ~censored +df = df[keep].copy() +y = label[keep].to_numpy() +X = df[feature_cols].to_numpy(dtype=np.float32) +groups = df["replay_id"].to_numpy() + +print(f"kept={len(df)} censored_dropped={int(censored.sum())} base_rate={y.mean():.5f}") + +bad = ~np.isfinite(X).all(axis=0) +if bad.any(): + print( + "WARNING: non-finite feature columns:", + [feature_cols[i] for i in np.where(bad)[0]], + ) + X = np.nan_to_num(X, nan=0.0, posinf=0.0, neginf=0.0) + +dates = pd.to_datetime(df["date"], utc=True, errors="raise") +replay_dates = ( + pd.DataFrame({"replay_id": groups, "date": dates}) + .groupby("replay_id", sort=False)["date"] + .min() + .sort_values() +) +test_replay_count = max(1, int(np.ceil(len(replay_dates) * args.test_frac))) +test_replays = set(replay_dates.index[-test_replay_count:]) +test_mask = np.fromiter((replay_id in test_replays for replay_id in groups), dtype=bool) +train_idx = np.flatnonzero(~test_mask) +test_idx = np.flatnonzero(test_mask) +if not len(train_idx) or not len(test_idx): + raise ValueError("temporal replay split produced an empty partition") +Xtr, Xte, ytr, yte = X[train_idx], X[test_idx], y[train_idx], y[test_idx] +temporal_cutoff = replay_dates.loc[list(test_replays)].min().isoformat() +print( + f"train={len(train_idx)} test={len(test_idx)} " + f"(latest {test_replay_count} replays held out from {temporal_cutoff})" +) + + +def replay_set_hash(indices) -> str: + replay_ids = sorted({str(groups[index]) for index in indices}) + return hashlib.sha256("\n".join(replay_ids).encode()).hexdigest() + + +provenance = { + "dataset": str(csv_path), + "dataset_sha256": sha256_file(csv_path), + "manifest": args.manifest, + "manifest_sha256": sha256_file(pathlib.Path(args.manifest)) if args.manifest else None, + "seed": args.seed, + "test_fraction": args.test_frac, + "split": "temporal_by_replay_date", + "temporal_test_cutoff": temporal_cutoff, + "tau_seconds": tau, + "sample_hz": args.sample_hz, + "publish_model": "mlp-tanh", + "mlp_hidden_units": args.mlp_hidden_units, + "mlp_epochs": args.mlp_epochs, + "train_replay_ids_sha256": replay_set_hash(train_idx), + "test_replay_ids_sha256": replay_set_hash(test_idx), + "python": platform.python_version(), + "packages": { + name: importlib.metadata.version(name) for name in ("numpy", "pandas", "scikit-learn") + }, +} +(out_dir / "training_provenance.json").write_text(json.dumps(provenance, indent=2) + "\n") + +scaler = StandardScaler().fit(Xtr) +Xtr_s, Xte_s = scaler.transform(Xtr), scaler.transform(Xte) + + +def new_mlp(): + return MLPClassifier( + hidden_layer_sizes=(args.mlp_hidden_units,), + activation="tanh", + solver="adam", + alpha=1e-4, + batch_size=4096, + learning_rate_init=1e-3, + max_iter=args.mlp_epochs, + shuffle=True, + random_state=args.seed, + tol=1e-5, + n_iter_no_change=3, + verbose=True, + ) + + +lr = LogisticRegression(max_iter=2000, C=1.0) +lr.fit(Xtr_s, ytr) +p_lr = lr.predict_proba(Xte_s)[:, 1] + +lines = [ + "provenance:", + *(f" {key}={value}" for key, value in provenance.items() if key != "packages"), + *(f" {name}={version}" for name, version in provenance["packages"].items()), + "", +] + + +def report(name, p, yt): + base = np.full_like(p, ytr.mean()) + msg = ( + f"{name}: log_loss={log_loss(yt, p):.5f} brier={brier_score_loss(yt, p):.5f} " + f"auc={roc_auc_score(yt, p):.4f} | baseline log_loss={log_loss(yt, base):.5f} " + f"brier={brier_score_loss(yt, base):.5f}" + ) + print(msg) + lines.append(msg) + + +report("logistic", p_lr, yte) +predictions = {"logistic": p_lr} + + +def feature_knockout(name, predicate, standardized_test): + """Measure reliance by replacing one feature family with its training mean.""" + knocked_out = standardized_test.copy() + columns = [index for index, column in enumerate(feature_cols) if predicate(column)] + knocked_out[:, columns] = 0.0 + probabilities = lr.predict_proba(knocked_out)[:, 1] + delta = log_loss(yte, probabilities) - log_loss(yte, p_lr) + msg = f"knockout {name}: columns={len(columns)} delta_log_loss={delta:+.5f}" + print(msg) + lines.append(msg) + + +feature_knockout("all-player-state", lambda column: column not in ball_fields, Xte_s) +feature_knockout("boost", lambda column: column.endswith("_boost"), Xte_s) +feature_knockout("dodge-available", lambda column: column.endswith("_dodge_available"), Xte_s) +feature_knockout("demo-state", lambda column: column.endswith("_demoed"), Xte_s) + +if args.gbt: + gbt = HistGradientBoostingClassifier(max_iter=300, learning_rate=0.1, random_state=args.seed) + gbt.fit(Xtr, ytr) + p_gbt = gbt.predict_proba(Xte)[:, 1] + report("gbt-ceiling", p_gbt, yte) + predictions["gbt-ceiling"] = p_gbt + del gbt + gc.collect() + +mlp = new_mlp() +mlp.fit(Xtr_s, ytr) +p_mlp = mlp.predict_proba(Xte_s)[:, 1] +report(f"mlp-tanh-{args.mlp_hidden_units}", p_mlp, yte) +predictions[f"mlp-tanh-{args.mlp_hidden_units}"] = p_mlp + + +def calibration_table(p, yt, n_bins=15): + qs = np.quantile(p, np.linspace(0, 1, n_bins + 1)) + qs[0], qs[-1] = -np.inf, np.inf + rows = [] + for lo, hi in zip(qs[:-1], qs[1:]): + m = (p > lo) & (p <= hi) + if m.sum() == 0: + continue + rows.append((p[m].mean(), yt[m].mean(), int(m.sum()))) + return rows + + +for model_name, model_predictions in predictions.items(): + calibration = calibration_table(model_predictions, yte) + ece = sum(abs(pred - obs) * n for pred, obs, n in calibration) / len(yte) + lines.append(f"\n{model_name} expected calibration error (15 equal-frequency bins): {ece:.5f}") + lines.append("calibration (predicted, observed, n):") + for pred, obs, n in calibration: + lines.append(f" {pred:.4f} {obs:.4f} n={n}") + + +def temporal_stability(p, indices): + samples = df.iloc[indices][["replay_id", "is_team0", "time"]].copy() + samples["prediction"] = p + samples = samples.sort_values(["replay_id", "is_team0", "time"]) + group_keys = ["replay_id", "is_team0"] + dt = samples.groupby(group_keys, sort=False)["time"].diff().to_numpy() + delta = samples.groupby(group_keys, sort=False)["prediction"].diff().abs().to_numpy() + max_live_sample_gap = 2.0 / args.sample_hz + contiguous = np.isfinite(dt) & (dt > 0.0) & (dt <= max_live_sample_gap) + contiguous_delta = delta[contiguous] + live_minutes = float(dt[contiguous].sum() / 60.0) + previous = samples.groupby(group_keys, sort=False)["prediction"].shift().to_numpy() + current = samples["prediction"].to_numpy() + crossings = {} + for threshold in (0.05, 0.15): + crossed = contiguous & ( + ((previous <= threshold) & (current > threshold)) + | ((previous > threshold) & (current <= threshold)) + ) + crossings[threshold] = float(crossed.sum() / live_minutes) + return { + "mean_abs_step": float(contiguous_delta.mean()), + "p95_abs_step": float(np.quantile(contiguous_delta, 0.95)), + "p99_abs_step": float(np.quantile(contiguous_delta, 0.99)), + "crossings_005_per_minute": crossings[0.05], + "crossings_015_per_minute": crossings[0.15], + } + + +for model_name, model_predictions in predictions.items(): + stability = temporal_stability(model_predictions, test_idx) + lines.append(f"\n{model_name} held-out temporal stability (contiguous 4 Hz samples):") + lines.append( + " mean_abs_step={mean_abs_step:.5f} p95_abs_step={p95_abs_step:.5f} " + "p99_abs_step={p99_abs_step:.5f}".format(**stability) + ) + lines.append( + " crossings/min: 0.05={crossings_005_per_minute:.3f} " + "0.15={crossings_015_per_minute:.3f}".format(**stability) + ) + +# Count-scale validation: integrate overlapping five-second probabilities for +# each held-out replay/team, then compare against distinct observed goal times. +count_scale_base = df.iloc[test_idx].copy() +group_keys = ["replay_id", "is_team0"] +for model_name, model_predictions in predictions.items(): + count_scale = count_scale_base.copy() + count_scale["prediction"] = model_predictions + count_scale = count_scale.sort_values(["replay_id", "is_team0", "time"]) + count_scale["dt"] = count_scale.groupby(group_keys, sort=False)["time"].diff() + # Rows exist only during live play. A large replay-time gap crosses a kickoff + # or goal stoppage and must contribute zero rather than integrating through + # time in which the model was not evaluated. + max_live_sample_gap = 2.0 / args.sample_hz + count_scale["dt"] = count_scale["dt"].where( + count_scale["dt"].between(0.0, max_live_sample_gap), 0.0 + ) + count_scale["goal_time"] = (count_scale["time"] + count_scale["time_to_next_goal_for"]).round(2) + count_scale["xg_contribution"] = count_scale["prediction"] * count_scale["dt"].fillna(0.0) / tau + team_games = count_scale.groupby(group_keys, sort=False).agg( + predicted_xg=("xg_contribution", "sum"), + goals=("goal_time", lambda values: values.dropna().nunique()), + ) + goal_mean = float(team_games["goals"].mean()) + xg_mean = float(team_games["predicted_xg"].mean()) + count_mae = float((team_games["predicted_xg"] - team_games["goals"]).abs().mean()) + count_rmse = float(np.sqrt(np.mean((team_games["predicted_xg"] - team_games["goals"]) ** 2))) + count_correlation = float(team_games["predicted_xg"].corr(team_games["goals"])) + lines.extend( + [ + f"\n{model_name} held-out replay/team count-scale validation:", + f" team_games={len(team_games)} mean_xg={xg_mean:.4f} mean_goals={goal_mean:.4f} " + f"ratio={xg_mean / goal_mean:.4f}", + f" mae={count_mae:.4f} rmse={count_rmse:.4f} correlation={count_correlation:.4f}", + ] + ) + +# Per-rank calibration on test set +test_df = df.iloc[test_idx] +tiers = test_df["median_rank_tier"].to_numpy() +for model_name, model_predictions in predictions.items(): + lines.append(f"\nper-rank-tier test metrics ({model_name}):") + for tier in sorted(pd.unique(tiers[~pd.isna(tiers)])): + m = tiers == tier + if m.sum() < 2000 or yte[m].sum() < 20: + lines.append(f" tier={tier}: n={int(m.sum())} (too small, skipped)") + continue + lines.append( + f" tier={tier:g}: n={int(m.sum())} base={yte[m].mean():.5f} " + f"pred_mean={model_predictions[m].mean():.5f} " + f"log_loss={log_loss(yte[m], model_predictions[m]):.5f} " + f"brier={brier_score_loss(yte[m], model_predictions[m]):.5f}" + ) + +(out_dir / "metrics.txt").write_text("\n".join(lines) + "\n") +del count_scale, team_games, Xtr_s, Xte_s +gc.collect() + +# After the frozen temporal evaluation, refit the MLP on the full corpus. +# Held-out metrics above always come from models fit only +# on `Xtr`; generated coefficients and parity values below come from this +# full-corpus refit. +publish_scaler = StandardScaler().fit(X) +X_publish = publish_scaler.transform(X) +mu, sigma = publish_scaler.mean_, publish_scaler.scale_ + +# Emitted in the exact shape of expected_goals_model_weights.rs. Literals are +# shortest-f32 so clippy's excessive_precision lint stays quiet. + + +def f32(x) -> str: + text = str(np.float32(x)) + return text if any(c in text for c in ".e") else text + ".0" + + +rust = ["// Generated by scripts/threat_model/train_threat_model.py — do not hand-edit values.\n"] +published_estimator = new_mlp() +published_estimator.fit(X_publish, y) +hidden_weights_z = published_estimator.coefs_[0] +hidden_weights_raw = hidden_weights_z / sigma[:, np.newaxis] +hidden_biases_raw = published_estimator.intercepts_[0] - ((mu / sigma) @ hidden_weights_z) +output_weights = published_estimator.coefs_[1][:, 0] +output_bias = float(published_estimator.intercepts_[1][0]) +input_weights = { + name: [float(weight) for weight in weights] + for name, weights in zip(feature_cols, hidden_weights_raw) +} +artifact = { + "model": "mlp-tanh", + "hidden_units": args.mlp_hidden_units, + "hidden_biases": [float(value) for value in hidden_biases_raw], + "input_weights": input_weights, + "output_bias": output_bias, + "output_weights": [float(value) for value in output_weights], + "tau": tau, + "provenance": provenance, +} +rust.append(f"pub const THREAT_MODEL_HIDDEN_UNITS: usize = {args.mlp_hidden_units};") +rust.append( + "pub const THREAT_MODEL_HIDDEN_BIASES: [f32; THREAT_MODEL_HIDDEN_UNITS] = [" + + ", ".join(f32(value) for value in hidden_biases_raw) + + "];" +) +rust.append( + "pub const THREAT_MODEL_INPUT_WEIGHTS: " + "[(&str, [f32; THREAT_MODEL_HIDDEN_UNITS]); THREAT_FEATURE_COUNT] = [" +) +for name in feature_cols: + weights = ", ".join(f32(value) for value in input_weights[name]) + rust.append(f' ("{name}", [{weights}]),') +rust.append("];") +rust.append(f"pub const THREAT_MODEL_OUTPUT_BIAS: f32 = {f32(output_bias)};") +rust.append( + "pub const THREAT_MODEL_OUTPUT_WEIGHTS: [f32; THREAT_MODEL_HIDDEN_UNITS] = [" + + ", ".join(f32(value) for value in output_weights) + + "];" +) + +(out_dir / "coefficients.json").write_text(json.dumps(artifact, indent=1) + "\n") +(out_dir / "model_coefficients.rs").write_text("\n".join(rust) + "\n") + +# Parity fixture: 6 temporal-test rows spanning the published model's range. +p_publish_test = published_estimator.predict_proba(publish_scaler.transform(Xte))[:, 1] +order = np.argsort(p_publish_test) +picks = [ + order[0], + order[len(order) // 4], + order[len(order) // 2], + order[3 * len(order) // 4], + order[-2], + order[-1], +] +fix = ["// (features in FEATURE_NAMES order, expected_v)"] +for i in picks: + raw = Xte[i] + vals = ", ".join(f32(x) for x in raw) + fix.append(f"(&[{vals}], {f32(p_publish_test[i])}),") +(out_dir / "parity_fixture.rs").write_text("\n".join(fix) + "\n") + +print( + f"wrote {out_dir}/metrics.txt, training_provenance.json, coefficients.json, " + "model_coefficients.rs, parity_fixture.rs" +) diff --git a/scripts/threat_model/uv.lock b/scripts/threat_model/uv.lock new file mode 100644 index 000000000..69cc49ac4 --- /dev/null +++ b/scripts/threat_model/uv.lock @@ -0,0 +1,197 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" +resolution-markers = [ + "sys_platform == 'win32'", + "sys_platform == 'emscripten'", + "sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "narwhals" +version = "2.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/66ed1fc6e38a0c0f330627ec5c5d597990d6159b6712b82af0ad2c65f06c/narwhals-2.23.0.tar.gz", hash = "sha256:13e7ff5b4bb4a2f77b907c2e4d8a76e273dfc1323a3c997440a2f9fd26aed408", size = 656209, upload-time = "2026-07-01T11:21:53.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/4e/afc8c31605cb8be1d3bb4438c4d979daa104dab6306cd2b87abe9c3a7299/narwhals-2.23.0-py3-none-any.whl", hash = "sha256:769e7b9ab102c93d8fa019f6b4cd1a657909b04a20bf6210e5a35aae06814ae9", size = 458938, upload-time = "2026-07-01T11:21:51.677Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/20/75f915ff375d6249e6550ac740fdbbd66159a068fd3af1400ff62036b07a/scikit_learn-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac", size = 8741122, upload-time = "2026-06-02T11:53:24.08Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d5/2b5148f2279196775e1db2aeb85d14b70ac80e7e32b3b28e7ebeafb0901d/scikit_learn-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1", size = 8261512, upload-time = "2026-06-02T11:53:27.183Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ee/5adbc77656b71f9456a2f5a7a9fdb4bcf9207a6b962889f1c2f9323afa4e/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f", size = 8837603, upload-time = "2026-06-02T11:53:30.328Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c2/63fdda36c56437eeb44aaf9493c8bcd62ce230ab1598924fc626ffbfa943/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8", size = 9132097, upload-time = "2026-06-02T11:53:33.456Z" }, + { url = "https://files.pythonhosted.org/packages/83/a4/c8e67227c680e2259c8864ae72ff48b06e16a6f51253a22167aa02a8aa4e/scikit_learn-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283", size = 8211173, upload-time = "2026-06-02T11:53:36.602Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fd/3c0863792e98e67e9184aa4029288a175935eb65443afcd30d4f143450cf/scikit_learn-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60", size = 7867451, upload-time = "2026-06-02T11:53:39.075Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "subtr-actor-threat-model" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "numpy" }, + { name = "pandas" }, + { name = "scikit-learn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "numpy", specifier = ">=2.0,<3" }, + { name = "pandas", specifier = ">=2.2,<4" }, + { name = "scikit-learn", specifier = ">=1.5,<2" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "ruff", specifier = ">=0.15,<0.16" }] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] diff --git a/src/collector/ndarray/analysis_builtins.rs b/src/collector/ndarray/analysis_builtins.rs index 9c04886dd..9d2ad8229 100644 --- a/src/collector/ndarray/analysis_builtins.rs +++ b/src/collector/ndarray/analysis_builtins.rs @@ -2,6 +2,168 @@ use crate::stats::analysis_graph::*; use crate::stats::calculators::*; use crate::*; use boxcars; +use std::marker::PhantomData; +use std::sync::{Arc, OnceLock}; + +fn threat_feature_headers() -> &'static [&'static str] { + static HEADERS: OnceLock> = OnceLock::new(); + HEADERS.get_or_init(|| { + ["team_zero", "team_one"] + .into_iter() + .flat_map(|team| { + ThreatFeatures::FEATURE_NAMES.iter().map(move |feature| { + let header: &'static mut str = + Box::leak(format!("{team}_threat_{feature}").into_boxed_str()); + &*header + }) + }) + .collect() + }) +} + +/// Both teams' attacking-normalized threat inputs as one ndarray row. +pub struct ThreatFeaturesBothTeams(PhantomData); + +impl ThreatFeaturesBothTeams { + pub fn arc_new() -> Arc + Send + Sync> + where + F: TryFrom + Send + Sync + 'static, + >::Error: std::fmt::Debug, + { + Arc::new(Self(PhantomData)) + } +} + +impl AnalysisFeatureAdder for ThreatFeaturesBothTeams +where + F: TryFrom + Send + Sync, + >::Error: std::fmt::Debug, +{ + fn get_column_headers(&self) -> &[&str] { + threat_feature_headers() + } + + fn analysis_dependencies(&self) -> Vec { + vec![threat_features_dependency()] + } + + fn add_features( + &self, + context: &AnalysisFeatureContext<'_>, + _processor: &dyn ProcessorView, + _frame: &boxcars::Frame, + _frame_count: usize, + _current_time: f32, + vector: &mut Vec, + ) -> SubtrActorResult<()> { + let features = context + .state::()? + .current() + .ok_or_else(|| { + SubtrActorError::new(SubtrActorErrorVariant::CallbackError( + "threat features requested outside live play".to_owned(), + )) + })?; + for value in features.iter().flat_map(ThreatFeatures::to_array) { + vector.push(F::try_from(value).map_err(convert_float_conversion_error)?); + } + Ok(()) + } +} + +/// Current model values for both teams. Including this adder also requests the +/// expected-goals analysis node, allowing dataset callers to inspect its goal +/// and episode state after matrix collection. +pub struct ThreatModelValues(PhantomData); + +impl ThreatModelValues { + pub fn arc_new() -> Arc + Send + Sync> + where + F: TryFrom + Send + Sync + 'static, + >::Error: std::fmt::Debug, + { + Arc::new(Self(PhantomData)) + } +} + +impl AnalysisFeatureAdder for ThreatModelValues +where + F: TryFrom + Send + Sync, + >::Error: std::fmt::Debug, +{ + fn get_column_headers(&self) -> &[&str] { + &["team_zero_threat_value", "team_one_threat_value"] + } + + fn analysis_dependencies(&self) -> Vec { + vec![expected_goals_dependency()] + } + + fn add_features( + &self, + context: &AnalysisFeatureContext<'_>, + _processor: &dyn ProcessorView, + _frame: &boxcars::Frame, + _frame_count: usize, + _current_time: f32, + vector: &mut Vec, + ) -> SubtrActorResult<()> { + let values = context + .state::()? + .current_values() + .ok_or_else(|| { + SubtrActorError::new(SubtrActorErrorVariant::CallbackError( + "threat values requested outside live play".to_owned(), + )) + })?; + for value in values { + vector.push(F::try_from(value).map_err(convert_float_conversion_error)?); + } + Ok(()) + } +} + +/// Selects live threat frames at a fixed interval while the backing ndarray +/// analysis graph continues evaluating every replay frame. +pub struct LiveThreatSampleFilter { + sample_interval_seconds: f32, + last_sample_time: Option, +} + +impl LiveThreatSampleFilter { + pub fn new(sample_interval_seconds: f32) -> Self { + Self { + sample_interval_seconds, + last_sample_time: None, + } + } +} + +impl AnalysisFrameFilter for LiveThreatSampleFilter { + fn analysis_dependencies(&self) -> Vec { + vec![threat_features_dependency()] + } + + fn include_frame( + &mut self, + context: &AnalysisFeatureContext<'_>, + _processor: &dyn ProcessorView, + _frame: &boxcars::Frame, + _frame_count: usize, + current_time: f32, + ) -> SubtrActorResult { + if context.state::()?.current().is_none() { + return Ok(false); + } + let due = self.last_sample_time.is_none_or(|last| { + current_time - last >= self.sample_interval_seconds || current_time < last + }); + if due { + self.last_sample_time = Some(current_time); + } + Ok(due) + } +} macro_rules! build_analysis_player_event_indicator { ( diff --git a/src/collector/ndarray/collector.rs b/src/collector/ndarray/collector.rs index 4989de1bb..f36381834 100644 --- a/src/collector/ndarray/collector.rs +++ b/src/collector/ndarray/collector.rs @@ -69,6 +69,7 @@ pub struct NDArrayCollector { feature_adders: NDArrayFeatureAdders, player_feature_adders: NDArrayPlayerFeatureAdders, analysis_runtime: Option, + analysis_frame_filter: Option>, data: Vec, replay_meta: Option, frames_added: usize, @@ -156,12 +157,42 @@ impl NDArrayCollector { player_feature_adders, analysis_runtime: uses_analysis .then(|| NDArrayAnalysisRuntime::new(analysis_dependencies)), + analysis_frame_filter: None, data: Vec::new(), replay_meta: None, frames_added: 0, } } + /// Filters materialized rows using analysis state while continuing to + /// evaluate the analysis graph on every input frame. + pub fn with_analysis_frame_filter( + mut self, + filter: impl AnalysisFrameFilter + Send + 'static, + ) -> Self { + let mut dependencies = self + .feature_adders + .iter() + .flat_map(NDArrayFeatureAdder::analysis_dependencies) + .chain( + self.player_feature_adders + .iter() + .flat_map(NDArrayPlayerFeatureAdder::analysis_dependencies), + ) + .collect::>(); + dependencies.extend(filter.analysis_dependencies()); + self.analysis_runtime = Some(NDArrayAnalysisRuntime::new(dependencies)); + self.analysis_frame_filter = Some(Box::new(filter)); + self + } + + /// Reads analysis state accumulated by analysis-backed feature adders. + pub fn analysis_state(&self) -> Option<&T> { + self.analysis_runtime + .as_ref() + .and_then(|runtime| runtime.graph.state::()) + } + /// Returns the column headers implied by the configured feature adders. pub fn get_column_headers(&self) -> NDArrayColumnHeaders { let global_headers = self @@ -276,6 +307,21 @@ impl Collector for NDArrayCollector { .as_ref() .map(NDArrayAnalysisRuntime::context); + if let Some(filter) = self.analysis_frame_filter.as_mut() { + let include = filter.include_frame( + analysis_context + .as_ref() + .expect("analysis runtime exists for analysis frame filters"), + processor, + frame, + frame_number, + current_time, + )?; + if !include { + return Ok(TimeAdvance::NextFrame); + } + } + for feature_adder in &self.feature_adders { match feature_adder { NDArrayFeatureAdder::Plain(adder) => adder.add_features( @@ -377,6 +423,12 @@ where ReplicatedGameStateTimeRemaining::::arc_new(), )), "BallHasBeenHit" => Some(NDArrayFeatureAdder::plain(BallHasBeenHit::::arc_new())), + "ThreatFeatures" => Some(NDArrayFeatureAdder::analysis( + ThreatFeaturesBothTeams::::arc_new(), + )), + "ThreatModelValues" => Some(NDArrayFeatureAdder::analysis( + ThreatModelValues::::arc_new(), + )), _ => None, } } diff --git a/src/collector/ndarray/collector_tests.rs b/src/collector/ndarray/collector_tests.rs index ca55abcb9..36148dbd6 100644 --- a/src/collector/ndarray/collector_tests.rs +++ b/src/collector/ndarray/collector_tests.rs @@ -6,7 +6,8 @@ use crate::stats::analysis_graph::{AnalysisNode, AnalysisStateContext}; use crate::{Collector, FrameRateDecorator}; use std::path::Path; -const NDARRAY_ANALYSIS_FIXTURE: &str = "assets/post-eac-ranked-duel-2026-04-28-a.replay"; +const NDARRAY_ANALYSIS_FIXTURE: &str = "assets/post-eac-ranked-doubles-2026-04-28.replay"; +const NDARRAY_DUEL_FIXTURE: &str = "assets/post-eac-ranked-duel-2026-04-28-a.replay"; #[derive(Default)] struct SharedAnalysisState { @@ -174,6 +175,68 @@ fn string_feature_names_can_create_analysis_backed_touch_adders() { ); } +#[test] +fn threat_training_rows_use_ndarray_features_with_streaming_model_parity() { + let replay = parse_replay(NDARRAY_ANALYSIS_FIXTURE); + let collector = NDArrayCollector::::from_strings( + &["CurrentTime", "ThreatFeatures", "ThreatModelValues"], + &[], + ) + .expect("threat ndarray features should be registered") + .with_analysis_frame_filter(LiveThreatSampleFilter::new(0.25)) + .process_replay(&replay) + .expect("threat ndarray should process replay"); + + assert!( + collector + .analysis_state::() + .is_some(), + "requesting model values should wire the opt-in expected-goals node" + ); + let (meta, matrix) = collector + .get_meta_and_ndarray() + .expect("threat ndarray should materialize"); + assert!(matrix.nrows() > 5); + assert_eq!(matrix.ncols(), 1 + 2 * THREAT_FEATURE_COUNT + 2); + for (team_index, team_name) in ["team_zero", "team_one"].into_iter().enumerate() { + for (feature_index, feature_name) in ThreatFeatures::FEATURE_NAMES.iter().enumerate() { + assert_eq!( + meta.column_headers.global_headers + [1 + team_index * THREAT_FEATURE_COUNT + feature_index], + format!("{team_name}_threat_{feature_name}") + ); + } + } + + for row in matrix.rows().into_iter().take(8) { + for team_index in 0..2 { + let start = 1 + team_index * THREAT_FEATURE_COUNT; + let values: [f32; THREAT_FEATURE_COUNT] = + std::array::from_fn(|index| row[start + index]); + let model_value = row[1 + 2 * THREAT_FEATURE_COUNT + team_index]; + assert!((threat_value_from_array(&values) - model_value).abs() < 1e-6); + } + } +} + +#[test] +fn threat_ndarray_rejects_non_doubles_replays_by_producing_no_rows() { + let replay = parse_replay(NDARRAY_DUEL_FIXTURE); + let collector = NDArrayCollector::::from_strings( + &["CurrentTime", "ThreatFeatures", "ThreatModelValues"], + &[], + ) + .expect("threat ndarray features should be registered") + .with_analysis_frame_filter(LiveThreatSampleFilter::new(0.25)) + .process_replay(&replay) + .expect("non-doubles replay traversal itself should remain valid"); + let (_, matrix) = collector + .get_meta_and_ndarray() + .expect("empty threat matrix should still have a valid schema"); + assert_eq!(matrix.nrows(), 0); + assert_eq!(matrix.ncols(), 1 + 2 * THREAT_FEATURE_COUNT + 2); +} + #[test] fn player_event_names_create_registered_analysis_indicators() { let event_names = [ diff --git a/src/collector/ndarray/mod.rs b/src/collector/ndarray/mod.rs index 1f22e3552..928f71974 100644 --- a/src/collector/ndarray/mod.rs +++ b/src/collector/ndarray/mod.rs @@ -34,6 +34,12 @@ //! `BallRigidBodyNoVelocities`, `BallRigidBodyQuaternions`, //! `BallRigidBodyBasis`, `InterpolatedBallRigidBodyNoVelocities`, //! `CurrentTime`, `FrameTime`, `SecondsRemaining`, and `BallHasBeenHit`. +//! Model-oriented global names include `ThreatFeatures` (both teams' 17 +//! attacking-normalized inputs) and `ThreatModelValues`. Use +//! [`LiveThreatSampleFilter`] with +//! [`NDArrayCollector::with_analysis_frame_filter`] to keep the analysis graph +//! streaming on every replay frame while materializing only sampled live-play +//! rows. //! Recognized per-player names include `PlayerRigidBody`, //! `PlayerRigidBodyNoVelocities`, `PlayerRelativeBallPosition`, //! `PlayerLocalRelativeBallVelocity`, `PlayerBoost`, `PlayerJump`, diff --git a/src/collector/ndarray/traits.rs b/src/collector/ndarray/traits.rs index e3ac56daf..765b2ea3f 100644 --- a/src/collector/ndarray/traits.rs +++ b/src/collector/ndarray/traits.rs @@ -72,6 +72,23 @@ pub trait AnalysisFeatureAdder { ) -> SubtrActorResult<()>; } +/// Stateful predicate that decides which fully evaluated analysis frames +/// become ndarray rows. The analysis graph still runs on every replay frame, +/// which is essential for temporal calculators; only matrix materialization is +/// filtered. +pub trait AnalysisFrameFilter { + fn analysis_dependencies(&self) -> Vec; + + fn include_frame( + &mut self, + context: &AnalysisFeatureContext<'_>, + processor: &dyn ProcessorView, + frame: &boxcars::Frame, + frame_count: usize, + current_time: f32, + ) -> SubtrActorResult; +} + /// Fixed-width analysis-backed feature extractor with compile-time column count validation. pub trait LengthCheckedAnalysisFeatureAdder { fn get_column_headers_array(&self) -> &[&str; N]; diff --git a/src/collector/stats/builtins.rs b/src/collector/stats/builtins.rs index 2baa8fd30..e4187c612 100644 --- a/src/collector/stats/builtins.rs +++ b/src/collector/stats/builtins.rs @@ -419,6 +419,7 @@ pub fn builtin_stats_module_names() -> &'static [&'static str] { "ball_carry", "controlled_play", "air_dribble", + "expected_goals", "boost", "bump", "movement", @@ -428,6 +429,19 @@ pub fn builtin_stats_module_names() -> &'static [&'static str] { ] } +/// Builtin modules calculated by [`StatsCollector::new`](crate::StatsCollector::new). +/// Model-backed expected goals remains available by name but is opt-in. +pub fn default_stats_module_names() -> &'static [&'static str] { + static NAMES: std::sync::OnceLock> = std::sync::OnceLock::new(); + NAMES.get_or_init(|| { + builtin_stats_module_names() + .iter() + .copied() + .filter(|name| *name != "expected_goals") + .collect() + }) +} + fn graph_state<'a, T: 'static>( graph: &'a AnalysisGraph, module_name: &str, @@ -844,6 +858,17 @@ pub(crate) fn builtin_module_json( events: calculator.events(), }) } + "expected_goals" => { + let calculator = graph_state::(graph, module_name)?; + let projection = projected_stats(graph, module_name)?; + serialize_to_json_value(&serde_json::json!({ + "team_zero": projection.expected_goals.team_stats(true), + "team_one": projection.expected_goals.team_stats(false), + "player_stats": player_stats_entries(projection.expected_goals.player_stats()), + "touch_events": calculator.touch_events(), + "episode_events": calculator.episode_events(), + })) + } "boost" => { let calculator = graph_state::(graph, module_name)?; let projection = projected_stats(graph, module_name)?; @@ -1146,6 +1171,30 @@ pub fn builtin_analysis_node_json( "last_touch_team_is_team_0": state.last_touch_team_is_team_0, }) } + "expected_goals" => { + let state = graph_state::(graph, node_name)?; + json!({ + "config": { + "episode_threshold": state.config().episode_threshold, + "episode_end_threshold": state.config().episode_end_threshold, + "goal_touch_exclusion_seconds": state.config().goal_touch_exclusion_seconds, + "incident_xg_calibration_factor": state.config().incident_xg_calibration_factor, + }, + "current_values": state.current_values(), + "touch_events": state.touch_events(), + "episode_events": state.episode_events(), + "goal_records": state.goal_records(), + "last_frame_time": state.last_frame_time(), + }) + } + "threat_features" => { + let state = graph_state::(graph, node_name)?; + json!({ "current": state.current() }) + } + "player_control_state" => { + let state = graph_state::(graph, node_name)?; + json!({ "players": player_stats_entries(&state.players) }) + } "possession_state" => { let state = graph_state::(graph, node_name)?; json!({ @@ -1529,6 +1578,14 @@ pub(crate) fn builtin_snapshot_frame_json( ), })? } + "expected_goals" => { + let projection = projected_stats(graph, module_name)?; + serialize_to_json_value(&TeamPlayerStatsExport { + team_zero: projection.expected_goals.team_stats(true), + team_one: projection.expected_goals.team_stats(false), + player_stats: player_stats_entries(projection.expected_goals.player_stats()), + })? + } "boost" => { let projection = projected_stats(graph, module_name)?; serialize_to_json_value(&TeamPlayerStatsExport { @@ -1754,6 +1811,15 @@ pub(crate) fn builtin_snapshot_config_json( "half_volley_min_ball_speed": calculator.config().min_ball_speed, }))?) } + "expected_goals" => { + let calculator = graph_state::(graph, module_name)?; + Some(serialize_to_json_value(&serde_json::json!({ + "expected_goals_episode_threshold": calculator.config().episode_threshold, + "expected_goals_episode_end_threshold": calculator.config().episode_end_threshold, + "expected_goals_goal_touch_exclusion_seconds": calculator.config().goal_touch_exclusion_seconds, + "expected_goals_incident_xg_calibration_factor": calculator.config().incident_xg_calibration_factor, + }))?) + } "core" | "backboard" | "ceiling_shot" diff --git a/src/collector/stats/collector.rs b/src/collector/stats/collector.rs index ce078ba1f..71e2c2bdc 100644 --- a/src/collector/stats/collector.rs +++ b/src/collector/stats/collector.rs @@ -6,13 +6,15 @@ use serde_json::{Map, Value}; use crate::collector::frame_resolution::{ FinalStatsFrameAction, StatsFramePersistenceController, StatsFrameResolution, }; -use crate::stats::analysis_graph::{AnalysisGraph, graph_with_builtin_analysis_nodes}; +use crate::stats::analysis_graph::{ + AnalysisGraph, StatsProjectionNode, graph_with_builtin_analysis_nodes, +}; use crate::stats::calculators::ReplayFrameInputBuilder; use crate::*; use super::builtins::{ builtin_module_json, builtin_snapshot_config_json, builtin_snapshot_frame_json, - builtin_stats_module_names, + builtin_stats_module_names, default_stats_module_names, }; use super::playback::{ CapturedStatsData, CapturedStatsFrame, StatsSnapshotData, StatsSnapshotFrame, @@ -48,7 +50,7 @@ struct BuiltinModuleSelection { impl BuiltinModuleSelection { fn all() -> Self { Self { - module_names: builtin_stats_module_names().to_vec(), + module_names: default_stats_module_names().to_vec(), } } @@ -80,16 +82,23 @@ impl BuiltinModuleSelection { } fn graph(&self) -> SubtrActorResult { - if self.module_names == builtin_stats_module_names() { + if self.module_names == default_stats_module_names() { return Ok(build_legacy_timeline_graph()); } - let mut node_names: Vec<&str> = self + let node_names: Vec<&str> = self .module_names .iter() .map(|module_name| stats_module_analysis_node_name(module_name)) .collect(); - node_names.push("stats_projection"); - graph_with_builtin_analysis_nodes(node_names) + let include_expected_goals = self.module_names.contains(&"expected_goals"); + let mut graph = graph_with_builtin_analysis_nodes(node_names)?; + let projection = if include_expected_goals { + StatsProjectionNode::new().with_expected_goals() + } else { + StatsProjectionNode::new() + }; + graph.push_node(projection); + Ok(graph) } fn collected_modules( @@ -319,6 +328,10 @@ impl Default for StatsCollector { } impl StatsCollector { + /// Creates a collector with the default non-model-backed stats modules. + /// `expected_goals` remains available through + /// [`with_builtin_module_names`](Self::with_builtin_module_names), but is + /// deliberately opt-in. pub fn new() -> Self { Self::with_selection_and_frame_transform( BuiltinModuleSelection::all(), diff --git a/src/collector/stats/mod.rs b/src/collector/stats/mod.rs index ed6a26d0f..80e2d3acc 100644 --- a/src/collector/stats/mod.rs +++ b/src/collector/stats/mod.rs @@ -21,6 +21,7 @@ mod types; pub use builtins::{ builtin_analysis_node_json, builtin_analysis_nodes_json, builtin_stats_module_config_json, builtin_stats_module_frame_json, builtin_stats_module_json, builtin_stats_module_names, + default_stats_module_names, }; pub use collector::{ FrameTransform, IdentityFrameTransform, ModuleFrameTransform, StatsCollector, diff --git a/src/collector/stats/playback_frames.rs b/src/collector/stats/playback_frames.rs index be2c6cc4f..2106399c4 100644 --- a/src/collector/stats/playback_frames.rs +++ b/src/collector/stats/playback_frames.rs @@ -159,6 +159,11 @@ impl CapturedStatsData { positioning: self.frame_team_stat_or_default_typed(frame, "positioning", team_key)?, powerslide: self.frame_team_stat_or_default_typed(frame, "powerslide", team_key)?, demo: self.frame_team_stat_or_default_typed(frame, "demo", team_key)?, + expected_goals: self.frame_team_stat_or_default_typed( + frame, + "expected_goals", + team_key, + )?, }) } @@ -292,6 +297,11 @@ impl CapturedStatsData { &player_key, )?, demo: self.frame_player_stat_or_default_typed_by_key(frame, "demo", &player_key)?, + expected_goals: self.frame_player_stat_or_default_typed_by_key( + frame, + "expected_goals", + &player_key, + )?, }) } diff --git a/src/stats/accumulators/expected_goals.rs b/src/stats/accumulators/expected_goals.rs new file mode 100644 index 000000000..9daa9b6d2 --- /dev/null +++ b/src/stats/accumulators/expected_goals.rs @@ -0,0 +1,172 @@ +use super::*; + +pub(crate) fn threat_metric_threat_added_label() -> StatLabel { + StatLabel::new("metric", "threat_added") +} + +pub(crate) fn threat_metric_xg_label() -> StatLabel { + StatLabel::new("metric", "xg") +} + +pub(crate) fn threat_team_label(is_team_0: bool) -> StatLabel { + if is_team_0 { + StatLabel::new("team", "team_zero") + } else { + StatLabel::new("team", "team_one") + } +} + +/// Per-player accumulated threat stats. The labeled sums are the canonical +/// record (`metric=threat_added` / `metric=xg`); the plain fields are +/// convenience projections kept in sync. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)] +#[ts(export)] +pub struct ExpectedGoalsPlayerStats { + /// Sum of positive detection-frame threat deltas (detection-frame V minus + /// preceding-live-frame V, from the toucher's team's perspective) over the + /// player's touches. This is an observed one-frame delta, not a causal + /// estimate of each touch's multi-frame impulse. + pub threat_added: f32, + /// Sum of episode xG time integrals (`sum(V * dt) / tau` per episode) + /// over episodes credited to this player. + pub xg: f32, + pub credited_episode_count: u32, + pub credited_goal_episode_count: u32, + #[serde(default, skip_serializing_if = "LabeledFloatSums::is_empty")] + pub labeled_sums: LabeledFloatSums, +} + +impl ExpectedGoalsPlayerStats { + fn record_touch(&mut self, is_team_0: bool, positive_delta: f32) { + self.labeled_sums.add( + [ + threat_metric_threat_added_label(), + threat_team_label(is_team_0), + ], + positive_delta, + ); + self.sync_projections(); + } + + fn record_episode(&mut self, event: &ThreatEpisodeEvent) { + self.labeled_sums.add( + [ + threat_metric_xg_label(), + threat_team_label(event.team_is_team_0), + ], + event.xg, + ); + self.credited_episode_count += 1; + if event.ended_in_goal { + self.credited_goal_episode_count += 1; + } + self.sync_projections(); + } + + fn sync_projections(&mut self) { + self.threat_added = self + .labeled_sums + .sum_matching(&[threat_metric_threat_added_label()]); + self.xg = self.labeled_sums.sum_matching(&[threat_metric_xg_label()]); + } +} + +/// Per-team accumulated threat stats. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)] +#[ts(export)] +pub struct ExpectedGoalsTeamStats { + /// Instantaneous probability that this team scores within the model's + /// prediction horizon on the current live-play frame. `None` outside live + /// play or when the replay is not a supported 2v2 match. + pub current_threat: Option, + /// Sum of count-calibrated, one-peak contributions from threshold-delimited + /// incidents. For an incident ending in a goal, samples from shortly before + /// the scoring team's final touch onward are excluded to avoid outcome + /// leakage. Raw selected probabilities remain available on the incident + /// events. + pub incident_xg: f32, + /// The team's full-match xG time integral (`sum(V * dt) / tau` over every + /// evaluated live frame, sub-threshold frames included), fed from + /// [`ExpectedGoalsCalculator::team_xg_integrals`]. NOT a sum of episode + /// xG: per-player `xg` sums to LESS than this, because diffuse + /// sub-threshold threat is not attributed to any player (empirically only + /// ~62% of the integral falls inside above-threshold episodes). + pub xg: f32, + pub episode_count: u32, + pub goal_episode_count: u32, +} + +/// Accumulates threat/expected-goals stats over the replay from +/// [`ThreatTouchEvent`]s and [`ThreatEpisodeEvent`]s. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ExpectedGoalsStatsAccumulator { + player_stats: HashMap, + team_stats: [ExpectedGoalsTeamStats; 2], +} + +impl ExpectedGoalsStatsAccumulator { + pub fn new() -> Self { + Self::default() + } + + pub fn player_stats(&self) -> &HashMap { + &self.player_stats + } + + pub fn team_stats(&self, is_team_0: bool) -> &ExpectedGoalsTeamStats { + &self.team_stats[usize::from(!is_team_0)] + } + + /// Fold one detection-frame touch threat delta: only positive deltas count + /// toward the toucher's threat-added sum. The delta is an observed + /// one-frame state change, not a causal multi-frame impulse estimate. + pub fn apply_touch_event(&mut self, event: &ThreatTouchEvent) { + let delta = event.delta(); + if delta <= 0.0 { + return; + } + let Some(player) = event.player.as_ref() else { + return; + }; + self.player_stats + .entry(player.clone()) + .or_default() + .record_touch(event.team_is_team_0, delta); + } + + /// Fold one closed threat episode: its xG (the within-episode time + /// integral) is credited to the episode's player when one is known, and + /// the team's episode counters advance. Team `xg` is NOT summed from + /// episodes -- it is the full-match integral set through + /// [`Self::set_team_xg_integrals`]. + pub fn apply_episode_event(&mut self, event: &ThreatEpisodeEvent) { + if let Some(player) = event.credited_player.as_ref() { + self.player_stats + .entry(player.clone()) + .or_default() + .record_episode(event); + } + let team = &mut self.team_stats[usize::from(!event.team_is_team_0)]; + team.incident_xg += event.incident_xg; + team.episode_count += 1; + if event.ended_in_goal { + team.goal_episode_count += 1; + } + } + + /// Overwrite both teams' accumulated xG with the calculator's full-match + /// integrals (`[team zero, team one]`). Called with the current absolute + /// totals each projection step, so it is idempotent per frame. + pub fn set_team_xg_integrals(&mut self, integrals: [f64; 2]) { + self.team_stats[0].xg = integrals[0] as f32; + self.team_stats[1].xg = integrals[1] as f32; + } + + /// Overwrite both teams' current live threat values. The calculator uses + /// `[team zero, team one]`; `None` clears both values during stoppages and + /// for unsupported replay shapes. + pub fn set_current_values(&mut self, values: Option<[f32; 2]>) { + self.team_stats[0].current_threat = values.map(|values| values[0]); + self.team_stats[1].current_threat = values.map(|values| values[1]); + } +} diff --git a/src/stats/accumulators/mod.rs b/src/stats/accumulators/mod.rs index 5ced4e28d..12d3f34f6 100644 --- a/src/stats/accumulators/mod.rs +++ b/src/stats/accumulators/mod.rs @@ -51,6 +51,8 @@ pub mod dodge_reset; pub use dodge_reset::*; pub mod double_tap; pub use double_tap::*; +pub mod expected_goals; +pub use expected_goals::*; pub mod fifty_fifty; pub use fifty_fifty::*; pub mod flick; diff --git a/src/stats/analysis_graph/graph_tests.rs b/src/stats/analysis_graph/graph_tests.rs index 8443f2db2..a8ec4214a 100644 --- a/src/stats/analysis_graph/graph_tests.rs +++ b/src/stats/analysis_graph/graph_tests.rs @@ -394,6 +394,8 @@ fn builtin_event_metadata_contains_emitted_event_payloads() { "rush", "speed_flip", "territorial_pressure", + "threat_episode", + "threat_touch", "timeline", "touch", "wall_aerial", diff --git a/src/stats/analysis_graph/mod.rs b/src/stats/analysis_graph/mod.rs index 7809b0971..e52acbbad 100644 --- a/src/stats/analysis_graph/mod.rs +++ b/src/stats/analysis_graph/mod.rs @@ -36,13 +36,14 @@ //! is another way to browse them. By role: //! //! - **Per-frame source state** — [`FrameInfoNode`], [`GameplayStateNode`], -//! [`BallFrameStateNode`], [`PlayerFrameStateNode`], [`FrameEventsStateNode`], -//! [`LivePlayNode`], [`SettingsNode`]. +//! [`BallFrameStateNode`], [`PlayerFrameStateNode`], +//! [`PlayerControlStateNode`], [`FrameEventsStateNode`], [`LivePlayNode`], +//! [`SettingsNode`]. //! - **Shared derived state** — [`TouchStateNode`], [`PossessionStateNode`], //! [`PlayerPossessionNode`], [`PossessionNode`], [`BallHalfNode`], //! [`PlayerVerticalStateNode`], [`PositioningNode`], [`RotationNode`], //! [`BackboardBounceStateNode`], [`FiftyFiftyStateNode`], -//! [`ContinuousBallControlNode`]. +//! [`ContinuousBallControlNode`], [`ThreatFeaturesNode`]. //! - **Mechanic detection** — [`FlickNode`], [`HalfFlipNode`], //! [`SpeedFlipNode`], [`WavedashNode`], [`PowerslideNode`], //! [`FlipImpulseNode`], [`DodgeResetNode`], [`WallAerialNode`], @@ -52,7 +53,8 @@ //! - **Play & contest detection** — [`TouchNode`], [`PassNode`], [`CenterNode`], //! [`KickoffNode`], [`BumpNode`], [`DemoNode`], [`RushNode`], //! [`ControlledPlayNode`], [`TerritorialPressureNode`], [`WhiffNode`], -//! [`FiftyFiftyNode`], [`BackboardNode`], [`MovementNode`], [`BoostNode`]. +//! [`FiftyFiftyNode`], [`BackboardNode`], [`MovementNode`], [`BoostNode`], +//! [`ExpectedGoalsNode`]. //! - **Match-level & projection** — [`MatchStatsNode`], goal-tag nodes (e.g. //! [`HalfVolleyGoalNode`] plus the `*_goal` registry names), //! [`StatsProjectionNode`], [`StatsTimelineEventsNode`], @@ -113,6 +115,7 @@ builtin_analysis_nodes! { GameplayStateNode, BallFrameStateNode, PlayerFrameStateNode, + PlayerControlStateNode, FrameEventsStateNode, LivePlayNode, MatchStatsNode, @@ -123,6 +126,8 @@ builtin_analysis_nodes! { ControlledPlayNode, ContinuousBallControlNode, DoubleTapNode, + ThreatFeaturesNode, + ExpectedGoalsNode, FiftyFiftyNode, FiftyFiftyStateNode, KickoffNode, diff --git a/src/stats/analysis_graph/nodes/expected_goals.rs b/src/stats/analysis_graph/nodes/expected_goals.rs new file mode 100644 index 000000000..5b52e79ad --- /dev/null +++ b/src/stats/analysis_graph/nodes/expected_goals.rs @@ -0,0 +1,38 @@ +use super::*; +use crate::stats::calculators::*; +use crate::*; + +/// Evaluates the continuous threat value (expected-goals state value) for both +/// teams each live-play frame and derives touch threat deltas and threat +/// episodes. +pub struct ExpectedGoalsNode { + calculator: ExpectedGoalsCalculator, +} + +impl ExpectedGoalsNode { + pub fn new() -> Self { + Self::with_config(ExpectedGoalsCalculatorConfig::default()) + } + + pub fn with_config(config: ExpectedGoalsCalculatorConfig) -> Self { + Self { + calculator: ExpectedGoalsCalculator::with_config(config), + } + } +} + +impl_analysis_node! { + node = ExpectedGoalsNode, + state = ExpectedGoalsCalculator, + name = "expected_goals", + emitted_events = crate::stats::calculators::EXPECTED_GOALS_EMITTED_EVENTS, + dependencies = [ + frame_info_dependency() => FrameInfo, + gameplay_state_dependency() => GameplayState, + frame_events_state_dependency() => FrameEventsState, + touch_state_dependency() => TouchState, + threat_features_dependency() => ThreatFeaturesState, + ], + call = calculator.update_parts, + finish = calculator.finish_calculation, +} diff --git a/src/stats/analysis_graph/nodes/mod.rs b/src/stats/analysis_graph/nodes/mod.rs index 446dc1fed..043b2e413 100644 --- a/src/stats/analysis_graph/nodes/mod.rs +++ b/src/stats/analysis_graph/nodes/mod.rs @@ -8,17 +8,19 @@ use crate::stats::calculators::{ BumpGoalCalculator, CeilingShotCalculator, CeilingShotGoalCalculator, CenterCalculator, ContinuousBallControlState, ControlledPlayCalculator, CounterAttackGoalCalculator, DemoCalculator, DemoGoalCalculator, DodgeResetCalculator, DoubleTapCalculator, - DoubleTapGoalCalculator, EmptyNetGoalCalculator, FiftyFiftyCalculator, FiftyFiftyState, - FlickCalculator, FlickGoalCalculator, FlipImpulseCalculator, FlipIntoBallGoalCalculator, - FlipResetGoalCalculator, FrameEventsState, FrameInfo, GameplayState, HalfFlipCalculator, - HalfVolleyCalculator, HalfVolleyGoalCalculator, HighAerialGoalCalculator, KickoffCalculator, - KickoffGoalCalculator, LivePlayState, LongDistanceGoalCalculator, LoosePossessionCalculator, - MatchStatsCalculator, MovementCalculator, OneTimerCalculator, OneTimerGoalCalculator, - OwnHalfGoalCalculator, PassCalculator, PassingGoalCalculator, PlayerFrameState, + DoubleTapGoalCalculator, EmptyNetGoalCalculator, ExpectedGoalsCalculator, FiftyFiftyCalculator, + FiftyFiftyState, FlickCalculator, FlickGoalCalculator, FlipImpulseCalculator, + FlipIntoBallGoalCalculator, FlipResetGoalCalculator, FrameEventsState, FrameInfo, + GameplayState, HalfFlipCalculator, HalfVolleyCalculator, HalfVolleyGoalCalculator, + HighAerialGoalCalculator, KickoffCalculator, KickoffGoalCalculator, LivePlayState, + LongDistanceGoalCalculator, LoosePossessionCalculator, MatchStatsCalculator, + MovementCalculator, OneTimerCalculator, OneTimerGoalCalculator, OwnHalfGoalCalculator, + PassCalculator, PassingGoalCalculator, PlayerControlState, PlayerFrameState, PlayerPossessionCalculator, PlayerVerticalState, PositioningCalculator, PossessionCalculator, PossessionState, PowerslideCalculator, RotationCalculator, RushCalculator, SpeedFlipCalculator, - SustainedPressureGoalCalculator, TerritorialPressureCalculator, TouchCalculator, TouchState, - WallAerialCalculator, WallAerialShotCalculator, WavedashCalculator, WhiffCalculator, + SustainedPressureGoalCalculator, TerritorialPressureCalculator, ThreatFeaturesState, + TouchCalculator, TouchState, WallAerialCalculator, WallAerialShotCalculator, + WavedashCalculator, WhiffCalculator, }; pub(crate) mod air_dribble; @@ -38,6 +40,7 @@ pub(crate) mod controlled_play; pub(crate) mod demo; pub(crate) mod dodge_reset; pub(crate) mod double_tap; +pub(crate) mod expected_goals; pub(crate) mod fifty_fifty; pub(crate) mod fifty_fifty_state; pub(crate) mod flick; @@ -56,6 +59,7 @@ pub(crate) mod match_stats; pub(crate) mod movement; pub(crate) mod one_timer; pub(crate) mod pass; +pub(crate) mod player_control_state; pub(crate) mod player_frame_state; pub(crate) mod player_possession; pub(crate) mod player_vertical_state; @@ -71,6 +75,7 @@ pub(crate) mod stats_projection; pub(crate) mod stats_timeline_events; pub(crate) mod stats_timeline_frame; pub(crate) mod territorial_pressure; +pub(crate) mod threat_features; pub(crate) mod touch; pub(crate) mod touch_state; pub(crate) mod wall_aerial; @@ -113,6 +118,8 @@ pub use dodge_reset::DodgeResetNode; #[allow(unused_imports)] pub use double_tap::DoubleTapNode; #[allow(unused_imports)] +pub use expected_goals::ExpectedGoalsNode; +#[allow(unused_imports)] pub use fifty_fifty::FiftyFiftyNode; #[allow(unused_imports)] pub use fifty_fifty_state::FiftyFiftyStateNode; @@ -155,6 +162,8 @@ pub use one_timer::OneTimerNode; #[allow(unused_imports)] pub use pass::PassNode; #[allow(unused_imports)] +pub use player_control_state::PlayerControlStateNode; +#[allow(unused_imports)] pub use player_frame_state::PlayerFrameStateNode; #[allow(unused_imports)] pub use player_possession::PlayerPossessionNode; @@ -187,6 +196,8 @@ pub use stats_timeline_frame::{StatsTimelineFrameNode, StatsTimelineFrameState}; #[allow(unused_imports)] pub use territorial_pressure::TerritorialPressureNode; #[allow(unused_imports)] +pub use threat_features::ThreatFeaturesNode; +#[allow(unused_imports)] pub use touch::TouchNode; #[allow(unused_imports)] pub use touch_state::TouchStateNode; @@ -217,6 +228,10 @@ pub(crate) fn player_frame_state_dependency() -> AnalysisDependency { AnalysisDependency::with_default::(player_frame_state::boxed_default) } +pub(crate) fn player_control_state_dependency() -> AnalysisDependency { + AnalysisDependency::with_default::(player_control_state::boxed_default) +} + pub(crate) fn player_vertical_state_dependency() -> AnalysisDependency { AnalysisDependency::with_default::(player_vertical_state::boxed_default) } @@ -369,6 +384,14 @@ pub(crate) fn double_tap_dependency() -> AnalysisDependency { AnalysisDependency::with_default::(double_tap::boxed_default) } +pub(crate) fn expected_goals_dependency() -> AnalysisDependency { + AnalysisDependency::with_default::(expected_goals::boxed_default) +} + +pub(crate) fn threat_features_dependency() -> AnalysisDependency { + AnalysisDependency::with_default::(threat_features::boxed_default) +} + pub(crate) fn fifty_fifty_dependency() -> AnalysisDependency { AnalysisDependency::with_default::(fifty_fifty::boxed_default) } diff --git a/src/stats/analysis_graph/nodes/player_control_state.rs b/src/stats/analysis_graph/nodes/player_control_state.rs new file mode 100644 index 000000000..f44040164 --- /dev/null +++ b/src/stats/analysis_graph/nodes/player_control_state.rs @@ -0,0 +1,46 @@ +use super::*; +use crate::stats::calculators::*; +use crate::*; + +pub struct PlayerControlStateNode { + state: PlayerControlState, +} + +impl PlayerControlStateNode { + pub fn new() -> Self { + Self { + state: PlayerControlState::default(), + } + } +} + +impl Default for PlayerControlStateNode { + fn default() -> Self { + Self::new() + } +} + +impl AnalysisNode for PlayerControlStateNode { + type State = PlayerControlState; + + fn name(&self) -> &'static str { + "player_control_state" + } + + fn dependencies(&self) -> Vec { + vec![AnalysisDependency::required::()] + } + + fn evaluate(&mut self, ctx: &AnalysisStateContext<'_>) -> SubtrActorResult<()> { + self.state = ctx.get::()?.player_control_state(); + Ok(()) + } + + fn state(&self) -> &Self::State { + &self.state + } +} + +pub(super) fn boxed_default() -> Box { + Box::new(PlayerControlStateNode::new()) +} diff --git a/src/stats/analysis_graph/nodes/stats_projection.rs b/src/stats/analysis_graph/nodes/stats_projection.rs index e1bf2e4a0..342d5c24e 100644 --- a/src/stats/analysis_graph/nodes/stats_projection.rs +++ b/src/stats/analysis_graph/nodes/stats_projection.rs @@ -42,6 +42,7 @@ pub struct StatsProjectionState { pub demo: DemoStatsAccumulator, pub center: CenterStatsAccumulator, pub controlled_play: ControlledPlayStatsAccumulator, + pub expected_goals: ExpectedGoalsStatsAccumulator, } #[derive(Debug, Clone, Default)] @@ -155,6 +156,7 @@ impl IncrementalMovementProjection { /// Folds every mechanic/state calculator's events into per-frame cumulative stat accumulators. #[derive(Debug, Clone, Default)] pub struct StatsProjectionNode { + include_expected_goals: bool, state: StatsProjectionState, cursors: StatsProjectionCursors, movement_projection: IncrementalMovementProjection, @@ -214,6 +216,8 @@ struct StatsProjectionCursors { demo_timeline: usize, center: usize, controlled_play: usize, + expected_goals_touch: usize, + expected_goals_episode: usize, } impl StatsProjectionNode { @@ -221,6 +225,11 @@ impl StatsProjectionNode { Self::default() } + pub fn with_expected_goals(mut self) -> Self { + self.include_expected_goals = true; + self + } + /// Fold buffered possession frames up to (and including) `end_frame` into the /// stats under `label`, the team the resolver assigned them. fn drain_possession_buffer_through(&mut self, end_frame: usize, label: &str) { @@ -644,6 +653,29 @@ impl StatsProjectionNode { { self.state.controlled_play.apply_event(event); } + if self.include_expected_goals { + let expected_goals = ctx.get::()?; + for event in Self::events_since( + &mut self.cursors.expected_goals_touch, + expected_goals.touch_events(), + ) { + self.state.expected_goals.apply_touch_event(event); + } + for event in Self::events_since( + &mut self.cursors.expected_goals_episode, + expected_goals.episode_events(), + ) { + self.state.expected_goals.apply_episode_event(event); + } + // Team xG is the calculator's full-match integral (absolute totals, + // not an event fold), refreshed every projection step. + self.state + .expected_goals + .set_team_xg_integrals(expected_goals.team_xg_integrals()); + self.state + .expected_goals + .set_current_values(expected_goals.current_values()); + } self.finish_sample(); self.previous_live_play = Some(live_play); @@ -659,7 +691,7 @@ impl AnalysisNode for StatsProjectionNode { } fn dependencies(&self) -> NodeDependencies { - vec![ + let mut dependencies = vec![ frame_info_dependency(), gameplay_state_dependency(), live_play_dependency(), @@ -698,7 +730,11 @@ impl AnalysisNode for StatsProjectionNode { demo_dependency(), center_dependency(), controlled_play_dependency(), - ] + ]; + if self.include_expected_goals { + dependencies.push(expected_goals_dependency()); + } + dependencies } fn evaluate(&mut self, ctx: &AnalysisStateContext<'_>) -> SubtrActorResult<()> { diff --git a/src/stats/analysis_graph/nodes/stats_timeline_events.rs b/src/stats/analysis_graph/nodes/stats_timeline_events.rs index 9ec93528d..e73102ef6 100644 --- a/src/stats/analysis_graph/nodes/stats_timeline_events.rs +++ b/src/stats/analysis_graph/nodes/stats_timeline_events.rs @@ -68,17 +68,24 @@ pub const STATS_TIMELINE_MECHANIC_KINDS: &[&str] = &[ /// (finalized events never change or vanish, confirmed events never vanish). pub struct StatsTimelineEventsNode { state: StatsTimelineEventsState, + include_expected_goals: bool, } impl StatsTimelineEventsNode { pub fn new() -> Self { Self { state: StatsTimelineEventsState, + include_expected_goals: false, } } - fn dependencies() -> NodeDependencies { - vec![ + pub fn with_expected_goals(mut self) -> Self { + self.include_expected_goals = true; + self + } + + fn configured_dependencies(&self) -> NodeDependencies { + let mut dependencies = vec![ frame_info_dependency(), gameplay_state_dependency(), live_play_dependency(), @@ -124,7 +131,11 @@ impl StatsTimelineEventsNode { // The goal-context composition node (which itself pulls match // stats plus every goal-tag calculator). goal_context_dependency(), - ] + ]; + if self.include_expected_goals { + dependencies.push(expected_goals_dependency()); + } + dependencies } } @@ -142,7 +153,7 @@ impl AnalysisNode for StatsTimelineEventsNode { } fn dependencies(&self) -> Vec { - Self::dependencies() + self.configured_dependencies() } fn evaluate(&mut self, _ctx: &AnalysisStateContext<'_>) -> SubtrActorResult<()> { diff --git a/src/stats/analysis_graph/nodes/stats_timeline_frame.rs b/src/stats/analysis_graph/nodes/stats_timeline_frame.rs index ddd30029d..cce0b3772 100644 --- a/src/stats/analysis_graph/nodes/stats_timeline_frame.rs +++ b/src/stats/analysis_graph/nodes/stats_timeline_frame.rs @@ -140,6 +140,7 @@ impl StatsTimelineFrameNode { } else { projection.demo.team_one_stats().clone() }, + expected_goals: projection.expected_goals.team_stats(is_team_zero).clone(), }) } @@ -329,6 +330,12 @@ impl StatsTimelineFrameNode { .get(player_id) .cloned() .unwrap_or_default(), + expected_goals: projection + .expected_goals + .player_stats() + .get(player_id) + .cloned() + .unwrap_or_default(), }) } diff --git a/src/stats/analysis_graph/nodes/threat_features.rs b/src/stats/analysis_graph/nodes/threat_features.rs new file mode 100644 index 000000000..1c86bd9c0 --- /dev/null +++ b/src/stats/analysis_graph/nodes/threat_features.rs @@ -0,0 +1,159 @@ +use super::*; +use crate::stats::calculators::*; +use crate::*; +use std::collections::HashSet; + +/// Publishes the canonical two team-relative threat feature rows for the +/// current live frame. This is intentionally separate from model evaluation: +/// ndarray consumers can request the numeric inputs without running the +/// expected-goals state machine. +pub struct ThreatFeaturesNode { + state: ThreatFeaturesState, + is_doubles: bool, + dodge_trackers: HashMap, +} + +#[derive(Debug, Clone, Default)] +struct DodgeAvailabilityTracker { + available: bool, + was_grounded: bool, + takeoff_time: Option, + unlimited_reset: bool, + previous_double_jump_active: bool, + previous_dodge_active: bool, +} + +const STANDARD_DODGE_WINDOW_SECONDS: f32 = 1.25; + +impl ThreatFeaturesNode { + pub fn new() -> Self { + Self { + state: ThreatFeaturesState::default(), + is_doubles: false, + dodge_trackers: HashMap::new(), + } + } + + fn update_dodge_availability( + &mut self, + frame: &FrameInfo, + players: &PlayerFrameState, + controls: &PlayerControlState, + events: &FrameEventsState, + ) -> HashMap { + let refreshes = events + .dodge_refreshed_events + .iter() + .map(|event| event.player.clone()) + .collect::>(); + let mut availability = HashMap::new(); + for player in &players.players { + let grounded = player + .position() + .is_some_and(|position| position.z <= PLAYER_GROUND_Z_THRESHOLD); + let control = controls.sample(&player.player_id); + let tracker = self + .dodge_trackers + .entry(player.player_id.clone()) + .or_default(); + + if grounded { + tracker.available = true; + tracker.takeoff_time = None; + tracker.unlimited_reset = false; + } else if tracker.was_grounded { + tracker.available = true; + tracker.takeoff_time = Some(frame.time); + tracker.unlimited_reset = false; + } + + if !grounded + && !tracker.unlimited_reset + && tracker + .takeoff_time + .is_some_and(|time| frame.time - time > STANDARD_DODGE_WINDOW_SECONDS) + { + tracker.available = false; + } + + let consumed = (control.dodge_active && !tracker.previous_dodge_active) + || (control.double_jump_active && !tracker.previous_double_jump_active); + if consumed { + tracker.available = false; + tracker.unlimited_reset = false; + } + if refreshes.contains(&player.player_id) { + tracker.available = true; + tracker.unlimited_reset = true; + tracker.takeoff_time = None; + } + + tracker.was_grounded = grounded; + tracker.previous_dodge_active = control.dodge_active; + tracker.previous_double_jump_active = control.double_jump_active; + availability.insert(player.player_id.clone(), tracker.available); + } + availability + } +} + +impl Default for ThreatFeaturesNode { + fn default() -> Self { + Self::new() + } +} + +impl AnalysisNode for ThreatFeaturesNode { + type State = ThreatFeaturesState; + + fn name(&self) -> &'static str { + "threat_features" + } + + fn dependencies(&self) -> Vec { + vec![ + ball_frame_state_dependency(), + player_frame_state_dependency(), + player_control_state_dependency(), + frame_info_dependency(), + frame_events_state_dependency(), + live_play_dependency(), + ] + } + + fn on_replay_meta(&mut self, meta: &ReplayMeta) -> SubtrActorResult<()> { + self.is_doubles = meta.team_zero.len() == 2 && meta.team_one.len() == 2; + self.dodge_trackers.clear(); + Ok(()) + } + + fn evaluate(&mut self, ctx: &AnalysisStateContext<'_>) -> SubtrActorResult<()> { + let availability = self.update_dodge_availability( + ctx.get::()?, + ctx.get::()?, + ctx.get::()?, + ctx.get::()?, + ); + self.state.update( + self.is_doubles, + ctx.get::()?, + ctx.get::()?, + ctx.get::()?, + &availability, + ctx.get::()?, + ); + Ok(()) + } + + fn state(&self) -> &Self::State { + &self.state + } +} + +pub(super) fn boxed_default() -> Box { + Box::new(ThreatFeaturesNode::new()) +} + +#[cfg(test)] +#[path = "threat_features_tests.rs"] +mod tests; diff --git a/src/stats/analysis_graph/nodes/threat_features_tests.rs b/src/stats/analysis_graph/nodes/threat_features_tests.rs new file mode 100644 index 000000000..4298cc924 --- /dev/null +++ b/src/stats/analysis_graph/nodes/threat_features_tests.rs @@ -0,0 +1,181 @@ +use super::*; +use boxcars::RemoteId; + +fn rigid_body(z: f32) -> boxcars::RigidBody { + boxcars::RigidBody { + sleeping: false, + location: boxcars::Vector3f { x: 0.0, y: 0.0, z }, + rotation: boxcars::Quaternion { + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + }, + linear_velocity: None, + angular_velocity: None, + } +} + +fn player(player_id: PlayerId, z: f32) -> PlayerSample { + PlayerSample { + player_id, + is_team_0: true, + hitbox: default_car_hitbox(), + rigid_body: Some(rigid_body(z)), + boost_amount: None, + last_boost_amount: None, + boost_active: false, + dodge_active: false, + dodge_torque: None, + powerslide_active: false, + match_goals: None, + match_assists: None, + match_saves: None, + match_shots: None, + match_score: None, + } +} + +fn availability( + node: &mut ThreatFeaturesNode, + player_id: &PlayerId, + time: f32, + z: f32, + control: PlayerControlSample, + refreshed: bool, +) -> bool { + let players = PlayerFrameState { + players: vec![player(player_id.clone(), z)], + }; + let controls = PlayerControlState { + players: HashMap::from([(player_id.clone(), control)]), + }; + let events = FrameEventsState { + dodge_refreshed_events: refreshed + .then(|| DodgeRefreshedEvent { + time, + frame: 0, + player: player_id.clone(), + player_position: None, + is_team_0: true, + counter_value: 1, + }) + .into_iter() + .collect(), + ..FrameEventsState::default() + }; + node.update_dodge_availability( + &FrameInfo { + time, + ..FrameInfo::default() + }, + &players, + &controls, + &events, + )[player_id] +} + +#[test] +fn dodge_is_available_on_ground_then_expires_after_takeoff() { + let player_id = RemoteId::SplitScreen(1); + let mut node = ThreatFeaturesNode::new(); + + assert!(availability( + &mut node, + &player_id, + 0.0, + PLAYER_GROUND_Z_THRESHOLD, + PlayerControlSample::default(), + false, + )); + assert!(availability( + &mut node, + &player_id, + 0.1, + PLAYER_GROUND_Z_THRESHOLD + 100.0, + PlayerControlSample::default(), + false, + )); + assert!(!availability( + &mut node, + &player_id, + 1.36, + PLAYER_GROUND_Z_THRESHOLD + 100.0, + PlayerControlSample::default(), + false, + )); +} + +#[test] +fn dodge_or_double_jump_consumes_availability() { + for control in [ + PlayerControlSample { + dodge_active: true, + ..PlayerControlSample::default() + }, + PlayerControlSample { + double_jump_active: true, + ..PlayerControlSample::default() + }, + ] { + let player_id = RemoteId::SplitScreen(2); + let mut node = ThreatFeaturesNode::new(); + assert!(availability( + &mut node, + &player_id, + 0.0, + PLAYER_GROUND_Z_THRESHOLD, + PlayerControlSample::default(), + false, + )); + assert!(!availability( + &mut node, + &player_id, + 0.1, + PLAYER_GROUND_Z_THRESHOLD + 100.0, + control, + false, + )); + } +} + +#[test] +fn flip_reset_refreshes_and_removes_the_standard_timeout() { + let player_id = RemoteId::SplitScreen(3); + let mut node = ThreatFeaturesNode::new(); + assert!(availability( + &mut node, + &player_id, + 0.0, + PLAYER_GROUND_Z_THRESHOLD, + PlayerControlSample::default(), + false, + )); + assert!(availability( + &mut node, + &player_id, + 2.0, + PLAYER_GROUND_Z_THRESHOLD + 100.0, + PlayerControlSample::default(), + true, + )); + assert!(availability( + &mut node, + &player_id, + 10.0, + PLAYER_GROUND_Z_THRESHOLD + 100.0, + PlayerControlSample::default(), + false, + )); + assert!(!availability( + &mut node, + &player_id, + 10.1, + PLAYER_GROUND_Z_THRESHOLD + 100.0, + PlayerControlSample { + dodge_active: true, + ..PlayerControlSample::default() + }, + false, + )); +} diff --git a/src/stats/calculators/event_definition.rs b/src/stats/calculators/event_definition.rs index cba584cc2..cb4525df3 100644 --- a/src/stats/calculators/event_definition.rs +++ b/src/stats/calculators/event_definition.rs @@ -14,8 +14,9 @@ use super::{ FirstManChangeEvent, FlickEvent, FlipResetEvent, HalfFlipEvent, HalfVolleyEvent, LoosePossessionEvent, MovementEvent, OneTimerEvent, PassEvent, PlayerActivityEvent, PlayerPossessionEvent, PossessionEvent, PowerslideEvent, RespawnEvent, RotationRoleEvent, - RushEvent, SpeedFlipEvent, TerritorialPressureEvent, TimelineEvent, TouchClassificationEvent, - WallAerialEvent, WallAerialShotEvent, WavedashEvent, WhiffEvent, + RushEvent, SpeedFlipEvent, TerritorialPressureEvent, ThreatEpisodeEvent, ThreatTouchEvent, + TimelineEvent, TouchClassificationEvent, WallAerialEvent, WallAerialShotEvent, WavedashEvent, + WhiffEvent, }; use crate::ShadowDefenseEvent; use crate::stats::timeline::{Event, EventPayload, EventScope}; @@ -1145,6 +1146,38 @@ define_stats_event!( ], scope = EventScope::Player ); +// The threat streams are calculator/accumulator observations (and dataset +// export rows) that do not project a timeline stream yet, so they are hidden +// from the review picker until a projection ships. +define_stats_event!( + ThreatTouchEvent, + THREAT_TOUCH_EVENT_DEFINITION, + "threat_touch", + "Threat Touch Delta", + EventCategory::Other, + summary = "The positive detection-frame change in the touching team's continuous threat value (expected-goals state value), not a causal estimate of the touch's multi-frame impulse.", + approach = [ + "Evaluate the versioned compact nonlinear threat model V(state) for both teams on every live-play frame from full ball and player physics state.", + "On each attributed touch, emit the toucher's team's V on the preceding live frame and on the detection frame; positive deltas contribute to threat_added.", + ], + hidden = true, + scope = EventScope::Player +); +define_stats_event!( + ThreatEpisodeEvent, + THREAT_EPISODE_EVENT_DEFINITION, + "threat_episode", + "Threat Episode", + EventCategory::Other, + summary = "A contiguous span where one team's continuous threat value exceeds the episode threshold; its xG is the goal-calibrated time integral of V over the span (peak V is kept separately for intensity), credited to the attacking toucher associated with the peak.", + approach = [ + "Open an episode when a team's threat value V rises above the episode threshold during live play, accumulating the time integral sum(V * dt) / tau alongside the peak V and the most recent attacking toucher when that peak was established.", + "Close on V dropping back under the threshold, a goal for the team (always a goal-outcome close), or a stoppage.", + "Hold stoppage-closed episodes pending until the goal that caused the stoppage is attributed (or the next kickoff/grace window passes), so goal outcomes are not lost to attribution lag.", + ], + hidden = true, + scope = EventScope::Player +); define_stats_event!( Event, TIMELINE_ENVELOPE_EVENT_DEFINITION, @@ -1646,6 +1679,24 @@ pub(crate) const ROTATION_EMITTED_EVENTS: &[EmittedEvent] = &[ ), ]; +// Threat events are consumed by the stats projection (accumulators) and the +// dataset export tooling but are not projected onto the stats timeline, so +// these are detection-state contributions with no owned stream. +pub(crate) const EXPECTED_GOALS_EMITTED_EVENTS: &[EmittedEvent] = &[ + contributed_event( + &THREAT_TOUCH_EVENT_DEFINITION, + "expected_goals", + "ExpectedGoalsNode", + "ExpectedGoalsCalculator", + ), + contributed_event( + &THREAT_EPISODE_EVENT_DEFINITION, + "expected_goals", + "ExpectedGoalsNode", + "ExpectedGoalsCalculator", + ), +]; + pub(crate) const GOAL_CONTEXT_EMITTED_EVENTS: &[EmittedEvent] = &[produced_event( // MatchEnd justification: `pressure_duration_before_goal` attaches // only at finish (it reads the finalized territorial-pressure diff --git a/src/stats/calculators/expected_goals.rs b/src/stats/calculators/expected_goals.rs new file mode 100644 index 000000000..4397a4c9c --- /dev/null +++ b/src/stats/calculators/expected_goals.rs @@ -0,0 +1,1246 @@ +//! Continuous threat / expected-goals state value. +//! +//! Evaluates a state-value function `V(state)` for BOTH teams on every +//! live-play frame: the probability (per the versioned model in +//! [`super::expected_goals_model`]) that the team scores within the next +//! [`THREAT_HORIZON_SECONDS`](super::expected_goals_model::THREAT_HORIZON_SECONDS) +//! seconds, computed from full ball + player physics state. Shots are *not* a +//! gating event -- threat is continuous. Derived observations: +//! +//! - [`ThreatTouchEvent`]: the detection-frame change in the touching team's V +//! (detection-frame V minus the preceding live-frame V, both from the +//! toucher's team's perspective). This is an observed one-frame delta, not a +//! causal estimate of a touch's multi-frame impulse. +//! - [`ThreatEpisodeEvent`]: a threat incident that opens when one team's V +//! exceeds [`THREAT_EPISODE_THRESHOLD`] and remains open until V falls to +//! [`THREAT_EPISODE_END_THRESHOLD`]. The event retains both the time +//! integral +//! `sum(V * dt) / tau` over the span (`tau` = +//! [`THREAT_HORIZON_SECONDS`](super::expected_goals_model::THREAT_HORIZON_SECONDS)), +//! and one incident xG peak. Goal-ending incidents exclude samples from +//! shortly before the scoring team's final touch onward, preventing the +//! model from receiving credit for a result its physics inputs already make +//! nearly inevitable. The ordinary peak survives as `peak_value` for +//! display/intensity. +//! - The per-team full-match integral (over ALL evaluated live frames, not +//! just above-threshold ones) is exposed via +//! [`ExpectedGoalsCalculator::team_xg_integrals`] and is the team's +//! accumulated xG. + +use super::*; + +/// Episode threshold on V. This keeps episodes focused on elevated scoring +/// probability rather than ordinary offensive-half possession. +pub const THREAT_EPISODE_THRESHOLD: f32 = 0.15; + +/// Release threshold for an open threat incident. Keeping this below the +/// opening threshold provides hysteresis: a small dip no longer fragments one +/// developing chance into multiple incidents. +pub const THREAT_EPISODE_END_THRESHOLD: f32 = 0.05; + +/// Multiplicative count calibration applied after selecting one raw peak per +/// incident. Updated only from a replay-grouped, date-held-out corpus audit; +/// the timeline retains the raw selected probability alongside the calibrated +/// contribution so the transformation remains inspectable. +pub const INCIDENT_XG_CALIBRATION_FACTOR: f32 = 0.583_503; + +/// Goal-ending incidents ignore model samples from this long before the +/// scoring team's final touch onward. This removes immediate pre-contact and +/// post-contact outcome leakage while preserving earlier chance development. +pub const GOAL_TOUCH_EXCLUSION_SECONDS: f32 = 0.5; + +/// A ballistic trajectory must cross the goal line within this many seconds +/// for the `on_target` feature to fire. Slightly looser than the shot +/// detector's 2.5s so slower-developing on-frame balls still register; beyond +/// this a wall/ground bounce almost certainly intervenes. +const ON_TARGET_MAX_SECONDS: f32 = 3.0; + +/// Normalizer for player-to-ball / player-to-goal context distances. Distances +/// are clamped to this before dividing, so the features saturate at "too far +/// to matter" rather than scaling with arena diagonals. +const PLAYER_DISTANCE_NORM: f32 = 4000.0; + +/// Maximum in-field distance to the goal center (far corner at ceiling +/// height), used to normalize ball/player goal distances into [0, 1]. +const GOAL_DISTANCE_NORM: f32 = 11_200.0; + +/// Ball-center height of a ball resting on the goal line; the aim point used +/// for goal-center distances and radial speed. +const GOAL_CENTER_Z: f32 = BALL_RADIUS_Z; + +/// Net-region box for each player's `in_net` feature. +const NET_REGION_DEPTH_Y: f32 = 650.0; +const NET_REGION_MARGIN: f32 = 150.0; + +/// How long after a stoppage closes an episode a goal may still resolve it as +/// a goal episode. Goal attribution (score change / goal event) can trail the +/// moment live play ends by the length of the goal-replay intro, so +/// stoppage-closed episodes are held pending until a goal arrives, the next +/// kickoff phase begins, or this grace expires. +const PENDING_EPISODE_GOAL_GRACE_SECONDS: f32 = 10.0; + +/// Two goal records for the same team within this window are treated as one +/// goal (a replicated goal event plus the scoreboard increment). +const GOAL_RECORD_DEDUPE_SECONDS: f32 = 2.0; + +pub const PLAYER_THREAT_FEATURE_COUNT: usize = 16; +pub const TEAM_THREAT_FEATURE_COUNT: usize = 2 * PLAYER_THREAT_FEATURE_COUNT; +pub const THREAT_FEATURE_COUNT: usize = 8 + 2 * TEAM_THREAT_FEATURE_COUNT; + +/// Identically shaped state computed for every player before any team +/// aggregation. No player receives a positional role or a distinct schema. +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize)] +pub struct PlayerThreatFeatures { + pub position_x: f32, + pub position_y: f32, + pub position_z: f32, + pub velocity_x: f32, + pub velocity_y: f32, + pub velocity_z: f32, + pub forward_x: f32, + pub forward_y: f32, + pub forward_z: f32, + pub distance_to_ball: f32, + pub distance_to_goal: f32, + pub boost: f32, + pub is_goalside: f32, + pub in_net: f32, + pub dodge_available: f32, + pub demoed: f32, +} + +impl PlayerThreatFeatures { + pub const FEATURE_NAMES: [&'static str; PLAYER_THREAT_FEATURE_COUNT] = [ + "position_x", + "position_y", + "position_z", + "velocity_x", + "velocity_y", + "velocity_z", + "forward_x", + "forward_y", + "forward_z", + "distance_to_ball", + "distance_to_goal", + "boost", + "is_goalside", + "in_net", + "dodge_available", + "demoed", + ]; + + pub fn to_array(self) -> [f32; PLAYER_THREAT_FEATURE_COUNT] { + [ + self.position_x, + self.position_y, + self.position_z, + self.velocity_x, + self.velocity_y, + self.velocity_z, + self.forward_x, + self.forward_y, + self.forward_z, + self.distance_to_ball, + self.distance_to_goal, + self.boost, + self.is_goalside, + self.in_net, + self.dodge_available, + self.demoed, + ] + } + + fn from_array(values: [f32; PLAYER_THREAT_FEATURE_COUNT]) -> Self { + Self { + position_x: values[0], + position_y: values[1], + position_z: values[2], + velocity_x: values[3], + velocity_y: values[4], + velocity_z: values[5], + forward_x: values[6], + forward_y: values[7], + forward_z: values[8], + distance_to_ball: values[9], + distance_to_goal: values[10], + boost: values[11], + is_goalside: values[12], + in_net: values[13], + dodge_available: values[14], + demoed: values[15], + } + } +} + +/// Permutation-invariant representation of one two-player team. `mean` +/// captures the team's center state and `spread` is the component-wise +/// absolute difference between its players. Both are unchanged when the two +/// players are swapped. +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize)] +pub struct TeamThreatFeatures { + pub mean: PlayerThreatFeatures, + pub spread: PlayerThreatFeatures, +} + +impl TeamThreatFeatures { + fn from_players(first: PlayerThreatFeatures, second: PlayerThreatFeatures) -> Self { + let first = first.to_array(); + let second = second.to_array(); + Self { + mean: PlayerThreatFeatures::from_array(std::array::from_fn(|index| { + (first[index] + second[index]) * 0.5 + })), + spread: PlayerThreatFeatures::from_array(std::array::from_fn(|index| { + (first[index] - second[index]).abs() + })), + } + } + + fn to_array(self) -> [f32; TEAM_THREAT_FEATURE_COUNT] { + let mut values = [0.0; TEAM_THREAT_FEATURE_COUNT]; + values[..PLAYER_THREAT_FEATURE_COUNT].copy_from_slice(&self.mean.to_array()); + values[PLAYER_THREAT_FEATURE_COUNT..].copy_from_slice(&self.spread.to_array()); + values + } +} + +/// Per-frame, per-team threat features, normalized so the team under +/// evaluation always attacks +Y (team one's world is rotated 180 degrees +/// about the z axis). Ball and per-player values are normalized into [-1, 1] +/// or [0, 1]; absolute pair spreads are therefore bounded by [0, 2]. +/// +/// [`ThreatFeatures::FEATURE_NAMES`] and [`ThreatFeatures::to_array`] share +/// one order -- that contract is what the offline training pipeline joins on. +/// This schema is defined only for 2v2. Every player passes through the same +/// feature transform, then each perspective-relative team pair is aggregated +/// without ordering. Team affiliation remains explicit because the output is +/// conditioned on which side is trying to score; there are no learned +/// near/far or first/second-player roles. +#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +pub struct ThreatFeatures { + /// Ball y in the attacking frame / 5120: -1 at own goal line, +1 at the + /// opponent goal line. + pub ball_forward_y: f32, + /// Ball distance to the opponent goal center (0, 5120, ball radius), + /// normalized by [`GOAL_DISTANCE_NORM`]. + pub ball_dist_to_goal: f32, + /// Ball height / ceiling height. + pub ball_height: f32, + /// Ball speed / the 6000 uu/s ball speed cap. + pub ball_speed: f32, + /// Radial ball speed toward the opponent goal center, / 6000 (negative + /// when moving away). + pub ball_speed_toward_goal: f32, + /// Horizontal angle subtended by the goal mouth (posts at x = +/-893) + /// from the ball, / pi. + pub goal_open_angle: f32, + /// 1.0 when the ballistic trajectory (gravity -650 uu/s^2) crosses the + /// goal plane inside the mouth within [`ON_TARGET_MAX_SECONDS`]. + pub on_target: f32, + /// 1 / (1 + seconds until the ball crosses the goal-line plane), or 0 + /// when it is not moving toward the plane. Higher = sooner. + pub time_to_goal_line: f32, + pub own_team: TeamThreatFeatures, + pub opponent_team: TeamThreatFeatures, +} + +/// Canonical per-frame threat feature rows published for ndarray extraction +/// and consumed by the expected-goals model. `current` is `None` outside live +/// play or when the ball state is unavailable. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ThreatFeaturesState { + current: Option<[ThreatFeatures; 2]>, +} + +impl ThreatFeaturesState { + pub fn current(&self) -> Option<&[ThreatFeatures; 2]> { + self.current.as_ref() + } + + pub(crate) fn update( + &mut self, + is_doubles: bool, + ball: &BallFrameState, + players: &PlayerFrameState, + events: &FrameEventsState, + dodge_available: &HashMap, + live_play_state: &LivePlayState, + ) { + let Some(ball_sample) = ball + .sample() + .filter(|_| live_play_state.is_live_play && is_doubles) + else { + self.current = None; + return; + }; + let demoed_players: HashSet = events + .active_demos + .iter() + .map(|demo| demo.victim.clone()) + .collect(); + self.current = compute_threat_features( + ball_sample.position(), + ball_sample.velocity(), + players, + &demoed_players, + dodge_available, + true, + ) + .zip(compute_threat_features( + ball_sample.position(), + ball_sample.velocity(), + players, + &demoed_players, + dodge_available, + false, + )) + .map(|(team_zero, team_one)| [team_zero, team_one]); + } +} + +impl ThreatFeatures { + /// Column names for [`Self::to_array`], in the same order. The offline + /// training pipeline joins on these names. + pub const FEATURE_NAMES: [&'static str; THREAT_FEATURE_COUNT] = [ + "ball_forward_y", + "ball_dist_to_goal", + "ball_height", + "ball_speed", + "ball_speed_toward_goal", + "goal_open_angle", + "on_target", + "time_to_goal_line", + "own_team_mean_position_x", + "own_team_mean_position_y", + "own_team_mean_position_z", + "own_team_mean_velocity_x", + "own_team_mean_velocity_y", + "own_team_mean_velocity_z", + "own_team_mean_forward_x", + "own_team_mean_forward_y", + "own_team_mean_forward_z", + "own_team_mean_distance_to_ball", + "own_team_mean_distance_to_goal", + "own_team_mean_boost", + "own_team_mean_is_goalside", + "own_team_mean_in_net", + "own_team_mean_dodge_available", + "own_team_mean_demoed", + "own_team_spread_position_x", + "own_team_spread_position_y", + "own_team_spread_position_z", + "own_team_spread_velocity_x", + "own_team_spread_velocity_y", + "own_team_spread_velocity_z", + "own_team_spread_forward_x", + "own_team_spread_forward_y", + "own_team_spread_forward_z", + "own_team_spread_distance_to_ball", + "own_team_spread_distance_to_goal", + "own_team_spread_boost", + "own_team_spread_is_goalside", + "own_team_spread_in_net", + "own_team_spread_dodge_available", + "own_team_spread_demoed", + "opponent_team_mean_position_x", + "opponent_team_mean_position_y", + "opponent_team_mean_position_z", + "opponent_team_mean_velocity_x", + "opponent_team_mean_velocity_y", + "opponent_team_mean_velocity_z", + "opponent_team_mean_forward_x", + "opponent_team_mean_forward_y", + "opponent_team_mean_forward_z", + "opponent_team_mean_distance_to_ball", + "opponent_team_mean_distance_to_goal", + "opponent_team_mean_boost", + "opponent_team_mean_is_goalside", + "opponent_team_mean_in_net", + "opponent_team_mean_dodge_available", + "opponent_team_mean_demoed", + "opponent_team_spread_position_x", + "opponent_team_spread_position_y", + "opponent_team_spread_position_z", + "opponent_team_spread_velocity_x", + "opponent_team_spread_velocity_y", + "opponent_team_spread_velocity_z", + "opponent_team_spread_forward_x", + "opponent_team_spread_forward_y", + "opponent_team_spread_forward_z", + "opponent_team_spread_distance_to_ball", + "opponent_team_spread_distance_to_goal", + "opponent_team_spread_boost", + "opponent_team_spread_is_goalside", + "opponent_team_spread_in_net", + "opponent_team_spread_dodge_available", + "opponent_team_spread_demoed", + ]; + + /// The feature vector, ordered exactly as [`Self::FEATURE_NAMES`]. + pub fn to_array(&self) -> [f32; THREAT_FEATURE_COUNT] { + let mut values = [0.0; THREAT_FEATURE_COUNT]; + values[..8].copy_from_slice(&[ + self.ball_forward_y, + self.ball_dist_to_goal, + self.ball_height, + self.ball_speed, + self.ball_speed_toward_goal, + self.goal_open_angle, + self.on_target, + self.time_to_goal_line, + ]); + values[8..8 + TEAM_THREAT_FEATURE_COUNT].copy_from_slice(&self.own_team.to_array()); + values[8 + TEAM_THREAT_FEATURE_COUNT..].copy_from_slice(&self.opponent_team.to_array()); + values + } +} + +/// Rotate a world-frame vector into the attacking frame for the given team: +/// team zero attacks +Y already; team one's world is rotated 180 degrees +/// about the z axis so it also attacks +Y. +fn attacking_frame(vector: glam::Vec3, attacking_team_is_team_0: bool) -> glam::Vec3 { + if attacking_team_is_team_0 { + vector + } else { + glam::Vec3::new(-vector.x, -vector.y, vector.z) + } +} + +/// Seconds until a ballistic trajectory reaches the goal-line plane +/// `y = 5120` in the attacking frame, or `None` when it never does. The +/// horizontal path is straight; only z is under gravity, so the crossing time +/// is the straight-line y solution. +fn seconds_to_goal_plane(position: glam::Vec3, velocity: glam::Vec3) -> Option { + if velocity.y <= f32::EPSILON { + return None; + } + let time = (STANDARD_GOAL_LINE_Y - position.y) / velocity.y; + (time.is_finite() && time >= 0.0).then_some(time) +} + +/// Whether the ballistic trajectory (gravity on z only) crosses the goal +/// plane inside the mouth (`|x| < 893`, `0 < z < 643`) within +/// [`ON_TARGET_MAX_SECONDS`]. +fn ballistic_on_target(position: glam::Vec3, velocity: glam::Vec3) -> bool { + let Some(time) = seconds_to_goal_plane(position, velocity) else { + return false; + }; + if time > ON_TARGET_MAX_SECONDS { + return false; + } + let crossing_x = position.x + velocity.x * time; + let crossing_z = position.z + velocity.z * time + 0.5 * STANDARD_BALL_GRAVITY_Z * time * time; + crossing_x.abs() < STANDARD_GOAL_MOUTH_HALF_WIDTH_X + && crossing_z > 0.0 + && crossing_z < STANDARD_GOAL_MOUTH_HEIGHT_Z +} + +/// Horizontal angle subtended by the goal mouth from the ball, in radians. +/// The angle between the xy-plane rays from the ball to each post. +fn goal_open_angle_radians(position: glam::Vec3) -> f32 { + let to_post = |post_x: f32| { + glam::Vec2::new(post_x - position.x, STANDARD_GOAL_LINE_Y - position.y).normalize_or_zero() + }; + let left = to_post(-STANDARD_GOAL_MOUTH_HALF_WIDTH_X); + let right = to_post(STANDARD_GOAL_MOUTH_HALF_WIDTH_X); + left.dot(right).clamp(-1.0, 1.0).acos() +} + +fn normalized_distance(distance: f32, norm: f32) -> f32 { + (distance / norm).clamp(0.0, 1.0) +} + +/// Compute one team's [`ThreatFeatures`] from raw world-frame ball and player +/// samples. Pure function of its inputs; the export tooling calls this same +/// path through the calculator, so training rows and inference can never +/// diverge. +pub fn compute_threat_features( + ball_position: glam::Vec3, + ball_velocity: glam::Vec3, + players: &PlayerFrameState, + demoed_players: &HashSet, + dodge_available: &HashMap, + attacking_team_is_team_0: bool, +) -> Option { + let ball = attacking_frame(ball_position, attacking_team_is_team_0); + let ball_vel = attacking_frame(ball_velocity, attacking_team_is_team_0); + let goal_center = glam::Vec3::new(0.0, STANDARD_GOAL_LINE_Y, GOAL_CENTER_Z); + let to_goal = goal_center - ball; + let ball_dist_to_goal = to_goal.length(); + let ball_speed_toward_goal = ball_vel.dot(to_goal.normalize_or_zero()); + + let team = |same_team: bool| { + let team = players + .players + .iter() + .filter(|player| (player.is_team_0 == attacking_team_is_team_0) == same_team) + .collect::>(); + (team.len() == 2).then_some(team) + }; + let own_team = team(true)?; + let opponent_team = team(false)?; + + let player_features = |player: &PlayerSample| { + let demoed = demoed_players.contains(&player.player_id); + let position = player + .position() + .map(|value| attacking_frame(value, attacking_team_is_team_0)); + let velocity = player + .velocity() + .map(|value| attacking_frame(value, attacking_team_is_team_0)) + .unwrap_or(glam::Vec3::ZERO); + let forward = player + .rigid_body + .as_ref() + .map(|body| quat_to_glam(&body.rotation) * glam::Vec3::X) + .map(|value| attacking_frame(value, attacking_team_is_team_0)) + .unwrap_or(glam::Vec3::ZERO); + let distance_to_ball = position + .map(|value| normalized_distance((value - ball).length(), PLAYER_DISTANCE_NORM)) + .unwrap_or(1.0); + let distance_to_goal = position + .map(|value| normalized_distance((value - goal_center).length(), GOAL_DISTANCE_NORM)) + .unwrap_or(1.0); + let position = position.unwrap_or(glam::Vec3::ZERO); + let in_net = !demoed + && position.y >= STANDARD_GOAL_LINE_Y - NET_REGION_DEPTH_Y + && position.x.abs() <= STANDARD_GOAL_MOUTH_HALF_WIDTH_X + NET_REGION_MARGIN + && position.z <= STANDARD_GOAL_MOUTH_HEIGHT_Z + NET_REGION_MARGIN; + + PlayerThreatFeatures { + position_x: (position.x / 4096.0).clamp(-1.0, 1.0), + position_y: (position.y / STANDARD_GOAL_LINE_Y).clamp(-1.0, 1.0), + position_z: (position.z / SOCCAR_CEILING_Z).clamp(0.0, 1.0), + velocity_x: (velocity.x / CAR_MAX_SPEED).clamp(-1.0, 1.0), + velocity_y: (velocity.y / CAR_MAX_SPEED).clamp(-1.0, 1.0), + velocity_z: (velocity.z / CAR_MAX_SPEED).clamp(-1.0, 1.0), + forward_x: forward.x.clamp(-1.0, 1.0), + forward_y: forward.y.clamp(-1.0, 1.0), + forward_z: forward.z.clamp(-1.0, 1.0), + distance_to_ball, + distance_to_goal, + boost: (player.boost_amount.unwrap_or(0.0) / BOOST_MAX_AMOUNT).clamp(0.0, 1.0), + is_goalside: f32::from(u8::from(!demoed && position.y >= ball.y)), + in_net: f32::from(u8::from(in_net)), + dodge_available: f32::from(u8::from( + dodge_available + .get(&player.player_id) + .copied() + .unwrap_or(false), + )), + demoed: f32::from(u8::from(demoed)), + } + }; + + Some(ThreatFeatures { + ball_forward_y: (ball.y / STANDARD_GOAL_LINE_Y).clamp(-1.0, 1.0), + ball_dist_to_goal: normalized_distance(ball_dist_to_goal, GOAL_DISTANCE_NORM), + ball_height: (ball.z / SOCCAR_CEILING_Z).clamp(0.0, 1.0), + ball_speed: (ball_vel.length() / STANDARD_BALL_MAX_SPEED).clamp(0.0, 1.0), + ball_speed_toward_goal: (ball_speed_toward_goal / STANDARD_BALL_MAX_SPEED).clamp(-1.0, 1.0), + goal_open_angle: (goal_open_angle_radians(ball) / std::f32::consts::PI).clamp(0.0, 1.0), + on_target: f32::from(u8::from(ballistic_on_target(ball, ball_vel))), + time_to_goal_line: seconds_to_goal_plane(ball, ball_vel) + .map(|time| 1.0 / (1.0 + time)) + .unwrap_or(0.0), + own_team: TeamThreatFeatures::from_players( + player_features(own_team[0]), + player_features(own_team[1]), + ), + opponent_team: TeamThreatFeatures::from_players( + player_features(opponent_team[0]), + player_features(opponent_team[1]), + ), + }) +} + +/// The detection-frame change in the touching team's V for one touch. +/// +/// A touch recovered from the candidate cache can be *backdated*: its contact +/// moment precedes the frame the stats pipeline detected it on. Fields +/// therefore split into two groups: +/// +/// - Contact-time fields, copied from the underlying [`TouchEvent`]: `time`, +/// `frame`, and `touch_id` (the touch's stable join key, when assigned). +/// - Detection-time fields: `detection_frame` / `detection_time` are the live +/// processing frame the touch surfaced on. `value_before` is the toucher's +/// team's V on the live frame *before* detection and `value_after` is the V +/// on the detection frame itself. The ΔV bracketing deliberately anchors on +/// detection rather than contact: V is only evaluated on processed live +/// frames, and the detection frame is the first frame whose ball/player +/// state reflects the touch. Consequently, [`Self::delta`] is an observed +/// one-frame state-value change, not a causal estimate of the touch's full +/// multi-frame impulse. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ThreatTouchEvent { + /// Contact time of the underlying touch (can precede `detection_time`). + pub time: f32, + /// Contact frame of the underlying touch (can precede `detection_frame`). + pub frame: usize, + /// Stable id of the underlying attributed touch, `None` when the touch + /// pipeline had not assigned one. + pub touch_id: Option, + /// Frame the touch was detected on; `value_before`/`value_after` bracket + /// this frame. + pub detection_frame: usize, + /// Time of the detection frame. + pub detection_time: f32, + pub team_is_team_0: bool, + pub player: Option, + pub value_before: f32, + pub value_after: f32, +} + +impl ThreatTouchEvent { + pub fn delta(&self) -> f32 { + self.value_after - self.value_before + } +} + +/// Why a threat episode closed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ts_rs::TS)] +#[ts(export)] +#[serde(rename_all = "snake_case")] +pub enum ThreatEpisodeEndReason { + /// V dropped back to/under the threshold during live play. + ValueDropped, + /// The attacking team scored while the episode was open (or during the + /// post-close goal grace). + Goal, + /// Live play ended (goal replay, other stoppage, or missing ball data) + /// and no goal for the attacking team followed. + Stoppage, + ReplayEnd, +} + +/// A contiguous span where one team's V exceeded the episode threshold. +/// `credited_player` is the attacking team's most recent toucher when the +/// episode reached `peak_value`, `None` when the team had not touched during +/// the live stretch by that point -- team-only credit. Later touches at lower +/// V do not take credit for threat accumulated before them. +#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)] +#[ts(export)] +pub struct ThreatEpisodeEvent { + pub start_time: f32, + pub start_frame: usize, + pub end_time: f32, + pub end_frame: usize, + pub team_is_team_0: bool, + /// The episode's continuous threat integral: `sum(V * dt) / tau` over the + /// span, where `tau` is + /// [`THREAT_HORIZON_SECONDS`](super::expected_goals_model::THREAT_HORIZON_SECONDS). + /// Frames that count: every evaluated live-play frame from the frame that + /// opens the episode through the frame that closes it (for value-drop + /// closes the final sub-threshold frame contributes too; stoppage / + /// replay-end closes end at the last evaluated live frame). This is kept + /// for attribution and comparison with the full-match integral; the + /// incident-based goal-count estimator is [`Self::incident_xg`]. + pub xg: f32, + /// Peak V over the span, kept for display and intensity ranking. + pub peak_value: f32, + /// Frame/time where [`Self::peak_value`] occurred. + pub peak_frame: usize, + pub peak_time: f32, + /// One peak probability contributed to the team's incident-based xG. + /// For ordinary incidents this equals `peak_value`. For a goal-ending + /// incident it is the largest value strictly before + /// `goal_exclusion_start_time`, or zero when the incident only became + /// dangerous inside the excluded window. + pub incident_peak_value: f32, + /// Count-calibrated contribution derived from `incident_peak_value`. + pub incident_xg: f32, + /// Frame/time of the sample selected for [`Self::incident_xg`]. `None` + /// when a goal-ending incident has no eligible pre-touch sample. + pub incident_xg_frame: Option, + pub incident_xg_time: Option, + /// Start of the excluded goal-result window. `None` for non-goal + /// incidents or when no scoring-team touch was available. + pub goal_exclusion_start_time: Option, + #[ts(as = "Option")] + pub credited_player: Option, + pub ended_in_goal: bool, + pub end_reason: ThreatEpisodeEndReason, +} + +/// A goal observed while processing (from replicated goal events, with a +/// scoreboard-increment fallback), kept for episode outcomes and dataset +/// labeling. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ThreatGoalRecord { + pub time: f32, + pub frame: usize, + pub scoring_team_is_team_0: bool, +} + +/// Configuration for [`ExpectedGoalsCalculator`]. +#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)] +#[ts(export)] +pub struct ExpectedGoalsCalculatorConfig { + /// V required to open an incident. + pub episode_threshold: f32, + /// V at or below which an open incident closes. This is deliberately + /// lower than `episode_threshold` to avoid splitting on small dips. + pub episode_end_threshold: f32, + /// Seconds before the scoring team's final touch at which a goal-ending + /// incident stops being eligible for incident xG. + pub goal_touch_exclusion_seconds: f32, + /// Count-scale calibration applied to the selected incident peak. + pub incident_xg_calibration_factor: f32, +} + +impl Default for ExpectedGoalsCalculatorConfig { + fn default() -> Self { + Self { + episode_threshold: THREAT_EPISODE_THRESHOLD, + episode_end_threshold: THREAT_EPISODE_END_THRESHOLD, + goal_touch_exclusion_seconds: GOAL_TOUCH_EXCLUSION_SECONDS, + incident_xg_calibration_factor: INCIDENT_XG_CALIBRATION_FACTOR, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct ThreatPeakCandidate { + frame: usize, + time: f32, + value: f32, +} + +#[derive(Debug, Clone, PartialEq)] +struct ActiveThreatEpisode { + start_time: f32, + start_frame: usize, + peak_value: f32, + peak_frame: usize, + peak_time: f32, + /// Monotonically increasing running maxima. The last candidate before a + /// goal-exclusion cutoff is the incident's censored peak without retaining + /// every frame in memory. + peak_candidates: Vec, + /// Running `sum(V * dt) / tau` over the episode's evaluated live frames. + xg_integral: f64, + /// Most recent attacking toucher when `peak_value` was established. + credited_player: Option, +} + +/// A stoppage-closed episode held until its goal outcome is known (a goal for +/// the team resolves it as a goal episode; the next kickoff phase or the goal +/// grace expiring resolves it as a plain stoppage). +#[derive(Debug, Clone, PartialEq)] +struct PendingThreatEpisode { + event: ThreatEpisodeEvent, + peak_candidates: Vec, + scoring_team_last_touch_time: Option, + closed_at: f32, +} + +#[derive(Debug, Clone, Default, PartialEq)] +struct TeamThreatState { + active_episode: Option, + pending_episode: Option, + /// Most recent toucher on this team within the current live stretch. + last_toucher: Option, + /// Contact time of that team's most recent touch in the live stretch, + /// retained separately because some authoritative touches lack a player. + last_touch_time: Option, +} + +/// Evaluates the continuous threat value for both teams each live-play frame +/// and derives touch threat deltas and threat episodes. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ExpectedGoalsCalculator { + config: ExpectedGoalsCalculatorConfig, + touch_events: EventStream, + episode_events: EventStream, + goal_records: Vec, + team_states: [TeamThreatState; 2], + /// Per-team full-match `sum(V * dt) / tau`, accumulated over EVERY + /// evaluated live-play frame (sub-threshold frames included), indexed + /// `[team zero, team one]`. This is the calibrated team xG. + team_xg_integrals: [f64; 2], + /// Both teams' V on the previous live frame, if it was live. + previous_values: Option<[f32; 2]>, + last_score: Option<(i32, i32)>, + last_frame: Option<(usize, f32)>, + was_live: bool, +} + +fn team_index(is_team_0: bool) -> usize { + usize::from(!is_team_0) +} + +impl ExpectedGoalsCalculator { + pub fn new() -> Self { + Self::with_config(ExpectedGoalsCalculatorConfig::default()) + } + + pub fn with_config(config: ExpectedGoalsCalculatorConfig) -> Self { + assert!( + config.episode_threshold.is_finite() && (0.0..=1.0).contains(&config.episode_threshold), + "episode_threshold must be a finite probability" + ); + assert!( + config.episode_end_threshold.is_finite() + && (0.0..=config.episode_threshold).contains(&config.episode_end_threshold), + "episode_end_threshold must be finite and no greater than episode_threshold" + ); + assert!( + config.goal_touch_exclusion_seconds.is_finite() + && config.goal_touch_exclusion_seconds >= 0.0, + "goal_touch_exclusion_seconds must be finite and non-negative" + ); + assert!( + config.incident_xg_calibration_factor.is_finite() + && config.incident_xg_calibration_factor >= 0.0, + "incident_xg_calibration_factor must be finite and non-negative" + ); + Self { + config, + ..Self::default() + } + } + + pub fn config(&self) -> &ExpectedGoalsCalculatorConfig { + &self.config + } + + pub fn touch_events(&self) -> &[ThreatTouchEvent] { + self.touch_events.all() + } + + pub fn new_touch_events(&self) -> &[ThreatTouchEvent] { + self.touch_events.new_events() + } + + pub fn episode_events(&self) -> &[ThreatEpisodeEvent] { + self.episode_events.all() + } + + pub fn new_episode_events(&self) -> &[ThreatEpisodeEvent] { + self.episode_events.new_events() + } + + pub fn goal_records(&self) -> &[ThreatGoalRecord] { + &self.goal_records + } + + /// Per-team full-match `sum(V * dt) / tau` over every evaluated live-play + /// frame (`[team zero, team one]`). Corpus-calibrated to actual goals per + /// team-game within ~1%; this is the team's accumulated xG. Episodes + /// capture only the above-threshold portion of it (empirically ~62%). + pub fn team_xg_integrals(&self) -> [f64; 2] { + self.team_xg_integrals + } + + /// Both teams' V on the most recent live frame (`[team zero, team one]`), + /// `None` outside live play. + pub fn current_values(&self) -> Option<[f32; 2]> { + self.previous_values + } + + /// Time of the last processed frame (any phase), for downstream + /// censoring: seconds-to-replay-end labels. + pub fn last_frame_time(&self) -> Option { + self.last_frame.map(|(_, time)| time) + } + + fn record_goal(&mut self, frame: &FrameInfo, time: f32, scoring_team_is_team_0: bool) { + let duplicate = self.goal_records.iter().any(|record| { + record.scoring_team_is_team_0 == scoring_team_is_team_0 + && (time - record.time).abs() <= GOAL_RECORD_DEDUPE_SECONDS + }); + if duplicate { + return; + } + self.goal_records.push(ThreatGoalRecord { + time, + frame: frame.frame_number, + scoring_team_is_team_0, + }); + self.close_episode_as_goal(frame, time, scoring_team_is_team_0); + } + + fn close_episode_as_goal( + &mut self, + frame: &FrameInfo, + time: f32, + scoring_team_is_team_0: bool, + ) { + let state = &mut self.team_states[team_index(scoring_team_is_team_0)]; + if let Some(active) = state.active_episode.take() { + let goal_exclusion_start_time = state + .last_touch_time + .map(|time| time - self.config.goal_touch_exclusion_seconds); + let event = Self::event_from_active( + &active, + frame.frame_number, + frame.time, + scoring_team_is_team_0, + ThreatEpisodeEndReason::Goal, + goal_exclusion_start_time, + self.config.incident_xg_calibration_factor, + ); + self.episode_events.push(event); + return; + } + if let Some(pending) = state.pending_episode.take() { + let mut event = pending.event; + // Enforce the goal grace inside attribution too: goal detection + // runs before stale-pending expiry within a frame, so without + // this bound a goal arriving long after the stoppage (a later, + // unrelated attack on a quiet scoreboard) would still upgrade the + // stale episode. A goal past the grace resolves it as the plain + // stoppage it already was. + if time - pending.closed_at <= PENDING_EPISODE_GOAL_GRACE_SECONDS { + event.ended_in_goal = true; + event.end_reason = ThreatEpisodeEndReason::Goal; + let goal_exclusion_start_time = pending + .scoring_team_last_touch_time + .map(|time| time - self.config.goal_touch_exclusion_seconds); + Self::apply_goal_exclusion( + &mut event, + &pending.peak_candidates, + goal_exclusion_start_time, + self.config.incident_xg_calibration_factor, + ); + } + self.episode_events.push(event); + } + } + + fn event_from_active( + active: &ActiveThreatEpisode, + end_frame: usize, + end_time: f32, + team_is_team_0: bool, + end_reason: ThreatEpisodeEndReason, + goal_exclusion_start_time: Option, + incident_xg_calibration_factor: f32, + ) -> ThreatEpisodeEvent { + let mut event = ThreatEpisodeEvent { + start_time: active.start_time, + start_frame: active.start_frame, + end_time, + end_frame, + team_is_team_0, + xg: active.xg_integral as f32, + peak_value: active.peak_value, + peak_frame: active.peak_frame, + peak_time: active.peak_time, + incident_peak_value: active.peak_value, + incident_xg: active.peak_value * incident_xg_calibration_factor, + incident_xg_frame: Some(active.peak_frame), + incident_xg_time: Some(active.peak_time), + goal_exclusion_start_time: None, + credited_player: active.credited_player.clone(), + ended_in_goal: end_reason == ThreatEpisodeEndReason::Goal, + end_reason, + }; + if event.ended_in_goal { + Self::apply_goal_exclusion( + &mut event, + &active.peak_candidates, + goal_exclusion_start_time, + incident_xg_calibration_factor, + ); + } + event + } + + fn apply_goal_exclusion( + event: &mut ThreatEpisodeEvent, + peak_candidates: &[ThreatPeakCandidate], + goal_exclusion_start_time: Option, + incident_xg_calibration_factor: f32, + ) { + event.goal_exclusion_start_time = goal_exclusion_start_time; + let candidate = goal_exclusion_start_time.and_then(|cutoff| { + peak_candidates + .iter() + .rev() + .find(|sample| sample.time < cutoff) + }); + if let Some(candidate) = candidate { + event.incident_peak_value = candidate.value; + event.incident_xg = candidate.value * incident_xg_calibration_factor; + event.incident_xg_frame = Some(candidate.frame); + event.incident_xg_time = Some(candidate.time); + } else if goal_exclusion_start_time.is_some() { + event.incident_peak_value = 0.0; + event.incident_xg = 0.0; + event.incident_xg_frame = None; + event.incident_xg_time = None; + } + } + + /// One frame's contribution to an xG time integral: `V * dt / tau`. + fn integral_contribution(value: f32, dt: f32) -> f64 { + f64::from(value) * f64::from(dt) / f64::from(expected_goals_model::THREAT_HORIZON_SECONDS) + } + + fn detect_goals( + &mut self, + frame: &FrameInfo, + gameplay: &GameplayState, + events: &FrameEventsState, + ) { + for goal in events.goal_events.clone() { + self.record_goal(frame, goal.time, goal.scoring_team_is_team_0); + } + // Scoreboard fallback: some replays attribute goals only through the + // team score, mirroring the live-play tracker's score-change path. + if let (Some((team_zero, team_one)), Some((last_zero, last_one))) = + (gameplay.current_score(), self.last_score) + { + if team_zero > last_zero { + self.record_goal(frame, frame.time, true); + } + if team_one > last_one { + self.record_goal(frame, frame.time, false); + } + } + if let Some(score) = gameplay.current_score() { + self.last_score = Some(score); + } + } + + /// Resolve stoppage-closed episodes whose goal grace has passed (or once + /// the next kickoff phase begins) as plain stoppages. + fn resolve_stale_pending_episodes(&mut self, frame: &FrameInfo, kickoff_phase_active: bool) { + for team_index in 0..2 { + let expired = self.team_states[team_index] + .pending_episode + .as_ref() + .is_some_and(|pending| { + kickoff_phase_active + || frame.time - pending.closed_at > PENDING_EPISODE_GOAL_GRACE_SECONDS + }); + if expired { + let pending = self.team_states[team_index] + .pending_episode + .take() + .expect("pending episode exists when expired"); + self.episode_events.push(pending.event); + } + } + } + + /// Close any active episodes because live play ended. The closed episode + /// is held pending: the goal that caused the stoppage may only be + /// attributed a few frames later. + fn suspend_active_episodes(&mut self, frame: &FrameInfo) { + for (team_index, state) in self.team_states.iter_mut().enumerate() { + let Some(active) = state.active_episode.take() else { + continue; + }; + let event = Self::event_from_active( + &active, + frame.frame_number, + frame.time, + team_index == 0, + ThreatEpisodeEndReason::Stoppage, + None, + self.config.incident_xg_calibration_factor, + ); + // A newer stoppage-closed episode supersedes an unresolved older + // one; flush the older one un-goaled first. + if let Some(previous) = state.pending_episode.take() { + self.episode_events.push(previous.event); + } + state.pending_episode = Some(PendingThreatEpisode { + event, + peak_candidates: active.peak_candidates, + scoring_team_last_touch_time: state.last_touch_time, + closed_at: frame.time, + }); + } + } + + /// Observe primary touches before goal detection. A goal and its final + /// touch can surface on the same processing frame, and the contact time is + /// what anchors the exclusion window even though V is evaluated later. + fn observe_touches(&mut self, touch_state: &TouchState) { + for is_team_0 in [true, false] { + let Some(touch) = touch_state.primary_touch_event_for_team(is_team_0) else { + continue; + }; + let state = &mut self.team_states[team_index(is_team_0)]; + state.last_touch_time = Some(touch.time); + if let Some(player) = touch.player.clone() { + state.last_toucher = Some(player); + } + } + } + + /// Emit at most one [`ThreatTouchEvent`] per team per frame. + /// + /// [`TouchState`] can report several simultaneous contacts on one frame + /// (contested 50/50s, same-team double commits), but the change from the + /// previous live frame's V to this frame's V is a single transition: + /// crediting it to every same-team touch would count it once per toucher + /// in the accumulator. The team's *primary* touch -- the latest, + /// best-evidence contact per + /// [`TouchState::primary_touch_event_for_team`], the same notion of "the" + /// decisive touch that `TouchState` already encodes for the rest of the + /// stats pipeline -- receives the whole transition. + fn emit_touch_events(&mut self, frame: &FrameInfo, touch_state: &TouchState, values: [f32; 2]) { + for is_team_0 in [true, false] { + let Some(touch) = touch_state.primary_touch_event_for_team(is_team_0) else { + continue; + }; + let index = team_index(is_team_0); + let value_before = self + .previous_values + .map(|previous| previous[index]) + .unwrap_or(values[index]); + self.touch_events.push(ThreatTouchEvent { + time: touch.time, + frame: touch.frame, + touch_id: touch.touch_id, + detection_frame: frame.frame_number, + detection_time: frame.time, + team_is_team_0: is_team_0, + player: touch.player.clone(), + value_before, + value_after: values[index], + }); + } + } + + /// Track episode state for one evaluated live frame. Episodes integrate + /// `V * dt / tau` over exactly these frames -- the same live-play frames + /// where V is evaluated -- from the frame that opens the episode through + /// the frame that closes it (a value-drop close's final sub-threshold + /// frame contributes too; stoppage/replay-end closes end at the last + /// evaluated live frame, since non-live frames are never evaluated). + fn update_episodes(&mut self, frame: &FrameInfo, values: [f32; 2]) { + for (team_index, state) in self.team_states.iter_mut().enumerate() { + let value = values[team_index]; + let last_toucher = state.last_toucher.clone(); + match state.active_episode.as_mut() { + Some(active) => { + if value > active.peak_value { + active.peak_value = value; + active.peak_frame = frame.frame_number; + active.peak_time = frame.time; + active.peak_candidates.push(ThreatPeakCandidate { + frame: frame.frame_number, + time: frame.time, + value, + }); + active.credited_player = last_toucher; + } + active.xg_integral += Self::integral_contribution(value, frame.dt); + if value <= self.config.episode_end_threshold { + let active = state + .active_episode + .take() + .expect("active episode exists when closing"); + self.episode_events.push(Self::event_from_active( + &active, + frame.frame_number, + frame.time, + team_index == 0, + ThreatEpisodeEndReason::ValueDropped, + None, + self.config.incident_xg_calibration_factor, + )); + } + } + None => { + if value > self.config.episode_threshold { + state.active_episode = Some(ActiveThreatEpisode { + start_time: frame.time, + start_frame: frame.frame_number, + peak_value: value, + peak_frame: frame.frame_number, + peak_time: frame.time, + peak_candidates: vec![ThreatPeakCandidate { + frame: frame.frame_number, + time: frame.time, + value, + }], + xg_integral: Self::integral_contribution(value, frame.dt), + credited_player: last_toucher, + }); + } + } + } + } + } + + pub fn update_parts( + &mut self, + frame: &FrameInfo, + gameplay: &GameplayState, + events: &FrameEventsState, + touch_state: &TouchState, + threat_features: &ThreatFeaturesState, + ) -> SubtrActorResult<()> { + self.touch_events.begin_update(); + self.episode_events.begin_update(); + self.last_frame = Some((frame.frame_number, frame.time)); + + self.observe_touches(touch_state); + self.detect_goals(frame, gameplay, events); + self.resolve_stale_pending_episodes(frame, gameplay.kickoff_phase_active()); + + let Some(features) = threat_features.current().copied() else { + self.suspend_active_episodes(frame); + if self.was_live { + for state in self.team_states.iter_mut() { + state.last_toucher = None; + state.last_touch_time = None; + } + } + self.previous_values = None; + self.was_live = false; + return Ok(()); + }; + let values = [ + expected_goals_model::threat_value(&features[0]), + expected_goals_model::threat_value(&features[1]), + ]; + + // The full-match team integral covers EVERY evaluated live frame, + // sub-threshold ones included -- diffuse below-threshold threat is + // ~38% of the calibrated total. + for (team_index, value) in values.iter().enumerate() { + self.team_xg_integrals[team_index] += Self::integral_contribution(*value, frame.dt); + } + + self.emit_touch_events(frame, touch_state, values); + self.update_episodes(frame, values); + self.previous_values = Some(values); + self.was_live = true; + Ok(()) + } + + pub fn finish_calculation(&mut self) -> SubtrActorResult<()> { + self.touch_events.begin_update(); + self.episode_events.begin_update(); + let (end_frame, end_time) = self.last_frame.unwrap_or((0, 0.0)); + for (team_index, state) in self.team_states.iter_mut().enumerate() { + if let Some(active) = state.active_episode.take() { + let event = Self::event_from_active( + &active, + end_frame, + end_time, + team_index == 0, + ThreatEpisodeEndReason::ReplayEnd, + None, + self.config.incident_xg_calibration_factor, + ); + self.episode_events.push(event); + } + if let Some(pending) = state.pending_episode.take() { + self.episode_events.push(pending.event); + } + } + Ok(()) + } +} + +#[cfg(test)] +#[path = "expected_goals_tests.rs"] +mod tests; diff --git a/src/stats/calculators/expected_goals_model.rs b/src/stats/calculators/expected_goals_model.rs new file mode 100644 index 000000000..aa752aec6 --- /dev/null +++ b/src/stats/calculators/expected_goals_model.rs @@ -0,0 +1,78 @@ +//! Versioned nonlinear threat model for the expected-goals stat. +//! +//! The model maps a [`ThreatFeatures`] vector to +//! an eight-hidden-unit tanh MLP: the probability that the attacking team +//! scores within the next [`THREAT_HORIZON_SECONDS`] seconds, evaluated on raw +//! (unstandardized) features. The offline training pipeline folds input +//! standardization into the published first-layer weights, so inference here +//! is a pair of small matrix-vector products over [`ThreatFeatures::to_array`]. +//! +//! Replacing the model is a mechanical edit confined to the generated +//! coefficients module and version/provenance section below. The input-weight +//! table has one named row per [`ThreatFeatures::FEATURE_NAMES`] entry, in the +//! same order. + +use super::expected_goals::{THREAT_FEATURE_COUNT, ThreatFeatures}; + +/// Label horizon the model is (to be) trained against: V estimates the +/// probability of the attacking team scoring within this many seconds. +pub const THREAT_HORIZON_SECONDS: f32 = 5.0; + +// --------------------------------------------------------------------------- +// GENERATED COEFFICIENTS -- BEGIN +// +// trained-v5 provenance: 8-hidden-unit tanh MLP fit by +// scripts/threat_model/train_threat_model.py (uv-locked environment) on +// 5.22M live-play team rows sampled at 4 Hz from 2,544 rank-stratified +// ranked-doubles replays (rocket-sense production corpus, rank tiers 3-22, +// 2026-07-12). The newest 20% of replays were held out temporally: log_loss +// 0.13005, Brier 0.03456, AUC 0.8936, and 15-bin ECE 0.00129 (linear +// baseline log_loss 0.13548; GBT reference ceiling 0.12816). The published +// model was refit on the full corpus. Input standardization is folded into +// the first-layer weights, which apply directly to raw features. +// --------------------------------------------------------------------------- + +/// Identifies the generated model embedded below. +pub const THREAT_MODEL_VERSION: &str = "trained-v5"; + +include!("expected_goals_model_weights.rs"); + +// GENERATED COEFFICIENTS -- END +// --------------------------------------------------------------------------- + +fn sigmoid(x: f32) -> f32 { + 1.0 / (1.0 + (-x).exp()) +} + +/// Evaluate the threat model on one feature vector: the probability, in +/// (0, 1), that the attacking team scores within [`THREAT_HORIZON_SECONDS`]. +pub fn threat_value(features: &ThreatFeatures) -> f32 { + threat_value_from_array(&features.to_array()) +} + +/// Evaluate the model on a raw feature vector in +/// [`ThreatFeatures::FEATURE_NAMES`] order. This is the exact inference path +/// [`threat_value`] uses; it is public so parity against the offline training +/// pipeline's predictions can be asserted on shared fixtures. +pub fn threat_value_from_array(values: &[f32; THREAT_FEATURE_COUNT]) -> f32 { + let mut hidden = THREAT_MODEL_HIDDEN_BIASES; + for ((_, weights), value) in THREAT_MODEL_INPUT_WEIGHTS.iter().zip(values.iter()) { + for (activation, weight) in hidden.iter_mut().zip(weights) { + *activation += weight * value; + } + } + hidden.iter_mut().for_each(|activation| { + *activation = activation.tanh(); + }); + let logit = hidden + .iter() + .zip(THREAT_MODEL_OUTPUT_WEIGHTS) + .fold(THREAT_MODEL_OUTPUT_BIAS, |sum, (activation, weight)| { + sum + activation * weight + }); + sigmoid(logit) +} + +#[cfg(test)] +#[path = "expected_goals_model_tests.rs"] +mod tests; diff --git a/src/stats/calculators/expected_goals_model_tests.rs b/src/stats/calculators/expected_goals_model_tests.rs new file mode 100644 index 000000000..68a79adca --- /dev/null +++ b/src/stats/calculators/expected_goals_model_tests.rs @@ -0,0 +1,156 @@ +use super::*; +use crate::TeamThreatFeatures; + +/// The first-layer weight table must stay keyed exactly by +/// [`ThreatFeatures::FEATURE_NAMES`], in order -- pasting trained weights is a +/// mechanical edit only while this holds. +#[test] +fn weights_cover_every_feature_in_order() { + assert_eq!(THREAT_MODEL_INPUT_WEIGHTS.len(), THREAT_FEATURE_COUNT); + for ((weight_name, weights), feature_name) in THREAT_MODEL_INPUT_WEIGHTS + .iter() + .zip(ThreatFeatures::FEATURE_NAMES.iter()) + { + assert_eq!(weight_name, feature_name); + assert_eq!(weights.len(), THREAT_MODEL_HIDDEN_UNITS); + } + assert_eq!(THREAT_MODEL_HIDDEN_BIASES.len(), THREAT_MODEL_HIDDEN_UNITS); + assert_eq!(THREAT_MODEL_OUTPUT_WEIGHTS.len(), THREAT_MODEL_HIDDEN_UNITS); +} + +#[test] +fn rust_inference_matches_locked_training_pipeline() { + // Low, median, and high temporal-holdout rows emitted by the uv/Nix-locked + // trainer. Three rows cover the numerical range without checking in a + // bulky dataset fixture. + let fixtures: &[(&[f32; THREAT_FEATURE_COUNT], f32)] = &[ + ( + &[ + -0.96218, 0.925188, 0.104428, 0.249714, -0.241733, 0.053084, 0.0, 0.0, -0.487075, + -0.062788, 0.039922, 0.024665, -0.847772, -0.041446, -0.27894, -0.611723, + -0.214726, 0.955397, 0.551591, 0.201961, 1.0, 0.0, 0.0, 0.0, 0.795908, 0.657334, + 0.018151, 0.163557, 0.062961, 0.07677, 0.141425, 0.523912, 1.308831, 0.089206, + 0.170685, 0.403922, 0.0, 0.0, 0.0, 0.0, -0.196389, -0.357214, 0.008319, -0.301372, + -0.054663, 0.000135, -0.473223, -0.44209, -0.00963, 0.825421, 0.672191, 1.0, 1.0, + 0.0, 1.0, 0.0, 1.396052, 0.296608, 5e-06, 0.618239, 0.220761, 5.2e-05, 1.04027, + 1.113524, 0.000169, 0.349158, 0.179705, 0.0, 0.0, 0.0, 0.0, 0.0, + ], + 1.2671989e-05, + ), + ( + &[ + -0.834768, 0.878126, 0.194393, 0.14428, 0.032285, 0.055151, 0.0, 0.0, 0.584054, + -0.853876, 0.055987, 0.43948, 0.10223, 0.129237, 0.858966, 0.196267, 0.141177, + 0.153861, 0.874028, 0.790474, 0.5, 0.0, 1.0, 0.0, 0.000884, 0.071093, 0.077821, + 0.128004, 0.311243, 0.001274, 0.138747, 0.834277, 0.315651, 0.032005, 0.03156, + 0.320915, 1.0, 0.0, 0.0, 0.0, 0.61513, -0.356371, 0.016677, 0.265415, -0.11932, + -0.073148, 0.013493, -0.18861, -0.156092, 0.575371, 0.659995, 0.647059, 0.5, 0.0, + 0.5, 0.0, 0.371751, 1.077598, 0.016729, 1.363196, 0.072961, 0.146757, 1.913777, + 0.105561, 0.292978, 0.849259, 0.509122, 0.705882, 1.0, 0.0, 1.0, 0.0, + ], + 0.0021030817, + ), + ( + &[ + 0.066164, 0.538284, 0.300059, 0.342233, 0.057796, 0.075575, 0.0, 0.0, -0.022155, + 0.014199, 0.026363, -0.055111, -0.047265, -0.003167, 0.469069, 0.430992, -0.485502, + 0.777381, 0.49211, 0.268627, 0.5, 0.0, 0.5, 0.0, 1.046838, 0.457496, 0.036081, + 1.139352, 0.971261, 0.006552, 0.56392, 0.458414, 0.951779, 0.445237, 0.185265, + 0.537255, 1.0, 0.0, 1.0, 0.0, 0.684725, 0.250195, 0.008322, 0.543522, 0.527483, + 0.000117, 0.692867, 0.583069, -0.009586, 0.448671, 0.428114, 0.0, 0.5, 0.0, 1.0, + 0.0, 0.139915, 0.54652, 0.0, 0.200922, 0.7114, 0.0, 0.546158, 0.649006, 2e-06, + 0.433581, 0.229961, 0.0, 1.0, 0.0, 0.0, 0.0, + ], + 0.012173547, + ), + ( + &[ + 0.43535, 0.436157, 0.378987, 0.198702, 0.095721, 0.071475, 0.0, 0.194624, 0.805072, + -0.131209, 0.151426, 0.276896, -0.272502, 0.216659, 0.313352, -0.13858, 0.425392, + 0.533471, 0.627687, 0.0, 0.0, 0.0, 1.0, 0.0, 0.38156, 1.117762, 0.286208, 0.55427, + 1.076161, 0.433074, 0.60767, 1.296356, 0.869935, 0.933058, 0.353907, 0.0, 0.0, 0.0, + 0.0, 0.0, -0.011678, 0.433968, 0.035881, -0.212563, 0.501893, 0.23715, -0.178231, + 0.670245, 0.411837, 0.816757, 0.434632, 0.5, 0.5, 0.0, 1.0, 0.0, 1.667278, + 0.938955, 0.055127, 0.671822, 0.3839, 0.473943, 0.767197, 0.313906, 0.842886, + 0.366487, 0.261577, 1.0, 1.0, 0.0, 0.0, 0.0, + ], + 0.047502104, + ), + ( + &[ + 0.966717, 0.029268, 0.047515, 0.367709, 0.34372, 0.867728, 1.0, 0.910348, 0.0083, + 0.789181, 0.019889, 0.759365, 0.429302, 0.055039, 0.742522, 0.592933, -0.10626, + 0.240938, 0.096608, 0.492157, 0.0, 0.0, 0.5, 0.0, 0.022379, 0.081877, 0.023134, + 0.469, 0.841839, 0.109843, 0.327925, 0.445301, 0.193319, 0.107006, 0.037799, + 0.262745, 0.0, 0.0, 1.0, 0.0, -0.678044, -0.087629, 0.025289, 0.125807, 0.853424, + -0.001824, -0.264759, 0.882893, 0.111929, 1.0, 0.565673, 0.545201, 0.0, 0.0, 0.5, + 0.0, 0.420317, 0.363465, 0.034276, 0.244196, 0.1095, 0.009804, 0.663393, 0.229643, + 0.242214, 0.0, 0.078619, 0.815892, 0.0, 0.0, 1.0, 0.0, + ], + 0.9994111, + ), + ( + &[ + 0.952699, 0.021898, 0.045572, 0.332711, 0.325819, 0.831078, 1.0, 0.885155, + 0.035382, 0.388345, 0.295964, -0.13335, 0.870457, 0.109611, 0.214073, -0.189625, + -0.133566, 0.742218, 0.293057, 0.396078, 0.0, 0.0, 0.0, 0.0, 0.1228, 0.500603, + 0.49774, 0.481222, 0.235687, 0.192752, 0.201112, 0.929666, 1.642186, 0.515564, + 0.202191, 0.792157, 0.0, 0.0, 0.0, 0.0, 0.447262, -0.203876, 0.030824, -0.266467, + 0.901317, -0.007328, -0.663567, 0.640405, 0.214863, 1.0, 0.599773, 0.037255, 0.0, + 0.0, 0.5, 0.0, 0.798191, 0.497635, 0.046208, 0.502578, 0.121104, 0.001204, + 0.244412, 0.400742, 0.439597, 0.0, 0.12917, 0.07451, 0.0, 0.0, 1.0, 0.0, + ], + 0.99955446, + ), + ]; + + for &(features, expected) in fixtures { + let actual = threat_value_from_array(features); + assert!( + (actual - expected).abs() < 2e-6, + "training parity mismatch: expected {expected}, got {actual}" + ); + } +} + +fn neutral_features() -> ThreatFeatures { + ThreatFeatures { + ball_forward_y: 0.0, + ball_dist_to_goal: 0.46, + ball_height: 0.05, + ball_speed: 0.2, + ball_speed_toward_goal: 0.0, + goal_open_angle: 0.11, + on_target: 0.0, + time_to_goal_line: 0.0, + own_team: TeamThreatFeatures::default(), + opponent_team: TeamThreatFeatures::default(), + } +} + +#[test] +fn threat_value_is_a_probability_and_orders_danger_over_neutral() { + let neutral = neutral_features(); + let dangerous = ThreatFeatures { + ball_forward_y: 0.8, + ball_dist_to_goal: 0.12, + ball_speed_toward_goal: 0.3, + goal_open_angle: 0.45, + on_target: 1.0, + time_to_goal_line: 0.6, + ..neutral + }; + + let neutral_value = threat_value(&neutral); + let dangerous_value = threat_value(&dangerous); + assert!(neutral_value > 0.0 && neutral_value < 1.0); + assert!(dangerous_value > 0.0 && dangerous_value < 1.0); + assert!( + dangerous_value > neutral_value, + "dangerous state ({dangerous_value}) must out-rank neutral ({neutral_value})" + ); + // The model keeps neutral midfield states well under the episode + // threshold and clear on-target chances above it. + assert!(neutral_value < super::super::expected_goals::THREAT_EPISODE_THRESHOLD); + assert!(dangerous_value > super::super::expected_goals::THREAT_EPISODE_THRESHOLD); +} diff --git a/src/stats/calculators/expected_goals_model_weights.rs b/src/stats/calculators/expected_goals_model_weights.rs new file mode 100644 index 000000000..fa1b26eb0 --- /dev/null +++ b/src/stats/calculators/expected_goals_model_weights.rs @@ -0,0 +1,80 @@ +// Generated by scripts/threat_model/train_threat_model.py — do not hand-edit values. + +pub const THREAT_MODEL_HIDDEN_UNITS: usize = 8; +pub const THREAT_MODEL_HIDDEN_BIASES: [f32; THREAT_MODEL_HIDDEN_UNITS] = [0.047919597, 0.3392923, 2.3145552, 1.0096473, -0.099848315, 0.9117676, 0.13935329, 0.4774461]; +pub const THREAT_MODEL_INPUT_WEIGHTS: [(&str, [f32; THREAT_MODEL_HIDDEN_UNITS]); THREAT_FEATURE_COUNT] = [ + ("ball_forward_y", [-1.481775, 0.3076497, -0.5522127, 0.1272162, 0.2448419, 0.17243838, 0.47686675, -0.53589594]), + ("ball_dist_to_goal", [-1.7687428, 0.07904287, 1.3254597, -1.0965666, -0.8508507, -2.788781, 1.7914606, -2.5512626]), + ("ball_height", [-1.5726534, 1.5991954, 1.1950356, -1.2228006, 1.74517, 1.0853496, 0.23563068, -0.012824095]), + ("ball_speed", [-1.6957233, -3.903021, -3.0970666, 2.6824315, -4.1445265, 3.4171047, 9.894485, -3.2771273]), + ("ball_speed_toward_goal", [1.390688, -0.49036518, 0.82623595, 2.5485878, 6.5580354, -0.5851379, -11.352074, 2.931813]), + ("goal_open_angle", [2.3246067, -3.8064308, -0.12800576, 4.4048295, -2.481102, 3.2157903, -0.57584727, 3.7854998]), + ("on_target", [-0.9317081, 7.2855406, -0.3710465, 4.4410434, 8.2491665, -0.22934026, 0.16640073, 0.43673676]), + ("time_to_goal_line", [-0.0034713661, 0.10792843, -0.3766507, -0.5833043, 2.5266888, -1.3152958, -1.5935066, -2.0782971]), + ("own_team_mean_position_x", [-0.0010260928, -0.009500642, -0.022248745, -0.07143763, -0.029969648, 0.04570056, 0.03880349, 0.06963847]), + ("own_team_mean_position_y", [0.14357953, 0.44493198, -0.46219245, 0.8443055, -0.32577878, 0.13238966, 0.046166297, -0.050066505]), + ("own_team_mean_position_z", [0.30727673, -0.69319206, 0.35089478, 0.07947597, -1.4302638, -0.95632076, -0.42337334, 0.8417223]), + ("own_team_mean_velocity_x", [-0.016982408, 0.16142055, 0.10726119, -0.012339549, 0.020611273, -0.09711414, -0.03224458, -0.04737523]), + ("own_team_mean_velocity_y", [0.36293572, 0.3146509, -0.09085843, 0.9885795, -0.8465545, 0.4191029, 0.31835476, 0.2138044]), + ("own_team_mean_velocity_z", [0.5678845, 0.6971385, -0.059857327, 0.5345594, -0.43917924, -0.85581887, 0.6157006, 1.1802027]), + ("own_team_mean_forward_x", [0.01168882, -0.12326758, -0.16734894, 0.023314856, 0.00829361, 0.03239478, 0.04952753, -0.0028133246]), + ("own_team_mean_forward_y", [0.06920103, 0.57149756, -0.20540762, -0.15955727, -0.3004338, 0.10041588, 0.30676568, 0.35240227]), + ("own_team_mean_forward_z", [0.06971425, 0.11638507, 0.21310075, -0.021208366, 0.09405489, -0.06102098, 0.1024265, 0.15702839]), + ("own_team_mean_distance_to_ball", [-0.47447792, -1.1657789, -0.5193875, 0.0032739283, -0.06653273, -0.37753066, 0.07769674, -0.8124651]), + ("own_team_mean_distance_to_goal", [-0.34776616, -0.13946155, -0.6734228, 0.19294234, -0.7964509, 0.60291815, 0.16460623, -0.2072966]), + ("own_team_mean_boost", [-0.13765444, 0.40019026, -0.055486333, 0.5260755, 0.08687401, -0.07702655, 0.079200335, 0.23523909]), + ("own_team_mean_is_goalside", [-0.44347253, -1.1144712, 0.8193741, -1.1890154, 1.1492335, -0.5330752, -1.0003014, -0.6983744]), + ("own_team_mean_in_net", [0.35930207, -0.85255367, 0.80763495, -5.1742644, 13.098904, -10.720265, -14.954534, 0.5347086]), + ("own_team_mean_dodge_available", [-0.021524165, 0.44136944, -0.11017665, 0.17131871, -0.1878541, 0.02605651, 0.11738175, 0.15731797]), + ("own_team_mean_demoed", [-2.1172705, -1.0069427, -0.7226119, -1.3396227, 1.4397235, -7.8871856, -21.5305, -0.14364965]), + ("own_team_spread_position_x", [0.072769225, 0.1522959, 0.051570717, 0.19295406, -0.14829749, 0.07166533, 0.055979565, 0.03832826]), + ("own_team_spread_position_y", [-0.07074743, -0.29245225, 0.042570375, -0.28096744, 0.04831071, 0.051589802, 0.056270577, 0.043022204]), + ("own_team_spread_position_z", [0.2739475, -0.4794163, 0.29590794, 0.588379, -0.060299724, -0.3194129, -0.2291669, 0.05662039]), + ("own_team_spread_velocity_x", [-0.053717434, 0.13575512, 0.114934556, 0.09733822, -0.14084, 0.05952541, -0.014681506, 0.014977816]), + ("own_team_spread_velocity_y", [0.12519586, 0.2105106, -0.112971865, 0.42153338, -0.5354734, 0.23209746, 0.21596076, 0.19892573]), + ("own_team_spread_velocity_z", [0.29347676, 0.9198569, 0.3498644, 0.34766424, -0.8800932, -0.22610015, 0.57396877, 0.51591796]), + ("own_team_spread_forward_x", [-0.039480567, 0.0049921763, -0.039094783, -0.11292795, 0.032650527, -0.091370136, 0.011923187, 0.045823034]), + ("own_team_spread_forward_y", [0.104682066, 0.1656321, -0.115288965, 0.0078085004, -0.25321597, -0.021889055, 0.11772672, 0.14939366]), + ("own_team_spread_forward_z", [-0.103342876, -0.016241025, -0.050506756, 0.0040273042, 0.11823306, -0.17482373, 0.014588009, 0.075098775]), + ("own_team_spread_distance_to_ball", [0.16456106, 0.43931857, 0.24341345, -0.21280296, 0.7528084, 0.401606, 0.01408844, 0.130539]), + ("own_team_spread_distance_to_goal", [0.45696136, -0.0648816, 0.45915493, 0.3721219, -0.07217636, -0.06423363, -0.6962814, -0.1699857]), + ("own_team_spread_boost", [-0.012329574, -0.104161404, -0.0656438, 0.09938114, -0.0695983, 0.01084281, -0.0043316134, -0.06994925]), + ("own_team_spread_is_goalside", [0.13332447, 0.49198902, -0.3450085, 0.27043787, -0.56518036, 0.3390436, 0.48900267, 0.3015111]), + ("own_team_spread_in_net", [0.3302477, 0.8923754, 0.28576174, -3.6829202, 7.26489, -4.9323974, -10.35524, 0.7711495]), + ("own_team_spread_dodge_available", [-0.04779309, 0.050396185, -0.091842964, -0.0056609884, -0.0861147, 0.083406955, 0.07283662, -0.050691366]), + ("own_team_spread_demoed", [1.2198066, -0.22678408, 0.25265083, -0.24091247, -0.19332193, -4.8687987, -9.43233, 0.8761488]), + ("opponent_team_mean_position_x", [0.04132268, 0.03948432, 0.069354564, 0.06778399, 0.0083567975, -0.030832788, 0.0053996774, -0.045158684]), + ("opponent_team_mean_position_y", [0.32786202, 0.08682234, 0.034403734, -0.42943537, 0.46840358, 0.014049029, 0.2902803, 0.19643857]), + ("opponent_team_mean_position_z", [-0.18007335, 2.521379, -1.1158464, 0.64420825, 1.4428667, 0.006208679, 0.8773932, -1.0632181]), + ("opponent_team_mean_velocity_x", [-0.030069117, -0.047030006, -0.0479487, -0.03637789, 0.16172507, 0.007147721, -0.027146744, -0.063879736]), + ("opponent_team_mean_velocity_y", [0.30163008, -0.19865319, 0.2089363, 0.18859707, -0.022576386, -0.5579768, 0.052503947, 0.03172408]), + ("opponent_team_mean_velocity_z", [-0.11233972, 0.24142309, 0.5970352, -1.0393857, 1.9432884, 1.5035708, 0.45322335, -1.6537834]), + ("opponent_team_mean_forward_x", [-0.021246085, 0.06044121, 0.04719321, -0.0525419, -0.12752192, 0.02550144, -0.0034903358, 0.059559565]), + ("opponent_team_mean_forward_y", [0.10534816, 0.12552384, 0.24805239, 0.018138038, 0.028325714, -0.023114711, -0.0018605398, 0.09847064]), + ("opponent_team_mean_forward_z", [-0.0155947665, 0.05795982, -0.043499123, 0.11910694, 0.104350835, -0.05005444, 0.0076247505, -0.13887148]), + ("opponent_team_mean_distance_to_ball", [0.44010863, 0.620445, -0.8613741, 0.3456144, -0.54265106, -0.086691886, 0.5460123, -0.25463948]), + ("opponent_team_mean_distance_to_goal", [0.018039862, 0.40042523, -3.7376385, -0.67264605, 0.22590147, 0.40597174, -2.01623, 1.3536441]), + ("opponent_team_mean_boost", [0.0026733666, -0.1304225, 0.20291434, -0.32380533, -0.23652083, -0.2714212, -0.06346003, -0.17823048]), + ("opponent_team_mean_is_goalside", [-0.36770043, 0.3665325, 0.5382453, -0.77866834, 0.10056483, 0.41506115, 0.44867903, -0.05442885]), + ("opponent_team_mean_in_net", [0.0062071737, 0.20443065, 0.30786276, -0.20162646, -0.15650246, -6.915529, -12.794223, -0.04055599]), + ("opponent_team_mean_dodge_available", [-0.15812746, -0.11958809, -0.09916452, 0.20150957, -0.16224712, -0.11826422, 0.0004270958, -0.022222986]), + ("opponent_team_mean_demoed", [-0.5568088, -0.50119966, -10.837227, -1.4501834, 15.8918915, -0.1437553, -0.29687488, -0.67711276]), + ("opponent_team_spread_position_x", [-0.060726184, -0.008997038, 0.08759276, 0.042382143, -0.15451835, -0.103209406, -0.09951653, 0.19910377]), + ("opponent_team_spread_position_y", [0.26793972, -0.053101256, -0.19028248, 0.15177141, 0.49582377, -0.3063869, 0.073477305, 0.11098754]), + ("opponent_team_spread_position_z", [0.14738838, -1.0920229, -0.22200161, -0.11280404, -0.7522147, -0.1148326, -0.26183867, 0.32771227]), + ("opponent_team_spread_velocity_x", [-0.048866596, -0.03545358, 0.041525163, -0.074881725, 0.00897554, 0.16705462, -0.017694281, -0.14146443]), + ("opponent_team_spread_velocity_y", [-0.060105186, 0.048882656, 0.1591574, -0.2889368, -0.00072903256, -0.014464113, 0.042502984, 0.024510935]), + ("opponent_team_spread_velocity_z", [0.18435152, 0.24953082, 0.3107726, -0.19863787, 0.32525882, 0.38007432, 0.5369397, -0.6784904]), + ("opponent_team_spread_forward_x", [0.04155521, 0.0040758853, -0.0034370301, 0.014901741, -0.056647178, -0.07675293, 0.018923685, 0.06653228]), + ("opponent_team_spread_forward_y", [0.03623745, 0.0245737, -0.044349153, 0.0587301, -0.0012095398, -0.002352324, 0.030720625, -0.012525151]), + ("opponent_team_spread_forward_z", [0.03448579, -0.021131556, -0.0015915224, 0.07642666, 0.064066015, -0.023360653, -0.0077898516, 0.04801378]), + ("opponent_team_spread_distance_to_ball", [-0.045283504, -0.22097966, 0.33365896, -0.13787934, 0.30488697, 0.011164199, -0.29697546, -0.23816092]), + ("opponent_team_spread_distance_to_goal", [-0.5349321, 0.4112274, 2.3653753, -0.025231155, 0.038224857, 1.3081763, 1.3798069, -0.1889271]), + ("opponent_team_spread_boost", [-0.031267453, -0.0374296, -0.017185181, 0.09960664, 0.18843797, 0.048329208, -0.01944548, -0.07678894]), + ("opponent_team_spread_is_goalside", [-0.072860025, 0.1614491, 0.1953252, -0.14651284, 0.018519344, 0.06900753, 0.03809659, -0.01141388]), + ("opponent_team_spread_in_net", [-0.02497729, 0.10355299, -0.06091275, -0.344638, -0.24811603, -3.5930326, -7.0020213, 0.022411231]), + ("opponent_team_spread_dodge_available", [0.012480921, -0.03841705, 0.06400547, 0.04957554, -0.006891128, -0.036733955, -0.00090332126, 0.0576382]), + ("opponent_team_spread_demoed", [0.09421095, -0.6257593, -5.246738, -0.80866736, 7.4770045, -0.4149512, -0.22124292, -0.93384665]), +]; +pub const THREAT_MODEL_OUTPUT_BIAS: f32 = -1.2502689; +pub const THREAT_MODEL_OUTPUT_WEIGHTS: [f32; THREAT_MODEL_HIDDEN_UNITS] = [1.9143015, 1.0447489, -0.87892187, 1.311468, 0.6706584, 1.6562582, -1.7538291, 1.1284286]; diff --git a/src/stats/calculators/expected_goals_tests.rs b/src/stats/calculators/expected_goals_tests.rs new file mode 100644 index 000000000..7d3741271 --- /dev/null +++ b/src/stats/calculators/expected_goals_tests.rs @@ -0,0 +1,1080 @@ +use super::*; + +fn player_id(id: u64) -> PlayerId { + boxcars::RemoteId::Steam(id) +} + +fn rigid_body(position: glam::Vec3, velocity: glam::Vec3) -> boxcars::RigidBody { + boxcars::RigidBody { + sleeping: false, + location: glam_to_vec(&position), + rotation: boxcars::Quaternion { + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + }, + linear_velocity: Some(glam_to_vec(&velocity)), + angular_velocity: Some(glam_to_vec(&glam::Vec3::ZERO)), + } +} + +fn ball(position: glam::Vec3, velocity: glam::Vec3) -> BallFrameState { + BallFrameState::Present(BallSample { + rigid_body: rigid_body(position, velocity), + }) +} + +fn player( + id: u64, + is_team_0: bool, + position: glam::Vec3, + boost_amount: Option, +) -> PlayerSample { + PlayerSample { + player_id: player_id(id), + is_team_0, + hitbox: default_car_hitbox(), + rigid_body: Some(rigid_body(position, glam::Vec3::ZERO)), + boost_amount, + last_boost_amount: boost_amount, + boost_active: false, + dodge_active: false, + dodge_torque: None, + powerslide_active: false, + match_goals: None, + match_assists: None, + match_saves: None, + match_shots: None, + match_score: None, + } +} + +fn frame(frame_number: usize, time: f32) -> FrameInfo { + FrameInfo { + frame_number, + time, + dt: 0.1, + seconds_remaining: None, + } +} + +fn live_play() -> LivePlayState { + LivePlayState::active_play() +} + +fn stoppage() -> LivePlayState { + LivePlayState::new(GameplayPhase::PostGoal) +} + +fn touch(frame_number: usize, time: f32, id: u64, is_team_0: bool) -> TouchEvent { + TouchEvent { + touch_id: None, + time, + frame: frame_number, + team_is_team_0: is_team_0, + player: Some(player_id(id)), + player_position: None, + closest_approach_distance: None, + contact_local_ball_position: None, + contact_local_hitbox_point: None, + contact_world_hitbox_point: None, + dodge_contact: false, + } +} + +fn touch_with_id( + frame_number: usize, + time: f32, + id: u64, + is_team_0: bool, + touch_id: Option, +) -> TouchEvent { + TouchEvent { + touch_id, + ..touch(frame_number, time, id, is_team_0) + } +} + +fn touch_state(touches: Vec) -> TouchState { + TouchState { + touch_events: touches, + ..TouchState::default() + } +} + +fn goal_event(frame_number: usize, time: f32, scoring_team_is_team_0: bool) -> GoalEvent { + GoalEvent { + time, + frame: frame_number, + scoring_team_is_team_0, + player: None, + player_position: None, + team_zero_score: None, + team_one_score: None, + } +} + +fn no_demoed() -> HashSet { + HashSet::new() +} + +/// Ball rolling in on an open team-zero net; the placeholder model rates this +/// far above the episode threshold. +fn dangerous_state() -> (BallFrameState, PlayerFrameState) { + ( + ball( + glam::Vec3::new(0.0, 4300.0, 93.0), + glam::Vec3::new(0.0, 1400.0, 250.0), + ), + PlayerFrameState { + players: vec![ + player(1, true, glam::Vec3::new(0.0, 4000.0, 17.0), Some(100.0)), + player(5, true, glam::Vec3::new(-900.0, 2600.0, 17.0), Some(25.0)), + player(2, false, glam::Vec3::new(2500.0, 1000.0, 17.0), Some(50.0)), + player(6, false, glam::Vec3::new(-2500.0, 800.0, 17.0), Some(75.0)), + ], + }, + ) +} + +/// Slow midfield ball with the defense set; rated well under the threshold. +fn neutral_state() -> (BallFrameState, PlayerFrameState) { + ( + ball(glam::Vec3::new(0.0, 0.0, 93.0), glam::Vec3::ZERO), + PlayerFrameState { + players: vec![ + player(1, true, glam::Vec3::new(0.0, -1000.0, 17.0), Some(100.0)), + player(5, true, glam::Vec3::new(-1500.0, -2200.0, 17.0), Some(25.0)), + player(2, false, glam::Vec3::new(0.0, 2000.0, 17.0), Some(50.0)), + player(6, false, glam::Vec3::new(1200.0, 3200.0, 17.0), Some(75.0)), + ], + }, + ) +} + +#[allow(clippy::too_many_arguments)] +fn update( + calculator: &mut ExpectedGoalsCalculator, + frame_number: usize, + time: f32, + ball: &BallFrameState, + players: &PlayerFrameState, + events: FrameEventsState, + touches: Vec, + live: LivePlayState, +) { + let gameplay = GameplayState::default(); + let mut threat_features = ThreatFeaturesState::default(); + threat_features.update(true, ball, players, &events, &HashMap::new(), &live); + calculator + .update_parts( + &frame(frame_number, time), + &gameplay, + &events, + &touch_state(touches), + &threat_features, + ) + .unwrap(); +} + +#[test] +fn feature_names_and_array_agree() { + assert_eq!(ThreatFeatures::FEATURE_NAMES.len(), THREAT_FEATURE_COUNT); + let (ball, players) = dangerous_state(); + let features = compute_threat_features( + ball.position().unwrap(), + ball.velocity().unwrap(), + &players, + &no_demoed(), + &HashMap::new(), + true, + ) + .unwrap(); + assert_eq!( + features.to_array().len(), + ThreatFeatures::FEATURE_NAMES.len() + ); + let mut names: Vec<_> = ThreatFeatures::FEATURE_NAMES.to_vec(); + names.sort_unstable(); + names.dedup(); + assert_eq!( + names.len(), + THREAT_FEATURE_COUNT, + "feature names must be unique" + ); +} + +#[test] +fn features_are_bounded() { + let (ball, players) = dangerous_state(); + let features = compute_threat_features( + ball.position().unwrap(), + ball.velocity().unwrap(), + &players, + &no_demoed(), + &HashMap::new(), + true, + ) + .unwrap(); + for (name, value) in ThreatFeatures::FEATURE_NAMES + .iter() + .zip(features.to_array()) + { + let bounds = if name.contains("_spread_") { + 0.0..=2.0 + } else { + -1.0..=1.0 + }; + assert!( + bounds.contains(&value), + "feature {name} out of bounds: {value}" + ); + } +} + +/// A team-one attack that is the exact 180-degree rotation of a team-zero +/// attack must produce identical features: the attacking-frame normalization +/// is what makes one model serve both teams. +#[test] +fn mirrored_state_yields_identical_features_for_the_other_team() { + let ball_position = glam::Vec3::new(800.0, 3900.0, 250.0); + let ball_velocity = glam::Vec3::new(-300.0, 1200.0, 150.0); + let mirror = |v: glam::Vec3| glam::Vec3::new(-v.x, -v.y, v.z); + + let players_team_zero_attacking = PlayerFrameState { + players: vec![ + player(1, true, glam::Vec3::new(500.0, 3400.0, 17.0), Some(80.0)), + player(2, true, glam::Vec3::new(-1200.0, 1500.0, 17.0), Some(20.0)), + player(3, false, glam::Vec3::new(100.0, 4700.0, 17.0), Some(140.0)), + player(4, false, glam::Vec3::new(900.0, 2500.0, 400.0), None), + ], + }; + let players_mirrored = PlayerFrameState { + players: players_team_zero_attacking + .players + .iter() + .map(|sample| { + let mut mirrored = player( + match sample.player_id { + boxcars::RemoteId::Steam(id) => id, + _ => unreachable!(), + }, + !sample.is_team_0, + mirror(sample.position().unwrap()), + sample.boost_amount, + ); + mirrored.rigid_body.as_mut().unwrap().rotation = boxcars::Quaternion { + x: 0.0, + y: 0.0, + z: 1.0, + w: 0.0, + }; + mirrored + }) + .collect(), + }; + + let features_team_zero = compute_threat_features( + ball_position, + ball_velocity, + &players_team_zero_attacking, + &no_demoed(), + &HashMap::new(), + true, + ) + .unwrap(); + let features_team_one = compute_threat_features( + mirror(ball_position), + mirror(ball_velocity), + &players_mirrored, + &no_demoed(), + &HashMap::new(), + false, + ) + .unwrap(); + + assert_eq!(features_team_zero.to_array(), features_team_one.to_array()); +} + +#[test] +fn ballistic_on_target_hand_computed_fixture() { + // 820 uu from the goal line at 1400 uu/s: crosses at t = 0.5857s with + // z = 100 + 250 t + 0.5 * (-650) t^2 = 100 + 146.4 - 111.5 = 134.9 -- in + // the mouth, dead center. + let position = glam::Vec3::new(0.0, 4300.0, 100.0); + let velocity = glam::Vec3::new(0.0, 1400.0, 250.0); + assert!(ballistic_on_target(position, velocity)); + let expected_time = (STANDARD_GOAL_LINE_Y - 4300.0) / 1400.0; + assert!((seconds_to_goal_plane(position, velocity).unwrap() - expected_time).abs() < 1e-6); + + // Same shot hit much higher: z at the line = 100 + 1200 * 0.5857 - 111.5 + // = 691.4 -- over the 642.775 crossbar. + assert!(!ballistic_on_target( + glam::Vec3::new(0.0, 4300.0, 100.0), + glam::Vec3::new(0.0, 1400.0, 1200.0), + )); + + // Crossing x = 2000: wide of the 892.755 post. + assert!(!ballistic_on_target( + glam::Vec3::new(2000.0, 4300.0, 100.0), + glam::Vec3::new(0.0, 1400.0, 250.0), + )); + + // Moving away from the goal: no crossing, no time-to-goal-line. + assert!( + seconds_to_goal_plane( + glam::Vec3::new(0.0, 4300.0, 100.0), + glam::Vec3::new(0.0, -1400.0, 250.0), + ) + .is_none() + ); +} + +#[test] +fn touch_delta_event_carries_before_and_after_values() { + let mut calculator = ExpectedGoalsCalculator::new(); + let (neutral_ball, neutral_players) = neutral_state(); + let (danger_ball, danger_players) = dangerous_state(); + + update( + &mut calculator, + 1, + 1.0, + &neutral_ball, + &neutral_players, + FrameEventsState::default(), + vec![], + live_play(), + ); + assert!(calculator.touch_events().is_empty()); + let neutral_value = calculator.current_values().unwrap()[0]; + + update( + &mut calculator, + 2, + 1.1, + &danger_ball, + &danger_players, + FrameEventsState::default(), + vec![touch(2, 1.1, 1, true)], + live_play(), + ); + + let events = calculator.touch_events(); + assert_eq!(events.len(), 1); + let event = &events[0]; + assert_eq!(event.player, Some(player_id(1))); + assert!(event.team_is_team_0); + assert!((event.value_before - neutral_value).abs() < 1e-6); + assert_eq!(event.value_after, calculator.current_values().unwrap()[0]); + assert!( + event.delta() > 0.0, + "turning a neutral ball into an on-target chance must be a positive delta" + ); +} + +/// Two same-team touches on one frame share a single previous-frame -> +/// current-frame V transition; only the team's primary (latest, +/// best-evidence) touch may be credited or the accumulator double-counts it. +#[test] +fn simultaneous_same_team_touches_credit_one_event_for_the_frame_transition() { + let mut calculator = ExpectedGoalsCalculator::new(); + let (neutral_ball, neutral_players) = neutral_state(); + let (danger_ball, danger_players) = dangerous_state(); + + update( + &mut calculator, + 1, + 1.0, + &neutral_ball, + &neutral_players, + FrameEventsState::default(), + vec![], + live_play(), + ); + let neutral_value = calculator.current_values().unwrap()[0]; + + // Player 5's contact is backdated a frame; player 1's is the latest + // contact and therefore the team's primary touch. + update( + &mut calculator, + 2, + 1.1, + &danger_ball, + &danger_players, + FrameEventsState::default(), + vec![touch(1, 1.05, 5, true), touch(2, 1.1, 1, true)], + live_play(), + ); + + let events = calculator.touch_events(); + assert_eq!( + events.len(), + 1, + "one frame transition must be credited exactly once per team" + ); + let event = &events[0]; + assert_eq!(event.player, Some(player_id(1))); + assert!((event.value_before - neutral_value).abs() < 1e-6); + assert_eq!(event.value_after, calculator.current_values().unwrap()[0]); +} + +/// A cache-recovered touch is backdated: contact fields keep the touch's own +/// frame/time/id while the detection fields (which the ΔV brackets) carry the +/// processing frame. +#[test] +fn backdated_touch_keeps_contact_fields_and_detection_fields_separate() { + let mut calculator = ExpectedGoalsCalculator::new(); + let (neutral_ball, neutral_players) = neutral_state(); + let (danger_ball, danger_players) = dangerous_state(); + + update( + &mut calculator, + 2, + 1.1, + &neutral_ball, + &neutral_players, + FrameEventsState::default(), + vec![], + live_play(), + ); + let neutral_value = calculator.current_values().unwrap()[0]; + + update( + &mut calculator, + 3, + 1.2, + &danger_ball, + &danger_players, + FrameEventsState::default(), + vec![touch_with_id(1, 0.95, 1, true, Some(42))], + live_play(), + ); + + let events = calculator.touch_events(); + assert_eq!(events.len(), 1); + let event = &events[0]; + assert_eq!(event.frame, 1, "contact frame comes from the touch"); + assert!( + (event.time - 0.95).abs() < 1e-6, + "contact time comes from the touch" + ); + assert_eq!(event.touch_id, Some(42)); + assert_eq!( + event.detection_frame, 3, + "detection frame is the processing frame" + ); + assert!((event.detection_time - 1.2).abs() < 1e-6); + assert!( + (event.value_before - neutral_value).abs() < 1e-6, + "value_before brackets the detection frame, not the contact frame" + ); + assert_eq!(event.value_after, calculator.current_values().unwrap()[0]); +} + +#[test] +fn player_features_are_permutation_invariant_within_each_team() { + let players = PlayerFrameState { + players: vec![ + player(1, true, glam::Vec3::new(0.0, 2800.0, 17.0), Some(25.5)), + player(2, true, glam::Vec3::new(500.0, 500.0, 17.0), Some(51.0)), + player(3, false, glam::Vec3::new(0.0, 3600.0, 17.0), Some(76.5)), + player(4, false, glam::Vec3::new(500.0, 4800.0, 17.0), Some(102.0)), + ], + }; + let dodge_available = HashMap::from([ + (player_id(1), true), + (player_id(2), false), + (player_id(3), true), + (player_id(4), false), + ]); + let features = compute_threat_features( + glam::Vec3::new(0.0, 3000.0, 93.0), + glam::Vec3::ZERO, + &players, + &no_demoed(), + &dodge_available, + true, + ) + .unwrap(); + assert_eq!(features.own_team.mean.boost, 0.15); + assert!((features.own_team.spread.boost - 0.1).abs() < 1e-6); + assert!((features.opponent_team.mean.boost - 0.35).abs() < 1e-6); + assert!((features.opponent_team.spread.boost - 0.1).abs() < 1e-6); + assert_eq!(features.own_team.mean.dodge_available, 0.5); + assert_eq!(features.own_team.spread.dodge_available, 1.0); + assert_eq!(features.opponent_team.mean.dodge_available, 0.5); + assert_eq!(features.opponent_team.spread.dodge_available, 1.0); + + let reordered_players = PlayerFrameState { + players: players.players.iter().rev().cloned().collect(), + }; + let reordered = compute_threat_features( + glam::Vec3::new(0.0, 3000.0, 93.0), + glam::Vec3::ZERO, + &reordered_players, + &no_demoed(), + &dodge_available, + true, + ) + .unwrap(); + assert_eq!(features, reordered); + + // The same frame with the defender demoed: no eligible defenders, and the + // zero-roster guard keeps the feature finite. + let demoed: HashSet = [player_id(3)].into_iter().collect(); + let features_demoed = compute_threat_features( + glam::Vec3::new(0.0, 3000.0, 93.0), + glam::Vec3::ZERO, + &players, + &demoed, + &dodge_available, + true, + ) + .unwrap(); + assert_eq!(features_demoed.opponent_team.mean.demoed, 0.5); + assert_eq!(features_demoed.opponent_team.spread.demoed, 1.0); +} + +/// A same-team goal arriving after the pending episode's goal grace has +/// passed must NOT upgrade the stale episode: it closes as the stoppage it +/// already was. +#[test] +fn goal_after_pending_grace_expiry_does_not_upgrade_episode() { + let mut calculator = ExpectedGoalsCalculator::new(); + let (danger_ball, danger_players) = dangerous_state(); + + update( + &mut calculator, + 1, + 1.0, + &danger_ball, + &danger_players, + FrameEventsState::default(), + vec![], + live_play(), + ); + update( + &mut calculator, + 2, + 1.1, + &danger_ball, + &danger_players, + FrameEventsState::default(), + vec![], + stoppage(), + ); + assert!(calculator.episode_events().is_empty()); + + // Goal detection runs before stale-pending expiry within a frame, so + // this goal (well past closed_at + grace) sees the pending episode. + update( + &mut calculator, + 3, + 20.0, + &danger_ball, + &danger_players, + FrameEventsState { + goal_events: vec![goal_event(3, 20.0, true)], + ..FrameEventsState::default() + }, + vec![], + stoppage(), + ); + + let episodes = calculator.episode_events(); + assert_eq!(episodes.len(), 1); + assert!(!episodes[0].ended_in_goal); + assert_eq!(episodes[0].end_reason, ThreatEpisodeEndReason::Stoppage); + assert_eq!(calculator.goal_records().len(), 1); +} + +#[test] +fn episode_opens_above_threshold_and_closes_on_value_drop() { + let mut calculator = ExpectedGoalsCalculator::new(); + let (neutral_ball, neutral_players) = neutral_state(); + let (danger_ball, danger_players) = dangerous_state(); + + update( + &mut calculator, + 1, + 1.0, + &neutral_ball, + &neutral_players, + FrameEventsState::default(), + vec![], + live_play(), + ); + assert!(calculator.episode_events().is_empty()); + + // The touch that creates the chance opens the episode on the same frame. + update( + &mut calculator, + 2, + 1.1, + &danger_ball, + &danger_players, + FrameEventsState::default(), + vec![touch(2, 1.1, 1, true)], + live_play(), + ); + update( + &mut calculator, + 3, + 1.2, + &danger_ball, + &danger_players, + FrameEventsState::default(), + vec![], + live_play(), + ); + assert!(calculator.episode_events().is_empty()); + let peak = calculator.current_values().unwrap()[0]; + assert!(peak > THREAT_EPISODE_THRESHOLD); + + update( + &mut calculator, + 4, + 1.3, + &neutral_ball, + &neutral_players, + FrameEventsState::default(), + vec![], + live_play(), + ); + let neutral_value = calculator.current_values().unwrap()[0]; + + let episodes = calculator.episode_events(); + assert_eq!(episodes.len(), 1); + let episode = &episodes[0]; + assert!(episode.team_is_team_0); + assert_eq!(episode.start_frame, 2); + assert_eq!(episode.end_frame, 4); + assert_eq!(episode.end_reason, ThreatEpisodeEndReason::ValueDropped); + assert!(!episode.ended_in_goal); + assert!((episode.peak_value - peak).abs() < 1e-6); + // xg is the time integral over the episode's evaluated frames: the two + // danger frames plus the sub-threshold frame that closed it, each + // contributing V * dt / tau with dt = 0.1. + let expected_integral = + (2.0 * peak + neutral_value) * 0.1 / expected_goals_model::THREAT_HORIZON_SECONDS; + assert!((episode.xg - expected_integral).abs() < 1e-6); + assert!(episode.xg < episode.peak_value); + assert_eq!(episode.credited_player, Some(player_id(1))); +} + +#[test] +fn episode_hysteresis_keeps_small_threat_dips_in_one_incident() { + let mut calculator = ExpectedGoalsCalculator::new(); + + calculator.update_episodes(&frame(1, 1.0), [0.30, 0.0]); + calculator.update_episodes( + &frame(2, 1.1), + [ + (THREAT_EPISODE_THRESHOLD + THREAT_EPISODE_END_THRESHOLD) * 0.5, + 0.0, + ], + ); + assert!( + calculator.episode_events().is_empty(), + "a dip inside the hysteresis band must not split the incident" + ); + + calculator.update_episodes(&frame(3, 1.2), [THREAT_EPISODE_END_THRESHOLD, 0.0]); + let episodes = calculator.episode_events(); + assert_eq!(episodes.len(), 1); + assert_eq!(episodes[0].start_frame, 1); + assert_eq!(episodes[0].end_frame, 3); + assert!((episodes[0].incident_xg - 0.30 * INCIDENT_XG_CALIBRATION_FACTOR).abs() < 1e-6); +} + +#[test] +fn goal_incident_uses_peak_before_final_touch_exclusion_window() { + let mut calculator = ExpectedGoalsCalculator::new(); + calculator.update_episodes(&frame(1, 1.0), [0.30, 0.0]); + calculator.update_episodes(&frame(2, 1.3), [0.45, 0.0]); + calculator.update_episodes(&frame(3, 1.8), [0.80, 0.0]); + calculator.team_states[0].last_touch_time = Some(1.8); + + calculator.close_episode_as_goal(&frame(4, 2.0), 2.0, true); + + let episode = &calculator.episode_events()[0]; + assert!(episode.ended_in_goal); + assert!((episode.peak_value - 0.80).abs() < 1e-6); + assert_eq!(episode.peak_frame, 3); + assert!((episode.goal_exclusion_start_time.unwrap() - 1.3).abs() < 1e-6); + assert!((episode.incident_peak_value - 0.30).abs() < 1e-6); + assert!((episode.incident_xg - 0.30 * INCIDENT_XG_CALIBRATION_FACTOR).abs() < 1e-6); + assert_eq!(episode.incident_xg_frame, Some(1)); + assert_eq!(episode.incident_xg_time, Some(1.0)); +} + +#[test] +fn goal_incident_contributes_zero_when_it_opens_inside_exclusion_window() { + let mut calculator = ExpectedGoalsCalculator::new(); + calculator.update_episodes(&frame(1, 1.5), [0.80, 0.0]); + calculator.team_states[0].last_touch_time = Some(1.8); + + calculator.close_episode_as_goal(&frame(2, 2.0), 2.0, true); + + let episode = &calculator.episode_events()[0]; + assert_eq!(episode.incident_xg, 0.0); + assert_eq!(episode.incident_xg_frame, None); + assert_eq!(episode.incident_xg_time, None); +} + +/// Player credit follows the toucher associated with the episode's peak, not +/// simply the last teammate to touch before the episode closes. +#[test] +fn later_lower_value_touch_does_not_steal_episode_credit_from_peak_toucher() { + let mut calculator = ExpectedGoalsCalculator::new(); + calculator.team_states[0].last_toucher = Some(player_id(1)); + + calculator.update_episodes(&frame(1, 1.0), [0.5, 0.0]); + calculator.emit_touch_events( + &frame(2, 1.1), + &touch_state(vec![touch(2, 1.1, 2, true)]), + [0.3, 0.0], + ); + calculator.update_episodes(&frame(2, 1.1), [0.3, 0.0]); + calculator.update_episodes(&frame(3, 1.2), [0.0, 0.0]); + + let episodes = calculator.episode_events(); + assert_eq!(episodes.len(), 1); + assert_eq!(episodes[0].peak_value, 0.5); + assert_eq!(episodes[0].credited_player, Some(player_id(1))); +} + +#[test] +fn goal_closes_episode_with_goal_outcome() { + let mut calculator = ExpectedGoalsCalculator::new(); + let (danger_ball, danger_players) = dangerous_state(); + + update( + &mut calculator, + 1, + 1.0, + &danger_ball, + &danger_players, + FrameEventsState::default(), + vec![touch(1, 1.0, 1, true)], + live_play(), + ); + assert!(calculator.episode_events().is_empty()); + + // The goal frame: live play already over, goal event attributed. + update( + &mut calculator, + 2, + 1.1, + &danger_ball, + &danger_players, + FrameEventsState { + goal_events: vec![goal_event(2, 1.1, true)], + ..FrameEventsState::default() + }, + vec![], + stoppage(), + ); + + let episodes = calculator.episode_events(); + assert_eq!(episodes.len(), 1); + let episode = &episodes[0]; + assert!(episode.ended_in_goal); + assert_eq!(episode.end_reason, ThreatEpisodeEndReason::Goal); + assert_eq!(episode.credited_player, Some(player_id(1))); + assert_eq!(calculator.goal_records().len(), 1); +} + +/// A goal attributed a few frames *after* the stoppage that closed the +/// episode still upgrades it: stoppage-closed episodes wait pending for the +/// goal attribution. +#[test] +fn late_goal_attribution_resolves_pending_stoppage_episode_as_goal() { + let mut calculator = ExpectedGoalsCalculator::new(); + let (danger_ball, danger_players) = dangerous_state(); + + update( + &mut calculator, + 1, + 1.0, + &danger_ball, + &danger_players, + FrameEventsState::default(), + vec![touch(1, 1.0, 1, true)], + live_play(), + ); + update( + &mut calculator, + 2, + 1.1, + &danger_ball, + &danger_players, + FrameEventsState::default(), + vec![], + stoppage(), + ); + assert!( + calculator.episode_events().is_empty(), + "stoppage-closed episode must stay pending until the goal resolves" + ); + + update( + &mut calculator, + 3, + 1.5, + &danger_ball, + &danger_players, + FrameEventsState { + goal_events: vec![goal_event(3, 1.5, true)], + ..FrameEventsState::default() + }, + vec![], + stoppage(), + ); + + let episodes = calculator.episode_events(); + assert_eq!(episodes.len(), 1); + assert!(episodes[0].ended_in_goal); + assert_eq!(episodes[0].end_reason, ThreatEpisodeEndReason::Goal); + assert_eq!(episodes[0].incident_xg, 0.0); + assert_eq!(episodes[0].goal_exclusion_start_time, Some(0.5)); +} + +/// A stoppage with no following goal emits the episode as a plain stoppage +/// once the goal grace expires. +#[test] +fn pending_stoppage_episode_without_goal_resolves_as_stoppage() { + let mut calculator = ExpectedGoalsCalculator::new(); + let (danger_ball, danger_players) = dangerous_state(); + + update( + &mut calculator, + 1, + 1.0, + &danger_ball, + &danger_players, + FrameEventsState::default(), + vec![], + live_play(), + ); + update( + &mut calculator, + 2, + 1.1, + &danger_ball, + &danger_players, + FrameEventsState::default(), + vec![], + stoppage(), + ); + update( + &mut calculator, + 3, + 20.0, + &danger_ball, + &danger_players, + FrameEventsState::default(), + vec![], + stoppage(), + ); + + let episodes = calculator.episode_events(); + assert_eq!(episodes.len(), 1); + assert!(!episodes[0].ended_in_goal); + assert_eq!(episodes[0].end_reason, ThreatEpisodeEndReason::Stoppage); +} + +#[test] +fn finish_closes_active_episode_as_replay_end() { + let mut calculator = ExpectedGoalsCalculator::new(); + let (danger_ball, danger_players) = dangerous_state(); + + update( + &mut calculator, + 1, + 1.0, + &danger_ball, + &danger_players, + FrameEventsState::default(), + vec![], + live_play(), + ); + calculator.finish_calculation().unwrap(); + + let episodes = calculator.episode_events(); + assert_eq!(episodes.len(), 1); + assert_eq!(episodes[0].end_reason, ThreatEpisodeEndReason::ReplayEnd); +} + +/// Constant V over N evaluated frames of known dt integrates to exactly +/// N * V * dt / tau (replay-end close: no extra closing-frame contribution). +#[test] +fn episode_xg_is_the_time_integral_of_v_over_the_episode() { + let mut calculator = ExpectedGoalsCalculator::new(); + let (danger_ball, danger_players) = dangerous_state(); + + let frame_count = 3usize; + for step in 0..frame_count { + update( + &mut calculator, + step + 1, + 1.0 + step as f32 * 0.1, + &danger_ball, + &danger_players, + FrameEventsState::default(), + vec![], + live_play(), + ); + } + let value = calculator.current_values().unwrap()[0]; + calculator.finish_calculation().unwrap(); + + let episodes = calculator.episode_events(); + assert_eq!(episodes.len(), 1); + let episode = &episodes[0]; + let expected = frame_count as f32 * value * 0.1 / expected_goals_model::THREAT_HORIZON_SECONDS; + assert!( + (episode.xg - expected).abs() < 1e-6, + "episode xg {} != N * V * dt / tau = {}", + episode.xg, + expected + ); + assert!((episode.peak_value - value).abs() < 1e-6); +} + +/// The team's full-match integral accumulates on every evaluated live frame, +/// including sub-threshold ones where no episode ever opens. +#[test] +fn team_xg_integral_accumulates_sub_threshold_frames_without_episodes() { + let mut calculator = ExpectedGoalsCalculator::new(); + let (neutral_ball, neutral_players) = neutral_state(); + + let frame_count = 4usize; + for step in 0..frame_count { + update( + &mut calculator, + step + 1, + 1.0 + step as f32 * 0.1, + &neutral_ball, + &neutral_players, + FrameEventsState::default(), + vec![], + live_play(), + ); + } + let value = calculator.current_values().unwrap()[0]; + assert!(value < THREAT_EPISODE_THRESHOLD); + calculator.finish_calculation().unwrap(); + + assert!(calculator.episode_events().is_empty()); + let integrals = calculator.team_xg_integrals(); + let expected = + f64::from(frame_count as f32 * value * 0.1 / expected_goals_model::THREAT_HORIZON_SECONDS); + assert!(integrals[0] > 0.0); + assert!((integrals[0] - expected).abs() < 1e-6); + assert!(integrals[1] > 0.0); + + // The accumulator's team xg is fed from exactly this state. + let mut accumulator = ExpectedGoalsStatsAccumulator::new(); + accumulator.set_team_xg_integrals(integrals); + accumulator.set_current_values(calculator.current_values()); + assert!((f64::from(accumulator.team_stats(true).xg) - integrals[0]).abs() < 1e-6); + assert!((f64::from(accumulator.team_stats(false).xg) - integrals[1]).abs() < 1e-6); + assert_eq!(accumulator.team_stats(true).current_threat, Some(value)); +} + +#[test] +fn accumulator_folds_touch_deltas_and_episode_xg() { + let mut accumulator = ExpectedGoalsStatsAccumulator::new(); + + accumulator.apply_touch_event(&ThreatTouchEvent { + time: 1.0, + frame: 1, + touch_id: None, + detection_frame: 1, + detection_time: 1.0, + team_is_team_0: true, + player: Some(player_id(1)), + value_before: 0.05, + value_after: 0.30, + }); + // Negative deltas do not subtract from threat added. + accumulator.apply_touch_event(&ThreatTouchEvent { + time: 2.0, + frame: 2, + touch_id: None, + detection_frame: 2, + detection_time: 2.0, + team_is_team_0: true, + player: Some(player_id(1)), + value_before: 0.30, + value_after: 0.10, + }); + accumulator.apply_episode_event(&ThreatEpisodeEvent { + start_time: 1.0, + start_frame: 1, + end_time: 2.0, + end_frame: 2, + team_is_team_0: true, + xg: 0.4, + peak_value: 0.6, + peak_frame: 2, + peak_time: 2.0, + incident_peak_value: 0.2, + incident_xg: 0.2, + incident_xg_frame: Some(1), + incident_xg_time: Some(1.0), + goal_exclusion_start_time: Some(1.5), + credited_player: Some(player_id(1)), + ended_in_goal: true, + end_reason: ThreatEpisodeEndReason::Goal, + }); + // Team-only credit still advances the team's episode counters. + accumulator.apply_episode_event(&ThreatEpisodeEvent { + start_time: 3.0, + start_frame: 3, + end_time: 4.0, + end_frame: 4, + team_is_team_0: true, + xg: 0.2, + peak_value: 0.3, + peak_frame: 3, + peak_time: 3.0, + incident_peak_value: 0.3, + incident_xg: 0.3, + incident_xg_frame: Some(3), + incident_xg_time: Some(3.0), + goal_exclusion_start_time: None, + credited_player: None, + ended_in_goal: false, + end_reason: ThreatEpisodeEndReason::ValueDropped, + }); + // Team xG comes from the full-match integral, not the episode sum; the + // gap (1.0 vs the 0.6 of episode xg) is the diffuse sub-threshold threat + // that is never attributed to any player. + accumulator.set_team_xg_integrals([1.0, 0.25]); + accumulator.set_current_values(Some([0.4, 0.1])); + + let player_stats = accumulator.player_stats().get(&player_id(1)).unwrap(); + assert!((player_stats.threat_added - 0.25).abs() < 1e-6); + assert!((player_stats.xg - 0.4).abs() < 1e-6); + assert_eq!(player_stats.credited_episode_count, 1); + assert_eq!(player_stats.credited_goal_episode_count, 1); + + let team = accumulator.team_stats(true); + assert!((team.xg - 1.0).abs() < 1e-6); + assert!((team.incident_xg - 0.5).abs() < 1e-6); + assert_eq!(team.current_threat, Some(0.4)); + assert_eq!(team.episode_count, 2); + assert_eq!(team.goal_episode_count, 1); + let other_team = accumulator.team_stats(false); + assert_eq!(other_team.current_threat, Some(0.1)); + assert_eq!(other_team.episode_count, 0); + assert!((other_team.xg - 0.25).abs() < 1e-6); + + accumulator.set_current_values(None); + assert_eq!(accumulator.team_stats(true).current_threat, None); + assert_eq!(accumulator.team_stats(false).current_threat, None); +} diff --git a/src/stats/calculators/frame_components.rs b/src/stats/calculators/frame_components.rs index 28cbe6255..4ed5465d2 100644 --- a/src/stats/calculators/frame_components.rs +++ b/src/stats/calculators/frame_components.rs @@ -103,6 +103,27 @@ pub struct PlayerFrameState { pub players: Vec, } +/// Per-player replicated second-jump/dodge action bytes used by stateful feature +/// extraction. These are kept separate from physical player samples so +/// callers constructing synthetic physics fixtures do not need to provide +/// controller-state details. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +pub struct PlayerControlSample { + pub double_jump_active: bool, + pub dodge_active: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PlayerControlState { + pub players: HashMap, +} + +impl PlayerControlState { + pub fn sample(&self, player_id: &PlayerId) -> PlayerControlSample { + self.players.get(player_id).copied().unwrap_or_default() + } +} + impl PlayerFrameState { pub fn player(&self, player_id: &PlayerId) -> Option<&PlayerSample> { self.players diff --git a/src/stats/calculators/frame_input.rs b/src/stats/calculators/frame_input.rs index 594810d85..b0bda9f9d 100644 --- a/src/stats/calculators/frame_input.rs +++ b/src/stats/calculators/frame_input.rs @@ -2,7 +2,7 @@ use crate::*; use super::{ BallFrameState, BallSample, DemoEventSample, FrameEventsState, FrameInfo, GameplayState, - LivePlayState, PlayerFrameState, PlayerSample, + LivePlayState, PlayerControlSample, PlayerControlState, PlayerFrameState, PlayerSample, }; /// Builds the per-frame input fed into the analysis graph from replay state. @@ -83,6 +83,7 @@ pub struct FrameInput { gameplay_state: GameplayState, ball_frame_state: BallFrameState, player_frame_state: PlayerFrameState, + player_control_state: PlayerControlState, frame_events_state: FrameEventsState, live_play_state: Option, } @@ -105,6 +106,7 @@ impl FrameInput { gameplay_state, ball_frame_state, player_frame_state, + player_control_state: PlayerControlState::default(), frame_events_state, live_play_state: None, } @@ -128,6 +130,7 @@ impl FrameInput { gameplay_state, ball_frame_state, player_frame_state, + player_control_state: PlayerControlState::default(), frame_events_state, live_play_state: Some(live_play_state), } @@ -161,6 +164,7 @@ impl FrameInput { gameplay_state: Self::build_gameplay_state(processor), ball_frame_state: Self::build_ball_frame_state(processor, current_time), player_frame_state: Self::build_player_frame_state(processor, current_time), + player_control_state: Self::build_player_control_state(processor), frame_events_state, live_play_state: None, } @@ -268,6 +272,26 @@ impl FrameInput { PlayerFrameState { players } } + fn build_player_control_state(processor: &dyn ProcessorView) -> PlayerControlState { + let players = processor + .iter_player_ids_in_order() + .map(|player_id| { + ( + player_id.clone(), + PlayerControlSample { + double_jump_active: processor + .get_double_jump_active(player_id) + .unwrap_or(0) + % 2 + == 1, + dodge_active: processor.get_dodge_active(player_id).unwrap_or(0) % 2 == 1, + }, + ) + }) + .collect(); + PlayerControlState { players } + } + fn build_active_demo_events(processor: &dyn ProcessorView) -> Vec { let active_demo_events = processor.current_frame_active_demo_events(); if !active_demo_events.is_empty() { @@ -341,6 +365,10 @@ impl FrameInput { self.player_frame_state.clone() } + pub fn player_control_state(&self) -> PlayerControlState { + self.player_control_state.clone() + } + pub fn frame_events_state(&self) -> FrameEventsState { self.frame_events_state.clone() } diff --git a/src/stats/calculators/mod.rs b/src/stats/calculators/mod.rs index 1b3d51fa9..9fdcb5e7c 100644 --- a/src/stats/calculators/mod.rs +++ b/src/stats/calculators/mod.rs @@ -69,6 +69,10 @@ pub mod dodge_reset; pub use dodge_reset::*; pub mod double_tap; pub use double_tap::*; +pub mod expected_goals; +pub use expected_goals::*; +pub mod expected_goals_model; +pub use expected_goals_model::*; pub mod fifty_fifty; pub use fifty_fifty::*; pub mod fifty_fifty_state; diff --git a/src/stats/calculators/touch_state.rs b/src/stats/calculators/touch_state.rs index 6b6467aa9..46f2fc116 100644 --- a/src/stats/calculators/touch_state.rs +++ b/src/stats/calculators/touch_state.rs @@ -11,7 +11,19 @@ pub struct TouchState { impl TouchState { pub fn primary_touch_event(&self) -> Option<&TouchEvent> { - primary_touch_event(&self.touch_events) + primary_touch_event(self.touch_events.iter()) + } + + /// The primary touch among this frame's touches for one team: the latest + /// contact (by frame, then time), tie-broken by the same evidence ordering + /// as [`Self::primary_touch_event`] (contact score, then contact gap, + /// dodge evidence, and stable player identity). + pub fn primary_touch_event_for_team(&self, is_team_0: bool) -> Option<&TouchEvent> { + primary_touch_event( + self.touch_events + .iter() + .filter(|event| event.team_is_team_0 == is_team_0), + ) } } @@ -114,12 +126,13 @@ fn touch_event_chronological_ordering(left: &TouchEvent, right: &TouchEvent) -> TouchEvent::timestamp_ordering(left, right).then_with(|| touch_event_ordering(left, right)) } -fn primary_touch_event(touch_events: &[TouchEvent]) -> Option<&TouchEvent> { +fn primary_touch_event<'a>( + touch_events: impl Iterator + Clone, +) -> Option<&'a TouchEvent> { let latest_touch = touch_events - .iter() + .clone() .max_by(|left, right| TouchEvent::timestamp_ordering(left, right))?; touch_events - .iter() .filter(|event| TouchEvent::timestamp_ordering(event, latest_touch).is_eq()) .min_by(|left, right| touch_event_ordering(left, right)) } @@ -829,7 +842,7 @@ impl TouchStateCalculator { Vec::new() }; - if let Some(last_touch) = primary_touch_event(&touch_events) { + if let Some(last_touch) = primary_touch_event(touch_events.iter()) { self.current_last_touch = Some(last_touch.clone()); } self.previous_ball_rigid_body = ball.sample().map(|sample| (sample.rigid_body, frame.time)); diff --git a/src/stats/timeline/collector.rs b/src/stats/timeline/collector.rs index 3fdf37324..d5b1f9e69 100644 --- a/src/stats/timeline/collector.rs +++ b/src/stats/timeline/collector.rs @@ -2,7 +2,8 @@ use crate::collector::frame_resolution::{ FinalStatsFrameAction, StatsFramePersistenceController, StatsFrameResolution, }; use crate::stats::analysis_graph::{ - AnalysisGraph, StatsTimelineEventsNode, StatsTimelineFrameNode, StatsTimelineFrameState, + AnalysisGraph, StatsProjectionNode, StatsTimelineEventsNode, StatsTimelineFrameNode, + StatsTimelineFrameState, }; use crate::stats::calculators::ReplayFrameInputBuilder; use crate::*; @@ -26,15 +27,43 @@ fn reduced_timeline_events(graph: &AnalysisGraph) -> ReplayStatsTimelineEvents { const ABSENT_PLAYER_MIN_MISSING_SECONDS: f32 = 30.0; pub fn build_legacy_timeline_graph() -> AnalysisGraph { + build_legacy_timeline_graph_with_expected_goals(false) +} + +pub fn build_legacy_timeline_graph_with_expected_goals( + include_expected_goals: bool, +) -> AnalysisGraph { let mut graph = AnalysisGraph::new().with_input_state_type::(); + let projection = if include_expected_goals { + StatsProjectionNode::new().with_expected_goals() + } else { + StatsProjectionNode::new() + }; + graph.push_boxed_node(Box::new(projection)); graph.push_boxed_node(Box::new(StatsTimelineFrameNode::new())); - graph.push_boxed_node(Box::new(StatsTimelineEventsNode::new())); + let events = if include_expected_goals { + StatsTimelineEventsNode::new().with_expected_goals() + } else { + StatsTimelineEventsNode::new() + }; + graph.push_boxed_node(Box::new(events)); graph } pub fn build_timeline_event_graph() -> AnalysisGraph { + build_timeline_event_graph_with_expected_goals(false) +} + +pub fn build_timeline_event_graph_with_expected_goals( + include_expected_goals: bool, +) -> AnalysisGraph { let mut graph = AnalysisGraph::new().with_input_state_type::(); - graph.push_boxed_node(Box::new(StatsTimelineEventsNode::new())); + let node = if include_expected_goals { + StatsTimelineEventsNode::new().with_expected_goals() + } else { + StatsTimelineEventsNode::new() + }; + graph.push_boxed_node(Box::new(node)); graph } @@ -151,6 +180,11 @@ impl StatsTimelineCollector { } } + pub fn with_expected_goals(mut self) -> Self { + self.graph = build_legacy_timeline_graph_with_expected_goals(true); + self + } + fn timeline_config(&self) -> StatsTimelineConfig { default_stats_timeline_config() } @@ -208,6 +242,11 @@ pub struct StatsTimelineEventCollector { last_replay_meta_player_count: Option, last_sample_time: Option, frame_persistence: StatsFramePersistenceController, + include_expected_goals: bool, + expected_goals: ExpectedGoalsStatsAccumulator, + expected_goals_touch_cursor: usize, + expected_goals_episode_cursor: usize, + expected_goals_tracks: ExpectedGoalsTimelineTracks, } impl Default for StatsTimelineEventCollector { @@ -226,9 +265,37 @@ impl StatsTimelineEventCollector { last_replay_meta_player_count: None, last_sample_time: None, frame_persistence: StatsFramePersistenceController::new(StatsFrameResolution::default()), + include_expected_goals: false, + expected_goals: ExpectedGoalsStatsAccumulator::new(), + expected_goals_touch_cursor: 0, + expected_goals_episode_cursor: 0, + expected_goals_tracks: ExpectedGoalsTimelineTracks { + config: ExpectedGoalsCalculatorConfig::default(), + teams: vec![ + ExpectedGoalsTeamTimelineTrack { + is_team_0: true, + points: Vec::new(), + }, + ExpectedGoalsTeamTimelineTrack { + is_team_0: false, + points: Vec::new(), + }, + ], + players: Vec::new(), + episodes: Vec::new(), + }, } } + /// Enables model-backed expected-goals calculation and compact tracks. + /// It is disabled by default because it evaluates an ML feature/model path + /// on every live replay frame. + pub fn with_expected_goals(mut self) -> Self { + self.graph = build_timeline_event_graph_with_expected_goals(true); + self.include_expected_goals = true; + self + } + pub fn with_frame_resolution(mut self, resolution: StatsFrameResolution) -> Self { self.frame_persistence = StatsFramePersistenceController::new(resolution); self @@ -290,6 +357,103 @@ impl StatsTimelineEventCollector { }) } + fn refresh_expected_goals(&mut self) -> SubtrActorResult<()> { + let calculator = self + .graph + .state::() + .ok_or_else(|| { + SubtrActorError::new(SubtrActorErrorVariant::CallbackError( + "missing ExpectedGoalsCalculator while building compact stats timeline" + .to_owned(), + )) + })?; + let touch_events = calculator.touch_events()[self.expected_goals_touch_cursor..].to_vec(); + let episode_events = + calculator.episode_events()[self.expected_goals_episode_cursor..].to_vec(); + let team_xg_integrals = calculator.team_xg_integrals(); + let current_values = calculator.current_values(); + self.expected_goals_tracks.config = calculator.config().clone(); + self.expected_goals_touch_cursor = calculator.touch_events().len(); + self.expected_goals_episode_cursor = calculator.episode_events().len(); + + for event in &touch_events { + self.expected_goals.apply_touch_event(event); + } + for event in &episode_events { + self.expected_goals.apply_episode_event(event); + } + self.expected_goals_tracks.episodes.extend(episode_events); + self.expected_goals.set_team_xg_integrals(team_xg_integrals); + self.expected_goals.set_current_values(current_values); + Ok(()) + } + + fn record_expected_goals_tracks(&mut self, frame: usize) -> SubtrActorResult<()> { + self.refresh_expected_goals()?; + + for (index, is_team_0) in [true, false].into_iter().enumerate() { + let stats = self.expected_goals.team_stats(is_team_0).clone(); + let track = &mut self.expected_goals_tracks.teams[index]; + if track + .points + .last() + .is_some_and(|point| point.frame == frame) + { + track.points.last_mut().expect("point exists").stats = stats; + } else if track.points.last().is_none_or(|point| point.stats != stats) { + track + .points + .push(ExpectedGoalsTeamTimelinePoint { frame, stats }); + } + } + + let replay_meta = self.replay_meta()?.clone(); + for player in replay_meta.player_order() { + let Some(stats) = self + .expected_goals + .player_stats() + .get(&player.remote_id) + .cloned() + else { + continue; + }; + let is_team_0 = Self::is_team_zero_player(&replay_meta, player); + let track = match self + .expected_goals_tracks + .players + .iter_mut() + .find(|track| track.player_id == player.remote_id) + { + Some(track) => track, + None => { + self.expected_goals_tracks + .players + .push(ExpectedGoalsPlayerTimelineTrack { + player_id: player.remote_id.clone(), + is_team_0, + points: Vec::new(), + }); + self.expected_goals_tracks + .players + .last_mut() + .expect("just pushed player track") + } + }; + if track + .points + .last() + .is_some_and(|point| point.frame == frame) + { + track.points.last_mut().expect("point exists").stats = stats; + } else if track.points.last().is_none_or(|point| point.stats != stats) { + track + .points + .push(ExpectedGoalsPlayerTimelinePoint { frame, stats }); + } + } + Ok(()) + } + pub fn into_replay_stats_timeline_scaffold( self, ) -> SubtrActorResult { @@ -328,6 +492,7 @@ impl StatsTimelineEventCollector { activity_summary, positioning_summary, accumulation_tracks, + expected_goals_tracks: self.expected_goals_tracks, }) } @@ -488,6 +653,9 @@ impl Collector for StatsTimelineEventCollector { if let Some(emitted_dt) = self.frame_persistence.on_frame(frame_number, current_time) { let mut frame = self.snapshot_frame_scaffold()?; frame.dt = emitted_dt; + if self.include_expected_goals { + self.record_expected_goals_tracks(frame.frame_number)?; + } self.frames.push(frame); } @@ -503,6 +671,9 @@ impl Collector for StatsTimelineEventCollector { return Ok(()); }; let mut final_snapshot = self.snapshot_frame_scaffold()?; + if self.include_expected_goals { + self.record_expected_goals_tracks(final_snapshot.frame_number)?; + } match self .frame_persistence .final_frame_action(final_snapshot.frame_number, final_snapshot.time) diff --git a/src/stats/timeline/collector_tests.rs b/src/stats/timeline/collector_tests.rs index dd03797d7..cd285da3e 100644 --- a/src/stats/timeline/collector_tests.rs +++ b/src/stats/timeline/collector_tests.rs @@ -64,6 +64,29 @@ fn event_set_counts(events: &ReplayStatsTimelineEvents) -> BTreeMap ExpectedGoalsTeamStats { + track + .points + .iter() + .rev() + .find(|point| point.frame <= frame) + .map(|point| point.stats.clone()) + .unwrap_or_default() +} + +fn expected_goals_player_at( + track: Option<&ExpectedGoalsPlayerTimelineTrack>, + frame: usize, +) -> ExpectedGoalsPlayerStats { + track + .and_then(|track| track.points.iter().rev().find(|point| point.frame <= frame)) + .map(|point| point.stats.clone()) + .unwrap_or_default() +} + fn canonical_event_sets(events: &ReplayStatsTimelineEvents) -> BTreeMap> { let value = serde_json::to_value(events).expect("events should serialize"); value @@ -138,13 +161,50 @@ fn event_timeline_graph_does_not_build_full_stats_frame_snapshots() { !node_names.contains(&"stats_projection"), "event timeline transfer should not evaluate full partial-sum projections" ); + assert!( + !node_names.contains(&"expected_goals"), + "model-backed expected goals should be off in the default timeline graph" + ); + + let mut opt_in_graph = build_timeline_event_graph_with_expected_goals(true); + opt_in_graph + .resolve() + .expect("opt-in expected-goals graph should resolve"); + assert!( + opt_in_graph + .node_names() + .any(|name| name == "expected_goals") + ); +} + +#[test] +fn legacy_timeline_graph_also_requires_expected_goals_opt_in() { + let mut default_graph = build_legacy_timeline_graph(); + default_graph + .resolve() + .expect("default legacy graph should resolve"); + assert!( + !default_graph + .node_names() + .any(|name| name == "expected_goals") + ); + + let mut opt_in_graph = build_legacy_timeline_graph_with_expected_goals(true); + opt_in_graph + .resolve() + .expect("opt-in legacy graph should resolve"); + assert!( + opt_in_graph + .node_names() + .any(|name| name == "expected_goals") + ); } fn assert_event_timeline_scaffold_matches_full_timeline_without_stat_snapshots(replay_path: &str) { let replay = parse_replay(replay_path); let mut processor = ReplayProcessor::new(&replay).expect("replay processor should initialize"); - let mut full_collector = StatsTimelineCollector::new(); - let mut scaffold_collector = StatsTimelineEventCollector::new(); + let mut full_collector = StatsTimelineCollector::new().with_expected_goals(); + let mut scaffold_collector = StatsTimelineEventCollector::new().with_expected_goals(); processor .process_all(&mut [&mut full_collector, &mut scaffold_collector]) .expect("full and event stats timelines should collect from the same processor"); @@ -225,6 +285,23 @@ fn assert_event_timeline_scaffold_matches_full_timeline_without_stat_snapshots(r "{replay_path} scaffold live-play flag should match" ); + for (is_team_0, full_stats) in [ + (true, &full_frame.team_zero.expected_goals), + (false, &full_frame.team_one.expected_goals), + ] { + let track = scaffold_timeline + .expected_goals_tracks + .teams + .iter() + .find(|track| track.is_team_0 == is_team_0) + .expect("compact timeline should include both expected-goals team tracks"); + assert_eq!( + expected_goals_team_at(track, scaffold_frame.frame_number), + *full_stats, + "{replay_path} compact team expected goals should match full frame" + ); + } + assert!( scaffold_frame.team_zero.is_empty(), "{replay_path} event scaffold should not carry team-zero stat modules" @@ -252,6 +329,16 @@ fn assert_event_timeline_scaffold_matches_full_timeline_without_stat_snapshots(r scaffold_player.is_team_0, full_player.is_team_0, "{replay_path} scaffold player team should match" ); + let track = scaffold_timeline + .expected_goals_tracks + .players + .iter() + .find(|track| track.player_id == scaffold_player.player_id); + assert_eq!( + expected_goals_player_at(track, scaffold_frame.frame_number), + full_player.expected_goals, + "{replay_path} compact player expected goals should match full frame" + ); } } diff --git a/src/stats/timeline/types.rs b/src/stats/timeline/types.rs index d36ebedc9..f098349b4 100644 --- a/src/stats/timeline/types.rs +++ b/src/stats/timeline/types.rs @@ -91,6 +91,54 @@ pub struct ReplayStatsTimelineScaffold { /// alongside the event-only frames so the player can show a value growing during playback /// without re-deriving it from events. See [`AccumulationTrack`]. pub accumulation_tracks: Vec, + /// Change-point-compressed expected-goals snapshots. Team xG is a + /// continuous full-play integral and therefore cannot be reconstructed + /// from the discrete threat episodes alone; these tracks preserve that + /// value together with the sparse player and episode counters. + pub expected_goals_tracks: ExpectedGoalsTimelineTracks, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, ts_rs::TS)] +#[ts(export)] +pub struct ExpectedGoalsTimelineTracks { + pub config: ExpectedGoalsCalculatorConfig, + pub teams: Vec, + pub players: Vec, + /// Finalized threshold-delimited incidents, including peak timing and any + /// scoring-touch exclusion applied to incident xG. + #[serde(default)] + pub episodes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)] +#[ts(export)] +pub struct ExpectedGoalsTeamTimelineTrack { + pub is_team_0: bool, + pub points: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)] +#[ts(export)] +pub struct ExpectedGoalsTeamTimelinePoint { + pub frame: usize, + pub stats: ExpectedGoalsTeamStats, +} + +#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)] +#[ts(export)] +pub struct ExpectedGoalsPlayerTimelineTrack { + #[serde(rename = "player_id")] + #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")] + pub player_id: PlayerId, + pub is_team_0: bool, + pub points: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)] +#[ts(export)] +pub struct ExpectedGoalsPlayerTimelinePoint { + pub frame: usize, + pub stats: ExpectedGoalsPlayerStats, } #[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)] @@ -513,6 +561,7 @@ pub struct TeamStatsSnapshot { pub positioning: PositioningTeamStats, pub powerslide: PowerslideStats, pub demo: DemoTeamStats, + pub expected_goals: ExpectedGoalsTeamStats, } /// Player-owned fields in the materialized stats timeline export. @@ -555,4 +604,5 @@ pub struct PlayerStatsSnapshot { pub rotation: RotationPlayerStats, pub powerslide: PowerslideStats, pub demo: DemoPlayerStats, + pub expected_goals: ExpectedGoalsPlayerStats, } diff --git a/tests/stats_collector_test.rs b/tests/stats_collector_test.rs index dc89a08e6..12bd14e52 100644 --- a/tests/stats_collector_test.rs +++ b/tests/stats_collector_test.rs @@ -1,6 +1,8 @@ mod common; -use subtr_actor::{StatsCollector, StatsFrameResolution, builtin_stats_module_names}; +use subtr_actor::{ + StatsCollector, StatsFrameResolution, builtin_stats_module_names, default_stats_module_names, +}; const SMALL_STATS_FIXTURE: &str = "assets/post-eac-ranked-duel-2026-04-28-a.replay"; @@ -32,7 +34,7 @@ fn stats_collector_serializes_selected_modules_and_aliases_by_name() { #[test] #[ignore = "broad all-module aggregate replay pass is slow; selected-module replay coverage runs in CI"] -fn stats_collector_processes_all_builtin_modules() { +fn stats_collector_processes_all_default_modules() { let replay = common::parse_replay(SMALL_STATS_FIXTURE); let collected = StatsCollector::new() .get_stats(&replay) @@ -44,8 +46,8 @@ fn stats_collector_processes_all_builtin_modules() { .and_then(|value| value.as_object()) .expect("modules should serialize as an object"); - assert_eq!(modules.len(), builtin_stats_module_names().len()); - for module_name in builtin_stats_module_names() { + assert_eq!(modules.len(), default_stats_module_names().len()); + for module_name in default_stats_module_names() { assert!( modules.contains_key(*module_name), "expected serialized modules to include {module_name}" @@ -53,6 +55,24 @@ fn stats_collector_processes_all_builtin_modules() { } } +#[test] +fn expected_goals_is_available_but_not_a_default_stats_module() { + assert!(builtin_stats_module_names().contains(&"expected_goals")); + assert!(!default_stats_module_names().contains(&"expected_goals")); + + let replay = common::parse_replay(SMALL_STATS_FIXTURE); + let collected = StatsCollector::with_builtin_module_names(["expected_goals"]) + .expect("expected goals should remain selectable") + .get_stats(&replay) + .expect("opt-in expected-goals collection should succeed"); + let value = serde_json::to_value(collected).expect("stats should serialize"); + let modules = value["modules"] + .as_object() + .expect("modules should be an object"); + assert_eq!(modules.len(), 1); + assert!(modules.contains_key("expected_goals")); +} + #[test] #[ignore = "captured-frame transform test covers the frame capture path in default CI"] fn stats_collector_captures_module_keyed_snapshot_frames() { diff --git a/tests/stats_timeline_collector_test/support_boost.rs b/tests/stats_timeline_collector_test/support_boost.rs index 76cc0f4e4..2d002c43e 100644 --- a/tests/stats_timeline_collector_test/support_boost.rs +++ b/tests/stats_timeline_collector_test/support_boost.rs @@ -174,6 +174,7 @@ fn default_team_stats_snapshot() -> TeamStatsSnapshot { positioning: PositioningTeamStats::default(), powerslide: PowerslideStats::default(), demo: DemoTeamStats::default(), + expected_goals: ExpectedGoalsTeamStats::default(), } } @@ -215,6 +216,7 @@ fn default_player_stats_snapshot( rotation: RotationPlayerStats::default(), powerslide: PowerslideStats::default(), demo: DemoPlayerStats::default(), + expected_goals: ExpectedGoalsPlayerStats::default(), } }