From b543b61f450cbdf4678698c1c7013dcac9a3db35 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Sun, 12 Jul 2026 11:44:47 -0700 Subject: [PATCH 01/12] Add expected-goals stat via continuous threat model Model each team's threat V(state) = P(score within 5s) every live-play frame with a logistic model over 17 attacking-normalized physics features (ball kinematics, gravity-aware on-target projection, goal open angle, attacker/defender context). Shots are not a gating event: touches emit threat-delta events (V after minus before), and above-threshold threat spans close as episode events whose peak V is the xG, credited to the last attacking toucher. Accumulators derive per-player threat-added and xG plus per-team totals. Feature extraction lives only in compute_threat_features; the new threat_dataset_dump bin exports training rows through that same path, so training and inference cannot diverge. trained-v1 coefficients were fit on 10.3M rows from 5,280 rank-stratified ranked replays (tiers 1-22, rocket-sense production corpus): held-out log-loss 0.169 vs 0.252 baseline, AUC 0.885, calibration within ~10% relative across prediction quantiles and rank tiers, integrated xG recovering 6.72 goals/game vs 6.67 actual. A single rank-blind model calibrates acceptably across the ladder, so no per-rank models. Pipeline (corpus fetch, dataset dump, training, embed) is documented under scripts/threat_model/, and a parity test pins Rust inference to the training pipeline's predictions. Co-Authored-By: Claude Fable 5 --- .../src/bin/threat_dataset_dump.rs | 307 +++++++ docs/event-definitions.html | 82 +- docs/event-definitions.md | 59 ++ .../eventDefinitionCatalog.generated.ts | 14 + scripts/threat_model/README.md | 62 ++ scripts/threat_model/fetch_corpus.py | 147 +++ scripts/threat_model/train_threat_model.py | 182 ++++ src/stats/accumulators/expected_goals.rs | 134 +++ src/stats/accumulators/mod.rs | 2 + src/stats/analysis_graph/graph_tests.rs | 2 + src/stats/analysis_graph/mod.rs | 4 +- .../analysis_graph/nodes/expected_goals.rs | 40 + src/stats/analysis_graph/nodes/mod.rs | 26 +- .../analysis_graph/nodes/stats_projection.rs | 17 + src/stats/calculators/event_definition.rs | 55 +- src/stats/calculators/expected_goals.rs | 834 ++++++++++++++++++ src/stats/calculators/expected_goals_model.rs | 96 ++ .../calculators/expected_goals_model_tests.rs | 129 +++ src/stats/calculators/expected_goals_tests.rs | 676 ++++++++++++++ src/stats/calculators/mod.rs | 4 + 20 files changed, 2832 insertions(+), 40 deletions(-) create mode 100644 crates/subtr-actor-tools/src/bin/threat_dataset_dump.rs create mode 100644 scripts/threat_model/README.md create mode 100644 scripts/threat_model/fetch_corpus.py create mode 100644 scripts/threat_model/train_threat_model.py create mode 100644 src/stats/accumulators/expected_goals.rs create mode 100644 src/stats/analysis_graph/nodes/expected_goals.rs create mode 100644 src/stats/calculators/expected_goals.rs create mode 100644 src/stats/calculators/expected_goals_model.rs create mode 100644 src/stats/calculators/expected_goals_model_tests.rs create mode 100644 src/stats/calculators/expected_goals_tests.rs 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 00000000..0eccbbef --- /dev/null +++ b/crates/subtr-actor-tools/src/bin/threat_dataset_dump.rs @@ -0,0 +1,307 @@ +//! 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 +//! `ExpectedGoalsCalculator` the stats pipeline runs -- 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": ..., +//! "min_rank_tier": ..., "max_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::analysis_graph::{ + AnalysisGraph, ExpectedGoalsNode, collect_analysis_graph_for_replay, +}; +use subtr_actor::{ + ExpectedGoalsCalculator, ExpectedGoalsCalculatorConfig, 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, +} + +#[derive(Debug, Clone, Deserialize)] +struct ManifestRow { + path: String, + #[serde(default)] + ballchasing_id: Option, + #[serde(default)] + playlist: Option, + #[serde(default)] + min_rank_tier: Option, + #[serde(default)] + max_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, +} + +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 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}")?; + } + 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()?; + + eprintln!( + "done: {processed} replays processed, {skipped} skipped, {total_rows} rows -> {}", + args.out.display() + ); + Ok(()) +} + +fn header() -> String { + let mut columns = vec![ + "replay_id", + "playlist", + "min_rank_tier", + "max_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 process_replay(row: &ManifestRow, sample_interval: f32) -> anyhow::Result { + let replay = parse_replay(&row.path)?; + let graph = AnalysisGraph::new().with_node(ExpectedGoalsNode::with_config( + ExpectedGoalsCalculatorConfig { + sample_interval_seconds: Some(sample_interval), + ..ExpectedGoalsCalculatorConfig::default() + }, + )); + let graph = collect_analysis_graph_for_replay(&replay, graph) + .map_err(|error| anyhow::anyhow!("failed to process replay: {error:?}"))?; + let calculator = graph + .state::() + .context("expected_goals state missing from graph")?; + + let goals = calculator.goal_records(); + let replay_end_time = calculator.last_frame_time().unwrap_or(0.0); + let replay_id = row.replay_id(); + let metadata_prefix = format!( + "{},{},{},{},{}", + csv_field(&replay_id), + csv_field(row.playlist.as_deref().unwrap_or("")), + optional_int(row.min_rank_tier), + optional_int(row.max_rank_tier), + row.team_size + .map(|value| value.to_string()) + .unwrap_or_default(), + ); + + let mut lines = Vec::with_capacity(calculator.samples().len()); + let mut value_min = f32::INFINITY; + let mut value_max = f32::NEG_INFINITY; + for sample in calculator.samples() { + value_min = value_min.min(sample.value); + value_max = value_max.max(sample.value); + let mut line = format!( + "{metadata_prefix},{},{:.4}", + u8::from(sample.is_team_0), + sample.time, + ); + for value in sample.features.to_array() { + line.push_str(&format!(",{value:.6}")); + } + line.push_str(&format!( + ",{},{},{:.4}", + time_to_next_goal(goals, sample.time, sample.is_team_0), + time_to_next_goal(goals, sample.time, !sample.is_team_0), + (replay_end_time - sample.time).max(0.0), + )); + lines.push(line); + } + + 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(), + }) +} + +/// 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 36e67f84..bdb8fb91 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. 49 events

Contents

Basic
@@ -135,7 +135,6 @@

Contents

Other
- @@ -147,6 +146,8 @@

Contents

+ +
EventIDSummary
Ball Halfball_halfDefinition pending.
Beaten to ballbeaten_to_ballA player who was actively challenging for the ball when an opponent beat them to the touch.
Boost Pickupboost_pickupsDefinition pending.
Respawnboost_respawnDefinition pending.
BumpbumpDefinition pending.
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 span's peak value, credited to the attacking team's most recent toucher.
Threat Touch Deltathreat_touchThe change in the touching team's continuous threat value (expected-goals state value) across one touch.
WhiffwhiffA committed attempt near the ball that does not result in that player touching it.
Positioning
@@ -809,32 +810,6 @@

Events

  • ball_half via BallHalfNode / BallHalfCalculator
  • -
    -

    Beaten to ball

    beaten_to_ballOthertop ↑
    -

    A player who was actively challenging for the ball when an opponent beat them to the touch.

    - -
    -Approach Unknown -True positive Not Evaluated -False positive Not Evaluated -False negative Not Evaluated -Testing Untested -
    - -
      -
    • Keep a short rolling motion history for every player relative to the ball during live play.
    • -
    • At each confirmed touch, evaluate every non-touching opponent's lookback window for sustained convergence toward the ball and commitment (approach speed or a dodge toward the ball).
    • -
    • Emit when the loss margin at the touch is narrow: small estimated time-to-ball or close hitbox distance, hard-capped on distance.
    • -
    - -

    None documented.

    - -

    None documented.

    - -
      -
    • beaten_to_ball via BeatenToBallNode / BeatenToBallCalculator
    • -
    -

    Boost Pickup

    boost_pickupsOtherhidden from reviewtop ↑

    Definition pending.

    @@ -1097,6 +1072,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 span's peak value, credited to the attacking team's most recent toucher.

    + +
    +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, and track the peak V and the team's most recent toucher.
    • +
    • 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 change in the touching team's continuous threat value (expected-goals state value) across one touch.

    + +
    +Approach Unknown +True positive Not Evaluated +False positive Not Evaluated +False negative Not Evaluated +Testing Untested +
    + +
      +
    • Evaluate the versioned logistic 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 just before the touch (previous live frame) and just after (the touch's frame).
    • +
    + +

    None documented.

    + +

    None documented.

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

    Whiff

    whiffOthertop ↑

    A committed attempt near the ball that does not result in that player touching it.

    diff --git a/docs/event-definitions.md b/docs/event-definitions.md index 3dfc6a4e..a4ed8a55 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 span's peak value, credited to the attacking team's most recent toucher. + +**Approach** + +- Open an episode when a team's threat value V rises above the episode threshold during live play, and track the peak V and the team's most recent toucher. +- 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 change in the touching team's continuous threat value (expected-goals state value) across one touch. + +**Approach** + +- Evaluate the versioned logistic 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 just before the touch (previous live frame) and just after (the touch's frame). + +**Limitations** + +_None documented._ + +**Known Issues** + +_None documented._ + ### Replay Timeline Event (`timeline`) - Category: `core` diff --git a/js/stat-evaluation-player/src/generated/eventDefinitionCatalog.generated.ts b/js/stat-evaluation-player/src/generated/eventDefinitionCatalog.generated.ts index 850e8981..b12ba03a 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/scripts/threat_model/README.md b/scripts/threat_model/README.md new file mode 100644 index 00000000..492f7f3d --- /dev/null +++ b/scripts/threat_model/README.md @@ -0,0 +1,62 @@ +# Threat model training pipeline + +Offline pipeline that fits the expected-goals threat model embedded in +`src/stats/calculators/expected_goals_model.rs`. The model is +`V = sigmoid(bias + w · features)`: the probability that the attacking team +scores within `THREAT_HORIZON_SECONDS` (5s), evaluated per frame on the +`ThreatFeatures` vector. Feature extraction lives only in Rust +(`compute_threat_features`); training consumes rows exported through that +exact code path, so train and inference can never diverge. + +## Steps + +1. **Fetch a corpus** (optional — any manifest of local replays works): + + ```sh + python3 fetch_corpus.py + ``` + + Downloads a rank-stratified sample of processed replays from the + rocket-sense production API (JWT read from `pass show rocket-sense/token`) + into `~/.cache/subtr-actor-threat-corpus/`, and writes `manifest.jsonl` + there. Tune `PER_STRATUM` (default 150 per playlist × rank tier) via env. + +2. **Export the dataset** through the shared Rust feature path: + + ```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 + ``` + + Two attacking-normalized rows per sampled live-play frame (one per team), + with τ-agnostic goal-time columns for downstream labeling/censoring. + +3. **Train and evaluate** (needs numpy/pandas/scikit-learn): + + ```sh + python3 train_threat_model.py threat_dataset.csv --tau 5.0 --gbt \ + --out-dir threat_model_out + ``` + + Grouped train/test split by replay. Writes `metrics.txt` (log-loss, Brier, + AUC, calibration table, per-rank-tier calibration), `coefficients.json`, + `model_coefficients.rs`, and `parity_fixture.rs`. `--gbt` also fits a + gradient-boosted reference to quantify what the linear model leaves behind. + +4. **Embed**: paste `model_coefficients.rs` into the GENERATED COEFFICIENTS + section of `src/stats/calculators/expected_goals_model.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-v1 + +Fit 2026-07-12 on 10.3M rows from 5,280 rank-stratified ranked-duels/-doubles +replays (rocket-sense production, tiers 1–22, ~150 per playlist × tier where +available). Held-out: log-loss 0.169 / Brier 0.0468 / AUC 0.885 vs 0.252 +baseline log-loss; GBT ceiling 0.154. Calibration tracks observed frequency +across all 15 prediction-quantile bins and across rank tiers (drift ≲10% +relative for mid/high tiers), supporting a single rank-blind model as v1. +Aggregate sanity: integrated V·dt/τ recovers 6.72 mean goals/game vs 6.67 +actual; per-replay corr(xG, goals) = 0.78. diff --git a/scripts/threat_model/fetch_corpus.py b/scripts/threat_model/fetch_corpus.py new file mode 100644 index 00000000..c4cc57f8 --- /dev/null +++ b/scripts/threat_model/fetch_corpus.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Fetch a rank-stratified replay corpus from the rocket-sense production API. + +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. +""" + +import concurrent.futures +import json +import os +import pathlib +import statistics +import subprocess +import sys +import urllib.request + +BASE = "https://rocket-sense.duckdns.org/api/v1" +CACHE = pathlib.Path.home() / ".cache/subtr-actor-threat-corpus" +LISTING = CACHE / "listing.jsonl" +MANIFEST = CACHE / "manifest.jsonl" +REPLAYS = CACHE / "replays" +PER_STRATUM = int(os.environ.get("PER_STRATUM", "150")) # per (playlist, tier) +PLAYLISTS = {"ranked-doubles", "ranked-duels", "ranked-standard"} + +TOKEN = subprocess.run( + ["pass", "show", "rocket-sense/token"], capture_output=True, text=True, check=True +).stdout.splitlines()[0].strip() + + +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(l) for l 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): + seen_sha = set() + strata = {} + for r in rows: + if r["playlist"] not in PLAYLISTS or r["median_rank_tier"] is None: + continue + if r["sha256"] in seen_sha: + continue + seen_sha.add(r["sha256"]) + key = (r["playlist"], int(round(r["median_rank_tier"]))) + strata.setdefault(key, []).append(r) + picked = [] + for key in sorted(strata): + bucket = strata[key] + bucket.sort(key=lambda r: r["id"]) # deterministic + 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 main(): + REPLAYS.mkdir(parents=True, exist_ok=True) + rows = fetch_listing() + picked = stratify(rows) + 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" + ) + print(f"manifest: {MANIFEST}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/scripts/threat_model/train_threat_model.py b/scripts/threat_model/train_threat_model.py new file mode 100644 index 00000000..905e03fe --- /dev/null +++ b/scripts/threat_model/train_threat_model.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Train the subtr-actor expected-goals threat model. + +Input: CSV from `threat_dataset_dump` with columns + replay_id, playlist, min_rank_tier, max_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: raw-feature logistic coefficients (standardization folded) + - parity_fixture.rs: feature rows + expected V for a Rust parity test +""" + +import argparse +import json +import pathlib +import sys + +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.model_selection import GroupShuffleSplit +from sklearn.preprocessing import StandardScaler + +META_COLS = [ + "replay_id", + "playlist", + "min_rank_tier", + "max_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("--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("--gbt", action="store_true", help="also fit a GBT ceiling reference") +args = parser.parse_args() + +out_dir = pathlib.Path(args.out_dir) +out_dir.mkdir(parents=True, exist_ok=True) + +df = pd.read_csv(args.csv) +feature_cols = [c for c in df.columns if c not in META_COLS] +print(f"rows={len(df)} features={feature_cols}") + +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.float64) +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) + +splitter = GroupShuffleSplit(n_splits=1, test_size=args.test_frac, random_state=args.seed) +train_idx, test_idx = next(splitter.split(X, y, groups)) +Xtr, Xte, ytr, yte = X[train_idx], X[test_idx], y[train_idx], y[test_idx] +print(f"train={len(train_idx)} test={len(test_idx)} (grouped by replay)") + +scaler = StandardScaler().fit(Xtr) +Xtr_s, Xte_s = scaler.transform(Xtr), scaler.transform(Xte) + +lr = LogisticRegression(max_iter=2000, C=1.0) +lr.fit(Xtr_s, ytr) +p_lr = lr.predict_proba(Xte_s)[:, 1] + +lines = [] + + +def report(name, p, yt): + base = np.full_like(p, yt.mean() if name.endswith("(train-rate)") else 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) + +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) + + +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 + + +lines.append("\ncalibration (predicted, observed, n):") +for pred, obs, n in calibration_table(p_lr, yte): + lines.append(f" {pred:.4f} {obs:.4f} n={n}") + +# Per-rank calibration on test set +test_df = df.iloc[test_idx] +lines.append("\nper-rank-tier test metrics (logistic):") +tiers = test_df["min_rank_tier"].to_numpy() +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={int(tier)}: n={int(m.sum())} base={yte[m].mean():.5f} pred_mean={p_lr[m].mean():.5f} " + f"log_loss={log_loss(yte[m], p_lr[m]):.5f} brier={brier_score_loss(yte[m], p_lr[m]):.5f}" + ) + +(out_dir / "metrics.txt").write_text("\n".join(lines) + "\n") + +# Fold standardization into raw-feature coefficients: +# z = (x - mu) / sigma ; w_z . z + b = (w_z / sigma) . x + (b - w_z . mu / sigma) +w_z = lr.coef_[0] +mu, sigma = scaler.mean_, scaler.scale_ +w_raw = w_z / sigma +b_raw = float(lr.intercept_[0] - np.sum(w_z * mu / sigma)) + +coeffs = {name: float(w) for name, w in zip(feature_cols, w_raw)} +(out_dir / "coefficients.json").write_text( + json.dumps({"bias": b_raw, "weights": coeffs, "tau": tau}, indent=1) +) + +# Emitted in the exact shape of the GENERATED COEFFICIENTS section of +# src/stats/calculators/expected_goals_model.rs; paste between the markers and +# bump THREAT_MODEL_VERSION. +rust = ["// Generated by scripts/threat_model/train_threat_model.py — do not hand-edit values.\n"] +rust.append(f"pub const THREAT_MODEL_BIAS: f32 = {b_raw!r};") +rust.append("pub const THREAT_MODEL_WEIGHTS: [(&str, f32); THREAT_FEATURE_COUNT] = [") +for name in feature_cols: + rust.append(f' ("{name}", {coeffs[name]!r}),') +rust.append("];") +(out_dir / "model_coefficients.rs").write_text("\n".join(rust) + "\n") + +# Parity fixture: 6 test rows spanning the prediction range +order = np.argsort(p_lr) +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] + v = float(p_lr[i]) + vals = ", ".join(f"{float(x)!r}f32" for x in raw) + fix.append(f"(&[{vals}], {v!r}f32),") +(out_dir / "parity_fixture.rs").write_text("\n".join(fix) + "\n") + +print(f"wrote {out_dir}/metrics.txt, coefficients.json, model_coefficients.rs, parity_fixture.rs") diff --git a/src/stats/accumulators/expected_goals.rs b/src/stats/accumulators/expected_goals.rs new file mode 100644 index 00000000..3b87d946 --- /dev/null +++ b/src/stats/accumulators/expected_goals.rs @@ -0,0 +1,134 @@ +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)] +pub struct ExpectedGoalsPlayerStats { + /// Sum of positive touch threat deltas (V after minus V before, from the + /// toucher's team's perspective) over the player's touches. + pub threat_added: f32, + /// Sum of episode peak values (xG) for 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: total xG over all episodes (including +/// player-uncredited ones). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ExpectedGoalsTeamStats { + 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 touch threat delta: only positive deltas count toward the + /// toucher's threat-added sum (giving the ball away is not negative + /// threat creation). + 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: the peak V is the episode's xG, + /// credited to the episode's player when one is known and always to the + /// team. + 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.xg += event.xg; + team.episode_count += 1; + if event.ended_in_goal { + team.goal_episode_count += 1; + } + } +} diff --git a/src/stats/accumulators/mod.rs b/src/stats/accumulators/mod.rs index 5ced4e28..12d3f34f 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 8443f2db..a8ec4214 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 7809b097..a599a055 100644 --- a/src/stats/analysis_graph/mod.rs +++ b/src/stats/analysis_graph/mod.rs @@ -52,7 +52,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`], @@ -123,6 +124,7 @@ builtin_analysis_nodes! { ControlledPlayNode, ContinuousBallControlNode, DoubleTapNode, + 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 00000000..62598c34 --- /dev/null +++ b/src/stats/analysis_graph/nodes/expected_goals.rs @@ -0,0 +1,40 @@ +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, + ball_frame_state_dependency() => BallFrameState, + player_frame_state_dependency() => PlayerFrameState, + frame_events_state_dependency() => FrameEventsState, + touch_state_dependency() => TouchState, + live_play_dependency() => LivePlayState, + ], + 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 446dc1fe..b510e499 100644 --- a/src/stats/analysis_graph/nodes/mod.rs +++ b/src/stats/analysis_graph/nodes/mod.rs @@ -8,15 +8,16 @@ 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, - PlayerPossessionCalculator, PlayerVerticalState, PositioningCalculator, PossessionCalculator, - PossessionState, PowerslideCalculator, RotationCalculator, RushCalculator, SpeedFlipCalculator, + 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, PlayerFrameState, PlayerPossessionCalculator, + PlayerVerticalState, PositioningCalculator, PossessionCalculator, PossessionState, + PowerslideCalculator, RotationCalculator, RushCalculator, SpeedFlipCalculator, SustainedPressureGoalCalculator, TerritorialPressureCalculator, TouchCalculator, TouchState, WallAerialCalculator, WallAerialShotCalculator, WavedashCalculator, WhiffCalculator, }; @@ -38,6 +39,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; @@ -113,6 +115,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; @@ -369,6 +373,10 @@ 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 fifty_fifty_dependency() -> AnalysisDependency { AnalysisDependency::with_default::(fifty_fifty::boxed_default) } diff --git a/src/stats/analysis_graph/nodes/stats_projection.rs b/src/stats/analysis_graph/nodes/stats_projection.rs index e1bf2e4a..eb41b5fc 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)] @@ -214,6 +215,8 @@ struct StatsProjectionCursors { demo_timeline: usize, center: usize, controlled_play: usize, + expected_goals_touch: usize, + expected_goals_episode: usize, } impl StatsProjectionNode { @@ -644,6 +647,19 @@ impl StatsProjectionNode { { self.state.controlled_play.apply_event(event); } + 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); + } self.finish_sample(); self.previous_live_play = Some(live_play); @@ -698,6 +714,7 @@ impl AnalysisNode for StatsProjectionNode { demo_dependency(), center_dependency(), controlled_play_dependency(), + expected_goals_dependency(), ] } diff --git a/src/stats/calculators/event_definition.rs b/src/stats/calculators/event_definition.rs index ca8124db..8391cd3a 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 change in the touching team's continuous threat value (expected-goals state value) across one touch.", + approach = [ + "Evaluate the versioned logistic 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 just before the touch (previous live frame) and just after (the touch's frame).", + ], + 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 span's peak value, credited to the attacking team's most recent toucher.", + approach = [ + "Open an episode when a team's threat value V rises above the episode threshold during live play, and track the peak V and the team's most recent toucher.", + "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 00000000..34c83f19 --- /dev/null +++ b/src/stats/calculators/expected_goals.rs @@ -0,0 +1,834 @@ +//! 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 change in the touching team's V across each +//! touch (V just after minus V just before, both from the toucher's team's +//! perspective). +//! - [`ThreatEpisodeEvent`]: a contiguous span where one team's V exceeds +//! [`THREAT_EPISODE_THRESHOLD`]; the episode's xG is the peak V over the +//! span, credited to the attacking team's most recent toucher. + +use super::*; + +/// Episode threshold on V. The heuristic model's neutral-midfield baseline +/// sits around 0.02-0.04, so 0.15 is roughly 4x baseline: episodes open only +/// on genuinely elevated scoring probability (an on-target ball, a breakaway +/// toward an under-defended net) rather than ordinary offensive-half +/// possession. +pub const THREAT_EPISODE_THRESHOLD: f32 = 0.15; + +/// 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_dist_to_goal` and +/// `nearest_defender_to_goal_dist` 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 the `defender_in_net` feature: a defender inside the +/// mouth (or just in front of it, within this depth) and under crossbar +/// height (plus a car-sized margin) is covering the net. +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 THREAT_FEATURE_COUNT: usize = 17; + +/// 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). All values are bounded; everything except +/// `attacking_team_size` is normalized into [-1, 1] or [0, 1]. +/// +/// [`ThreatFeatures::FEATURE_NAMES`] and [`ThreatFeatures::to_array`] share +/// one order -- that contract is what the offline training pipeline joins on. +#[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, + /// Nearest attacker distance to the ball, clamped to + /// [`PLAYER_DISTANCE_NORM`] and normalized. + pub nearest_attacker_dist: f32, + /// Attackers at or beyond the ball's attacking y / team size. + pub attackers_ahead_of_ball: f32, + /// Attackers behind the ball's attacking y / team size. + pub attackers_behind_ball: f32, + /// Nearest defender distance to the ball, clamped and normalized (1.0 + /// when no defender is on the field). + pub nearest_defender_dist: f32, + /// Nearest defender distance to their own goal center, normalized by + /// [`GOAL_DISTANCE_NORM`] (1.0 when no defender is on the field). + pub nearest_defender_to_goal_dist: f32, + /// Defenders goalside of the ball (attacking y beyond the ball's) / team + /// size. + pub defenders_goalside: f32, + /// 1.0 when any defender occupies the net region in front of their goal + /// mouth. + pub defender_in_net: f32, + /// Boost of the defender nearest the ball, raw 0-255 scaled to [0, 1] + /// (0.0 when unknown or no defender). + pub nearest_defender_boost: f32, + /// Raw attacking-team roster count this frame (the corpus is mostly 2s). + pub attacking_team_size: f32, +} + +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 [&'static str] = &[ + "ball_forward_y", + "ball_dist_to_goal", + "ball_height", + "ball_speed", + "ball_speed_toward_goal", + "goal_open_angle", + "on_target", + "time_to_goal_line", + "nearest_attacker_dist", + "attackers_ahead_of_ball", + "attackers_behind_ball", + "nearest_defender_dist", + "nearest_defender_to_goal_dist", + "defenders_goalside", + "defender_in_net", + "nearest_defender_boost", + "attacking_team_size", + ]; + + /// The feature vector, ordered exactly as [`Self::FEATURE_NAMES`]. + pub fn to_array(&self) -> [f32; THREAT_FEATURE_COUNT] { + [ + 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, + self.nearest_attacker_dist, + self.attackers_ahead_of_ball, + self.attackers_behind_ball, + self.nearest_defender_dist, + self.nearest_defender_to_goal_dist, + self.defenders_goalside, + self.defender_in_net, + self.nearest_defender_boost, + self.attacking_team_size, + ] + } +} + +/// 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, + attacking_team_is_team_0: bool, +) -> ThreatFeatures { + 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_size = players + .players + .iter() + .filter(|player| player.is_team_0 == attacking_team_is_team_0) + .count(); + let team_size_norm = (team_size as f32).max(1.0); + + let positioned = |same_team: bool| { + players.players.iter().filter(move |player| { + (player.is_team_0 == attacking_team_is_team_0) == same_team + && !demoed_players.contains(&player.player_id) + }) + }; + let positions = |same_team: bool| { + positioned(same_team).filter_map(|player| { + player + .position() + .map(|position| attacking_frame(position, attacking_team_is_team_0)) + }) + }; + + let nearest_attacker_dist = positions(true) + .map(|position| (position - ball).length()) + .fold(f32::INFINITY, f32::min); + let attackers_ahead = positions(true) + .filter(|position| position.y >= ball.y) + .count(); + let attackers_behind = positions(true) + .filter(|position| position.y < ball.y) + .count(); + + let mut nearest_defender_dist = f32::INFINITY; + let mut nearest_defender_boost = 0.0; + let mut nearest_defender_to_goal = f32::INFINITY; + let mut defenders_goalside = 0usize; + let mut defender_in_net = false; + for defender in positioned(false) { + let Some(position) = defender.position() else { + continue; + }; + let position = attacking_frame(position, attacking_team_is_team_0); + let ball_dist = (position - ball).length(); + if ball_dist < nearest_defender_dist { + nearest_defender_dist = ball_dist; + nearest_defender_boost = + (defender.boost_amount.unwrap_or(0.0) / BOOST_MAX_AMOUNT).clamp(0.0, 1.0); + } + nearest_defender_to_goal = nearest_defender_to_goal.min((position - goal_center).length()); + if position.y > ball.y { + defenders_goalside += 1; + } + if 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 + { + defender_in_net = true; + } + } + + 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), + nearest_attacker_dist: if nearest_attacker_dist.is_finite() { + normalized_distance(nearest_attacker_dist, PLAYER_DISTANCE_NORM) + } else { + 1.0 + }, + attackers_ahead_of_ball: (attackers_ahead as f32 / team_size_norm).clamp(0.0, 1.0), + attackers_behind_ball: (attackers_behind as f32 / team_size_norm).clamp(0.0, 1.0), + nearest_defender_dist: if nearest_defender_dist.is_finite() { + normalized_distance(nearest_defender_dist, PLAYER_DISTANCE_NORM) + } else { + 1.0 + }, + nearest_defender_to_goal_dist: if nearest_defender_to_goal.is_finite() { + normalized_distance(nearest_defender_to_goal, GOAL_DISTANCE_NORM) + } else { + 1.0 + }, + defenders_goalside: (defenders_goalside as f32 / team_size_norm).clamp(0.0, 1.0), + defender_in_net: f32::from(u8::from(defender_in_net)), + nearest_defender_boost, + attacking_team_size: team_size as f32, + } +} + +/// The change in the touching team's V across one touch. `value_before` is +/// the toucher's team's V on the previous live frame; `value_after` is the V +/// on the frame the touch registered. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ThreatTouchEvent { + pub time: f32, + pub frame: usize, + 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)] +#[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. `xg` +/// is the peak V over the span; `credited_player` is the attacking team's +/// most recent toucher (within the same live stretch) at close time, `None` +/// when the team never touched -- team-only credit. +#[derive(Debug, Clone, PartialEq, Serialize)] +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, + pub xg: f32, + pub credited_player: Option, + pub ended_in_goal: bool, + pub end_reason: ThreatEpisodeEndReason, +} + +/// One sampled feature/value row recorded when dataset sampling is enabled +/// via [`ExpectedGoalsCalculatorConfig::sample_interval_seconds`]. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ThreatFrameSample { + pub time: f32, + pub frame: usize, + pub is_team_0: bool, + pub features: ThreatFeatures, + pub value: f32, +} + +/// 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)] +pub struct ExpectedGoalsCalculatorConfig { + pub episode_threshold: f32, + /// When set, record a [`ThreatFrameSample`] per team at most once per + /// this many seconds of live play (for dataset export). `None` (the + /// default) records nothing. + pub sample_interval_seconds: Option, +} + +impl Default for ExpectedGoalsCalculatorConfig { + fn default() -> Self { + Self { + episode_threshold: THREAT_EPISODE_THRESHOLD, + sample_interval_seconds: None, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +struct ActiveThreatEpisode { + start_time: f32, + start_frame: usize, + peak_value: f32, + 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, + 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, +} + +/// 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, + samples: Vec, + goal_records: Vec, + team_states: [TeamThreatState; 2], + /// Both teams' V on the previous live frame, if it was live. + previous_values: Option<[f32; 2]>, + last_score: Option<(i32, i32)>, + last_sample_time: Option, + 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 { + 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() + } + + /// Sampled dataset rows (empty unless sampling is enabled in the config). + pub fn samples(&self) -> &[ThreatFrameSample] { + &self.samples + } + + pub fn goal_records(&self) -> &[ThreatGoalRecord] { + &self.goal_records + } + + /// 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, scoring_team_is_team_0); + } + + fn close_episode_as_goal(&mut self, frame: &FrameInfo, 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 event = Self::event_from_active( + &active, + frame.frame_number, + frame.time, + scoring_team_is_team_0, + true, + ThreatEpisodeEndReason::Goal, + ); + self.episode_events.push(event); + return; + } + if let Some(pending) = state.pending_episode.take() { + let mut event = pending.event; + event.ended_in_goal = true; + event.end_reason = ThreatEpisodeEndReason::Goal; + self.episode_events.push(event); + } + } + + fn event_from_active( + active: &ActiveThreatEpisode, + end_frame: usize, + end_time: f32, + team_is_team_0: bool, + ended_in_goal: bool, + end_reason: ThreatEpisodeEndReason, + ) -> ThreatEpisodeEvent { + ThreatEpisodeEvent { + start_time: active.start_time, + start_frame: active.start_frame, + end_time, + end_frame, + team_is_team_0, + xg: active.peak_value, + credited_player: active.credited_player.clone(), + ended_in_goal, + end_reason, + } + } + + 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, + false, + ThreatEpisodeEndReason::Stoppage, + ); + // 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, + closed_at: frame.time, + }); + } + } + + fn emit_touch_events(&mut self, frame: &FrameInfo, touch_state: &TouchState, values: [f32; 2]) { + for touch in &touch_state.touch_events { + let index = team_index(touch.team_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: frame.frame_number, + team_is_team_0: touch.team_is_team_0, + player: touch.player.clone(), + value_before, + value_after: values[index], + }); + if let Some(player) = touch.player.clone() { + let state = &mut self.team_states[index]; + state.last_toucher = Some(player.clone()); + if let Some(active) = state.active_episode.as_mut() { + active.credited_player = Some(player); + } + } + } + } + + 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]; + match state.active_episode.as_mut() { + Some(active) => { + active.peak_value = active.peak_value.max(value); + if value <= self.config.episode_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, + false, + ThreatEpisodeEndReason::ValueDropped, + )); + } + } + None => { + if value > self.config.episode_threshold { + state.active_episode = Some(ActiveThreatEpisode { + start_time: frame.time, + start_frame: frame.frame_number, + peak_value: value, + credited_player: state.last_toucher.clone(), + }); + } + } + } + } + } + + fn record_samples( + &mut self, + frame: &FrameInfo, + features: [ThreatFeatures; 2], + values: [f32; 2], + ) { + let Some(interval) = self.config.sample_interval_seconds else { + return; + }; + let due = self + .last_sample_time + .is_none_or(|last| frame.time - last >= interval || frame.time < last); + if !due { + return; + } + for team_index in 0..2 { + self.samples.push(ThreatFrameSample { + time: frame.time, + frame: frame.frame_number, + is_team_0: team_index == 0, + features: features[team_index], + value: values[team_index], + }); + } + self.last_sample_time = Some(frame.time); + } + + #[allow(clippy::too_many_arguments)] + pub fn update_parts( + &mut self, + frame: &FrameInfo, + gameplay: &GameplayState, + ball: &BallFrameState, + players: &PlayerFrameState, + events: &FrameEventsState, + touch_state: &TouchState, + live_play_state: &LivePlayState, + ) -> SubtrActorResult<()> { + self.touch_events.begin_update(); + self.episode_events.begin_update(); + self.last_frame = Some((frame.frame_number, frame.time)); + + self.detect_goals(frame, gameplay, events); + self.resolve_stale_pending_episodes(frame, gameplay.kickoff_phase_active()); + + let is_live = live_play_state.is_live_play && !gameplay.kickoff_phase_active(); + let ball_sample = ball.sample(); + let (Some(ball_sample), true) = (ball_sample, is_live) else { + self.suspend_active_episodes(frame); + if self.was_live { + for state in self.team_states.iter_mut() { + state.last_toucher = None; + } + } + self.previous_values = None; + self.was_live = false; + return Ok(()); + }; + + let demoed_players: HashSet = events + .active_demos + .iter() + .map(|demo| demo.victim.clone()) + .collect(); + let features = [true, false].map(|is_team_0| { + compute_threat_features( + ball_sample.position(), + ball_sample.velocity(), + players, + &demoed_players, + is_team_0, + ) + }); + let values = [ + expected_goals_model::threat_value(&features[0]), + expected_goals_model::threat_value(&features[1]), + ]; + + self.emit_touch_events(frame, touch_state, values); + self.update_episodes(frame, values); + self.record_samples(frame, features, 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, + false, + ThreatEpisodeEndReason::ReplayEnd, + ); + 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 00000000..2b53030b --- /dev/null +++ b/src/stats/calculators/expected_goals_model.rs @@ -0,0 +1,96 @@ +//! Versioned logistic threat model for the expected-goals stat. +//! +//! The model maps a [`ThreatFeatures`] vector to +//! `V = sigmoid(bias + w . features)`: the probability that the attacking team +//! scores within the next [`THREAT_HORIZON_SECONDS`] seconds, evaluated on RAW +//! (unstandardized) features. The offline training pipeline standardizes +//! features internally and folds the standardization back into the published +//! bias/weights, so inference here stays a plain dot product over +//! [`ThreatFeatures::to_array`]. +//! +//! Replacing the model is a mechanical edit confined to the generated +//! coefficients section below: paste a new `THREAT_MODEL_VERSION`, +//! `THREAT_MODEL_BIAS`, and `THREAT_MODEL_WEIGHTS` table (one weight per +//! [`ThreatFeatures::FEATURE_NAMES`] entry, 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 +// +// Everything between these markers is replaced wholesale when the offline +// training script publishes a fitted model. +// +// trained-v1 provenance: logistic regression fit by +// scripts/threat_model/train_threat_model.py on 10.3M live-play rows sampled +// at 4 Hz from 5,280 rank-stratified ranked-duels/-doubles replays +// (rocket-sense production corpus, rank tiers 1-22, 2026-07-12). Grouped +// train/test split by replay. Held-out: log_loss 0.169, Brier 0.0468, +// AUC 0.885 (constant-rate baseline log_loss 0.252); GBT reference ceiling +// log_loss 0.154. Calibration tracks observed frequency within ~10% relative +// across all 15 prediction-quantile bins and across rank tiers. +// Standardization is folded in; weights apply to raw features. +// --------------------------------------------------------------------------- + +/// Identifies the coefficient set embedded below. `heuristic-v0` marks the +/// hand-tuned placeholder; trained models use `trained-v` stamps. +pub const THREAT_MODEL_VERSION: &str = "trained-v1"; + +pub const THREAT_MODEL_BIAS: f32 = -0.441_601_53; + +/// One weight per [`ThreatFeatures::FEATURE_NAMES`] entry, in that exact +/// order. The pairing is enforced by `weights_cover_every_feature` in the +/// adjacent test module. +pub const THREAT_MODEL_WEIGHTS: [(&str, f32); THREAT_FEATURE_COUNT] = [ + ("ball_forward_y", -0.030_214_028), + ("ball_dist_to_goal", -4.603_913), + ("ball_height", -0.096_484_2), + ("ball_speed", -2.722_955_2), + ("ball_speed_toward_goal", 5.354_401_5), + ("goal_open_angle", 1.949_296_7), + ("on_target", 1.312_370_6), + ("time_to_goal_line", -0.260_346_38), + ("nearest_attacker_dist", -2.061_250_7), + ("attackers_ahead_of_ball", -0.552_131_2), + ("attackers_behind_ball", 0.387_900_9), + ("nearest_defender_dist", 1.003_443_6), + ("nearest_defender_to_goal_dist", 1.805_623), + ("defenders_goalside", -0.772_091_76), + ("defender_in_net", -0.183_698_83), + ("nearest_defender_boost", -0.404_413_3), + ("attacking_team_size", -0.265_604_6), +]; + +// --------------------------------------------------------------------------- +// 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 logit = THREAT_MODEL_BIAS; + for ((_, weight), value) in THREAT_MODEL_WEIGHTS.iter().zip(values.iter()) { + logit += weight * value; + } + 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 00000000..05a99cfa --- /dev/null +++ b/src/stats/calculators/expected_goals_model_tests.rs @@ -0,0 +1,129 @@ +use super::*; + +/// The coefficient 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_WEIGHTS.len(), THREAT_FEATURE_COUNT); + for ((weight_name, _), feature_name) in THREAT_MODEL_WEIGHTS + .iter() + .zip(ThreatFeatures::FEATURE_NAMES.iter()) + { + assert_eq!(weight_name, feature_name); + } +} + +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, + nearest_attacker_dist: 0.2, + attackers_ahead_of_ball: 0.5, + attackers_behind_ball: 0.5, + nearest_defender_dist: 0.5, + nearest_defender_to_goal_dist: 0.35, + defenders_goalside: 1.0, + defender_in_net: 0.0, + nearest_defender_boost: 0.5, + attacking_team_size: 2.0, + } +} + +#[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, + defenders_goalside: 0.0, + nearest_defender_to_goal_dist: 0.3, + ..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); +} + +/// Real feature rows from the trained-v1 held-out set with the offline +/// pipeline's predicted probabilities (float64). The embedded f32 model must +/// reproduce them; regenerated alongside the coefficients by +/// scripts/threat_model/train_threat_model.py. +const TRAINING_PARITY_FIXTURE: &[(&[f32; THREAT_FEATURE_COUNT], f32)] = &[ + ( + &[ + -0.839_109, 0.862_713, 0.078_395, 0.653_84, -0.615_211, 0.057_195, 0.0, 0.0, 1.0, 0.5, + 0.0, 0.063_089, 0.505_834, 1.0, 0.0, 0.274_51, 2.0, + ], + 5.375_665e-6, + ), + ( + &[ + -0.981_82, 0.914_914, 0.100_039, 0.062_519, 0.001_412, 0.054_801, 0.0, 0.0, 0.362_575, + 1.0, 0.0, 0.330_344, 0.813_828, 1.0, 0.0, 0.239_216, 1.0, + ], + 0.004_856_322_6, + ), + ( + &[ + -0.850_684, 0.855_126, 0.070_44, 0.175_894, 0.167_881, 0.058_558, 0.0, 0.092_799, + 0.214_89, 0.0, 1.0, 0.194_378, 0.571_433, 0.5, 0.0, 0.819_608, 2.0, + ], + 0.019_329_575, + ), + ( + &[ + 0.639_963, 0.219_892, 0.155_705, 0.295_717, 0.001_887, 0.179_142, 0.0, 0.388_036, + 0.254_12, 0.0, 1.0, 0.680_158, 0.102_873, 0.5, 0.0, 1.0, 2.0, + ], + 0.067_572_535, + ), + ( + &[ + 0.995_201, 0.002_314, 0.047_216, 0.510_596, 0.489_777, 0.982_483, 1.0, 0.992_038, + 0.305_113, 0.0, 1.0, 1.0, 0.912_356, 0.0, 0.0, 0.047_059, 1.0, + ], + 0.997_098_6, + ), + ( + &[ + 0.987_828, 0.017_902, 0.087_539, 0.415_882, 0.413_33, 0.953_978, 1.0, 0.919_608, + 0.067_34, 0.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 2.0, + ], + 0.997_472_2, + ), +]; + +/// The embedded f32 inference must agree with the float64 training-pipeline +/// predictions on held-out rows spanning the whole probability range. +#[test] +fn trained_model_matches_training_pipeline_predictions() { + for (index, (features, expected)) in TRAINING_PARITY_FIXTURE.iter().enumerate() { + let actual = threat_value_from_array(features); + let absolute = (actual - expected).abs(); + assert!( + absolute < 2e-4 && absolute / expected < 0.01, + "fixture row {index}: rust={actual} python={expected}" + ); + } +} diff --git a/src/stats/calculators/expected_goals_tests.rs b/src/stats/calculators/expected_goals_tests.rs new file mode 100644 index 00000000..30c2efc3 --- /dev/null +++ b/src/stats/calculators/expected_goals_tests.rs @@ -0,0 +1,676 @@ +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_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(2, false, glam::Vec3::new(2500.0, 1000.0, 17.0), Some(50.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(2, false, glam::Vec3::new(0.0, 2000.0, 17.0), Some(50.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, +) { + calculator + .update_parts( + &frame(frame_number, time), + &GameplayState::default(), + ball, + players, + &events, + &touch_state(touches), + &live, + ) + .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(), + true, + ); + 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(), + true, + ); + for (name, value) in ThreatFeatures::FEATURE_NAMES + .iter() + .zip(features.to_array()) + { + assert!( + (-1.0..=4.0).contains(&value), + "feature {name} out of bounds: {value}" + ); + if *name != "attacking_team_size" { + assert!( + (-1.0..=1.0).contains(&value), + "normalized feature {name} out of [-1, 1]: {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| { + player( + match sample.player_id { + boxcars::RemoteId::Steam(id) => id, + _ => unreachable!(), + }, + !sample.is_team_0, + mirror(sample.position().unwrap()), + sample.boost_amount, + ) + }) + .collect(), + }; + + let features_team_zero = compute_threat_features( + ball_position, + ball_velocity, + &players_team_zero_attacking, + &no_demoed(), + true, + ); + let features_team_one = compute_threat_features( + mirror(ball_position), + mirror(ball_velocity), + &players_mirrored, + &no_demoed(), + false, + ); + + assert_eq!(features_team_zero.to_array(), features_team_one.to_array()); +} + +#[test] +fn ballistic_on_target_hand_computed_fixture() { + let empty_players = PlayerFrameState::default(); + // 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 on_target = compute_threat_features( + glam::Vec3::new(0.0, 4300.0, 100.0), + glam::Vec3::new(0.0, 1400.0, 250.0), + &empty_players, + &no_demoed(), + true, + ); + assert_eq!(on_target.on_target, 1.0); + let expected_time = (STANDARD_GOAL_LINE_Y - 4300.0) / 1400.0; + assert!((on_target.time_to_goal_line - 1.0 / (1.0 + 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. + let over_the_bar = compute_threat_features( + glam::Vec3::new(0.0, 4300.0, 100.0), + glam::Vec3::new(0.0, 1400.0, 1200.0), + &empty_players, + &no_demoed(), + true, + ); + assert_eq!(over_the_bar.on_target, 0.0); + assert!(over_the_bar.time_to_goal_line > 0.0); + + // Crossing x = 2000: wide of the 892.755 post. + let wide = compute_threat_features( + glam::Vec3::new(2000.0, 4300.0, 100.0), + glam::Vec3::new(0.0, 1400.0, 250.0), + &empty_players, + &no_demoed(), + true, + ); + assert_eq!(wide.on_target, 0.0); + + // Moving away from the goal: no crossing, no time-to-goal-line. + let away = compute_threat_features( + glam::Vec3::new(0.0, 4300.0, 100.0), + glam::Vec3::new(0.0, -1400.0, 250.0), + &empty_players, + &no_demoed(), + true, + ); + assert_eq!(away.on_target, 0.0); + assert_eq!(away.time_to_goal_line, 0.0); +} + +#[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" + ); +} + +#[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 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.xg - peak).abs() < 1e-6); + assert_eq!(episode.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![], + 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); +} + +/// 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); +} + +#[test] +fn sampling_records_two_attacking_normalized_rows_per_sampled_frame() { + let mut calculator = ExpectedGoalsCalculator::with_config(ExpectedGoalsCalculatorConfig { + sample_interval_seconds: Some(0.25), + ..ExpectedGoalsCalculatorConfig::default() + }); + let (neutral_ball, neutral_players) = neutral_state(); + + for step in 0..4 { + update( + &mut calculator, + step + 1, + 1.0 + step as f32 * 0.1, + &neutral_ball, + &neutral_players, + FrameEventsState::default(), + vec![], + live_play(), + ); + } + + // Sampled at t=1.0 and t=1.3 (0.25s cadence over 0.1s frames). + let samples = calculator.samples(); + assert_eq!(samples.len(), 4); + assert!(samples[0].is_team_0); + assert!(!samples[1].is_team_0); + assert_eq!(samples[0].time, samples[1].time); + for sample in samples { + assert!(sample.value > 0.0 && sample.value < 1.0); + } +} + +#[test] +fn accumulator_folds_touch_deltas_and_episode_xg() { + let mut accumulator = ExpectedGoalsStatsAccumulator::new(); + + accumulator.apply_touch_event(&ThreatTouchEvent { + time: 1.0, + frame: 1, + 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, + 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, + credited_player: Some(player_id(1)), + ended_in_goal: true, + end_reason: ThreatEpisodeEndReason::Goal, + }); + // Team-only credit still counts toward the team total. + 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, + credited_player: None, + ended_in_goal: false, + end_reason: ThreatEpisodeEndReason::ValueDropped, + }); + + 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 - 0.6).abs() < 1e-6); + assert_eq!(team.episode_count, 2); + assert_eq!(team.goal_episode_count, 1); + assert_eq!(accumulator.team_stats(false).episode_count, 0); +} diff --git a/src/stats/calculators/mod.rs b/src/stats/calculators/mod.rs index 1b3d51fa..9fdcb5e7 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; From 584f4ddbaa084f76c296de8205e6a251581166aa Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Sun, 12 Jul 2026 12:22:27 -0700 Subject: [PATCH 02/12] Expose expected goals through analysis node JSON --- src/collector/stats/builtins.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/collector/stats/builtins.rs b/src/collector/stats/builtins.rs index 2baa8fd3..9513deca 100644 --- a/src/collector/stats/builtins.rs +++ b/src/collector/stats/builtins.rs @@ -1146,6 +1146,21 @@ 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, + "sample_interval_seconds": state.config().sample_interval_seconds, + }, + "current_values": state.current_values(), + "touch_events": state.touch_events(), + "episode_events": state.episode_events(), + "samples": state.samples(), + "goal_records": state.goal_records(), + "last_frame_time": state.last_frame_time(), + }) + } "possession_state" => { let state = graph_state::(graph, node_name)?; json!({ From 01662c1675d3df2d84a649cf2f976773295c5fd2 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Sun, 12 Jul 2026 12:31:10 -0700 Subject: [PATCH 03/12] Except hidden threat events from player parity --- js/stat-evaluation-player/src/eventCatalogParity.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/js/stat-evaluation-player/src/eventCatalogParity.test.ts b/js/stat-evaluation-player/src/eventCatalogParity.test.ts index 7ecdfb2a..26ca5561 100644 --- a/js/stat-evaluation-player/src/eventCatalogParity.test.ts +++ b/js/stat-evaluation-player/src/eventCatalogParity.test.ts @@ -29,6 +29,10 @@ const CORE_AGGREGATED = new Set(["assist", "goal", "save", "shot"]); // timeline stream. const NOT_SURFACED = new Set([ "event", // the generic timeline envelope, not a concrete event type + // Expected-goals events remain hidden until the threat-curve timeline UI is + // implemented; the Rust registry still documents them in the meantime. + "threat_episode", + "threat_touch", ]); test("every registry event is represented in the player (or explicitly excepted)", () => { From a33091e3f4902822fc605053ad7a529ca37ab2f2 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Sun, 12 Jul 2026 14:23:50 -0700 Subject: [PATCH 04/12] Address PR review: integral xG estimator, attribution fixes, exposure Rework the xG aggregation to the calibrated estimator: V is a per-5s-window probability, so summing episode peaks over-counts goals ~2.7x (9.87 vs 3.68 actual per team-game on the 5,280-replay corpus). Episode xg is now the within-episode time integral of V*dt/tau, team xg is the full-match integral (validated end-to-end through the shipping calculator: 3.62 vs 3.68 actual, within 2%), and the old peak moves to a peak_value display field. Per-player xg sums below team xg by design: diffuse sub-threshold threat (~37% of the integral) is not attributed to any player. Touch attribution now emits at most one threat_touch per team per frame, credited to the team's primary toucher, so simultaneous contacts cannot double-count a frame transition; events carry contact time/frame/touch id separately from the detection frame that anchors the V bracketing. Goals arriving after the pending-episode grace window no longer upgrade expired episodes. defenders_goalside now normalizes by the defending roster; the model was retrained after that feature fix (trained-v2, held-out log-loss 0.169 / AUC 0.885, unchanged) with a fresh parity fixture. Expected goals is now a registered builtin stats module end to end (module JSON, snapshot frames, config, timeline snapshots, TS bindings), the event catalog parity test skips hidden-from-review definitions generically, and threat_dataset_dump grows an --episode-summary mode that validates the shipped estimator through the real calculator path. Training scripts are reproducible: PEP 723 metadata + uv lock, and the corpus fetcher takes its token/base URL/cache from the environment instead of a hard-coded pass entry resolved at import. Co-Authored-By: Claude Fable 5 --- .../src/bin/threat_dataset_dump.rs | 73 +++ .../src/eventCatalogParity.test.ts | 6 + .../src/generated/ExpectedGoalsPlayerStats.ts | 19 + .../src/generated/ExpectedGoalsTeamStats.ts | 15 + .../src/generated/PlayerStatsSnapshot.ts | 3 +- .../src/generated/TeamStatsSnapshot.ts | 3 +- ...eplayFormatFixtureLoadChild.test-helper.ts | 17 +- .../src/statsSnapshotFactories.ts | 11 + .../src/statsTimelineDerivation.test.ts | 1 + .../src/statsTimelineDerivation.ts | 13 + scripts/threat_model/README.md | 40 +- scripts/threat_model/fetch_corpus.py | 39 +- scripts/threat_model/train_threat_model.py | 31 +- .../threat_model/train_threat_model.py.lock | 464 ++++++++++++++++++ src/collector/stats/builtins.rs | 26 + src/collector/stats/playback_frames.rs | 10 + src/stats/accumulators/expected_goals.rs | 35 +- .../analysis_graph/nodes/stats_projection.rs | 5 + .../nodes/stats_timeline_frame.rs | 7 + src/stats/calculators/event_definition.rs | 4 +- src/stats/calculators/expected_goals.rs | 157 +++++- src/stats/calculators/expected_goals_model.rs | 58 +-- .../calculators/expected_goals_model_tests.rs | 38 +- src/stats/calculators/expected_goals_tests.rs | 307 +++++++++++- src/stats/calculators/touch_state.rs | 23 +- src/stats/timeline/types.rs | 2 + .../support_boost.rs | 2 + 27 files changed, 1293 insertions(+), 116 deletions(-) create mode 100644 js/stat-evaluation-player/src/generated/ExpectedGoalsPlayerStats.ts create mode 100644 js/stat-evaluation-player/src/generated/ExpectedGoalsTeamStats.ts create mode 100644 scripts/threat_model/train_threat_model.py.lock diff --git a/crates/subtr-actor-tools/src/bin/threat_dataset_dump.rs b/crates/subtr-actor-tools/src/bin/threat_dataset_dump.rs index 0eccbbef..606505db 100644 --- a/crates/subtr-actor-tools/src/bin/threat_dataset_dump.rs +++ b/crates/subtr-actor-tools/src/bin/threat_dataset_dump.rs @@ -46,6 +46,16 @@ struct Args { /// 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,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). + #[arg(long)] + episode_summary: Option, } #[derive(Debug, Clone, Deserialize)] @@ -80,6 +90,8 @@ struct ReplayRows { 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<()> { @@ -108,6 +120,19 @@ fn main() -> anyhow::Result<()> { ); 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 @@ -153,6 +178,11 @@ fn main() -> anyhow::Result<()> { 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, @@ -171,6 +201,9 @@ fn main() -> anyhow::Result<()> { Ok(()) })?; out.flush()?; + if let Some(writer) = episode_summary_out.as_mut() { + writer.flush()?; + } eprintln!( "done: {processed} replays processed, {skipped} skipped, {total_rows} rows -> {}", @@ -198,6 +231,10 @@ fn header() -> String { columns.join(",") } +fn episode_summary_header() -> &'static str { + "replay_id,is_team0,episode_count,episode_xg_sum,full_integral_xg,peak_sum,goals" +} + fn process_replay(row: &ManifestRow, sample_interval: f32) -> anyhow::Result { let replay = parse_replay(&row.path)?; let graph = AnalysisGraph::new().with_node(ExpectedGoalsNode::with_config( @@ -249,6 +286,41 @@ fn process_replay(row: &ManifestRow, sample_interval: f32) -> anyhow::Result anyhow::Result { + // 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/generated/ExpectedGoalsPlayerStats.ts b/js/stat-evaluation-player/src/generated/ExpectedGoalsPlayerStats.ts new file mode 100644 index 00000000..0b91f159 --- /dev/null +++ b/js/stat-evaluation-player/src/generated/ExpectedGoalsPlayerStats.ts @@ -0,0 +1,19 @@ +// 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 touch threat deltas (V after minus V before, from the + * toucher's team's perspective) over the player's touches. + */ +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/ExpectedGoalsTeamStats.ts b/js/stat-evaluation-player/src/generated/ExpectedGoalsTeamStats.ts new file mode 100644 index 00000000..ceb7cd1b --- /dev/null +++ b/js/stat-evaluation-player/src/generated/ExpectedGoalsTeamStats.ts @@ -0,0 +1,15 @@ +// 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 = { +/** + * 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/PlayerStatsSnapshot.ts b/js/stat-evaluation-player/src/generated/PlayerStatsSnapshot.ts index d2341a33..2bd6d706 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/TeamStatsSnapshot.ts b/js/stat-evaluation-player/src/generated/TeamStatsSnapshot.ts index 50684611..e7f2e8ae 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/replayFormatFixtureLoadChild.test-helper.ts b/js/stat-evaluation-player/src/replayFormatFixtureLoadChild.test-helper.ts index 14df8e4e..af25bc26 100644 --- a/js/stat-evaluation-player/src/replayFormatFixtureLoadChild.test-helper.ts +++ b/js/stat-evaluation-player/src/replayFormatFixtureLoadChild.test-helper.ts @@ -200,6 +200,15 @@ function omitEventCounts(value: T): T { return rest as T; } +// The legacy serializer exports expected_goals, but its threat events are not +// projected onto the timeline event stream yet, so hydration cannot +// reconstruct the module (frames keep zeroed defaults). Exclude it from the +// comparison until a threat-event timeline projection ships. +function omitExpectedGoals(value: T): T { + const { expected_goals: _ignored, ...rest } = value as T & { expected_goals?: unknown }; + return rest as T; +} + // Labeled stat breakdowns that the legacy serializer exports but the // event-derived hydration does not reconstruct. const UNRECONSTRUCTED_LABELED_FIELDS: Record = { @@ -255,7 +264,9 @@ function omitPositioningSummaryFields(value: // export detail that the event-derived boost reconstruction does not rebuild. // Both are excluded so the comparison covers the shared stats surface. function comparablePlayer(player: T): T { - return omitPositioningSummaryFields(omitLabeledBoostStats(omitEventCounts(player))); + return omitPositioningSummaryFields( + omitLabeledBoostStats(omitExpectedGoals(omitEventCounts(player))), + ); } function comparableFrames( @@ -263,8 +274,8 @@ function comparableFrames( ): MaterializedStatsTimeline["frames"] { return frames.map((frame) => ({ ...frame, - team_zero: omitLabeledBoostStats(omitEventCounts(frame.team_zero)), - team_one: omitLabeledBoostStats(omitEventCounts(frame.team_one)), + team_zero: omitLabeledBoostStats(omitExpectedGoals(omitEventCounts(frame.team_zero))), + team_one: omitLabeledBoostStats(omitExpectedGoals(omitEventCounts(frame.team_one))), players: frame.players.map((player) => comparablePlayer(player)), })); } diff --git a/js/stat-evaluation-player/src/statsSnapshotFactories.ts b/js/stat-evaluation-player/src/statsSnapshotFactories.ts index 4ab3ad61..2623c7c8 100644 --- a/js/stat-evaluation-player/src/statsSnapshotFactories.ts +++ b/js/stat-evaluation-player/src/statsSnapshotFactories.ts @@ -260,6 +260,11 @@ export function createTeamStatsSnapshot( bumps_inflicted: 0, team_bumps_inflicted: 0, }, + expected_goals: { + xg: 0, + episode_count: 0, + goal_episode_count: 0, + }, }, overrides, ); @@ -625,6 +630,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 8c91953e..8547214c 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 71ade32f..3bbcdef8 100644 --- a/js/stat-evaluation-player/src/statsTimelineDerivation.ts +++ b/js/stat-evaluation-player/src/statsTimelineDerivation.ts @@ -157,6 +157,19 @@ 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[] = [ + { + // The threat events behind expected_goals (threat_touch / threat_episode) + // are hidden registry entries that are not projected onto the stats + // timeline event stream yet, so the module cannot be event-derived: + // hydrated frames keep the zeroed factory defaults until a threat-event + // timeline projection ships. Full values are available through the Rust + // stats-module surfaces (builtin module JSON / ReplayStatsFrame). + id: "expected-goals", + playerModules: ["expected_goals"], + teamModules: ["expected_goals"], + apply: (timeline) => timeline, + createFrameAccumulator: () => ({ applyFrame: () => {} }), + }, { id: "event-counts", playerModules: ["event_counts"], diff --git a/scripts/threat_model/README.md b/scripts/threat_model/README.md index 492f7f3d..349b6866 100644 --- a/scripts/threat_model/README.md +++ b/scripts/threat_model/README.md @@ -13,13 +13,16 @@ exact code path, so train and inference can never diverge. 1. **Fetch a corpus** (optional — any manifest of local replays works): ```sh - python3 fetch_corpus.py + python3 fetch_corpus.py # stdlib only ``` - Downloads a rank-stratified sample of processed replays from the - rocket-sense production API (JWT read from `pass show rocket-sense/token`) - into `~/.cache/subtr-actor-threat-corpus/`, and writes `manifest.jsonl` - there. Tune `PER_STRATUM` (default 150 per playlist × rank tier) via env. + 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). 2. **Export the dataset** through the shared Rust feature path: @@ -32,13 +35,17 @@ exact code path, so train and inference can never diverge. Two attacking-normalized rows per sampled live-play frame (one per team), with τ-agnostic goal-time columns for downstream labeling/censoring. -3. **Train and evaluate** (needs numpy/pandas/scikit-learn): +3. **Train and evaluate**: ```sh - python3 train_threat_model.py threat_dataset.csv --tau 5.0 --gbt \ + uv run --script train_threat_model.py threat_dataset.csv --tau 5.0 --gbt \ --out-dir threat_model_out ``` + Dependencies (numpy/pandas/scikit-learn) are declared in the script's + PEP 723 block and pinned by `train_threat_model.py.lock`, so training runs + in a reproducible environment and coefficient provenance is auditable. + Grouped train/test split by replay. Writes `metrics.txt` (log-loss, Brier, AUC, calibration table, per-rank-tier calibration), `coefficients.json`, `model_coefficients.rs`, and `parity_fixture.rs`. `--gbt` also fits a @@ -50,13 +57,24 @@ exact code path, so train and inference can never diverge. the parity fixture in `expected_goals_model_tests.rs` from `parity_fixture.rs`, and run `cargo test --lib expected_goals`. -## trained-v1 +## trained-v2 Fit 2026-07-12 on 10.3M rows from 5,280 rank-stratified ranked-duels/-doubles replays (rocket-sense production, tiers 1–22, ~150 per playlist × tier where -available). Held-out: log-loss 0.169 / Brier 0.0468 / AUC 0.885 vs 0.252 +available), after fixing `defenders_goalside` to normalize by the defending +roster. Held-out: log-loss 0.169 / Brier 0.0468 / AUC 0.885 vs 0.252 baseline log-loss; GBT ceiling 0.154. Calibration tracks observed frequency across all 15 prediction-quantile bins and across rank tiers (drift ≲10% relative for mid/high tiers), supporting a single rank-blind model as v1. -Aggregate sanity: integrated V·dt/τ recovers 6.72 mean goals/game vs 6.67 -actual; per-replay corr(xG, goals) = 0.78. + +## xG aggregation (why the integral) + +`V` is calibrated per 5s-window, so summing episode *peaks* over-counts goals +badly (measured 2.7× on this corpus: 9.87 peak-sum vs 3.68 goals per +team-game). The calibrated estimator is the time integral `Σ V·dt/τ`: the +full-match integral recovers 3.37 mean goals per team-game vs 3.33 actual +(within 1%, per-replay corr 0.75), and the within-episode portion of that +integral (what gets player-attributed) captures ~62% of it. This is why +`ThreatEpisodeEvent.xg` is the within-episode integral, team xg is the +full-match integral, and the old peak lives in `peak_value`. Validate any +estimator change with `threat_dataset_dump --episode-summary`. diff --git a/scripts/threat_model/fetch_corpus.py b/scripts/threat_model/fetch_corpus.py index c4cc57f8..a7304b06 100644 --- a/scripts/threat_model/fetch_corpus.py +++ b/scripts/threat_model/fetch_corpus.py @@ -1,9 +1,18 @@ #!/usr/bin/env python3 -"""Fetch a rank-stratified replay corpus from the rocket-sense production API. +"""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 (environment variables): + 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) """ import concurrent.futures @@ -15,17 +24,33 @@ import sys import urllib.request -BASE = "https://rocket-sense.duckdns.org/api/v1" -CACHE = pathlib.Path.home() / ".cache/subtr-actor-threat-corpus" +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" PER_STRATUM = int(os.environ.get("PER_STRATUM", "150")) # per (playlist, tier) PLAYLISTS = {"ranked-doubles", "ranked-duels", "ranked-standard"} -TOKEN = subprocess.run( - ["pass", "show", "rocket-sense/token"], capture_output=True, text=True, check=True -).stdout.splitlines()[0].strip() + +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): @@ -107,6 +132,8 @@ def download(r): def main(): + global TOKEN + TOKEN = resolve_token() REPLAYS.mkdir(parents=True, exist_ok=True) rows = fetch_listing() picked = stratify(rows) diff --git a/scripts/threat_model/train_threat_model.py b/scripts/threat_model/train_threat_model.py index 905e03fe..4cd99975 100644 --- a/scripts/threat_model/train_threat_model.py +++ b/scripts/threat_model/train_threat_model.py @@ -1,6 +1,18 @@ #!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "numpy>=1.26", +# "pandas>=2.1", +# "scikit-learn>=1.4", +# ] +# /// """Train the subtr-actor expected-goals threat model. +Run with `uv run train_threat_model.py ...` — the PEP 723 block above (and +the adjacent uv lock) pin the training environment so published coefficients +have reproducible provenance. + Input: CSV from `threat_dataset_dump` with columns replay_id, playlist, min_rank_tier, max_rank_tier, team_size, is_team0, time, , time_to_next_goal_for, time_to_next_goal_against, @@ -159,12 +171,20 @@ def calibration_table(p, yt, n_bins=15): # Emitted in the exact shape of the GENERATED COEFFICIENTS section of # src/stats/calculators/expected_goals_model.rs; paste between the markers and -# bump THREAT_MODEL_VERSION. +# bump THREAT_MODEL_VERSION. 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"] -rust.append(f"pub const THREAT_MODEL_BIAS: f32 = {b_raw!r};") +rust.append(f"pub const THREAT_MODEL_BIAS: f32 = {f32(b_raw)};") rust.append("pub const THREAT_MODEL_WEIGHTS: [(&str, f32); THREAT_FEATURE_COUNT] = [") for name in feature_cols: - rust.append(f' ("{name}", {coeffs[name]!r}),') + rust.append(f' ("{name}", {f32(coeffs[name])}),') rust.append("];") (out_dir / "model_coefficients.rs").write_text("\n".join(rust) + "\n") @@ -174,9 +194,8 @@ def calibration_table(p, yt, n_bins=15): fix = ["// (features in FEATURE_NAMES order, expected_v)"] for i in picks: raw = Xte[i] - v = float(p_lr[i]) - vals = ", ".join(f"{float(x)!r}f32" for x in raw) - fix.append(f"(&[{vals}], {v!r}f32),") + vals = ", ".join(f32(x) for x in raw) + fix.append(f"(&[{vals}], {f32(p_lr[i])}),") (out_dir / "parity_fixture.rs").write_text("\n".join(fix) + "\n") print(f"wrote {out_dir}/metrics.txt, coefficients.json, model_coefficients.rs, parity_fixture.rs") diff --git a/scripts/threat_model/train_threat_model.py.lock b/scripts/threat_model/train_threat_model.py.lock new file mode 100644 index 00000000..a00e9982 --- /dev/null +++ b/scripts/threat_model/train_threat_model.py.lock @@ -0,0 +1,464 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[manifest] +requirements = [ + { name = "numpy", specifier = ">=1.26" }, + { name = "pandas", specifier = ">=2.1" }, + { name = "scikit-learn", specifier = ">=1.4" }, +] + +[[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.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +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" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { 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/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495, upload-time = "2026-05-11T18:52:11.558Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250, upload-time = "2026-05-11T18:52:17.005Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" }, + { url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609, upload-time = "2026-05-11T18:52:30.982Z" }, + { url = "https://files.pythonhosted.org/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596, upload-time = "2026-05-11T18:52:33.334Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, +] + +[[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 = "scikit-learn" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { 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/f5/be/e844fd9586e66540a15b71924d17a6cbc1bb749e81ddd0a796bcdba4c055/scikit_learn-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9db6f4d34e68c8899e4cab27fdf8eafe6ed21f2ba52ceb25ea250cd237f8e47b", size = 8789686, upload-time = "2026-06-02T11:53:05.439Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/ff880f62677a17d035817d543cb0fc8727d01eccbee81c5f7fc733a9d856/scikit_learn-1.9.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f401448645a3e7bc115aa3c094097865155b34bff1cba8101857d9104e99074c", size = 8256782, upload-time = "2026-06-02T11:53:08.904Z" }, + { url = "https://files.pythonhosted.org/packages/25/64/eb40435e1a508ab1b4e284ce43ae80f6a162e5be5e38ed5a6fab467a9ea4/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd3a8ef0c758555a3b23c03adaa858af32f7736785ded50ad5991f59c4ed03fa", size = 8992419, upload-time = "2026-06-02T11:53:11.551Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/4810a28e473185429e45a57eebcc91fc991b33d889cc0676063e671db03d/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7e254636164090da847715a27f8e5478feb98c40a9e0ee90cbd277de9e5ceb8", size = 9281411, upload-time = "2026-06-02T11:53:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/be3d369f40d8178ba3bd86635d132e08cb5329b023e4669d9426d84bc007/scikit_learn-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:5dc1818c77575d149e25fce9ef82dd7b7263ae372f03494158668ad632a69759", size = 8272736, upload-time = "2026-06-02T11:53:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/37/79/a733f02dc2118da7e77a134b34f39f40201a353311b011d20859d2db3556/scikit_learn-1.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:366652351f092b219c248f1e72821e841960a63d8f358f1dcfd54dc1cbdbbc28", size = 7919564, upload-time = "2026-06-02T11:53:21.2Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, + { url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" }, + { url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/c9a35cf59b20a86fec24d306f1547b78dec194b08d367ce2a3e4854169d9/scikit_learn-1.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9656acd4e93f74e0b66c8a36c88830a99252dfa900044d36bc2212ae89a47162", size = 8713289, upload-time = "2026-06-02T11:53:58.788Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a7/552a7821597c632b907f7bfe8f36f9f572777af8ef8a48353041cf8e091a/scikit_learn-1.9.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:24360002ae845e7866522b0a5bbf690802e7bc388cac8663502e78aa98598aa2", size = 8245141, upload-time = "2026-06-02T11:54:01.694Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/f4a0c4fe9711154cddabf913471153af79056382ddc612cfe5ee0ff4b72e/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5162ad10a418c8a282dde04c9aa06965de3e9a65f33c1440c0ae69bb1a09d913", size = 8847671, upload-time = "2026-06-02T11:54:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/f0/af/4d72d9e475ac83719160c662619e4bf7b95c19507cd582e7d0167a3c3dae/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fea2cc5677ab49d6f5bade978c866da44957b712d92e9635e8b4f723013c3cb", size = 9118104, upload-time = "2026-06-02T11:54:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d5/6a58eea2cb9abbb9b3f2bb8b2cfb3243d1152d69f442d256c7af71304769/scikit_learn-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:64fa347efc1c839c487433e40c5144d38c336e8a2b59c81aa8660373945c2673", size = 8290674, upload-time = "2026-06-02T11:54:10.087Z" }, + { url = "https://files.pythonhosted.org/packages/65/5b/d4c879cf358f1187141cf90ced473f087183489090244f50c124a2ee478b/scikit_learn-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:1b944b6db288f6b926e3650026ddafb988929de95d11fc2cc5fa117773c9ba42", size = 7978807, upload-time = "2026-06-02T11:54:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/8a/43/bfae3121ec67ae09150d453c442c7c1cc166e9aefe056e6ab3b7728a5cfc/scikit_learn-1.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4ccacf04ca5f4b492158a5f28afe0ace43f81b2571e4b9a66d34848b46128949", size = 9031941, upload-time = "2026-06-02T11:54:15.436Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/20a4546eb17f3b25d3c66df15810411c14ed5065bcfab50b53c96fb627b2/scikit_learn-1.9.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ee1a8db2c18c08e34c7412d4b10be1cac214cd4ea7dc9715a6a327eb49a37c96", size = 8613528, upload-time = "2026-06-02T11:54:18.842Z" }, + { url = "https://files.pythonhosted.org/packages/18/3c/e440e039bb82cd19004edaaad00acbde0fb9b461083c3ecf37941c557312/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:147e9329ef0e39f75d4cffa02b2aa48d827832684926cd5210d9a2cb5c57246b", size = 8855050, upload-time = "2026-06-02T11:54:21.699Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/b341b8dab5998da6270a3a42c2152c578501354d36f944b5856757035ef8/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bad8f8b9950321b54c965fdcbac6c6c55e79e16646b49977bcf3668d3870a1a", size = 9097190, upload-time = "2026-06-02T11:54:24.454Z" }, + { url = "https://files.pythonhosted.org/packages/fb/de/b650b4d69b84468cfa2e28a3ff7b8103743029e6446ce1a97fe060ef688c/scikit_learn-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:78fc56eafd4edb9575d2d8950d1dd152061abb573341a1cb7e099fc40f6c6666", size = 8963204, upload-time = "2026-06-02T11:54:27.428Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/ff83d76d7418112e5a61326443cdda87be3545dd8d6599c95b2481a4419e/scikit_learn-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:051075bda8b7aab87b1906ab3d4740a1e1224a19d7b3781a576736edc94e76aa", size = 8222661, upload-time = "2026-06-02T11:54:30.192Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, +] +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" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, +] + +[[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 = "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/stats/builtins.rs b/src/collector/stats/builtins.rs index 9513deca..1b2b76cf 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", @@ -844,6 +845,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)?; @@ -1544,6 +1556,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 { @@ -1769,6 +1789,12 @@ 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, + }))?) + } "core" | "backboard" | "ceiling_shot" diff --git a/src/collector/stats/playback_frames.rs b/src/collector/stats/playback_frames.rs index be2c6cc4..2106399c 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 index 3b87d946..400cd062 100644 --- a/src/stats/accumulators/expected_goals.rs +++ b/src/stats/accumulators/expected_goals.rs @@ -19,12 +19,14 @@ pub(crate) fn threat_team_label(is_team_0: bool) -> StatLabel { /// 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)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)] +#[ts(export)] pub struct ExpectedGoalsPlayerStats { /// Sum of positive touch threat deltas (V after minus V before, from the /// toucher's team's perspective) over the player's touches. pub threat_added: f32, - /// Sum of episode peak values (xG) for episodes credited to this player. + /// 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, @@ -67,10 +69,16 @@ impl ExpectedGoalsPlayerStats { } } -/// Per-team accumulated threat stats: total xG over all episodes (including -/// player-uncredited ones). -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +/// Per-team accumulated threat stats. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)] +#[ts(export)] pub struct ExpectedGoalsTeamStats { + /// 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, @@ -114,9 +122,11 @@ impl ExpectedGoalsStatsAccumulator { .record_touch(event.team_is_team_0, delta); } - /// Fold one closed threat episode: the peak V is the episode's xG, - /// credited to the episode's player when one is known and always to the - /// team. + /// 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 @@ -125,10 +135,17 @@ impl ExpectedGoalsStatsAccumulator { .record_episode(event); } let team = &mut self.team_stats[usize::from(!event.team_is_team_0)]; - team.xg += event.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; + } } diff --git a/src/stats/analysis_graph/nodes/stats_projection.rs b/src/stats/analysis_graph/nodes/stats_projection.rs index eb41b5fc..ba29c3c4 100644 --- a/src/stats/analysis_graph/nodes/stats_projection.rs +++ b/src/stats/analysis_graph/nodes/stats_projection.rs @@ -660,6 +660,11 @@ impl StatsProjectionNode { ) { 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.finish_sample(); self.previous_live_play = Some(live_play); diff --git a/src/stats/analysis_graph/nodes/stats_timeline_frame.rs b/src/stats/analysis_graph/nodes/stats_timeline_frame.rs index ddd30029..cce0b377 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/calculators/event_definition.rs b/src/stats/calculators/event_definition.rs index 8391cd3a..4577ab38 100644 --- a/src/stats/calculators/event_definition.rs +++ b/src/stats/calculators/event_definition.rs @@ -1169,9 +1169,9 @@ define_stats_event!( "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 span's peak value, credited to the attacking team's most recent toucher.", + 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 team's most recent toucher.", approach = [ - "Open an episode when a team's threat value V rises above the episode threshold during live play, and track the peak V and the team's most recent toucher.", + "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 team's most recent toucher.", "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.", ], diff --git a/src/stats/calculators/expected_goals.rs b/src/stats/calculators/expected_goals.rs index 34c83f19..19f859df 100644 --- a/src/stats/calculators/expected_goals.rs +++ b/src/stats/calculators/expected_goals.rs @@ -11,8 +11,17 @@ //! touch (V just after minus V just before, both from the toucher's team's //! perspective). //! - [`ThreatEpisodeEvent`]: a contiguous span where one team's V exceeds -//! [`THREAT_EPISODE_THRESHOLD`]; the episode's xG is the peak V over the -//! span, credited to the attacking team's most recent toucher. +//! [`THREAT_EPISODE_THRESHOLD`]; the episode's xG is the time integral +//! `sum(V * dt) / tau` over the span (`tau` = +//! [`THREAT_HORIZON_SECONDS`](super::expected_goals_model::THREAT_HORIZON_SECONDS)), +//! credited to the attacking team's most recent toucher. Corpus-calibrated: +//! the full-match integral matches actual goals per team-game within ~1%, +//! whereas summing episode *peak* V over-counts goals ~2.7x; the peak +//! survives on the event 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::*; @@ -106,8 +115,9 @@ pub struct ThreatFeatures { /// Nearest defender distance to their own goal center, normalized by /// [`GOAL_DISTANCE_NORM`] (1.0 when no defender is on the field). pub nearest_defender_to_goal_dist: f32, - /// Defenders goalside of the ball (attacking y beyond the ball's) / team - /// size. + /// Defenders goalside of the ball (attacking y beyond the ball's) / + /// defending-team roster size (non-demoed, the same eligibility used to + /// iterate defenders). pub defenders_goalside: f32, /// 1.0 when any defender occupies the net region in front of their goal /// mouth. @@ -270,6 +280,12 @@ pub fn compute_threat_features( .filter(|position| position.y < ball.y) .count(); + // Defender-count features normalize by the DEFENDING team's eligible + // roster (the same non-demoed filter the iteration below uses), not the + // attacking team's: on uneven-team / leaver frames the two differ. + let defending_team_size = positioned(false).count(); + let defending_team_size_norm = (defending_team_size as f32).max(1.0); + let mut nearest_defender_dist = f32::INFINITY; let mut nearest_defender_boost = 0.0; let mut nearest_defender_to_goal = f32::INFINITY; @@ -326,20 +342,42 @@ pub fn compute_threat_features( } else { 1.0 }, - defenders_goalside: (defenders_goalside as f32 / team_size_norm).clamp(0.0, 1.0), + defenders_goalside: (defenders_goalside as f32 / defending_team_size_norm).clamp(0.0, 1.0), defender_in_net: f32::from(u8::from(defender_in_net)), nearest_defender_boost, attacking_team_size: team_size as f32, } } -/// The change in the touching team's V across one touch. `value_before` is -/// the toucher's team's V on the previous live frame; `value_after` is the V -/// on the frame the touch registered. +/// The change in the touching team's V across 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. #[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, @@ -367,10 +405,10 @@ pub enum ThreatEpisodeEndReason { ReplayEnd, } -/// A contiguous span where one team's V exceeded the episode threshold. `xg` -/// is the peak V over the span; `credited_player` is the attacking team's -/// most recent toucher (within the same live stretch) at close time, `None` -/// when the team never touched -- team-only credit. +/// A contiguous span where one team's V exceeded the episode threshold. +/// `credited_player` is the attacking team's most recent toucher (within the +/// same live stretch) at close time, `None` when the team never touched -- +/// team-only credit. #[derive(Debug, Clone, PartialEq, Serialize)] pub struct ThreatEpisodeEvent { pub start_time: f32, @@ -378,7 +416,19 @@ pub struct ThreatEpisodeEvent { pub end_time: f32, pub end_frame: usize, pub team_is_team_0: bool, + /// The episode's xG: the time 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 the + /// calibrated goal-scale estimator -- summing episode peaks over-counts + /// goals ~2.7x. pub xg: f32, + /// Peak V over the span (the pre-calibration `xg`), kept for + /// display/intensity ranking. + pub peak_value: f32, pub credited_player: Option, pub ended_in_goal: bool, pub end_reason: ThreatEpisodeEndReason, @@ -429,6 +479,8 @@ struct ActiveThreatEpisode { start_time: f32, start_frame: usize, peak_value: f32, + /// Running `sum(V * dt) / tau` over the episode's evaluated live frames. + xg_integral: f64, credited_player: Option, } @@ -459,6 +511,10 @@ pub struct ExpectedGoalsCalculator { samples: Vec, 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)>, @@ -512,6 +568,14 @@ impl ExpectedGoalsCalculator { &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]> { @@ -537,10 +601,15 @@ impl ExpectedGoalsCalculator { frame: frame.frame_number, scoring_team_is_team_0, }); - self.close_episode_as_goal(frame, 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, scoring_team_is_team_0: bool) { + 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 event = Self::event_from_active( @@ -556,8 +625,16 @@ impl ExpectedGoalsCalculator { } if let Some(pending) = state.pending_episode.take() { let mut event = pending.event; - event.ended_in_goal = true; - event.end_reason = ThreatEpisodeEndReason::Goal; + // 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; + } self.episode_events.push(event); } } @@ -576,13 +653,19 @@ impl ExpectedGoalsCalculator { end_time, end_frame, team_is_team_0, - xg: active.peak_value, + xg: active.xg_integral as f32, + peak_value: active.peak_value, credited_player: active.credited_player.clone(), ended_in_goal, end_reason, } } + /// 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, @@ -658,17 +741,34 @@ impl ExpectedGoalsCalculator { } } + /// 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 touch in &touch_state.touch_events { - let index = team_index(touch.team_is_team_0); + 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: frame.frame_number, - team_is_team_0: touch.team_is_team_0, + 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], @@ -683,12 +783,19 @@ impl ExpectedGoalsCalculator { } } + /// 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]; match state.active_episode.as_mut() { Some(active) => { active.peak_value = active.peak_value.max(value); + active.xg_integral += Self::integral_contribution(value, frame.dt); if value <= self.config.episode_threshold { let active = state .active_episode @@ -710,6 +817,7 @@ impl ExpectedGoalsCalculator { start_time: frame.time, start_frame: frame.frame_number, peak_value: value, + xg_integral: Self::integral_contribution(value, frame.dt), credited_player: state.last_toucher.clone(), }); } @@ -796,6 +904,13 @@ impl ExpectedGoalsCalculator { 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.record_samples(frame, features, values); diff --git a/src/stats/calculators/expected_goals_model.rs b/src/stats/calculators/expected_goals_model.rs index 2b53030b..02a8315f 100644 --- a/src/stats/calculators/expected_goals_model.rs +++ b/src/stats/calculators/expected_goals_model.rs @@ -25,44 +25,46 @@ pub const THREAT_HORIZON_SECONDS: f32 = 5.0; // Everything between these markers is replaced wholesale when the offline // training script publishes a fitted model. // -// trained-v1 provenance: logistic regression fit by -// scripts/threat_model/train_threat_model.py on 10.3M live-play rows sampled -// at 4 Hz from 5,280 rank-stratified ranked-duels/-doubles replays -// (rocket-sense production corpus, rank tiers 1-22, 2026-07-12). Grouped -// train/test split by replay. Held-out: log_loss 0.169, Brier 0.0468, -// AUC 0.885 (constant-rate baseline log_loss 0.252); GBT reference ceiling -// log_loss 0.154. Calibration tracks observed frequency within ~10% relative -// across all 15 prediction-quantile bins and across rank tiers. -// Standardization is folded in; weights apply to raw features. +// trained-v2 provenance: logistic regression fit by +// scripts/threat_model/train_threat_model.py (uv-locked environment) on +// 10.3M live-play rows sampled at 4 Hz from 5,280 rank-stratified +// ranked-duels/-doubles replays (rocket-sense production corpus, rank tiers +// 1-22, 2026-07-12). Retrained after fixing defenders_goalside to normalize +// by the defending roster. Grouped train/test split by replay. Held-out: +// log_loss 0.169, Brier 0.0468, AUC 0.885 (constant-rate baseline log_loss +// 0.252); GBT reference ceiling log_loss 0.154. Calibration tracks observed +// frequency within ~10% relative across all 15 prediction-quantile bins and +// across rank tiers. Standardization is folded in; weights apply to raw +// features. // --------------------------------------------------------------------------- /// Identifies the coefficient set embedded below. `heuristic-v0` marks the /// hand-tuned placeholder; trained models use `trained-v` stamps. -pub const THREAT_MODEL_VERSION: &str = "trained-v1"; +pub const THREAT_MODEL_VERSION: &str = "trained-v2"; -pub const THREAT_MODEL_BIAS: f32 = -0.441_601_53; +pub const THREAT_MODEL_BIAS: f32 = -0.44989115; /// One weight per [`ThreatFeatures::FEATURE_NAMES`] entry, in that exact /// order. The pairing is enforced by `weights_cover_every_feature` in the /// adjacent test module. pub const THREAT_MODEL_WEIGHTS: [(&str, f32); THREAT_FEATURE_COUNT] = [ - ("ball_forward_y", -0.030_214_028), - ("ball_dist_to_goal", -4.603_913), - ("ball_height", -0.096_484_2), - ("ball_speed", -2.722_955_2), - ("ball_speed_toward_goal", 5.354_401_5), - ("goal_open_angle", 1.949_296_7), - ("on_target", 1.312_370_6), - ("time_to_goal_line", -0.260_346_38), - ("nearest_attacker_dist", -2.061_250_7), - ("attackers_ahead_of_ball", -0.552_131_2), - ("attackers_behind_ball", 0.387_900_9), - ("nearest_defender_dist", 1.003_443_6), - ("nearest_defender_to_goal_dist", 1.805_623), - ("defenders_goalside", -0.772_091_76), - ("defender_in_net", -0.183_698_83), - ("nearest_defender_boost", -0.404_413_3), - ("attacking_team_size", -0.265_604_6), + ("ball_forward_y", -0.030237095), + ("ball_dist_to_goal", -4.582302), + ("ball_height", -0.095492415), + ("ball_speed", -2.7069197), + ("ball_speed_toward_goal", 5.3320007), + ("goal_open_angle", 1.945358), + ("on_target", 1.3097814), + ("time_to_goal_line", -0.25473773), + ("nearest_attacker_dist", -2.0624259), + ("attackers_ahead_of_ball", -0.54946756), + ("attackers_behind_ball", 0.38570327), + ("nearest_defender_dist", 1.0084612), + ("nearest_defender_to_goal_dist", 1.7910457), + ("defenders_goalside", -0.7728923), + ("defender_in_net", -0.18298395), + ("nearest_defender_boost", -0.4043592), + ("attacking_team_size", -0.25949645), ]; // --------------------------------------------------------------------------- diff --git a/src/stats/calculators/expected_goals_model_tests.rs b/src/stats/calculators/expected_goals_model_tests.rs index 05a99cfa..a5dcd2b9 100644 --- a/src/stats/calculators/expected_goals_model_tests.rs +++ b/src/stats/calculators/expected_goals_model_tests.rs @@ -65,52 +65,52 @@ fn threat_value_is_a_probability_and_orders_danger_over_neutral() { assert!(dangerous_value > super::super::expected_goals::THREAT_EPISODE_THRESHOLD); } -/// Real feature rows from the trained-v1 held-out set with the offline +/// Real feature rows from the trained-v2 held-out set with the offline /// pipeline's predicted probabilities (float64). The embedded f32 model must /// reproduce them; regenerated alongside the coefficients by /// scripts/threat_model/train_threat_model.py. const TRAINING_PARITY_FIXTURE: &[(&[f32; THREAT_FEATURE_COUNT], f32)] = &[ ( &[ - -0.839_109, 0.862_713, 0.078_395, 0.653_84, -0.615_211, 0.057_195, 0.0, 0.0, 1.0, 0.5, - 0.0, 0.063_089, 0.505_834, 1.0, 0.0, 0.274_51, 2.0, + -0.839109, 0.862713, 0.078395, 0.65384, -0.615211, 0.057195, 0.0, 0.0, 1.0, 0.5, 0.0, + 0.063089, 0.505834, 1.0, 0.0, 0.27451, 2.0, ], - 5.375_665e-6, + 5.5895807e-06, ), ( &[ - -0.981_82, 0.914_914, 0.100_039, 0.062_519, 0.001_412, 0.054_801, 0.0, 0.0, 0.362_575, - 1.0, 0.0, 0.330_344, 0.813_828, 1.0, 0.0, 0.239_216, 1.0, + -0.876275, 0.865327, 0.628659, 0.247617, 0.210264, 0.058854, 0.0, 0.110026, 0.411007, + 1.0, 0.0, 0.464603, 0.484796, 1.0, 0.0, 0.121569, 2.0, ], - 0.004_856_322_6, + 0.0049046245, ), ( &[ - -0.850_684, 0.855_126, 0.070_44, 0.175_894, 0.167_881, 0.058_558, 0.0, 0.092_799, - 0.214_89, 0.0, 1.0, 0.194_378, 0.571_433, 0.5, 0.0, 0.819_608, 2.0, + -0.67292, 0.809615, 0.04566, 0.151471, 0.078867, 0.059097, 0.0, 0.075715, 0.110705, + 0.0, 1.0, 1.0, 0.314607, 1.0, 0.0, 0.793353, 2.0, ], - 0.019_329_575, + 0.019436132, ), ( &[ - 0.639_963, 0.219_892, 0.155_705, 0.295_717, 0.001_887, 0.179_142, 0.0, 0.388_036, - 0.254_12, 0.0, 1.0, 0.680_158, 0.102_873, 0.5, 0.0, 1.0, 2.0, + 0.899693, 0.206376, 0.045572, 0.045879, 0.0169, 0.063362, 0.0, 0.0, 0.354955, 0.0, 1.0, + 0.182267, 0.012131, 0.5, 1.0, 0.133333, 2.0, ], - 0.067_572_535, + 0.06779221, ), ( &[ - 0.995_201, 0.002_314, 0.047_216, 0.510_596, 0.489_777, 0.982_483, 1.0, 0.992_038, - 0.305_113, 0.0, 1.0, 1.0, 0.912_356, 0.0, 0.0, 0.047_059, 1.0, + 0.995201, 0.002314, 0.047216, 0.510596, 0.489777, 0.982483, 1.0, 0.992038, 0.305113, + 0.0, 1.0, 1.0, 0.912356, 0.0, 0.0, 0.047059, 1.0, ], - 0.997_098_6, + 0.99705017, ), ( &[ - 0.987_828, 0.017_902, 0.087_539, 0.415_882, 0.413_33, 0.953_978, 1.0, 0.919_608, - 0.067_34, 0.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 2.0, + 0.987828, 0.017902, 0.087539, 0.415882, 0.41333, 0.953978, 1.0, 0.919608, 0.06734, 0.0, + 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 2.0, ], - 0.997_472_2, + 0.99744374, ), ]; diff --git a/src/stats/calculators/expected_goals_tests.rs b/src/stats/calculators/expected_goals_tests.rs index 30c2efc3..d8349bc7 100644 --- a/src/stats/calculators/expected_goals_tests.rs +++ b/src/stats/calculators/expected_goals_tests.rs @@ -83,6 +83,19 @@ fn touch(frame_number: usize, time: f32, id: u64, is_team_0: bool) -> TouchEvent } } +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, @@ -359,6 +372,195 @@ fn touch_delta_event_carries_before_and_after_values() { ); } +/// 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]); +} + +/// `defenders_goalside` normalizes by the DEFENDING team's eligible roster: +/// on a 2v1 the lone goalside defender is 1/1, not 1/2. +#[test] +fn defenders_goalside_normalizes_by_defending_team_size() { + let players = PlayerFrameState { + players: vec![ + player(1, true, glam::Vec3::new(0.0, 2000.0, 17.0), Some(100.0)), + player(2, true, glam::Vec3::new(500.0, 1500.0, 17.0), Some(100.0)), + player(3, false, glam::Vec3::new(0.0, 4000.0, 17.0), Some(50.0)), + ], + }; + let features = compute_threat_features( + glam::Vec3::new(0.0, 3000.0, 93.0), + glam::Vec3::ZERO, + &players, + &no_demoed(), + true, + ); + assert_eq!(features.attacking_team_size, 2.0); + assert_eq!( + features.defenders_goalside, 1.0, + "one of one eligible defenders is goalside" + ); + + // 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, + true, + ); + assert_eq!(features_demoed.defenders_goalside, 0.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(); @@ -412,6 +614,7 @@ fn episode_opens_above_threshold_and_closes_on_value_drop() { vec![], live_play(), ); + let neutral_value = calculator.current_values().unwrap()[0]; let episodes = calculator.episode_events(); assert_eq!(episodes.len(), 1); @@ -421,7 +624,14 @@ fn episode_opens_above_threshold_and_closes_on_value_drop() { assert_eq!(episode.end_frame, 4); assert_eq!(episode.end_reason, ThreatEpisodeEndReason::ValueDropped); assert!(!episode.ended_in_goal); - assert!((episode.xg - peak).abs() < 1e-6); + 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))); } @@ -617,6 +827,81 @@ fn sampling_records_two_attacking_normalized_rows_per_sampled_frame() { } } +/// 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); + 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); +} + #[test] fn accumulator_folds_touch_deltas_and_episode_xg() { let mut accumulator = ExpectedGoalsStatsAccumulator::new(); @@ -624,6 +909,9 @@ fn accumulator_folds_touch_deltas_and_episode_xg() { 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, @@ -633,6 +921,9 @@ fn accumulator_folds_touch_deltas_and_episode_xg() { 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, @@ -645,11 +936,12 @@ fn accumulator_folds_touch_deltas_and_episode_xg() { end_frame: 2, team_is_team_0: true, xg: 0.4, + peak_value: 0.6, credited_player: Some(player_id(1)), ended_in_goal: true, end_reason: ThreatEpisodeEndReason::Goal, }); - // Team-only credit still counts toward the team total. + // Team-only credit still advances the team's episode counters. accumulator.apply_episode_event(&ThreatEpisodeEvent { start_time: 3.0, start_frame: 3, @@ -657,10 +949,15 @@ fn accumulator_folds_touch_deltas_and_episode_xg() { end_frame: 4, team_is_team_0: true, xg: 0.2, + peak_value: 0.3, 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]); let player_stats = accumulator.player_stats().get(&player_id(1)).unwrap(); assert!((player_stats.threat_added - 0.25).abs() < 1e-6); @@ -669,8 +966,10 @@ fn accumulator_folds_touch_deltas_and_episode_xg() { assert_eq!(player_stats.credited_goal_episode_count, 1); let team = accumulator.team_stats(true); - assert!((team.xg - 0.6).abs() < 1e-6); + assert!((team.xg - 1.0).abs() < 1e-6); assert_eq!(team.episode_count, 2); assert_eq!(team.goal_episode_count, 1); - assert_eq!(accumulator.team_stats(false).episode_count, 0); + let other_team = accumulator.team_stats(false); + assert_eq!(other_team.episode_count, 0); + assert!((other_team.xg - 0.25).abs() < 1e-6); } diff --git a/src/stats/calculators/touch_state.rs b/src/stats/calculators/touch_state.rs index 6b6467aa..46f2fc11 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/types.rs b/src/stats/timeline/types.rs index d36ebedc..bdd107a1 100644 --- a/src/stats/timeline/types.rs +++ b/src/stats/timeline/types.rs @@ -513,6 +513,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 +556,5 @@ pub struct PlayerStatsSnapshot { pub rotation: RotationPlayerStats, pub powerslide: PowerslideStats, pub demo: DemoPlayerStats, + pub expected_goals: ExpectedGoalsPlayerStats, } diff --git a/tests/stats_timeline_collector_test/support_boost.rs b/tests/stats_timeline_collector_test/support_boost.rs index 76cc0f4e..2d002c43 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(), } } From c5afec31686d75f3cf76d8632825d34c6c697f42 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Sun, 12 Jul 2026 14:25:00 -0700 Subject: [PATCH 05/12] Drop redundant per-key parity exceptions for hidden events The parity test now skips hidden_from_review entries generically, so the hardcoded threat_episode/threat_touch keys added in 66d92b07 are covered by declaration. Co-Authored-By: Claude Fable 5 --- js/stat-evaluation-player/src/eventCatalogParity.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/js/stat-evaluation-player/src/eventCatalogParity.test.ts b/js/stat-evaluation-player/src/eventCatalogParity.test.ts index 3ab1cc49..712540b5 100644 --- a/js/stat-evaluation-player/src/eventCatalogParity.test.ts +++ b/js/stat-evaluation-player/src/eventCatalogParity.test.ts @@ -29,10 +29,6 @@ const CORE_AGGREGATED = new Set(["assist", "goal", "save", "shot"]); // timeline stream. const NOT_SURFACED = new Set([ "event", // the generic timeline envelope, not a concrete event type - // Expected-goals events remain hidden until the threat-curve timeline UI is - // implemented; the Rust registry still documents them in the meantime. - "threat_episode", - "threat_touch", ]); test("every registry event is represented in the player (or explicitly excepted)", () => { From 7373d56057c02decd889fd8fbc2aaa0072b1212f Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Sun, 12 Jul 2026 14:33:32 -0700 Subject: [PATCH 06/12] Regenerate stat definition docs for integral episode xG Co-Authored-By: Claude Fable 5 --- docs/event-definitions.html | 37 ++++++++++++++++++++++++++++++++----- docs/event-definitions.md | 4 ++-- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/docs/event-definitions.html b/docs/event-definitions.html index bdb8fb91..c294acfa 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. 49 events

    +

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

    Contents

    Basic
    @@ -135,6 +135,7 @@

    Contents

    Other
    + @@ -146,7 +147,7 @@

    Contents

    - +
    EventIDSummary
    Ball Halfball_halfDefinition pending.
    Beaten to ballbeaten_to_ballA player who was actively challenging for the ball when an opponent beat them to the touch.
    Boost Pickupboost_pickupsDefinition pending.
    Respawnboost_respawnDefinition pending.
    BumpbumpDefinition pending.
    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 span's peak value, credited to the attacking team's most recent toucher.
    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 team's most recent toucher.
    Threat Touch Deltathreat_touchThe change in the touching team's continuous threat value (expected-goals state value) across one touch.
    WhiffwhiffA committed attempt near the ball that does not result in that player touching it.
    @@ -810,6 +811,32 @@

    Events

  • ball_half via BallHalfNode / BallHalfCalculator
  • +
    +

    Beaten to ball

    beaten_to_ballOthertop ↑
    +

    A player who was actively challenging for the ball when an opponent beat them to the touch.

    + +
    +Approach Unknown +True positive Not Evaluated +False positive Not Evaluated +False negative Not Evaluated +Testing Untested +
    + +
      +
    • Keep a short rolling motion history for every player relative to the ball during live play.
    • +
    • At each confirmed touch, evaluate every non-touching opponent's lookback window for sustained convergence toward the ball and commitment (approach speed or a dodge toward the ball).
    • +
    • Emit when the loss margin at the touch is narrow: small estimated time-to-ball or close hitbox distance, hard-capped on distance.
    • +
    + +

    None documented.

    + +

    None documented.

    + +
      +
    • beaten_to_ball via BeatenToBallNode / BeatenToBallCalculator
    • +
    +

    Boost Pickup

    boost_pickupsOtherhidden from reviewtop ↑

    Definition pending.

    @@ -1072,9 +1099,9 @@

    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 span's peak value, credited to the attacking team's most recent toucher.

    +

    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 team's most recent toucher.

    Approach Unknown @@ -1085,7 +1112,7 @@

    Events

      -
    • Open an episode when a team's threat value V rises above the episode threshold during live play, and track the peak V and the team's most recent toucher.
    • +
    • 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 team's most recent toucher.
    • 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.
    diff --git a/docs/event-definitions.md b/docs/event-definitions.md index a4ed8a55..34413c4d 100644 --- a/docs/event-definitions.md +++ b/docs/event-definitions.md @@ -1241,11 +1241,11 @@ _None documented._ **Summary** -A contiguous span where one team's continuous threat value exceeds the episode threshold; its xG is the span's peak value, credited to the attacking team's most recent toucher. +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 team's most recent toucher. **Approach** -- Open an episode when a team's threat value V rises above the episode threshold during live play, and track the peak V and the team's most recent toucher. +- 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 team's most recent toucher. - 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. From 6866dbf3911679c285c452f06c79f6fec18834ea Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Sun, 12 Jul 2026 15:09:48 -0700 Subject: [PATCH 07/12] Finalize expected goals review fixes --- .../src/bin/threat_dataset_dump.rs | 11 +- docs/event-definitions.html | 16 +- docs/event-definitions.md | 8 +- flake.nix | 52 +- js/src/lib.rs | 5 + js/stat-evaluation-player/src/env.d.ts | 2 + .../src/expectedGoalsTrackDerivation.test.ts | 116 +++++ .../src/expectedGoalsTrackDerivation.ts | 89 ++++ .../src/generated/ExpectedGoalsPlayerStats.ts | 6 +- .../ExpectedGoalsPlayerTimelinePoint.ts | 4 + .../ExpectedGoalsPlayerTimelineTrack.ts | 5 + .../ExpectedGoalsTeamTimelinePoint.ts | 4 + .../ExpectedGoalsTeamTimelineTrack.ts | 4 + .../generated/ExpectedGoalsTimelineTracks.ts | 5 + .../generated/ReplayStatsTimelineScaffold.ts | 10 +- js/stat-evaluation-player/src/lib.ts | 1 + ...eplayFormatFixtureLoadChild.test-helper.ts | 21 +- js/stat-evaluation-player/src/replayLoader.ts | 6 + .../src/replayLoader.worker.ts | 2 + .../src/statsTimelineDerivation.ts | 14 +- justfile | 14 +- scripts/threat_model/README.md | 34 +- scripts/threat_model/fetch_corpus.py | 105 +++- scripts/threat_model/pyproject.toml | 20 + scripts/threat_model/train_threat_model.py | 95 +++- .../threat_model/train_threat_model.py.lock | 464 ------------------ scripts/threat_model/uv.lock | 197 ++++++++ src/stats/accumulators/expected_goals.rs | 12 +- .../nodes/stats_timeline_events.rs | 4 + src/stats/calculators/event_definition.rs | 8 +- src/stats/calculators/expected_goals.rs | 37 +- src/stats/calculators/expected_goals_tests.rs | 22 + src/stats/timeline/collector.rs | 116 +++++ src/stats/timeline/collector_tests.rs | 50 ++ src/stats/timeline/types.rs | 43 ++ 35 files changed, 1023 insertions(+), 579 deletions(-) create mode 100644 js/stat-evaluation-player/src/expectedGoalsTrackDerivation.test.ts create mode 100644 js/stat-evaluation-player/src/expectedGoalsTrackDerivation.ts create mode 100644 js/stat-evaluation-player/src/generated/ExpectedGoalsPlayerTimelinePoint.ts create mode 100644 js/stat-evaluation-player/src/generated/ExpectedGoalsPlayerTimelineTrack.ts create mode 100644 js/stat-evaluation-player/src/generated/ExpectedGoalsTeamTimelinePoint.ts create mode 100644 js/stat-evaluation-player/src/generated/ExpectedGoalsTeamTimelineTrack.ts create mode 100644 js/stat-evaluation-player/src/generated/ExpectedGoalsTimelineTracks.ts create mode 100644 scripts/threat_model/pyproject.toml delete mode 100644 scripts/threat_model/train_threat_model.py.lock create mode 100644 scripts/threat_model/uv.lock diff --git a/crates/subtr-actor-tools/src/bin/threat_dataset_dump.rs b/crates/subtr-actor-tools/src/bin/threat_dataset_dump.rs index 606505db..a806d350 100644 --- a/crates/subtr-actor-tools/src/bin/threat_dataset_dump.rs +++ b/crates/subtr-actor-tools/src/bin/threat_dataset_dump.rs @@ -10,7 +10,8 @@ //! //! Manifest rows are JSON objects, one per line: //! `{"path": ..., "ballchasing_id": ..., "playlist": ..., -//! "min_rank_tier": ..., "max_rank_tier": ..., "team_size": ..., ...}`. +//! "min_rank_tier": ..., "max_rank_tier": ..., "median_rank_tier": ..., +//! "team_size": ..., ...}`. //! Unknown keys are ignored. use std::io::{BufRead, Write}; @@ -70,6 +71,8 @@ struct ManifestRow { #[serde(default)] max_rank_tier: Option, #[serde(default)] + median_rank_tier: Option, + #[serde(default)] team_size: Option, } @@ -218,6 +221,7 @@ fn header() -> String { "playlist", "min_rank_tier", "max_rank_tier", + "median_rank_tier", "team_size", "is_team0", "time", @@ -253,11 +257,14 @@ fn process_replay(row: &ManifestRow, sample_interval: f32) -> anyhow::ResultContents 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 team's most recent toucher. -Threat Touch Deltathreat_touchThe change in the touching team's continuous threat value (expected-goals state value) across one touch. +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 does not result in that player touching it.
    Positioning
    @@ -1099,9 +1099,9 @@

    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 team's most recent toucher.

    +

    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 @@ -1112,7 +1112,7 @@

    Events

      -
    • 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 team's most recent toucher.
    • +
    • 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.
    @@ -1125,9 +1125,9 @@

    Events

  • expected_goals via ExpectedGoalsNode / ExpectedGoalsCalculator
  • -
    +

    Threat Touch Delta

    threat_touchOtherhidden from reviewtop ↑
    -

    The change in the touching team's continuous threat value (expected-goals state value) across one touch.

    +

    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 @@ -1139,7 +1139,7 @@

    Events

    • Evaluate the versioned logistic 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 just before the touch (previous live frame) and just after (the touch's frame).
    • +
    • 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.

    diff --git a/docs/event-definitions.md b/docs/event-definitions.md index 34413c4d..de4422e3 100644 --- a/docs/event-definitions.md +++ b/docs/event-definitions.md @@ -1241,11 +1241,11 @@ _None documented._ **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 team's most recent toucher. +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 team's most recent toucher. +- 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. @@ -1271,12 +1271,12 @@ _None documented._ **Summary** -The change in the touching team's continuous threat value (expected-goals state value) across one touch. +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 logistic 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 just before the touch (previous live frame) and just after (the touch's frame). +- 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** diff --git a/flake.nix b/flake.nix index 4d23d62c..d6e74fff 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/src/lib.rs b/js/src/lib.rs index f3122de0..15126405 100644 --- a/js/src/lib.rs +++ b/js/src/lib.rs @@ -251,6 +251,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:?}")))?; diff --git a/js/stat-evaluation-player/src/env.d.ts b/js/stat-evaluation-player/src/env.d.ts index 4b8515ec..bd8dc69b 100644 --- a/js/stat-evaluation-player/src/env.d.ts +++ b/js/stat-evaluation-player/src/env.d.ts @@ -41,6 +41,7 @@ declare module "@rlrml/subtr-actor" { activitySummary: Uint8Array; positioningSummary: Uint8Array; accumulationTracks: Uint8Array; + expectedGoalsTracks: Uint8Array; frameChunks: Uint8Array[]; }; }; @@ -63,6 +64,7 @@ declare module "@rlrml/subtr-actor" { activitySummary: Uint8Array; positioningSummary: Uint8Array; accumulationTracks: Uint8Array; + expectedGoalsTracks: Uint8Array; frameChunks: Uint8Array[]; }; } 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 00000000..2c68d448 --- /dev/null +++ b/js/stat-evaluation-player/src/expectedGoalsTrackDerivation.test.ts @@ -0,0 +1,116 @@ +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 = { + teams: [ + { + is_team_0: true, + points: [ + { + frame: 10, + stats: { xg: 0.25, episode_count: 1, goal_episode_count: 0 }, + }, + { + frame: 20, + stats: { xg: 0.6, episode_count: 1, goal_episode_count: 1 }, + }, + ], + }, + { + is_team_0: false, + points: [ + { + frame: 20, + stats: { xg: 0.1, episode_count: 0, goal_episode_count: 0 }, + }, + ], + }, + ], + 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.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.goal_episode_count, 1); + assert.equal(timeline.frames[2]?.team_one.expected_goals.xg, 0.1); + 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]?.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 00000000..84f2ab24 --- /dev/null +++ b/js/stat-evaluation-player/src/expectedGoalsTrackDerivation.ts @@ -0,0 +1,89 @@ +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 ?? { teams: [], players: [] } + ); +} + +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/ExpectedGoalsPlayerStats.ts b/js/stat-evaluation-player/src/generated/ExpectedGoalsPlayerStats.ts index 0b91f159..128b62e5 100644 --- a/js/stat-evaluation-player/src/generated/ExpectedGoalsPlayerStats.ts +++ b/js/stat-evaluation-player/src/generated/ExpectedGoalsPlayerStats.ts @@ -8,8 +8,10 @@ import type { LabeledFloatSums } from "./LabeledFloatSums.ts"; */ export type ExpectedGoalsPlayerStats = { /** - * Sum of positive touch threat deltas (V after minus V before, from the - * toucher's team's perspective) over the player's touches. + * 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, /** 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 00000000..b4cd0303 --- /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 00000000..50e21467 --- /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/ExpectedGoalsTeamTimelinePoint.ts b/js/stat-evaluation-player/src/generated/ExpectedGoalsTeamTimelinePoint.ts new file mode 100644 index 00000000..60eb864f --- /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 00000000..9e7dda01 --- /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 00000000..4d8bfdbb --- /dev/null +++ b/js/stat-evaluation-player/src/generated/ExpectedGoalsTimelineTracks.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 { ExpectedGoalsPlayerTimelineTrack } from "./ExpectedGoalsPlayerTimelineTrack.ts"; +import type { ExpectedGoalsTeamTimelineTrack } from "./ExpectedGoalsTeamTimelineTrack.ts"; + +export type ExpectedGoalsTimelineTracks = { teams: Array, players: Array, }; diff --git a/js/stat-evaluation-player/src/generated/ReplayStatsTimelineScaffold.ts b/js/stat-evaluation-player/src/generated/ReplayStatsTimelineScaffold.ts index 63cdc617..55c4616a 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/lib.ts b/js/stat-evaluation-player/src/lib.ts index 02041ee9..265bafb1 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/replayFormatFixtureLoadChild.test-helper.ts b/js/stat-evaluation-player/src/replayFormatFixtureLoadChild.test-helper.ts index af25bc26..f97f0663 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), @@ -200,15 +204,6 @@ function omitEventCounts(value: T): T { return rest as T; } -// The legacy serializer exports expected_goals, but its threat events are not -// projected onto the timeline event stream yet, so hydration cannot -// reconstruct the module (frames keep zeroed defaults). Exclude it from the -// comparison until a threat-event timeline projection ships. -function omitExpectedGoals(value: T): T { - const { expected_goals: _ignored, ...rest } = value as T & { expected_goals?: unknown }; - return rest as T; -} - // Labeled stat breakdowns that the legacy serializer exports but the // event-derived hydration does not reconstruct. const UNRECONSTRUCTED_LABELED_FIELDS: Record = { @@ -264,9 +259,7 @@ function omitPositioningSummaryFields(value: // export detail that the event-derived boost reconstruction does not rebuild. // Both are excluded so the comparison covers the shared stats surface. function comparablePlayer(player: T): T { - return omitPositioningSummaryFields( - omitLabeledBoostStats(omitExpectedGoals(omitEventCounts(player))), - ); + return omitPositioningSummaryFields(omitLabeledBoostStats(omitEventCounts(player))); } function comparableFrames( @@ -274,8 +267,8 @@ function comparableFrames( ): MaterializedStatsTimeline["frames"] { return frames.map((frame) => ({ ...frame, - team_zero: omitLabeledBoostStats(omitExpectedGoals(omitEventCounts(frame.team_zero))), - team_one: omitLabeledBoostStats(omitExpectedGoals(omitEventCounts(frame.team_one))), + team_zero: omitLabeledBoostStats(omitEventCounts(frame.team_zero)), + team_one: omitLabeledBoostStats(omitEventCounts(frame.team_one)), players: frame.players.map((player) => comparablePlayer(player)), })); } diff --git a/js/stat-evaluation-player/src/replayLoader.ts b/js/stat-evaluation-player/src/replayLoader.ts index 800ce41b..6b4236a1 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 fee924d9..0300200d 100644 --- a/js/stat-evaluation-player/src/replayLoader.worker.ts +++ b/js/stat-evaluation-player/src/replayLoader.worker.ts @@ -149,6 +149,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 +162,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/statsTimelineDerivation.ts b/js/stat-evaluation-player/src/statsTimelineDerivation.ts index 3bbcdef8..bc875223 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, @@ -158,17 +162,11 @@ const STATS_FRAME_MATERIALIZATION_CHUNK_GROWTH_FACTOR = 2; export const STATS_TIMELINE_EVENT_DERIVED_APPLIERS: readonly StatsTimelineEventDerivedApplier[] = [ { - // The threat events behind expected_goals (threat_touch / threat_episode) - // are hidden registry entries that are not projected onto the stats - // timeline event stream yet, so the module cannot be event-derived: - // hydrated frames keep the zeroed factory defaults until a threat-event - // timeline projection ships. Full values are available through the Rust - // stats-module surfaces (builtin module JSON / ReplayStatsFrame). id: "expected-goals", playerModules: ["expected_goals"], teamModules: ["expected_goals"], - apply: (timeline) => timeline, - createFrameAccumulator: () => ({ applyFrame: () => {} }), + apply: applyExpectedGoalsTrackDerivedStats, + createFrameAccumulator: createExpectedGoalsTrackDerivedStatsAccumulator, }, { id: "event-counts", diff --git a/justfile b/justfile index 9af4aa59..01dd04b6 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/scripts/threat_model/README.md b/scripts/threat_model/README.md index 349b6866..904605b5 100644 --- a/scripts/threat_model/README.md +++ b/scripts/threat_model/README.md @@ -13,7 +13,7 @@ exact code path, so train and inference can never diverge. 1. **Fetch a corpus** (optional — any manifest of local replays works): ```sh - python3 fetch_corpus.py # stdlib only + python3 fetch_corpus.py --seed 7 # stdlib only ``` Downloads a rank-stratified sample of processed replays from a @@ -22,7 +22,14 @@ exact code path, so train and inference can never diverge. `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). + `PER_STRATUM` (default 150 per playlist × rank tier), + `THREAT_CORPUS_SEED` (default 7), and `THREAT_CORPUS_PLAYLISTS` (default + `ranked-duels,ranked-doubles`). Repeat `--playlist` to override the playlist + set explicitly; 3v3 is supported but intentionally not part of the default + corpus. 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 the shared Rust feature path: @@ -35,20 +42,27 @@ exact code path, so train and inference can never diverge. Two attacking-normalized rows per sampled live-play frame (one per team), with τ-agnostic goal-time columns for downstream labeling/censoring. -3. **Train and evaluate**: +3. **Train and evaluate** (from the repository root): ```sh - uv run --script train_threat_model.py threat_dataset.csv --tau 5.0 --gbt \ - --out-dir threat_model_out + 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 declared in the script's - PEP 723 block and pinned by `train_threat_model.py.lock`, so training runs - in a reproducible environment and coefficient provenance is auditable. + 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. Grouped train/test split by replay. Writes `metrics.txt` (log-loss, Brier, - AUC, calibration table, per-rank-tier calibration), `coefficients.json`, - `model_coefficients.rs`, and `parity_fixture.rs`. `--gbt` also fits a + AUC, calibration table, per-median-rank-tier calibration), + `training_provenance.json` (dataset/manifest and split hashes, seed, Python + and package versions), `coefficients.json`, `model_coefficients.rs`, and + `parity_fixture.rs`. `--gbt` also fits a gradient-boosted reference to quantify what the linear model leaves behind. 4. **Embed**: paste `model_coefficients.rs` into the GENERATED COEFFICIENTS diff --git a/scripts/threat_model/fetch_corpus.py b/scripts/threat_model/fetch_corpus.py index a7304b06..436b1fb2 100644 --- a/scripts/threat_model/fetch_corpus.py +++ b/scripts/threat_model/fetch_corpus.py @@ -5,7 +5,7 @@ Phase 2: stratified sample by (playlist, median rank tier), download files. Writes manifest.jsonl compatible with threat_dataset_dump. -Configuration (environment variables): +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`) @@ -13,12 +13,18 @@ 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-duels,ranked-doubles) """ +import argparse import concurrent.futures +import hashlib import json import os import pathlib +import random import statistics import subprocess import sys @@ -31,8 +37,8 @@ LISTING = CACHE / "listing.jsonl" MANIFEST = CACHE / "manifest.jsonl" REPLAYS = CACHE / "replays" -PER_STRATUM = int(os.environ.get("PER_STRATUM", "150")) # per (playlist, tier) -PLAYLISTS = {"ranked-doubles", "ranked-duels", "ranked-standard"} +DEFAULT_PLAYLISTS = "ranked-duels,ranked-doubles" +ALLOWED_PLAYLISTS = {"ranked-doubles", "ranked-duels", "ranked-standard"} def resolve_token() -> str: @@ -44,8 +50,7 @@ def resolve_token() -> str: 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" + "no API token: set ROCKET_SENSE_API_TOKEN or make ROCKET_SENSE_TOKEN_COMMAND print one" ) return token @@ -61,7 +66,7 @@ def api(path): def fetch_listing(): if LISTING.exists(): - rows = [json.loads(l) for l in LISTING.read_text().splitlines()] + rows = [json.loads(line) for line in LISTING.read_text().splitlines()] print(f"listing cache: {len(rows)} rows", file=sys.stderr) return rows rows = [] @@ -69,7 +74,9 @@ def fetch_listing(): 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] + 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"], @@ -92,22 +99,26 @@ def fetch_listing(): return rows -def stratify(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: + if r["playlist"] not in playlists or r["median_rank_tier"] is None: continue - if r["sha256"] in seen_sha: + replay_identity = r["sha256"] or f"id:{r['id']}" + if replay_identity in seen_sha: continue - seen_sha.add(r["sha256"]) + 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 = strata[key] - bucket.sort(key=lambda r: r["id"]) # deterministic - take = bucket[:PER_STRATUM] + 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 @@ -119,7 +130,8 @@ def download(r): return "cached" try: req = urllib.request.Request( - f"{BASE}/replays/{r['id']}/file", headers={"Authorization": f"Bearer {TOKEN}"} + f"{BASE}/replays/{r['id']}/file", + headers={"Authorization": f"Bearer {TOKEN}"}, ) with urllib.request.urlopen(req, timeout=300) as resp: data = resp.read() @@ -131,12 +143,51 @@ def download(r): 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; repeat for multiple (default: duels and 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) + picked = stratify(rows, set(args.playlists), args.per_stratum, args.seed) print(f"selected {len(picked)} replays", file=sys.stderr) results = {} @@ -144,7 +195,10 @@ def main(): 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) + 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) @@ -167,7 +221,24 @@ def main(): ) + "\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__": diff --git a/scripts/threat_model/pyproject.toml b/scripts/threat_model/pyproject.toml new file mode 100644 index 00000000..dd19b525 --- /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 index 4cd99975..14ab7343 100644 --- a/scripts/threat_model/train_threat_model.py +++ b/scripts/threat_model/train_threat_model.py @@ -1,20 +1,12 @@ #!/usr/bin/env python3 -# /// script -# requires-python = ">=3.11" -# dependencies = [ -# "numpy>=1.26", -# "pandas>=2.1", -# "scikit-learn>=1.4", -# ] -# /// """Train the subtr-actor expected-goals threat model. -Run with `uv run train_threat_model.py ...` — the PEP 723 block above (and -the adjacent uv lock) pin the training environment so published coefficients -have reproducible provenance. +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, min_rank_tier, max_rank_tier, team_size, is_team0, time, + replay_id, playlist, 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 @@ -28,9 +20,11 @@ """ import argparse +import hashlib +import importlib.metadata import json import pathlib -import sys +import platform import numpy as np import pandas as pd @@ -45,6 +39,7 @@ "playlist", "min_rank_tier", "max_rank_tier", + "median_rank_tier", "team_size", "is_team0", "time", @@ -59,13 +54,27 @@ 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") args = parser.parse_args() + +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) -df = pd.read_csv(args.csv) +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}") @@ -87,7 +96,10 @@ 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]]) + 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) splitter = GroupShuffleSplit(n_splits=1, test_size=args.test_frac, random_state=args.seed) @@ -95,6 +107,29 @@ Xtr, Xte, ytr, yte = X[train_idx], X[test_idx], y[train_idx], y[test_idx] print(f"train={len(train_idx)} test={len(test_idx)} (grouped by replay)") + +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, + "tau_seconds": tau, + "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) @@ -102,7 +137,12 @@ lr.fit(Xtr_s, ytr) p_lr = lr.predict_proba(Xte_s)[:, 1] -lines = [] +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): @@ -144,14 +184,14 @@ def calibration_table(p, yt, n_bins=15): # Per-rank calibration on test set test_df = df.iloc[test_idx] lines.append("\nper-rank-tier test metrics (logistic):") -tiers = test_df["min_rank_tier"].to_numpy() +tiers = test_df["median_rank_tier"].to_numpy() 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={int(tier)}: n={int(m.sum())} base={yte[m].mean():.5f} pred_mean={p_lr[m].mean():.5f} " + f" tier={tier:g}: n={int(m.sum())} base={yte[m].mean():.5f} pred_mean={p_lr[m].mean():.5f} " f"log_loss={log_loss(yte[m], p_lr[m]):.5f} brier={brier_score_loss(yte[m], p_lr[m]):.5f}" ) @@ -166,7 +206,10 @@ def calibration_table(p, yt, n_bins=15): coeffs = {name: float(w) for name, w in zip(feature_cols, w_raw)} (out_dir / "coefficients.json").write_text( - json.dumps({"bias": b_raw, "weights": coeffs, "tau": tau}, indent=1) + json.dumps( + {"bias": b_raw, "weights": coeffs, "tau": tau, "provenance": provenance}, + indent=1, + ) ) # Emitted in the exact shape of the GENERATED COEFFICIENTS section of @@ -190,7 +233,14 @@ def f32(x) -> str: # Parity fixture: 6 test rows spanning the prediction range order = np.argsort(p_lr) -picks = [order[0], order[len(order) // 4], order[len(order) // 2], order[3 * len(order) // 4], order[-2], order[-1]] +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] @@ -198,4 +248,7 @@ def f32(x) -> str: fix.append(f"(&[{vals}], {f32(p_lr[i])}),") (out_dir / "parity_fixture.rs").write_text("\n".join(fix) + "\n") -print(f"wrote {out_dir}/metrics.txt, coefficients.json, model_coefficients.rs, parity_fixture.rs") +print( + f"wrote {out_dir}/metrics.txt, training_provenance.json, coefficients.json, " + "model_coefficients.rs, parity_fixture.rs" +) diff --git a/scripts/threat_model/train_threat_model.py.lock b/scripts/threat_model/train_threat_model.py.lock deleted file mode 100644 index a00e9982..00000000 --- a/scripts/threat_model/train_threat_model.py.lock +++ /dev/null @@ -1,464 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.11" -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform == 'emscripten'", - "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] - -[manifest] -requirements = [ - { name = "numpy", specifier = ">=1.26" }, - { name = "pandas", specifier = ">=2.1" }, - { name = "scikit-learn", specifier = ">=1.4" }, -] - -[[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.4.6" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform == 'emscripten'", - "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, - { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, - { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, - { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, - { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, - { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, - { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, - { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, - { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, - { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, - { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, - { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, - { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, - { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, - { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, - { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, - { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, - { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, - { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, - { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, - { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, - { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, - { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, - { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, - { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, - { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, - { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, - { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, - { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, - { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, - { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, - { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, - { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, - { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, - { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, - { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, - { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, - { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, - { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, - { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, - { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, - { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, - { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, - { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, - { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, - { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, - { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, - { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, - { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, - { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, - { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, - { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, - { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, - { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, - { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, - { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, -] - -[[package]] -name = "numpy" -version = "2.5.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -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" }, - { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, - { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, - { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, - { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, - { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, - { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, - { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, - { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, - { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, - { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, - { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, - { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, - { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, - { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, - { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, - { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, - { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, - { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, - { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, - { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, - { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, - { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, - { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, - { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, - { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, -] - -[[package]] -name = "pandas" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { 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/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495, upload-time = "2026-05-11T18:52:11.558Z" }, - { url = "https://files.pythonhosted.org/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250, upload-time = "2026-05-11T18:52:17.005Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" }, - { url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" }, - { url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" }, - { url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" }, - { url = "https://files.pythonhosted.org/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609, upload-time = "2026-05-11T18:52:30.982Z" }, - { url = "https://files.pythonhosted.org/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596, upload-time = "2026-05-11T18:52:33.334Z" }, - { 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" }, - { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, - { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, - { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, - { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, - { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, - { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, - { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, - { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, - { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, - { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, - { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, - { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, - { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, - { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, - { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, - { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, - { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, - { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, - { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, - { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, - { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, - { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, - { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, - { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, - { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, - { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, - { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, - { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, -] - -[[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 = "scikit-learn" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "joblib" }, - { name = "narwhals" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { 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/f5/be/e844fd9586e66540a15b71924d17a6cbc1bb749e81ddd0a796bcdba4c055/scikit_learn-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9db6f4d34e68c8899e4cab27fdf8eafe6ed21f2ba52ceb25ea250cd237f8e47b", size = 8789686, upload-time = "2026-06-02T11:53:05.439Z" }, - { url = "https://files.pythonhosted.org/packages/42/e2/ff880f62677a17d035817d543cb0fc8727d01eccbee81c5f7fc733a9d856/scikit_learn-1.9.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f401448645a3e7bc115aa3c094097865155b34bff1cba8101857d9104e99074c", size = 8256782, upload-time = "2026-06-02T11:53:08.904Z" }, - { url = "https://files.pythonhosted.org/packages/25/64/eb40435e1a508ab1b4e284ce43ae80f6a162e5be5e38ed5a6fab467a9ea4/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd3a8ef0c758555a3b23c03adaa858af32f7736785ded50ad5991f59c4ed03fa", size = 8992419, upload-time = "2026-06-02T11:53:11.551Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/4810a28e473185429e45a57eebcc91fc991b33d889cc0676063e671db03d/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7e254636164090da847715a27f8e5478feb98c40a9e0ee90cbd277de9e5ceb8", size = 9281411, upload-time = "2026-06-02T11:53:15.063Z" }, - { url = "https://files.pythonhosted.org/packages/3b/67/be3d369f40d8178ba3bd86635d132e08cb5329b023e4669d9426d84bc007/scikit_learn-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:5dc1818c77575d149e25fce9ef82dd7b7263ae372f03494158668ad632a69759", size = 8272736, upload-time = "2026-06-02T11:53:18.108Z" }, - { url = "https://files.pythonhosted.org/packages/37/79/a733f02dc2118da7e77a134b34f39f40201a353311b011d20859d2db3556/scikit_learn-1.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:366652351f092b219c248f1e72821e841960a63d8f358f1dcfd54dc1cbdbbc28", size = 7919564, upload-time = "2026-06-02T11:53:21.2Z" }, - { 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" }, - { url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" }, - { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" }, - { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" }, - { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, - { url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" }, - { url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" }, - { url = "https://files.pythonhosted.org/packages/f4/7d/c9a35cf59b20a86fec24d306f1547b78dec194b08d367ce2a3e4854169d9/scikit_learn-1.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9656acd4e93f74e0b66c8a36c88830a99252dfa900044d36bc2212ae89a47162", size = 8713289, upload-time = "2026-06-02T11:53:58.788Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a7/552a7821597c632b907f7bfe8f36f9f572777af8ef8a48353041cf8e091a/scikit_learn-1.9.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:24360002ae845e7866522b0a5bbf690802e7bc388cac8663502e78aa98598aa2", size = 8245141, upload-time = "2026-06-02T11:54:01.694Z" }, - { url = "https://files.pythonhosted.org/packages/7d/79/f4a0c4fe9711154cddabf913471153af79056382ddc612cfe5ee0ff4b72e/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5162ad10a418c8a282dde04c9aa06965de3e9a65f33c1440c0ae69bb1a09d913", size = 8847671, upload-time = "2026-06-02T11:54:04.448Z" }, - { url = "https://files.pythonhosted.org/packages/f0/af/4d72d9e475ac83719160c662619e4bf7b95c19507cd582e7d0167a3c3dae/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fea2cc5677ab49d6f5bade978c866da44957b712d92e9635e8b4f723013c3cb", size = 9118104, upload-time = "2026-06-02T11:54:07.205Z" }, - { url = "https://files.pythonhosted.org/packages/a2/d5/6a58eea2cb9abbb9b3f2bb8b2cfb3243d1152d69f442d256c7af71304769/scikit_learn-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:64fa347efc1c839c487433e40c5144d38c336e8a2b59c81aa8660373945c2673", size = 8290674, upload-time = "2026-06-02T11:54:10.087Z" }, - { url = "https://files.pythonhosted.org/packages/65/5b/d4c879cf358f1187141cf90ced473f087183489090244f50c124a2ee478b/scikit_learn-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:1b944b6db288f6b926e3650026ddafb988929de95d11fc2cc5fa117773c9ba42", size = 7978807, upload-time = "2026-06-02T11:54:12.769Z" }, - { url = "https://files.pythonhosted.org/packages/8a/43/bfae3121ec67ae09150d453c442c7c1cc166e9aefe056e6ab3b7728a5cfc/scikit_learn-1.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4ccacf04ca5f4b492158a5f28afe0ace43f81b2571e4b9a66d34848b46128949", size = 9031941, upload-time = "2026-06-02T11:54:15.436Z" }, - { url = "https://files.pythonhosted.org/packages/75/b0/20a4546eb17f3b25d3c66df15810411c14ed5065bcfab50b53c96fb627b2/scikit_learn-1.9.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ee1a8db2c18c08e34c7412d4b10be1cac214cd4ea7dc9715a6a327eb49a37c96", size = 8613528, upload-time = "2026-06-02T11:54:18.842Z" }, - { url = "https://files.pythonhosted.org/packages/18/3c/e440e039bb82cd19004edaaad00acbde0fb9b461083c3ecf37941c557312/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:147e9329ef0e39f75d4cffa02b2aa48d827832684926cd5210d9a2cb5c57246b", size = 8855050, upload-time = "2026-06-02T11:54:21.699Z" }, - { url = "https://files.pythonhosted.org/packages/43/26/b341b8dab5998da6270a3a42c2152c578501354d36f944b5856757035ef8/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bad8f8b9950321b54c965fdcbac6c6c55e79e16646b49977bcf3668d3870a1a", size = 9097190, upload-time = "2026-06-02T11:54:24.454Z" }, - { url = "https://files.pythonhosted.org/packages/fb/de/b650b4d69b84468cfa2e28a3ff7b8103743029e6446ce1a97fe060ef688c/scikit_learn-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:78fc56eafd4edb9575d2d8950d1dd152061abb573341a1cb7e099fc40f6c6666", size = 8963204, upload-time = "2026-06-02T11:54:27.428Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f3/ff83d76d7418112e5a61326443cdda87be3545dd8d6599c95b2481a4419e/scikit_learn-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:051075bda8b7aab87b1906ab3d4740a1e1224a19d7b3781a576736edc94e76aa", size = 8222661, upload-time = "2026-06-02T11:54:30.192Z" }, -] - -[[package]] -name = "scipy" -version = "1.17.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform == 'emscripten'", - "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, - { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, - { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, - { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, - { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, - { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, - { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, - { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, - { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, - { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, - { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, - { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, - { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, - { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, - { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, - { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, - { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, - { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, - { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, - { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, - { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, - { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, - { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, - { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, - { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, - { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, - { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, - { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, - { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, - { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, - { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, - { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, - { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, - { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, - { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, - { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, - { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, - { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, - { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, - { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, - { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, - { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, - { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, - { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, - { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, -] - -[[package]] -name = "scipy" -version = "1.18.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -dependencies = [ - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, -] -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" }, - { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, - { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, - { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, - { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, - { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, - { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, - { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, - { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, - { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, - { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, - { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, - { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, - { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, - { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, - { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, - { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, - { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, - { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, - { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, - { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, - { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, - { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, - { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, - { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, - { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, - { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, - { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, -] - -[[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 = "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/scripts/threat_model/uv.lock b/scripts/threat_model/uv.lock new file mode 100644 index 00000000..69cc49ac --- /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/stats/accumulators/expected_goals.rs b/src/stats/accumulators/expected_goals.rs index 400cd062..93aa093d 100644 --- a/src/stats/accumulators/expected_goals.rs +++ b/src/stats/accumulators/expected_goals.rs @@ -22,8 +22,10 @@ pub(crate) fn threat_team_label(is_team_0: bool) -> StatLabel { #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)] #[ts(export)] pub struct ExpectedGoalsPlayerStats { - /// Sum of positive touch threat deltas (V after minus V before, from the - /// toucher's team's perspective) over the player's touches. + /// 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. @@ -105,9 +107,9 @@ impl ExpectedGoalsStatsAccumulator { &self.team_stats[usize::from(!is_team_0)] } - /// Fold one touch threat delta: only positive deltas count toward the - /// toucher's threat-added sum (giving the ball away is not negative - /// threat creation). + /// 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 { diff --git a/src/stats/analysis_graph/nodes/stats_timeline_events.rs b/src/stats/analysis_graph/nodes/stats_timeline_events.rs index 9ec93528..ad91014e 100644 --- a/src/stats/analysis_graph/nodes/stats_timeline_events.rs +++ b/src/stats/analysis_graph/nodes/stats_timeline_events.rs @@ -121,6 +121,10 @@ impl StatsTimelineEventsNode { powerslide_dependency(), demo_dependency(), center_dependency(), + // Expected goals includes a continuous team integral that the + // compact timeline exports as a change-point track rather than a + // full partial-sum frame snapshot. + expected_goals_dependency(), // The goal-context composition node (which itself pulls match // stats plus every goal-tag calculator). goal_context_dependency(), diff --git a/src/stats/calculators/event_definition.rs b/src/stats/calculators/event_definition.rs index 4577ab38..133ce714 100644 --- a/src/stats/calculators/event_definition.rs +++ b/src/stats/calculators/event_definition.rs @@ -1155,10 +1155,10 @@ define_stats_event!( "threat_touch", "Threat Touch Delta", EventCategory::Other, - summary = "The change in the touching team's continuous threat value (expected-goals state value) across one touch.", + 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 logistic 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 just before the touch (previous live frame) and just after (the touch's frame).", + "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 @@ -1169,9 +1169,9 @@ define_stats_event!( "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 team's most recent toucher.", + 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 team's most recent toucher.", + "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.", ], diff --git a/src/stats/calculators/expected_goals.rs b/src/stats/calculators/expected_goals.rs index 19f859df..78361bb7 100644 --- a/src/stats/calculators/expected_goals.rs +++ b/src/stats/calculators/expected_goals.rs @@ -7,14 +7,16 @@ //! seconds, computed from full ball + player physics state. Shots are *not* a //! gating event -- threat is continuous. Derived observations: //! -//! - [`ThreatTouchEvent`]: the change in the touching team's V across each -//! touch (V just after minus V just before, both from the toucher's team's -//! perspective). +//! - [`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 contiguous span where one team's V exceeds //! [`THREAT_EPISODE_THRESHOLD`]; the episode's xG is the time integral //! `sum(V * dt) / tau` over the span (`tau` = //! [`THREAT_HORIZON_SECONDS`](super::expected_goals_model::THREAT_HORIZON_SECONDS)), -//! credited to the attacking team's most recent toucher. Corpus-calibrated: +//! credited to the attacking toucher associated with the episode's peak V. +//! Corpus-calibrated: //! the full-match integral matches actual goals per team-game within ~1%, //! whereas summing episode *peak* V over-counts goals ~2.7x; the peak //! survives on the event as `peak_value` for display/intensity. @@ -349,7 +351,7 @@ pub fn compute_threat_features( } } -/// The change in the touching team's V across one touch. +/// 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 @@ -363,7 +365,9 @@ pub fn compute_threat_features( /// 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. +/// 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`). @@ -406,9 +410,10 @@ pub enum ThreatEpisodeEndReason { } /// A contiguous span where one team's V exceeded the episode threshold. -/// `credited_player` is the attacking team's most recent toucher (within the -/// same live stretch) at close time, `None` when the team never touched -- -/// team-only credit. +/// `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)] pub struct ThreatEpisodeEvent { pub start_time: f32, @@ -481,6 +486,7 @@ struct ActiveThreatEpisode { peak_value: f32, /// 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, } @@ -775,10 +781,7 @@ impl ExpectedGoalsCalculator { }); if let Some(player) = touch.player.clone() { let state = &mut self.team_states[index]; - state.last_toucher = Some(player.clone()); - if let Some(active) = state.active_episode.as_mut() { - active.credited_player = Some(player); - } + state.last_toucher = Some(player); } } } @@ -792,9 +795,13 @@ impl ExpectedGoalsCalculator { 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) => { - active.peak_value = active.peak_value.max(value); + if value > active.peak_value { + active.peak_value = value; + active.credited_player = last_toucher; + } active.xg_integral += Self::integral_contribution(value, frame.dt); if value <= self.config.episode_threshold { let active = state @@ -818,7 +825,7 @@ impl ExpectedGoalsCalculator { start_frame: frame.frame_number, peak_value: value, xg_integral: Self::integral_contribution(value, frame.dt), - credited_player: state.last_toucher.clone(), + credited_player: last_toucher, }); } } diff --git a/src/stats/calculators/expected_goals_tests.rs b/src/stats/calculators/expected_goals_tests.rs index d8349bc7..8d4410fc 100644 --- a/src/stats/calculators/expected_goals_tests.rs +++ b/src/stats/calculators/expected_goals_tests.rs @@ -635,6 +635,28 @@ fn episode_opens_above_threshold_and_closes_on_value_drop() { assert_eq!(episode.credited_player, Some(player_id(1))); } +/// 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(); diff --git a/src/stats/timeline/collector.rs b/src/stats/timeline/collector.rs index 3fdf3732..ba9c97c0 100644 --- a/src/stats/timeline/collector.rs +++ b/src/stats/timeline/collector.rs @@ -208,6 +208,10 @@ pub struct StatsTimelineEventCollector { last_replay_meta_player_count: Option, last_sample_time: Option, frame_persistence: StatsFramePersistenceController, + expected_goals: ExpectedGoalsStatsAccumulator, + expected_goals_touch_cursor: usize, + expected_goals_episode_cursor: usize, + expected_goals_tracks: ExpectedGoalsTimelineTracks, } impl Default for StatsTimelineEventCollector { @@ -226,6 +230,22 @@ impl StatsTimelineEventCollector { last_replay_meta_player_count: None, last_sample_time: None, frame_persistence: StatsFramePersistenceController::new(StatsFrameResolution::default()), + expected_goals: ExpectedGoalsStatsAccumulator::new(), + expected_goals_touch_cursor: 0, + expected_goals_episode_cursor: 0, + expected_goals_tracks: ExpectedGoalsTimelineTracks { + teams: vec![ + ExpectedGoalsTeamTimelineTrack { + is_team_0: true, + points: Vec::new(), + }, + ExpectedGoalsTeamTimelineTrack { + is_team_0: false, + points: Vec::new(), + }, + ], + players: Vec::new(), + }, } } @@ -290,6 +310,99 @@ 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(); + 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.set_team_xg_integrals(team_xg_integrals); + 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 +441,7 @@ impl StatsTimelineEventCollector { activity_summary, positioning_summary, accumulation_tracks, + expected_goals_tracks: self.expected_goals_tracks, }) } @@ -488,6 +602,7 @@ 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; + self.record_expected_goals_tracks(frame.frame_number)?; self.frames.push(frame); } @@ -503,6 +618,7 @@ impl Collector for StatsTimelineEventCollector { return Ok(()); }; let mut final_snapshot = self.snapshot_frame_scaffold()?; + 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 dd03797d..b96beae6 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 @@ -225,6 +248,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 +292,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 bdd107a1..db6ef766 100644 --- a/src/stats/timeline/types.rs +++ b/src/stats/timeline/types.rs @@ -91,6 +91,49 @@ 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 teams: Vec, + pub players: 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)] From 60ae49f2c25ec11affe702c7032af944d31380ef Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Sun, 12 Jul 2026 18:32:34 -0700 Subject: [PATCH 08/12] Make expected goals opt-in with symmetric 2v2 features --- .../src/bin/threat_dataset_dump.rs | 95 +-- js/src/lib.rs | 47 +- js/stat-evaluation-player/src/env.d.ts | 9 +- ...eplayFormatFixtureLoadChild.test-helper.ts | 2 +- .../src/replayLoader.worker.ts | 1 + python/PYTHON-README.md | 18 +- python/src/lib.rs | 30 +- python/tests/test_filepath_functions.py | 9 +- scripts/threat_model/README.md | 87 ++- scripts/threat_model/fetch_corpus.py | 8 +- scripts/threat_model/train_threat_model.py | 177 +++++- src/collector/ndarray/analysis_builtins.rs | 162 +++++ src/collector/ndarray/collector.rs | 52 ++ src/collector/ndarray/collector_tests.rs | 65 +- src/collector/ndarray/mod.rs | 6 + src/collector/ndarray/traits.rs | 17 + src/collector/stats/builtins.rs | 23 +- src/collector/stats/collector.rs | 27 +- src/collector/stats/mod.rs | 1 + src/stats/analysis_graph/mod.rs | 9 +- .../analysis_graph/nodes/expected_goals.rs | 4 +- src/stats/analysis_graph/nodes/mod.rs | 25 +- .../nodes/player_control_state.rs | 46 ++ .../analysis_graph/nodes/stats_projection.rs | 51 +- .../nodes/stats_timeline_events.rs | 23 +- .../analysis_graph/nodes/threat_features.rs | 159 +++++ .../nodes/threat_features_tests.rs | 181 ++++++ src/stats/calculators/expected_goals.rs | 557 +++++++++++------- src/stats/calculators/expected_goals_model.rs | 116 ++-- .../calculators/expected_goals_model_tests.rs | 133 ++--- src/stats/calculators/expected_goals_tests.rs | 185 +++--- src/stats/calculators/frame_components.rs | 21 + src/stats/calculators/frame_input.rs | 30 +- src/stats/timeline/collector.rs | 59 +- src/stats/timeline/collector_tests.rs | 41 +- tests/stats_collector_test.rs | 28 +- 36 files changed, 1899 insertions(+), 605 deletions(-) create mode 100644 src/stats/analysis_graph/nodes/player_control_state.rs create mode 100644 src/stats/analysis_graph/nodes/threat_features.rs create mode 100644 src/stats/analysis_graph/nodes/threat_features_tests.rs diff --git a/crates/subtr-actor-tools/src/bin/threat_dataset_dump.rs b/crates/subtr-actor-tools/src/bin/threat_dataset_dump.rs index a806d350..27c70385 100644 --- a/crates/subtr-actor-tools/src/bin/threat_dataset_dump.rs +++ b/crates/subtr-actor-tools/src/bin/threat_dataset_dump.rs @@ -2,14 +2,14 @@ //! //! For each replay, samples both teams' attacking-normalized //! `ThreatFeatures` rows at `--sample-hz` during live play (through the same -//! `ExpectedGoalsCalculator` the stats pipeline runs -- the feature +//! 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": ..., +//! `{"path": ..., "ballchasing_id": ..., "playlist": ..., "date": ..., //! "min_rank_tier": ..., "max_rank_tier": ..., "median_rank_tier": ..., //! "team_size": ..., ...}`. //! Unknown keys are ignored. @@ -22,11 +22,9 @@ use std::sync::mpsc; use anyhow::Context; use clap::Parser; use serde::Deserialize; -use subtr_actor::analysis_graph::{ - AnalysisGraph, ExpectedGoalsNode, collect_analysis_graph_for_replay, -}; use subtr_actor::{ - ExpectedGoalsCalculator, ExpectedGoalsCalculatorConfig, ThreatFeatures, ThreatGoalRecord, + Collector, ExpectedGoalsCalculator, LiveThreatSampleFilter, NDArrayCollector, ThreatFeatures, + ThreatGoalRecord, }; #[derive(Debug, Parser)] @@ -67,6 +65,8 @@ struct ManifestRow { #[serde(default)] playlist: Option, #[serde(default)] + date: Option, + #[serde(default)] min_rank_tier: Option, #[serde(default)] max_rank_tier: Option, @@ -219,6 +219,7 @@ fn header() -> String { let mut columns = vec![ "replay_id", "playlist", + "date", "min_rank_tier", "max_rank_tier", "median_rank_tier", @@ -240,26 +241,39 @@ fn episode_summary_header() -> &'static str { } 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 graph = AnalysisGraph::new().with_node(ExpectedGoalsNode::with_config( - ExpectedGoalsCalculatorConfig { - sample_interval_seconds: Some(sample_interval), - ..ExpectedGoalsCalculatorConfig::default() - }, - )); - let graph = collect_analysis_graph_for_replay(&replay, graph) - .map_err(|error| anyhow::anyhow!("failed to process replay: {error:?}"))?; - let calculator = graph - .state::() + 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(); + 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 @@ -270,40 +284,39 @@ fn process_replay(row: &ManifestRow, sample_interval: f32) -> anyhow::Result, + 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)| { @@ -448,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(); @@ -493,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(); @@ -534,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:?}")))?; @@ -546,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:?}")))?; @@ -573,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/env.d.ts b/js/stat-evaluation-player/src/env.d.ts index bd8dc69b..18926d66 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: { @@ -49,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; diff --git a/js/stat-evaluation-player/src/replayFormatFixtureLoadChild.test-helper.ts b/js/stat-evaluation-player/src/replayFormatFixtureLoadChild.test-helper.ts index f97f0663..6fd877d4 100644 --- a/js/stat-evaluation-player/src/replayFormatFixtureLoadChild.test-helper.ts +++ b/js/stat-evaluation-player/src/replayFormatFixtureLoadChild.test-helper.ts @@ -365,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.worker.ts b/js/stat-evaluation-player/src/replayLoader.worker.ts index 0300200d..3d7fd3e4 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({ diff --git a/python/PYTHON-README.md b/python/PYTHON-README.md index 9939f056..7c679889 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 e734961b..21a9a9ba 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 ea693efa..dcda5d0b 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 index 904605b5..d4153ca7 100644 --- a/scripts/threat_model/README.md +++ b/scripts/threat_model/README.md @@ -4,13 +4,14 @@ Offline pipeline that fits the expected-goals threat model embedded in `src/stats/calculators/expected_goals_model.rs`. The model is `V = sigmoid(bias + w · features)`: the probability that the attacking team scores within `THREAT_HORIZON_SECONDS` (5s), evaluated per frame on the -`ThreatFeatures` vector. Feature extraction lives only in Rust -(`compute_threat_features`); training consumes rows exported through that -exact code path, so train and inference can never diverge. +`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. ## Steps -1. **Fetch a corpus** (optional — any manifest of local replays works): +1. **Fetch a corpus** (optional — a ranked-doubles manifest of local replays works): ```sh python3 fetch_corpus.py --seed 7 # stdlib only @@ -23,15 +24,15 @@ exact code path, so train and inference can never diverge. `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` (default - `ranked-duels,ranked-doubles`). Repeat `--playlist` to override the playlist - set explicitly; 3v3 is supported but intentionally not part of the default - corpus. Selection shuffles each rank stratum with the recorded seed instead + `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 the shared Rust feature path: +2. **Export the dataset** through `NDArrayCollector`: ```sh cargo run --release -p subtr-actor-tools --bin threat_dataset_dump -- \ @@ -39,8 +40,21 @@ exact code path, so train and inference can never diverge. --out threat_dataset.csv --sample-hz 4 ``` - Two attacking-normalized rows per sampled live-play frame (one per team), - with τ-agnostic goal-time columns for downstream labeling/censoring. + 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): @@ -58,8 +72,14 @@ exact code path, so train and inference can never diverge. `nix develop .#threat-model`. Without Nix, run `uv sync --locked` followed by `uv run --locked train_threat_model.py ...` from this directory. - Grouped train/test split by replay. Writes `metrics.txt` (log-loss, Brier, - AUC, calibration table, per-median-rank-tier calibration), + 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`. `--gbt` also fits a @@ -71,24 +91,29 @@ exact code path, so train and inference can never diverge. the parity fixture in `expected_goals_model_tests.rs` from `parity_fixture.rs`, and run `cargo test --lib expected_goals`. -## trained-v2 - -Fit 2026-07-12 on 10.3M rows from 5,280 rank-stratified ranked-duels/-doubles -replays (rocket-sense production, tiers 1–22, ~150 per playlist × tier where -available), after fixing `defenders_goalside` to normalize by the defending -roster. Held-out: log-loss 0.169 / Brier 0.0468 / AUC 0.885 vs 0.252 -baseline log-loss; GBT ceiling 0.154. Calibration tracks observed frequency -across all 15 prediction-quantile bins and across rank tiers (drift ≲10% -relative for mid/high tiers), supporting a single rank-blind model as v1. +## trained-v4 + +Fit 2026-07-12 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), logistic log-loss is 0.1355 versus +0.1964 for the constant-rate baseline, Brier score is 0.0359, AUC is 0.8837, +and 15-bin expected calibration error is 0.0015. The nonlinear GBT ceiling is +0.1287 log-loss, so the deployable linear model captures most—but not all—of +the available signal. Removing all player state worsens log-loss by 0.0180; +mean-substitution knockouts for boost and dodge availability worsen it by +0.00038 and 0.00031 respectively. Demo state is currently neutral after the +other physical inputs are present. On 1,018 held-out replay/team outcomes, the +time-integrated model averages 2.781 xG against 2.834 goals (0.981 ratio), with +0.594 per-team-game correlation. The aggregate count scale is good; individual +match totals remain noisy and should not be treated as precise forecasts. ## xG aggregation (why the integral) -`V` is calibrated per 5s-window, so summing episode *peaks* over-counts goals -badly (measured 2.7× on this corpus: 9.87 peak-sum vs 3.68 goals per -team-game). The calibrated estimator is the time integral `Σ V·dt/τ`: the -full-match integral recovers 3.37 mean goals per team-game vs 3.33 actual -(within 1%, per-replay corr 0.75), and the within-episode portion of that -integral (what gets player-attributed) captures ~62% of it. This is why -`ThreatEpisodeEvent.xg` is the within-episode integral, team xg is the -full-match integral, and the old peak lives in `peak_value`. Validate any -estimator change with `threat_dataset_dump --episode-summary`. +`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. Validate any estimator change on the full +corpus with `threat_dataset_dump --episode-summary`. diff --git a/scripts/threat_model/fetch_corpus.py b/scripts/threat_model/fetch_corpus.py index 436b1fb2..e052ce69 100644 --- a/scripts/threat_model/fetch_corpus.py +++ b/scripts/threat_model/fetch_corpus.py @@ -15,7 +15,7 @@ PER_STRATUM replays per (playlist, tier) stratum (150) THREAT_CORPUS_SEED deterministic sampling seed (7) THREAT_CORPUS_PLAYLISTS comma-separated playlists - (ranked-duels,ranked-doubles) + (ranked-doubles) """ import argparse @@ -37,8 +37,8 @@ LISTING = CACHE / "listing.jsonl" MANIFEST = CACHE / "manifest.jsonl" REPLAYS = CACHE / "replays" -DEFAULT_PLAYLISTS = "ranked-duels,ranked-doubles" -ALLOWED_PLAYLISTS = {"ranked-doubles", "ranked-duels", "ranked-standard"} +DEFAULT_PLAYLISTS = "ranked-doubles" +ALLOWED_PLAYLISTS = {"ranked-doubles"} def resolve_token() -> str: @@ -150,7 +150,7 @@ def parse_args(): action="append", dest="playlists", choices=sorted(ALLOWED_PLAYLISTS), - help="playlist to include; repeat for multiple (default: duels and doubles)", + help="playlist to include (fixed to ranked doubles)", ) parser.add_argument( "--per-stratum", type=int, default=int(os.environ.get("PER_STRATUM", "150")) diff --git a/scripts/threat_model/train_threat_model.py b/scripts/threat_model/train_threat_model.py index 14ab7343..7ae26600 100644 --- a/scripts/threat_model/train_threat_model.py +++ b/scripts/threat_model/train_threat_model.py @@ -5,7 +5,7 @@ from the repository root with `nix run .#train-threat-model -- ...`. Input: CSV from `threat_dataset_dump` with columns - replay_id, playlist, min_rank_tier, max_rank_tier, median_rank_tier, + 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 @@ -20,6 +20,7 @@ """ import argparse +import gc import hashlib import importlib.metadata import json @@ -31,12 +32,12 @@ 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.model_selection import GroupShuffleSplit from sklearn.preprocessing import StandardScaler META_COLS = [ "replay_id", "playlist", + "date", "min_rank_tier", "max_rank_tier", "median_rank_tier", @@ -51,6 +52,7 @@ 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) @@ -60,6 +62,8 @@ ) parser.add_argument("--gbt", action="store_true", help="also fit a GBT ceiling reference") args = parser.parse_args() +if args.sample_hz <= 0: + parser.error("--sample-hz must be positive") def sha256_file(path: pathlib.Path) -> str: @@ -78,6 +82,62 @@ def sha256_file(path: pathlib.Path) -> str: 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) @@ -89,7 +149,7 @@ def sha256_file(path: pathlib.Path) -> str: keep = ~censored df = df[keep].copy() y = label[keep].to_numpy() -X = df[feature_cols].to_numpy(dtype=np.float64) +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}") @@ -102,10 +162,26 @@ def sha256_file(path: pathlib.Path) -> str: ) X = np.nan_to_num(X, nan=0.0, posinf=0.0, neginf=0.0) -splitter = GroupShuffleSplit(n_splits=1, test_size=args.test_frac, random_state=args.seed) -train_idx, test_idx = next(splitter.split(X, y, groups)) +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] -print(f"train={len(train_idx)} test={len(test_idx)} (grouped by replay)") +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: @@ -120,7 +196,10 @@ def replay_set_hash(indices) -> str: "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, "train_replay_ids_sha256": replay_set_hash(train_idx), "test_replay_ids_sha256": replay_set_hash(test_idx), "python": platform.python_version(), @@ -146,7 +225,7 @@ def replay_set_hash(indices) -> str: def report(name, p, yt): - base = np.full_like(p, yt.mean() if name.endswith("(train-rate)") else ytr.mean()) + 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} " @@ -158,11 +237,31 @@ def report(name, p, yt): report("logistic", p_lr, yte) + +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) + del gbt, p_gbt + gc.collect() def calibration_table(p, yt, n_bins=15): @@ -177,10 +276,47 @@ def calibration_table(p, yt, n_bins=15): return rows -lines.append("\ncalibration (predicted, observed, n):") -for pred, obs, n in calibration_table(p_lr, yte): +calibration = calibration_table(p_lr, yte) +ece = sum(abs(pred - obs) * n for pred, obs, n in calibration) / len(yte) +lines.append(f"\nexpected 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}") +# Count-scale validation: integrate overlapping five-second probabilities for +# each held-out replay/team, then compare against distinct observed goal times. +count_scale = df.iloc[test_idx].copy() +count_scale["prediction"] = p_lr +count_scale = count_scale.sort_values(["replay_id", "is_team0", "time"]) +group_keys = ["replay_id", "is_team0"] +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["xg_contribution"] = count_scale["prediction"] * count_scale["dt"].fillna(0.0) / tau +count_scale["goal_time"] = (count_scale["time"] + count_scale["time_to_next_goal_for"]).round(2) +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( + [ + "\nheld-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] lines.append("\nper-rank-tier test metrics (logistic):") @@ -196,13 +332,23 @@ def calibration_table(p, yt, n_bins=15): ) (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 publishable coefficients on +# the full corpus. Held-out metrics above always come from `lr`; generated +# coefficients and parity values below always come from `publish_lr`. +publish_scaler = StandardScaler().fit(X) +X_publish = publish_scaler.transform(X) +publish_lr = LogisticRegression(max_iter=2000, C=1.0) +publish_lr.fit(X_publish, y) # Fold standardization into raw-feature coefficients: # z = (x - mu) / sigma ; w_z . z + b = (w_z / sigma) . x + (b - w_z . mu / sigma) -w_z = lr.coef_[0] -mu, sigma = scaler.mean_, scaler.scale_ +w_z = publish_lr.coef_[0] +mu, sigma = publish_scaler.mean_, publish_scaler.scale_ w_raw = w_z / sigma -b_raw = float(lr.intercept_[0] - np.sum(w_z * mu / sigma)) +b_raw = float(publish_lr.intercept_[0] - np.sum(w_z * mu / sigma)) coeffs = {name: float(w) for name, w in zip(feature_cols, w_raw)} (out_dir / "coefficients.json").write_text( @@ -231,8 +377,9 @@ def f32(x) -> str: rust.append("];") (out_dir / "model_coefficients.rs").write_text("\n".join(rust) + "\n") -# Parity fixture: 6 test rows spanning the prediction range -order = np.argsort(p_lr) +# Parity fixture: 6 temporal-test rows spanning the published model's range. +p_publish_test = publish_lr.predict_proba(publish_scaler.transform(Xte))[:, 1] +order = np.argsort(p_publish_test) picks = [ order[0], order[len(order) // 4], @@ -245,7 +392,7 @@ def f32(x) -> str: for i in picks: raw = Xte[i] vals = ", ".join(f32(x) for x in raw) - fix.append(f"(&[{vals}], {f32(p_lr[i])}),") + fix.append(f"(&[{vals}], {f32(p_publish_test[i])}),") (out_dir / "parity_fixture.rs").write_text("\n".join(fix) + "\n") print( diff --git a/src/collector/ndarray/analysis_builtins.rs b/src/collector/ndarray/analysis_builtins.rs index 9c04886d..9d2ad822 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 4989de1b..f3638183 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 ca55abcb..36148dbd 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 1f22e355..928f7197 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 e3ac56da..765b2ea3 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 1b2b76cf..8b8e55f9 100644 --- a/src/collector/stats/builtins.rs +++ b/src/collector/stats/builtins.rs @@ -429,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, @@ -1163,16 +1176,22 @@ pub fn builtin_analysis_node_json( json!({ "config": { "episode_threshold": state.config().episode_threshold, - "sample_interval_seconds": state.config().sample_interval_seconds, }, "current_values": state.current_values(), "touch_events": state.touch_events(), "episode_events": state.episode_events(), - "samples": state.samples(), "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": state.players }) + } "possession_state" => { let state = graph_state::(graph, node_name)?; json!({ diff --git a/src/collector/stats/collector.rs b/src/collector/stats/collector.rs index ce078ba1..71e2c2bd 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 ed6a26d0..80e2d3ac 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/stats/analysis_graph/mod.rs b/src/stats/analysis_graph/mod.rs index a599a055..e52acbba 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`], @@ -114,6 +115,7 @@ builtin_analysis_nodes! { GameplayStateNode, BallFrameStateNode, PlayerFrameStateNode, + PlayerControlStateNode, FrameEventsStateNode, LivePlayNode, MatchStatsNode, @@ -124,6 +126,7 @@ builtin_analysis_nodes! { ControlledPlayNode, ContinuousBallControlNode, DoubleTapNode, + ThreatFeaturesNode, ExpectedGoalsNode, FiftyFiftyNode, FiftyFiftyStateNode, diff --git a/src/stats/analysis_graph/nodes/expected_goals.rs b/src/stats/analysis_graph/nodes/expected_goals.rs index 62598c34..5b52e79a 100644 --- a/src/stats/analysis_graph/nodes/expected_goals.rs +++ b/src/stats/analysis_graph/nodes/expected_goals.rs @@ -29,11 +29,9 @@ impl_analysis_node! { dependencies = [ frame_info_dependency() => FrameInfo, gameplay_state_dependency() => GameplayState, - ball_frame_state_dependency() => BallFrameState, - player_frame_state_dependency() => PlayerFrameState, frame_events_state_dependency() => FrameEventsState, touch_state_dependency() => TouchState, - live_play_dependency() => LivePlayState, + 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 b510e499..043b2e41 100644 --- a/src/stats/analysis_graph/nodes/mod.rs +++ b/src/stats/analysis_graph/nodes/mod.rs @@ -15,11 +15,12 @@ use crate::stats::calculators::{ HighAerialGoalCalculator, KickoffCalculator, KickoffGoalCalculator, LivePlayState, LongDistanceGoalCalculator, LoosePossessionCalculator, MatchStatsCalculator, MovementCalculator, OneTimerCalculator, OneTimerGoalCalculator, OwnHalfGoalCalculator, - PassCalculator, PassingGoalCalculator, PlayerFrameState, PlayerPossessionCalculator, - PlayerVerticalState, PositioningCalculator, PossessionCalculator, PossessionState, - PowerslideCalculator, RotationCalculator, RushCalculator, SpeedFlipCalculator, - SustainedPressureGoalCalculator, TerritorialPressureCalculator, TouchCalculator, TouchState, - WallAerialCalculator, WallAerialShotCalculator, WavedashCalculator, WhiffCalculator, + PassCalculator, PassingGoalCalculator, PlayerControlState, PlayerFrameState, + PlayerPossessionCalculator, PlayerVerticalState, PositioningCalculator, PossessionCalculator, + PossessionState, PowerslideCalculator, RotationCalculator, RushCalculator, SpeedFlipCalculator, + SustainedPressureGoalCalculator, TerritorialPressureCalculator, ThreatFeaturesState, + TouchCalculator, TouchState, WallAerialCalculator, WallAerialShotCalculator, + WavedashCalculator, WhiffCalculator, }; pub(crate) mod air_dribble; @@ -58,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; @@ -73,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; @@ -159,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; @@ -191,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; @@ -221,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) } @@ -377,6 +388,10 @@ 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 00000000..f4404016 --- /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 ba29c3c4..e322a703 100644 --- a/src/stats/analysis_graph/nodes/stats_projection.rs +++ b/src/stats/analysis_graph/nodes/stats_projection.rs @@ -156,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, @@ -224,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) { @@ -647,24 +653,26 @@ impl StatsProjectionNode { { self.state.controlled_play.apply_event(event); } - 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); + 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()); } - // 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.finish_sample(); self.previous_live_play = Some(live_play); @@ -680,7 +688,7 @@ impl AnalysisNode for StatsProjectionNode { } fn dependencies(&self) -> NodeDependencies { - vec![ + let mut dependencies = vec![ frame_info_dependency(), gameplay_state_dependency(), live_play_dependency(), @@ -719,8 +727,11 @@ impl AnalysisNode for StatsProjectionNode { demo_dependency(), center_dependency(), controlled_play_dependency(), - expected_goals_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 ad91014e..e73102ef 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(), @@ -121,14 +128,14 @@ impl StatsTimelineEventsNode { powerslide_dependency(), demo_dependency(), center_dependency(), - // Expected goals includes a continuous team integral that the - // compact timeline exports as a change-point track rather than a - // full partial-sum frame snapshot. - expected_goals_dependency(), // 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 } } @@ -146,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/threat_features.rs b/src/stats/analysis_graph/nodes/threat_features.rs new file mode 100644 index 00000000..1c86bd9c --- /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 00000000..4298cc92 --- /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/expected_goals.rs b/src/stats/calculators/expected_goals.rs index 78361bb7..cb6c5dbf 100644 --- a/src/stats/calculators/expected_goals.rs +++ b/src/stats/calculators/expected_goals.rs @@ -16,10 +16,10 @@ //! `sum(V * dt) / tau` over the span (`tau` = //! [`THREAT_HORIZON_SECONDS`](super::expected_goals_model::THREAT_HORIZON_SECONDS)), //! credited to the attacking toucher associated with the episode's peak V. -//! Corpus-calibrated: -//! the full-match integral matches actual goals per team-game within ~1%, -//! whereas summing episode *peak* V over-counts goals ~2.7x; the peak -//! survives on the event as `peak_value` for display/intensity. +//! Dividing the time integral by the prediction horizon corrects for the +//! overlapping windows evaluated on adjacent frames; summing episode peaks +//! would count the same sustained chance repeatedly. The peak survives on +//! the event 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 @@ -27,11 +27,8 @@ use super::*; -/// Episode threshold on V. The heuristic model's neutral-midfield baseline -/// sits around 0.02-0.04, so 0.15 is roughly 4x baseline: episodes open only -/// on genuinely elevated scoring probability (an on-target ball, a breakaway -/// toward an under-defended net) rather than ordinary offensive-half -/// possession. +/// 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; /// A ballistic trajectory must cross the goal line within this many seconds @@ -46,17 +43,14 @@ const ON_TARGET_MAX_SECONDS: f32 = 3.0; const PLAYER_DISTANCE_NORM: f32 = 4000.0; /// Maximum in-field distance to the goal center (far corner at ceiling -/// height), used to normalize `ball_dist_to_goal` and -/// `nearest_defender_to_goal_dist` into [0, 1]. +/// 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 the `defender_in_net` feature: a defender inside the -/// mouth (or just in front of it, within this depth) and under crossbar -/// height (plus a car-sized margin) is covering the net. +/// 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; @@ -71,15 +65,139 @@ const PENDING_EPISODE_GOAL_GRACE_SECONDS: f32 = 10.0; /// goal (a replicated goal event plus the scoreboard increment). const GOAL_RECORD_DEDUPE_SECONDS: f32 = 2.0; -pub const THREAT_FEATURE_COUNT: usize = 17; +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). All values are bounded; everything except -/// `attacking_team_size` is normalized into [-1, 1] or [0, 1]. +/// 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 @@ -104,37 +222,68 @@ pub struct ThreatFeatures { /// 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, - /// Nearest attacker distance to the ball, clamped to - /// [`PLAYER_DISTANCE_NORM`] and normalized. - pub nearest_attacker_dist: f32, - /// Attackers at or beyond the ball's attacking y / team size. - pub attackers_ahead_of_ball: f32, - /// Attackers behind the ball's attacking y / team size. - pub attackers_behind_ball: f32, - /// Nearest defender distance to the ball, clamped and normalized (1.0 - /// when no defender is on the field). - pub nearest_defender_dist: f32, - /// Nearest defender distance to their own goal center, normalized by - /// [`GOAL_DISTANCE_NORM`] (1.0 when no defender is on the field). - pub nearest_defender_to_goal_dist: f32, - /// Defenders goalside of the ball (attacking y beyond the ball's) / - /// defending-team roster size (non-demoed, the same eligibility used to - /// iterate defenders). - pub defenders_goalside: f32, - /// 1.0 when any defender occupies the net region in front of their goal - /// mouth. - pub defender_in_net: f32, - /// Boost of the defender nearest the ball, raw 0-255 scaled to [0, 1] - /// (0.0 when unknown or no defender). - pub nearest_defender_boost: f32, - /// Raw attacking-team roster count this frame (the corpus is mostly 2s). - pub attacking_team_size: 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 [&'static str] = &[ + pub const FEATURE_NAMES: [&'static str; THREAT_FEATURE_COUNT] = [ "ball_forward_y", "ball_dist_to_goal", "ball_height", @@ -143,20 +292,76 @@ impl ThreatFeatures { "goal_open_angle", "on_target", "time_to_goal_line", - "nearest_attacker_dist", - "attackers_ahead_of_ball", - "attackers_behind_ball", - "nearest_defender_dist", - "nearest_defender_to_goal_dist", - "defenders_goalside", - "defender_in_net", - "nearest_defender_boost", - "attacking_team_size", + "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, @@ -165,16 +370,10 @@ impl ThreatFeatures { self.goal_open_angle, self.on_target, self.time_to_goal_line, - self.nearest_attacker_dist, - self.attackers_ahead_of_ball, - self.attackers_behind_ball, - self.nearest_defender_dist, - self.nearest_defender_to_goal_dist, - self.defenders_goalside, - self.defender_in_net, - self.nearest_defender_boost, - self.attacking_team_size, - ] + ]); + 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 } } @@ -242,8 +441,9 @@ pub fn compute_threat_features( ball_velocity: glam::Vec3, players: &PlayerFrameState, demoed_players: &HashSet, + dodge_available: &HashMap, attacking_team_is_team_0: bool, -) -> ThreatFeatures { +) -> 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); @@ -251,72 +451,70 @@ pub fn compute_threat_features( let ball_dist_to_goal = to_goal.length(); let ball_speed_toward_goal = ball_vel.dot(to_goal.normalize_or_zero()); - let team_size = players - .players - .iter() - .filter(|player| player.is_team_0 == attacking_team_is_team_0) - .count(); - let team_size_norm = (team_size as f32).max(1.0); - - let positioned = |same_team: bool| { - players.players.iter().filter(move |player| { - (player.is_team_0 == attacking_team_is_team_0) == same_team - && !demoed_players.contains(&player.player_id) - }) - }; - let positions = |same_team: bool| { - positioned(same_team).filter_map(|player| { - player - .position() - .map(|position| attacking_frame(position, attacking_team_is_team_0)) - }) + 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 nearest_attacker_dist = positions(true) - .map(|position| (position - ball).length()) - .fold(f32::INFINITY, f32::min); - let attackers_ahead = positions(true) - .filter(|position| position.y >= ball.y) - .count(); - let attackers_behind = positions(true) - .filter(|position| position.y < ball.y) - .count(); - - // Defender-count features normalize by the DEFENDING team's eligible - // roster (the same non-demoed filter the iteration below uses), not the - // attacking team's: on uneven-team / leaver frames the two differ. - let defending_team_size = positioned(false).count(); - let defending_team_size_norm = (defending_team_size as f32).max(1.0); - - let mut nearest_defender_dist = f32::INFINITY; - let mut nearest_defender_boost = 0.0; - let mut nearest_defender_to_goal = f32::INFINITY; - let mut defenders_goalside = 0usize; - let mut defender_in_net = false; - for defender in positioned(false) { - let Some(position) = defender.position() else { - continue; - }; - let position = attacking_frame(position, attacking_team_is_team_0); - let ball_dist = (position - ball).length(); - if ball_dist < nearest_defender_dist { - nearest_defender_dist = ball_dist; - nearest_defender_boost = - (defender.boost_amount.unwrap_or(0.0) / BOOST_MAX_AMOUNT).clamp(0.0, 1.0); - } - nearest_defender_to_goal = nearest_defender_to_goal.min((position - goal_center).length()); - if position.y > ball.y { - defenders_goalside += 1; - } - if position.y >= STANDARD_GOAL_LINE_Y - NET_REGION_DEPTH_Y + 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 - { - defender_in_net = true; + && 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)), } - } + }; - ThreatFeatures { + 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), @@ -327,28 +525,15 @@ pub fn compute_threat_features( time_to_goal_line: seconds_to_goal_plane(ball, ball_vel) .map(|time| 1.0 / (1.0 + time)) .unwrap_or(0.0), - nearest_attacker_dist: if nearest_attacker_dist.is_finite() { - normalized_distance(nearest_attacker_dist, PLAYER_DISTANCE_NORM) - } else { - 1.0 - }, - attackers_ahead_of_ball: (attackers_ahead as f32 / team_size_norm).clamp(0.0, 1.0), - attackers_behind_ball: (attackers_behind as f32 / team_size_norm).clamp(0.0, 1.0), - nearest_defender_dist: if nearest_defender_dist.is_finite() { - normalized_distance(nearest_defender_dist, PLAYER_DISTANCE_NORM) - } else { - 1.0 - }, - nearest_defender_to_goal_dist: if nearest_defender_to_goal.is_finite() { - normalized_distance(nearest_defender_to_goal, GOAL_DISTANCE_NORM) - } else { - 1.0 - }, - defenders_goalside: (defenders_goalside as f32 / defending_team_size_norm).clamp(0.0, 1.0), - defender_in_net: f32::from(u8::from(defender_in_net)), - nearest_defender_boost, - attacking_team_size: team_size as f32, - } + 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. @@ -439,17 +624,6 @@ pub struct ThreatEpisodeEvent { pub end_reason: ThreatEpisodeEndReason, } -/// One sampled feature/value row recorded when dataset sampling is enabled -/// via [`ExpectedGoalsCalculatorConfig::sample_interval_seconds`]. -#[derive(Debug, Clone, PartialEq, Serialize)] -pub struct ThreatFrameSample { - pub time: f32, - pub frame: usize, - pub is_team_0: bool, - pub features: ThreatFeatures, - pub value: f32, -} - /// A goal observed while processing (from replicated goal events, with a /// scoreboard-increment fallback), kept for episode outcomes and dataset /// labeling. @@ -464,17 +638,12 @@ pub struct ThreatGoalRecord { #[derive(Debug, Clone, PartialEq)] pub struct ExpectedGoalsCalculatorConfig { pub episode_threshold: f32, - /// When set, record a [`ThreatFrameSample`] per team at most once per - /// this many seconds of live play (for dataset export). `None` (the - /// default) records nothing. - pub sample_interval_seconds: Option, } impl Default for ExpectedGoalsCalculatorConfig { fn default() -> Self { Self { episode_threshold: THREAT_EPISODE_THRESHOLD, - sample_interval_seconds: None, } } } @@ -514,7 +683,6 @@ pub struct ExpectedGoalsCalculator { config: ExpectedGoalsCalculatorConfig, touch_events: EventStream, episode_events: EventStream, - samples: Vec, goal_records: Vec, team_states: [TeamThreatState; 2], /// Per-team full-match `sum(V * dt) / tau`, accumulated over EVERY @@ -524,7 +692,6 @@ pub struct ExpectedGoalsCalculator { /// Both teams' V on the previous live frame, if it was live. previous_values: Option<[f32; 2]>, last_score: Option<(i32, i32)>, - last_sample_time: Option, last_frame: Option<(usize, f32)>, was_live: bool, } @@ -565,11 +732,6 @@ impl ExpectedGoalsCalculator { self.episode_events.new_events() } - /// Sampled dataset rows (empty unless sampling is enabled in the config). - pub fn samples(&self) -> &[ThreatFrameSample] { - &self.samples - } - pub fn goal_records(&self) -> &[ThreatGoalRecord] { &self.goal_records } @@ -833,43 +995,13 @@ impl ExpectedGoalsCalculator { } } - fn record_samples( - &mut self, - frame: &FrameInfo, - features: [ThreatFeatures; 2], - values: [f32; 2], - ) { - let Some(interval) = self.config.sample_interval_seconds else { - return; - }; - let due = self - .last_sample_time - .is_none_or(|last| frame.time - last >= interval || frame.time < last); - if !due { - return; - } - for team_index in 0..2 { - self.samples.push(ThreatFrameSample { - time: frame.time, - frame: frame.frame_number, - is_team_0: team_index == 0, - features: features[team_index], - value: values[team_index], - }); - } - self.last_sample_time = Some(frame.time); - } - - #[allow(clippy::too_many_arguments)] pub fn update_parts( &mut self, frame: &FrameInfo, gameplay: &GameplayState, - ball: &BallFrameState, - players: &PlayerFrameState, events: &FrameEventsState, touch_state: &TouchState, - live_play_state: &LivePlayState, + threat_features: &ThreatFeaturesState, ) -> SubtrActorResult<()> { self.touch_events.begin_update(); self.episode_events.begin_update(); @@ -878,9 +1010,7 @@ impl ExpectedGoalsCalculator { self.detect_goals(frame, gameplay, events); self.resolve_stale_pending_episodes(frame, gameplay.kickoff_phase_active()); - let is_live = live_play_state.is_live_play && !gameplay.kickoff_phase_active(); - let ball_sample = ball.sample(); - let (Some(ball_sample), true) = (ball_sample, is_live) else { + 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() { @@ -891,21 +1021,6 @@ impl ExpectedGoalsCalculator { self.was_live = false; return Ok(()); }; - - let demoed_players: HashSet = events - .active_demos - .iter() - .map(|demo| demo.victim.clone()) - .collect(); - let features = [true, false].map(|is_team_0| { - compute_threat_features( - ball_sample.position(), - ball_sample.velocity(), - players, - &demoed_players, - is_team_0, - ) - }); let values = [ expected_goals_model::threat_value(&features[0]), expected_goals_model::threat_value(&features[1]), @@ -920,8 +1035,6 @@ impl ExpectedGoalsCalculator { self.emit_touch_events(frame, touch_state, values); self.update_episodes(frame, values); - self.record_samples(frame, features, values); - self.previous_values = Some(values); self.was_live = true; Ok(()) diff --git a/src/stats/calculators/expected_goals_model.rs b/src/stats/calculators/expected_goals_model.rs index 02a8315f..89d26fe7 100644 --- a/src/stats/calculators/expected_goals_model.rs +++ b/src/stats/calculators/expected_goals_model.rs @@ -25,46 +25,96 @@ pub const THREAT_HORIZON_SECONDS: f32 = 5.0; // Everything between these markers is replaced wholesale when the offline // training script publishes a fitted model. // -// trained-v2 provenance: logistic regression fit by +// trained-v4 provenance: logistic regression fit by // scripts/threat_model/train_threat_model.py (uv-locked environment) on -// 10.3M live-play rows sampled at 4 Hz from 5,280 rank-stratified -// ranked-duels/-doubles replays (rocket-sense production corpus, rank tiers -// 1-22, 2026-07-12). Retrained after fixing defenders_goalside to normalize -// by the defending roster. Grouped train/test split by replay. Held-out: -// log_loss 0.169, Brier 0.0468, AUC 0.885 (constant-rate baseline log_loss -// 0.252); GBT reference ceiling log_loss 0.154. Calibration tracks observed -// frequency within ~10% relative across all 15 prediction-quantile bins and -// across rank tiers. Standardization is folded in; weights apply to raw -// features. +// 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). Every player uses the same 16-field transform; each two-player +// team is represented by permutation-invariant mean/spread aggregates. The +// newest 20% of replays were held out temporally: log_loss 0.1355, Brier +// 0.0359, AUC 0.8837 (constant-rate baseline log_loss 0.1964); GBT reference +// ceiling log_loss 0.1287. Published coefficients were then refit on the full +// corpus. Standardization is folded in; weights apply to raw features. // --------------------------------------------------------------------------- /// Identifies the coefficient set embedded below. `heuristic-v0` marks the /// hand-tuned placeholder; trained models use `trained-v` stamps. -pub const THREAT_MODEL_VERSION: &str = "trained-v2"; +pub const THREAT_MODEL_VERSION: &str = "trained-v4"; -pub const THREAT_MODEL_BIAS: f32 = -0.44989115; - -/// One weight per [`ThreatFeatures::FEATURE_NAMES`] entry, in that exact -/// order. The pairing is enforced by `weights_cover_every_feature` in the -/// adjacent test module. +pub const THREAT_MODEL_BIAS: f32 = -1.3350503; pub const THREAT_MODEL_WEIGHTS: [(&str, f32); THREAT_FEATURE_COUNT] = [ - ("ball_forward_y", -0.030237095), - ("ball_dist_to_goal", -4.582302), - ("ball_height", -0.095492415), - ("ball_speed", -2.7069197), - ("ball_speed_toward_goal", 5.3320007), - ("goal_open_angle", 1.945358), - ("on_target", 1.3097814), - ("time_to_goal_line", -0.25473773), - ("nearest_attacker_dist", -2.0624259), - ("attackers_ahead_of_ball", -0.54946756), - ("attackers_behind_ball", 0.38570327), - ("nearest_defender_dist", 1.0084612), - ("nearest_defender_to_goal_dist", 1.7910457), - ("defenders_goalside", -0.7728923), - ("defender_in_net", -0.18298395), - ("nearest_defender_boost", -0.4043592), - ("attacking_team_size", -0.25949645), + ("ball_forward_y", -0.5779634), + ("ball_dist_to_goal", -5.708698), + ("ball_height", -0.099439904), + ("ball_speed", -2.3141916), + ("ball_speed_toward_goal", 3.9342568), + ("goal_open_angle", 1.511522), + ("on_target", 1.6267366), + ("time_to_goal_line", -0.3590436), + ("own_team_mean_position_x", 0.018717207), + ("own_team_mean_position_y", 0.30805534), + ("own_team_mean_position_z", -0.41071174), + ("own_team_mean_velocity_x", -0.0424128), + ("own_team_mean_velocity_y", 0.78416425), + ("own_team_mean_velocity_z", 0.9413253), + ("own_team_mean_forward_x", 0.02993548), + ("own_team_mean_forward_y", 0.4687313), + ("own_team_mean_forward_z", 0.039111286), + ("own_team_mean_distance_to_ball", -1.4057058), + ("own_team_mean_distance_to_goal", -0.7276727), + ("own_team_mean_boost", 0.46965355), + ("own_team_mean_is_goalside", -1.4693916), + ("own_team_mean_in_net", 0.13337676), + ("own_team_mean_dodge_available", 0.29578996), + ("own_team_mean_demoed", 0.80989605), + ("own_team_spread_position_x", 0.15600729), + ("own_team_spread_position_y", -0.35254636), + ("own_team_spread_position_z", 0.1198017), + ("own_team_spread_velocity_x", 0.061510094), + ("own_team_spread_velocity_y", 0.410587), + ("own_team_spread_velocity_z", 0.3519663), + ("own_team_spread_forward_x", -0.009791375), + ("own_team_spread_forward_y", 0.17669073), + ("own_team_spread_forward_z", -0.008849148), + ("own_team_spread_distance_to_ball", 0.45837495), + ("own_team_spread_distance_to_goal", 0.4484276), + ("own_team_spread_boost", -0.15753853), + ("own_team_spread_is_goalside", 0.53830355), + ("own_team_spread_in_net", -0.10456076), + ("own_team_spread_dodge_available", -0.04883177), + ("own_team_spread_demoed", -0.2931511), + ("opponent_team_mean_position_x", -0.008389273), + ("opponent_team_mean_position_y", 0.47667402), + ("opponent_team_mean_position_z", 0.7273689), + ("opponent_team_mean_velocity_x", -0.018365024), + ("opponent_team_mean_velocity_y", -0.14907622), + ("opponent_team_mean_velocity_z", -1.0305297), + ("opponent_team_mean_forward_x", -0.016495481), + ("opponent_team_mean_forward_y", 0.06543813), + ("opponent_team_mean_forward_z", -0.008724887), + ("opponent_team_mean_distance_to_ball", 0.6860846), + ("opponent_team_mean_distance_to_goal", 3.8129063), + ("opponent_team_mean_boost", -0.51951563), + ("opponent_team_mean_is_goalside", -0.67251307), + ("opponent_team_mean_in_net", -0.011148059), + ("opponent_team_mean_dodge_available", -0.1358538), + ("opponent_team_mean_demoed", -0.31512168), + ("opponent_team_spread_position_x", 0.058660593), + ("opponent_team_spread_position_y", 0.3145817), + ("opponent_team_spread_position_z", -0.10809895), + ("opponent_team_spread_velocity_x", -0.17743108), + ("opponent_team_spread_velocity_y", -0.12635869), + ("opponent_team_spread_velocity_z", -0.35638762), + ("opponent_team_spread_forward_x", 0.043583564), + ("opponent_team_spread_forward_y", 0.05177123), + ("opponent_team_spread_forward_z", 0.103313714), + ("opponent_team_spread_distance_to_ball", -0.33313403), + ("opponent_team_spread_distance_to_goal", -1.4004678), + ("opponent_team_spread_boost", 0.040076595), + ("opponent_team_spread_is_goalside", -0.15839666), + ("opponent_team_spread_in_net", -0.004282083), + ("opponent_team_spread_dodge_available", -0.012042165), + ("opponent_team_spread_demoed", 0.05703548), ]; // --------------------------------------------------------------------------- diff --git a/src/stats/calculators/expected_goals_model_tests.rs b/src/stats/calculators/expected_goals_model_tests.rs index a5dcd2b9..a80346bd 100644 --- a/src/stats/calculators/expected_goals_model_tests.rs +++ b/src/stats/calculators/expected_goals_model_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::TeamThreatFeatures; /// The coefficient table must stay keyed exactly by /// [`ThreatFeatures::FEATURE_NAMES`], in order -- pasting trained weights is a @@ -14,6 +15,62 @@ fn weights_cover_every_feature_in_order() { } } +#[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.806533, 0.852562, 0.715602, 0.675648, -0.617448, 0.058722, 0.0, 0.0, 0.189132, + -0.114463, 0.008182, -0.037291, -0.516876, 0.002107, -0.052617, -0.780234, + -0.009459, 1.0, 0.514733, 0.0, 1.0, 0.0, 1.0, 0.0, 0.212444, 0.503934, 0.00025, + 0.518965, 0.43723, 0.003222, 1.243573, 0.083867, 0.000331, 0.0, 0.238454, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.126997, 0.073385, 0.008324, -0.247852, -0.409815, 0.000122, + -0.615041, -0.705672, -0.009603, 1.0, 0.430744, 0.694118, 1.0, 0.0, 1.0, 0.0, + 0.415264, 0.750788, 5e-06, 0.205609, 0.501952, 9.6e-05, 0.530194, 0.462099, + 0.000124, 0.0, 0.353896, 0.031373, 0.0, 0.0, 0.0, 0.0, + ], + 4.3319797e-06, + ), + ( + &[ + -0.0, 0.457143, 0.045377, 0.0, 0.0, 0.1099, 0.0, 0.0, 0.240743, -0.681881, + 0.008322, -0.101115, 0.249102, 1.3e-05, -0.346748, 0.853473, -0.009506, 0.89641, + 0.780111, 0.225292, 0.0, 0.0, 1.0, 0.0, 0.480979, 0.393789, 0.0, 0.2105, 0.085465, + 0.0, 0.72064, 0.29278, 1.5e-05, 0.20718, 0.157569, 0.0, 0.0, 0.0, 0.0, 0.0, + -0.2403, 0.686037, 0.008322, 0.110463, -0.190139, 6.5e-05, 0.384705, -0.852546, + -0.00949, 0.89641, 0.17304, 0.279313, 1.0, 0.5, 1.0, 0.0, 0.481865, 0.402102, 0.0, + 0.191804, 0.032461, 0.000104, 0.644725, 0.290927, 2.5e-05, 0.20718, 0.241964, + 0.108041, 0.0, 1.0, 0.0, 0.0, + ], + 0.01381855, + ), + ( + &[ + 0.858832, 0.064548, 0.045572, 0.382405, 0.377735, 0.56668, 1.0, 0.758773, 0.372847, + 0.280386, 0.008324, 0.051628, 0.011826, 0.000222, 0.033638, 0.00966, -0.013242, + 0.524791, 0.361114, 0.22549, 0.0, 0.0, 1.0, 0.0, 0.778916, 1.088385, 2.4e-05, + 0.499065, 1.908748, 7e-05, 0.549057, 1.921688, 0.007092, 0.950417, 0.560816, 0.2, + 0.0, 0.0, 0.0, 0.0, 0.368682, -0.479594, 0.0217, 0.001815, 0.179693, -0.161724, + 0.010377, 0.393655, -0.308104, 1.0, 0.967672, 0.254533, 0.0, 0.0, 0.0, 0.5, + 0.737363, 0.959188, 0.0434, 0.00363, 0.359387, 0.323448, 0.020754, 0.787309, + 0.616209, 0.0, 0.064655, 0.408582, 0.0, 0.0, 0.0, 1.0, + ], + 0.99742234, + ), + ]; + + 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, @@ -24,15 +81,8 @@ fn neutral_features() -> ThreatFeatures { goal_open_angle: 0.11, on_target: 0.0, time_to_goal_line: 0.0, - nearest_attacker_dist: 0.2, - attackers_ahead_of_ball: 0.5, - attackers_behind_ball: 0.5, - nearest_defender_dist: 0.5, - nearest_defender_to_goal_dist: 0.35, - defenders_goalside: 1.0, - defender_in_net: 0.0, - nearest_defender_boost: 0.5, - attacking_team_size: 2.0, + own_team: TeamThreatFeatures::default(), + opponent_team: TeamThreatFeatures::default(), } } @@ -46,8 +96,6 @@ fn threat_value_is_a_probability_and_orders_danger_over_neutral() { goal_open_angle: 0.45, on_target: 1.0, time_to_goal_line: 0.6, - defenders_goalside: 0.0, - nearest_defender_to_goal_dist: 0.3, ..neutral }; @@ -64,66 +112,3 @@ fn threat_value_is_a_probability_and_orders_danger_over_neutral() { assert!(neutral_value < super::super::expected_goals::THREAT_EPISODE_THRESHOLD); assert!(dangerous_value > super::super::expected_goals::THREAT_EPISODE_THRESHOLD); } - -/// Real feature rows from the trained-v2 held-out set with the offline -/// pipeline's predicted probabilities (float64). The embedded f32 model must -/// reproduce them; regenerated alongside the coefficients by -/// scripts/threat_model/train_threat_model.py. -const TRAINING_PARITY_FIXTURE: &[(&[f32; THREAT_FEATURE_COUNT], f32)] = &[ - ( - &[ - -0.839109, 0.862713, 0.078395, 0.65384, -0.615211, 0.057195, 0.0, 0.0, 1.0, 0.5, 0.0, - 0.063089, 0.505834, 1.0, 0.0, 0.27451, 2.0, - ], - 5.5895807e-06, - ), - ( - &[ - -0.876275, 0.865327, 0.628659, 0.247617, 0.210264, 0.058854, 0.0, 0.110026, 0.411007, - 1.0, 0.0, 0.464603, 0.484796, 1.0, 0.0, 0.121569, 2.0, - ], - 0.0049046245, - ), - ( - &[ - -0.67292, 0.809615, 0.04566, 0.151471, 0.078867, 0.059097, 0.0, 0.075715, 0.110705, - 0.0, 1.0, 1.0, 0.314607, 1.0, 0.0, 0.793353, 2.0, - ], - 0.019436132, - ), - ( - &[ - 0.899693, 0.206376, 0.045572, 0.045879, 0.0169, 0.063362, 0.0, 0.0, 0.354955, 0.0, 1.0, - 0.182267, 0.012131, 0.5, 1.0, 0.133333, 2.0, - ], - 0.06779221, - ), - ( - &[ - 0.995201, 0.002314, 0.047216, 0.510596, 0.489777, 0.982483, 1.0, 0.992038, 0.305113, - 0.0, 1.0, 1.0, 0.912356, 0.0, 0.0, 0.047059, 1.0, - ], - 0.99705017, - ), - ( - &[ - 0.987828, 0.017902, 0.087539, 0.415882, 0.41333, 0.953978, 1.0, 0.919608, 0.06734, 0.0, - 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 2.0, - ], - 0.99744374, - ), -]; - -/// The embedded f32 inference must agree with the float64 training-pipeline -/// predictions on held-out rows spanning the whole probability range. -#[test] -fn trained_model_matches_training_pipeline_predictions() { - for (index, (features, expected)) in TRAINING_PARITY_FIXTURE.iter().enumerate() { - let actual = threat_value_from_array(features); - let absolute = (actual - expected).abs(); - assert!( - absolute < 2e-4 && absolute / expected < 0.01, - "fixture row {index}: rust={actual} python={expected}" - ); - } -} diff --git a/src/stats/calculators/expected_goals_tests.rs b/src/stats/calculators/expected_goals_tests.rs index 8d4410fc..7d37522a 100644 --- a/src/stats/calculators/expected_goals_tests.rs +++ b/src/stats/calculators/expected_goals_tests.rs @@ -130,7 +130,9 @@ fn dangerous_state() -> (BallFrameState, PlayerFrameState) { 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)), ], }, ) @@ -143,7 +145,9 @@ fn neutral_state() -> (BallFrameState, PlayerFrameState) { 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)), ], }, ) @@ -160,15 +164,16 @@ fn update( 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), - &GameplayState::default(), - ball, - players, + &gameplay, &events, &touch_state(touches), - &live, + &threat_features, ) .unwrap(); } @@ -182,8 +187,10 @@ fn feature_names_and_array_agree() { ball.velocity().unwrap(), &players, &no_demoed(), + &HashMap::new(), true, - ); + ) + .unwrap(); assert_eq!( features.to_array().len(), ThreatFeatures::FEATURE_NAMES.len() @@ -206,22 +213,23 @@ fn features_are_bounded() { 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!( - (-1.0..=4.0).contains(&value), + bounds.contains(&value), "feature {name} out of bounds: {value}" ); - if *name != "attacking_team_size" { - assert!( - (-1.0..=1.0).contains(&value), - "normalized feature {name} out of [-1, 1]: {value}" - ); - } } } @@ -247,7 +255,7 @@ fn mirrored_state_yields_identical_features_for_the_other_team() { .players .iter() .map(|sample| { - player( + let mut mirrored = player( match sample.player_id { boxcars::RemoteId::Steam(id) => id, _ => unreachable!(), @@ -255,7 +263,14 @@ fn mirrored_state_yields_identical_features_for_the_other_team() { !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(), }; @@ -265,68 +280,55 @@ fn mirrored_state_yields_identical_features_for_the_other_team() { 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() { - let empty_players = PlayerFrameState::default(); // 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 on_target = compute_threat_features( - glam::Vec3::new(0.0, 4300.0, 100.0), - glam::Vec3::new(0.0, 1400.0, 250.0), - &empty_players, - &no_demoed(), - true, - ); - assert_eq!(on_target.on_target, 1.0); + 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!((on_target.time_to_goal_line - 1.0 / (1.0 + expected_time)).abs() < 1e-6); + 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. - let over_the_bar = compute_threat_features( + assert!(!ballistic_on_target( glam::Vec3::new(0.0, 4300.0, 100.0), glam::Vec3::new(0.0, 1400.0, 1200.0), - &empty_players, - &no_demoed(), - true, - ); - assert_eq!(over_the_bar.on_target, 0.0); - assert!(over_the_bar.time_to_goal_line > 0.0); + )); // Crossing x = 2000: wide of the 892.755 post. - let wide = compute_threat_features( + assert!(!ballistic_on_target( glam::Vec3::new(2000.0, 4300.0, 100.0), glam::Vec3::new(0.0, 1400.0, 250.0), - &empty_players, - &no_demoed(), - true, - ); - assert_eq!(wide.on_target, 0.0); + )); // Moving away from the goal: no crossing, no time-to-goal-line. - let away = compute_threat_features( - glam::Vec3::new(0.0, 4300.0, 100.0), - glam::Vec3::new(0.0, -1400.0, 250.0), - &empty_players, - &no_demoed(), - true, + 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() ); - assert_eq!(away.on_target, 0.0); - assert_eq!(away.time_to_goal_line, 0.0); } #[test] @@ -471,29 +473,53 @@ fn backdated_touch_keeps_contact_fields_and_detection_fields_separate() { assert_eq!(event.value_after, calculator.current_values().unwrap()[0]); } -/// `defenders_goalside` normalizes by the DEFENDING team's eligible roster: -/// on a 2v1 the lone goalside defender is 1/1, not 1/2. #[test] -fn defenders_goalside_normalizes_by_defending_team_size() { +fn player_features_are_permutation_invariant_within_each_team() { let players = PlayerFrameState { players: vec![ - player(1, true, glam::Vec3::new(0.0, 2000.0, 17.0), Some(100.0)), - player(2, true, glam::Vec3::new(500.0, 1500.0, 17.0), Some(100.0)), - player(3, false, glam::Vec3::new(0.0, 4000.0, 17.0), Some(50.0)), + 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, - ); - assert_eq!(features.attacking_team_size, 2.0); - assert_eq!( - features.defenders_goalside, 1.0, - "one of one eligible defenders is goalside" - ); + ) + .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. @@ -503,9 +529,12 @@ fn defenders_goalside_normalizes_by_defending_team_size() { glam::Vec3::ZERO, &players, &demoed, + &dodge_available, true, - ); - assert_eq!(features_demoed.defenders_goalside, 0.0); + ) + .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 @@ -817,38 +846,6 @@ fn finish_closes_active_episode_as_replay_end() { assert_eq!(episodes[0].end_reason, ThreatEpisodeEndReason::ReplayEnd); } -#[test] -fn sampling_records_two_attacking_normalized_rows_per_sampled_frame() { - let mut calculator = ExpectedGoalsCalculator::with_config(ExpectedGoalsCalculatorConfig { - sample_interval_seconds: Some(0.25), - ..ExpectedGoalsCalculatorConfig::default() - }); - let (neutral_ball, neutral_players) = neutral_state(); - - for step in 0..4 { - update( - &mut calculator, - step + 1, - 1.0 + step as f32 * 0.1, - &neutral_ball, - &neutral_players, - FrameEventsState::default(), - vec![], - live_play(), - ); - } - - // Sampled at t=1.0 and t=1.3 (0.25s cadence over 0.1s frames). - let samples = calculator.samples(); - assert_eq!(samples.len(), 4); - assert!(samples[0].is_team_0); - assert!(!samples[1].is_team_0); - assert_eq!(samples[0].time, samples[1].time); - for sample in samples { - assert!(sample.value > 0.0 && sample.value < 1.0); - } -} - /// 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] diff --git a/src/stats/calculators/frame_components.rs b/src/stats/calculators/frame_components.rs index 28cbe625..4ed5465d 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 594810d8..b0bda9f9 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/timeline/collector.rs b/src/stats/timeline/collector.rs index ba9c97c0..a24bc7dd 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,7 @@ 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, @@ -230,6 +265,7 @@ 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, @@ -249,6 +285,15 @@ impl StatsTimelineEventCollector { } } + /// 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 @@ -602,7 +647,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; - self.record_expected_goals_tracks(frame.frame_number)?; + if self.include_expected_goals { + self.record_expected_goals_tracks(frame.frame_number)?; + } self.frames.push(frame); } @@ -618,7 +665,9 @@ impl Collector for StatsTimelineEventCollector { return Ok(()); }; let mut final_snapshot = self.snapshot_frame_scaffold()?; - self.record_expected_goals_tracks(final_snapshot.frame_number)?; + 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 b96beae6..cd285da3 100644 --- a/src/stats/timeline/collector_tests.rs +++ b/src/stats/timeline/collector_tests.rs @@ -161,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"); diff --git a/tests/stats_collector_test.rs b/tests/stats_collector_test.rs index dc89a08e..12bd14e5 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() { From 45a6f7cf883f46233385714b03c346afa1b97b41 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 13 Jul 2026 15:37:20 -0700 Subject: [PATCH 09/12] Show live threat in the stats player --- .../src/expectedGoalsTrackDerivation.test.ts | 25 +++- .../src/generated/ExpectedGoalsTeamStats.ts | 6 + .../src/scoreboardWindow.test.ts | 16 +++ .../src/scoreboardWindow.ts | 111 ++++++++++++++++++ .../src/statsSnapshotFactories.ts | 1 + js/stat-evaluation-player/src/styles/base.css | 88 ++++++++++++++ src/stats/accumulators/expected_goals.rs | 12 ++ .../analysis_graph/nodes/stats_projection.rs | 3 + src/stats/calculators/expected_goals_tests.rs | 9 ++ src/stats/timeline/collector.rs | 2 + 10 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 js/stat-evaluation-player/src/scoreboardWindow.test.ts diff --git a/js/stat-evaluation-player/src/expectedGoalsTrackDerivation.test.ts b/js/stat-evaluation-player/src/expectedGoalsTrackDerivation.test.ts index 2c68d448..d98d55c5 100644 --- a/js/stat-evaluation-player/src/expectedGoalsTrackDerivation.test.ts +++ b/js/stat-evaluation-player/src/expectedGoalsTrackDerivation.test.ts @@ -30,11 +30,21 @@ function fixture() { points: [ { frame: 10, - stats: { xg: 0.25, episode_count: 1, goal_episode_count: 0 }, + stats: { + current_threat: 0.35, + xg: 0.25, + episode_count: 1, + goal_episode_count: 0, + }, }, { frame: 20, - stats: { xg: 0.6, episode_count: 1, goal_episode_count: 1 }, + stats: { + current_threat: null, + xg: 0.6, + episode_count: 1, + goal_episode_count: 1, + }, }, ], }, @@ -43,7 +53,12 @@ function fixture() { points: [ { frame: 20, - stats: { xg: 0.1, episode_count: 0, goal_episode_count: 0 }, + stats: { + current_threat: 0.08, + xg: 0.1, + episode_count: 0, + goal_episode_count: 0, + }, }, ], }, @@ -83,13 +98,16 @@ function assertHydrated(timeline: ReturnType): void { 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.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.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); } @@ -112,5 +130,6 @@ test("missing expected-goals tracks preserve zero defaults for older compact pay }); 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/generated/ExpectedGoalsTeamStats.ts b/js/stat-evaluation-player/src/generated/ExpectedGoalsTeamStats.ts index ceb7cd1b..3a67b2bc 100644 --- a/js/stat-evaluation-player/src/generated/ExpectedGoalsTeamStats.ts +++ b/js/stat-evaluation-player/src/generated/ExpectedGoalsTeamStats.ts @@ -4,6 +4,12 @@ * 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, /** * The team's full-match xG time integral (`sum(V * dt) / tau` over every * evaluated live frame, sub-threshold frames included), fed from 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 00000000..bd776184 --- /dev/null +++ b/js/stat-evaluation-player/src/scoreboardWindow.test.ts @@ -0,0 +1,16 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { 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); +}); diff --git a/js/stat-evaluation-player/src/scoreboardWindow.ts b/js/stat-evaluation-player/src/scoreboardWindow.ts index a574ac78..0b7d2109 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,108 @@ 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.xg, + frame.team_one?.expected_goals.xg, + ), + ); + } } } +function createThreatReadout( + teamZeroThreat: number | null | undefined, + teamOneThreat: number | null | undefined, + teamZeroIntegratedThreat: number | null | undefined, + teamOneIntegratedThreat: 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. " + + "Integrated threat is the continuous full-match time integral, not incident xG."; + + 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( + createIntegratedThreatValue(teamZeroIntegratedThreat, true), + createIntegratedThreatLabel(), + createIntegratedThreatValue(teamOneIntegratedThreat, 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 createIntegratedThreatLabel(): HTMLElement { + const label = document.createElement("span"); + label.className = "scoreboard-threat-accumulated-label"; + label.textContent = "Integrated threat"; + return label; +} + +function createIntegratedThreatValue( + value: number | null | undefined, + isTeamZero: boolean, +): HTMLElement { + const output = document.createElement("span"); + output.className = `scoreboard-threat-accumulated-value ${getTeamClass(isTeamZero)}`; + output.textContent = formatIntegratedThreat(value); + return output; +} + +function formatIntegratedThreat(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/statsSnapshotFactories.ts b/js/stat-evaluation-player/src/statsSnapshotFactories.ts index 2623c7c8..71419186 100644 --- a/js/stat-evaluation-player/src/statsSnapshotFactories.ts +++ b/js/stat-evaluation-player/src/statsSnapshotFactories.ts @@ -261,6 +261,7 @@ export function createTeamStatsSnapshot( team_bumps_inflicted: 0, }, expected_goals: { + current_threat: null, xg: 0, episode_count: 0, goal_episode_count: 0, diff --git a/js/stat-evaluation-player/src/styles/base.css b/js/stat-evaluation-player/src/styles/base.css index 2826a6e3..20c9d69c 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/src/stats/accumulators/expected_goals.rs b/src/stats/accumulators/expected_goals.rs index 93aa093d..34c72ab0 100644 --- a/src/stats/accumulators/expected_goals.rs +++ b/src/stats/accumulators/expected_goals.rs @@ -75,6 +75,10 @@ impl ExpectedGoalsPlayerStats { #[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, /// 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 @@ -150,4 +154,12 @@ impl ExpectedGoalsStatsAccumulator { 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/analysis_graph/nodes/stats_projection.rs b/src/stats/analysis_graph/nodes/stats_projection.rs index e322a703..342d5c24 100644 --- a/src/stats/analysis_graph/nodes/stats_projection.rs +++ b/src/stats/analysis_graph/nodes/stats_projection.rs @@ -672,6 +672,9 @@ impl StatsProjectionNode { 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(); diff --git a/src/stats/calculators/expected_goals_tests.rs b/src/stats/calculators/expected_goals_tests.rs index 7d37522a..71a8437e 100644 --- a/src/stats/calculators/expected_goals_tests.rs +++ b/src/stats/calculators/expected_goals_tests.rs @@ -917,8 +917,10 @@ fn team_xg_integral_accumulates_sub_threshold_frames_without_episodes() { // 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] @@ -977,6 +979,7 @@ fn accumulator_folds_touch_deltas_and_episode_xg() { // 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); @@ -986,9 +989,15 @@ fn accumulator_folds_touch_deltas_and_episode_xg() { let team = accumulator.team_stats(true); assert!((team.xg - 1.0).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/timeline/collector.rs b/src/stats/timeline/collector.rs index a24bc7dd..0f17043a 100644 --- a/src/stats/timeline/collector.rs +++ b/src/stats/timeline/collector.rs @@ -369,6 +369,7 @@ impl StatsTimelineEventCollector { 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_touch_cursor = calculator.touch_events().len(); self.expected_goals_episode_cursor = calculator.episode_events().len(); @@ -379,6 +380,7 @@ impl StatsTimelineEventCollector { self.expected_goals.apply_episode_event(event); } self.expected_goals.set_team_xg_integrals(team_xg_integrals); + self.expected_goals.set_current_values(current_values); Ok(()) } From 0efa8afae592986ec46b1d975eb1f1fa248c6f08 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 13 Jul 2026 16:18:36 -0700 Subject: [PATCH 10/12] Add incident xG aggregation and timeline --- .../src/bin/threat_dataset_dump.rs | 18 +- js/player/src/lib.ts | 7 + js/player/src/timeline-overlay-style.ts | 128 ++++++- js/player/src/timeline-overlay.ts | 315 +++++++++++++++++- js/player/src/types.ts | 51 +++ js/player/test/timeline-overlay.test.ts | 23 +- .../src/activeModulesRuntime.ts | 25 +- .../src/expectedGoalsTimelineGraph.test.ts | 90 +++++ .../src/expectedGoalsTimelineGraph.ts | 168 ++++++++++ .../src/expectedGoalsTrackDerivation.test.ts | 12 + .../src/expectedGoalsTrackDerivation.ts | 12 +- .../ExpectedGoalsCalculatorConfig.ts | 24 ++ .../src/generated/ExpectedGoalsTeamStats.ts | 8 + .../generated/ExpectedGoalsTimelineTracks.ts | 9 +- .../src/generated/ThreatEpisodeEndReason.ts | 6 + .../src/generated/ThreatEpisodeEvent.ts | 54 +++ .../src/moduleControls.ts | 17 +- .../src/scoreboardWindow.test.ts | 13 +- .../src/scoreboardWindow.ts | 30 +- .../src/stat-modules/expectedGoalsModule.ts | 25 ++ .../src/stat-modules/index.ts | 2 + .../src/stat-modules/types.ts | 2 + .../src/statsSnapshotFactories.ts | 1 + scripts/threat_model/README.md | 17 +- src/collector/stats/builtins.rs | 6 + src/stats/accumulators/expected_goals.rs | 7 + src/stats/calculators/expected_goals.rs | 233 +++++++++++-- src/stats/calculators/expected_goals_tests.rs | 79 ++++- src/stats/timeline/collector.rs | 4 + src/stats/timeline/types.rs | 5 + 30 files changed, 1319 insertions(+), 72 deletions(-) create mode 100644 js/stat-evaluation-player/src/expectedGoalsTimelineGraph.test.ts create mode 100644 js/stat-evaluation-player/src/expectedGoalsTimelineGraph.ts create mode 100644 js/stat-evaluation-player/src/generated/ExpectedGoalsCalculatorConfig.ts create mode 100644 js/stat-evaluation-player/src/generated/ThreatEpisodeEndReason.ts create mode 100644 js/stat-evaluation-player/src/generated/ThreatEpisodeEvent.ts create mode 100644 js/stat-evaluation-player/src/stat-modules/expectedGoalsModule.ts diff --git a/crates/subtr-actor-tools/src/bin/threat_dataset_dump.rs b/crates/subtr-actor-tools/src/bin/threat_dataset_dump.rs index 27c70385..ef357619 100644 --- a/crates/subtr-actor-tools/src/bin/threat_dataset_dump.rs +++ b/crates/subtr-actor-tools/src/bin/threat_dataset_dump.rs @@ -47,12 +47,14 @@ struct Args { 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,goals`. + /// `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). + /// 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, } @@ -237,7 +239,7 @@ fn header() -> String { } fn episode_summary_header() -> &'static str { - "replay_id,is_team0,episode_count,episode_xg_sum,full_integral_xg,peak_sum,goals" + "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 { @@ -318,13 +320,15 @@ fn process_replay(row: &ManifestRow, sample_interval: f32) -> anyhow::Result anyhow::Result 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 309a7a41..223d3586 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 989e59b3..792bda8a 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/stat-evaluation-player/src/activeModulesRuntime.ts b/js/stat-evaluation-player/src/activeModulesRuntime.ts index 37a41c34..82a29d61 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/expectedGoalsTimelineGraph.test.ts b/js/stat-evaluation-player/src/expectedGoalsTimelineGraph.test.ts new file mode 100644 index 00000000..5649162b --- /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.629475, + }, + 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 00000000..0ff2f4aa --- /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 index d98d55c5..bd5838cf 100644 --- a/js/stat-evaluation-player/src/expectedGoalsTrackDerivation.test.ts +++ b/js/stat-evaluation-player/src/expectedGoalsTrackDerivation.test.ts @@ -24,6 +24,12 @@ function fixture() { 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.629475, + }, teams: [ { is_team_0: true, @@ -32,6 +38,7 @@ function fixture() { frame: 10, stats: { current_threat: 0.35, + incident_xg: 0.19, xg: 0.25, episode_count: 1, goal_episode_count: 0, @@ -41,6 +48,7 @@ function fixture() { frame: 20, stats: { current_threat: null, + incident_xg: 0.19, xg: 0.6, episode_count: 1, goal_episode_count: 1, @@ -55,6 +63,7 @@ function fixture() { frame: 20, stats: { current_threat: 0.08, + incident_xg: 0, xg: 0.1, episode_count: 0, goal_episode_count: 0, @@ -63,6 +72,7 @@ function fixture() { ], }, ], + episodes: [], players: [ { player_id: playerId, @@ -99,12 +109,14 @@ function assertHydrated(timeline: ReturnType): void { 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); diff --git a/js/stat-evaluation-player/src/expectedGoalsTrackDerivation.ts b/js/stat-evaluation-player/src/expectedGoalsTrackDerivation.ts index 84f2ab24..cc373d3f 100644 --- a/js/stat-evaluation-player/src/expectedGoalsTrackDerivation.ts +++ b/js/stat-evaluation-player/src/expectedGoalsTrackDerivation.ts @@ -32,7 +32,17 @@ function expectedGoalsTracks(timeline: MaterializedStatsTimeline): ExpectedGoals timeline as MaterializedStatsTimeline & { expected_goals_tracks?: ExpectedGoalsTimelineTracks; } - ).expected_goals_tracks ?? { teams: [], players: [] } + ).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: [], + } ); } 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 00000000..c9bcf7cd --- /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/ExpectedGoalsTeamStats.ts b/js/stat-evaluation-player/src/generated/ExpectedGoalsTeamStats.ts index 3a67b2bc..00490244 100644 --- a/js/stat-evaluation-player/src/generated/ExpectedGoalsTeamStats.ts +++ b/js/stat-evaluation-player/src/generated/ExpectedGoalsTeamStats.ts @@ -10,6 +10,14 @@ export type ExpectedGoalsTeamStats = { * 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 diff --git a/js/stat-evaluation-player/src/generated/ExpectedGoalsTimelineTracks.ts b/js/stat-evaluation-player/src/generated/ExpectedGoalsTimelineTracks.ts index 4d8bfdbb..3880c9a3 100644 --- a/js/stat-evaluation-player/src/generated/ExpectedGoalsTimelineTracks.ts +++ b/js/stat-evaluation-player/src/generated/ExpectedGoalsTimelineTracks.ts @@ -1,5 +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 = { teams: Array, players: Array, }; +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/ThreatEpisodeEndReason.ts b/js/stat-evaluation-player/src/generated/ThreatEpisodeEndReason.ts new file mode 100644 index 00000000..3f907d6a --- /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 00000000..eb3d966a --- /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/moduleControls.ts b/js/stat-evaluation-player/src/moduleControls.ts index 9ed94b11..c8e41100 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/scoreboardWindow.test.ts b/js/stat-evaluation-player/src/scoreboardWindow.test.ts index bd776184..62be687a 100644 --- a/js/stat-evaluation-player/src/scoreboardWindow.test.ts +++ b/js/stat-evaluation-player/src/scoreboardWindow.test.ts @@ -1,7 +1,11 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { formatThreatProbability, normalizeThreatProbability } from "./scoreboardWindow.ts"; +import { + formatIncidentXg, + formatThreatProbability, + normalizeThreatProbability, +} from "./scoreboardWindow.ts"; test("live threat probabilities render as bounded percentages", () => { assert.equal(formatThreatProbability(0.1264), "12.6%"); @@ -14,3 +18,10 @@ test("live threat probabilities render as bounded percentages", () => { 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 0b7d2109..8ee50d0d 100644 --- a/js/stat-evaluation-player/src/scoreboardWindow.ts +++ b/js/stat-evaluation-player/src/scoreboardWindow.ts @@ -53,8 +53,8 @@ export class ScoreboardWindowController { createThreatReadout( frame.team_zero?.expected_goals.current_threat, frame.team_one?.expected_goals.current_threat, - frame.team_zero?.expected_goals.xg, - frame.team_one?.expected_goals.xg, + frame.team_zero?.expected_goals.incident_xg, + frame.team_one?.expected_goals.incident_xg, ), ); } @@ -64,14 +64,15 @@ export class ScoreboardWindowController { function createThreatReadout( teamZeroThreat: number | null | undefined, teamOneThreat: number | null | undefined, - teamZeroIntegratedThreat: number | null | undefined, - teamOneIntegratedThreat: 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. " + - "Integrated threat is the continuous full-match time integral, not incident xG."; + "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"; @@ -92,9 +93,9 @@ function createThreatReadout( const accumulated = document.createElement("div"); accumulated.className = "scoreboard-threat-accumulated"; accumulated.append( - createIntegratedThreatValue(teamZeroIntegratedThreat, true), - createIntegratedThreatLabel(), - createIntegratedThreatValue(teamOneIntegratedThreat, false), + createIncidentXgValue(teamZeroIncidentXg, true), + createIncidentXgLabel(), + createIncidentXgValue(teamOneIncidentXg, false), ); readout.append(values, meter, accumulated); @@ -129,24 +130,21 @@ function createThreatMeterHalf(value: number | null | undefined, isTeamZero: boo return half; } -function createIntegratedThreatLabel(): HTMLElement { +function createIncidentXgLabel(): HTMLElement { const label = document.createElement("span"); label.className = "scoreboard-threat-accumulated-label"; - label.textContent = "Integrated threat"; + label.textContent = "Incident xG"; return label; } -function createIntegratedThreatValue( - value: number | null | undefined, - isTeamZero: boolean, -): HTMLElement { +function createIncidentXgValue(value: number | null | undefined, isTeamZero: boolean): HTMLElement { const output = document.createElement("span"); output.className = `scoreboard-threat-accumulated-value ${getTeamClass(isTeamZero)}`; - output.textContent = formatIntegratedThreat(value); + output.textContent = formatIncidentXg(value); return output; } -function formatIntegratedThreat(value: number | null | undefined): string { +export function formatIncidentXg(value: number | null | undefined): string { return typeof value === "number" && Number.isFinite(value) ? value.toFixed(2) : "--"; } 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 00000000..db3c65f1 --- /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 d62e34e5..5d0f84ed 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 cdbf9749..f4b5a4db 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 71419186..1f399bb4 100644 --- a/js/stat-evaluation-player/src/statsSnapshotFactories.ts +++ b/js/stat-evaluation-player/src/statsSnapshotFactories.ts @@ -262,6 +262,7 @@ export function createTeamStatsSnapshot( }, expected_goals: { current_threat: null, + incident_xg: 0, xg: 0, episode_count: 0, goal_episode_count: 0, diff --git a/scripts/threat_model/README.md b/scripts/threat_model/README.md index d4153ca7..49ed1490 100644 --- a/scripts/threat_model/README.md +++ b/scripts/threat_model/README.md @@ -109,11 +109,22 @@ time-integrated model averages 2.781 xG against 2.834 goals (0.981 ratio), with 0.594 per-team-game correlation. The aggregate count scale is good; individual match totals remain noisy and should not be treated as precise forecasts. -## xG aggregation (why the integral) +## 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. Validate any estimator change on the full -corpus with `threat_dataset_dump --episode-summary`. +`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.629475, +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.905 against 2.797 goals +(3.9% high). This alternate total has weaker per-game correlation than the +continuous integral, so both remain available rather than conflating their +semantics. Revalidate either estimator with +`threat_dataset_dump --episode-summary`. diff --git a/src/collector/stats/builtins.rs b/src/collector/stats/builtins.rs index 8b8e55f9..fb2116f4 100644 --- a/src/collector/stats/builtins.rs +++ b/src/collector/stats/builtins.rs @@ -1176,6 +1176,9 @@ pub fn builtin_analysis_node_json( 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(), @@ -1812,6 +1815,9 @@ pub(crate) fn builtin_snapshot_config_json( 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" diff --git a/src/stats/accumulators/expected_goals.rs b/src/stats/accumulators/expected_goals.rs index 34c72ab0..9daa9b6d 100644 --- a/src/stats/accumulators/expected_goals.rs +++ b/src/stats/accumulators/expected_goals.rs @@ -79,6 +79,12 @@ pub struct ExpectedGoalsTeamStats { /// 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 @@ -141,6 +147,7 @@ impl ExpectedGoalsStatsAccumulator { .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; diff --git a/src/stats/calculators/expected_goals.rs b/src/stats/calculators/expected_goals.rs index cb6c5dbf..1fc06e7b 100644 --- a/src/stats/calculators/expected_goals.rs +++ b/src/stats/calculators/expected_goals.rs @@ -11,15 +11,17 @@ //! (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 contiguous span where one team's V exceeds -//! [`THREAT_EPISODE_THRESHOLD`]; the episode's xG is the time integral +//! - [`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)), -//! credited to the attacking toucher associated with the episode's peak V. -//! Dividing the time integral by the prediction horizon corrects for the -//! overlapping windows evaluated on adjacent frames; summing episode peaks -//! would count the same sustained chance repeatedly. The peak survives on -//! the event as `peak_value` for display/intensity. +//! 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 @@ -31,6 +33,22 @@ use super::*; /// 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.629_475; + +/// 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 @@ -580,7 +598,8 @@ impl ThreatTouchEvent { } /// Why a threat episode closed. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[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. @@ -599,26 +618,45 @@ pub enum ThreatEpisodeEndReason { /// 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)] +#[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 xG: the time integral `sum(V * dt) / tau` over the + /// 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 the - /// calibrated goal-scale estimator -- summing episode peaks over-counts - /// goals ~2.7x. + /// 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 (the pre-calibration `xg`), kept for - /// display/intensity ranking. + /// 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, @@ -635,24 +673,50 @@ pub struct ThreatGoalRecord { } /// Configuration for [`ExpectedGoalsCalculator`]. -#[derive(Debug, Clone, PartialEq)] +#[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. @@ -665,6 +729,8 @@ struct ActiveThreatEpisode { #[derive(Debug, Clone, PartialEq)] struct PendingThreatEpisode { event: ThreatEpisodeEvent, + peak_candidates: Vec, + scoring_team_last_touch_time: Option, closed_at: f32, } @@ -674,6 +740,9 @@ struct TeamThreatState { 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 @@ -706,6 +775,25 @@ impl ExpectedGoalsCalculator { } 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() @@ -780,13 +868,17 @@ impl ExpectedGoalsCalculator { ) { 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, - true, ThreatEpisodeEndReason::Goal, + goal_exclusion_start_time, + self.config.incident_xg_calibration_factor, ); self.episode_events.push(event); return; @@ -802,6 +894,15 @@ impl ExpectedGoalsCalculator { 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); } @@ -812,10 +913,11 @@ impl ExpectedGoalsCalculator { end_frame: usize, end_time: f32, team_is_team_0: bool, - ended_in_goal: bool, end_reason: ThreatEpisodeEndReason, + goal_exclusion_start_time: Option, + incident_xg_calibration_factor: f32, ) -> ThreatEpisodeEvent { - ThreatEpisodeEvent { + let mut event = ThreatEpisodeEvent { start_time: active.start_time, start_frame: active.start_frame, end_time, @@ -823,9 +925,51 @@ impl ExpectedGoalsCalculator { 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, + 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; } } @@ -894,8 +1038,9 @@ impl ExpectedGoalsCalculator { frame.frame_number, frame.time, team_index == 0, - false, 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. @@ -904,11 +1049,29 @@ impl ExpectedGoalsCalculator { } 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 @@ -941,10 +1104,6 @@ impl ExpectedGoalsCalculator { value_before, value_after: values[index], }); - if let Some(player) = touch.player.clone() { - let state = &mut self.team_states[index]; - state.last_toucher = Some(player); - } } } @@ -962,10 +1121,17 @@ impl ExpectedGoalsCalculator { 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_threshold { + if value <= self.config.episode_end_threshold { let active = state .active_episode .take() @@ -975,8 +1141,9 @@ impl ExpectedGoalsCalculator { frame.frame_number, frame.time, team_index == 0, - false, ThreatEpisodeEndReason::ValueDropped, + None, + self.config.incident_xg_calibration_factor, )); } } @@ -986,6 +1153,13 @@ impl ExpectedGoalsCalculator { 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, }); @@ -1007,6 +1181,7 @@ impl ExpectedGoalsCalculator { 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()); @@ -1015,6 +1190,7 @@ impl ExpectedGoalsCalculator { 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; @@ -1051,8 +1227,9 @@ impl ExpectedGoalsCalculator { end_frame, end_time, team_index == 0, - false, ThreatEpisodeEndReason::ReplayEnd, + None, + self.config.incident_xg_calibration_factor, ); self.episode_events.push(event); } diff --git a/src/stats/calculators/expected_goals_tests.rs b/src/stats/calculators/expected_goals_tests.rs index 71a8437e..7d374127 100644 --- a/src/stats/calculators/expected_goals_tests.rs +++ b/src/stats/calculators/expected_goals_tests.rs @@ -664,6 +664,66 @@ fn episode_opens_above_threshold_and_closes_on_value_drop() { 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] @@ -742,7 +802,7 @@ fn late_goal_attribution_resolves_pending_stoppage_episode_as_goal() { &danger_ball, &danger_players, FrameEventsState::default(), - vec![], + vec![touch(1, 1.0, 1, true)], live_play(), ); update( @@ -778,6 +838,8 @@ fn late_goal_attribution_resolves_pending_stoppage_episode_as_goal() { 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 @@ -958,6 +1020,13 @@ fn accumulator_folds_touch_deltas_and_episode_xg() { 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, @@ -971,6 +1040,13 @@ fn accumulator_folds_touch_deltas_and_episode_xg() { 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, @@ -989,6 +1065,7 @@ fn accumulator_folds_touch_deltas_and_episode_xg() { 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); diff --git a/src/stats/timeline/collector.rs b/src/stats/timeline/collector.rs index 0f17043a..d5b1f9e6 100644 --- a/src/stats/timeline/collector.rs +++ b/src/stats/timeline/collector.rs @@ -270,6 +270,7 @@ impl StatsTimelineEventCollector { expected_goals_touch_cursor: 0, expected_goals_episode_cursor: 0, expected_goals_tracks: ExpectedGoalsTimelineTracks { + config: ExpectedGoalsCalculatorConfig::default(), teams: vec![ ExpectedGoalsTeamTimelineTrack { is_team_0: true, @@ -281,6 +282,7 @@ impl StatsTimelineEventCollector { }, ], players: Vec::new(), + episodes: Vec::new(), }, } } @@ -370,6 +372,7 @@ impl StatsTimelineEventCollector { 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(); @@ -379,6 +382,7 @@ impl StatsTimelineEventCollector { 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(()) diff --git a/src/stats/timeline/types.rs b/src/stats/timeline/types.rs index db6ef766..f098349b 100644 --- a/src/stats/timeline/types.rs +++ b/src/stats/timeline/types.rs @@ -101,8 +101,13 @@ pub struct ReplayStatsTimelineScaffold { #[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)] From 95488f4a1a756efae53cddfccbb3f87084bad875 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 13 Jul 2026 16:21:31 -0700 Subject: [PATCH 11/12] Update timeline view source contract --- bakkesmod/subtr-actor/verify-plugin-source.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bakkesmod/subtr-actor/verify-plugin-source.py b/bakkesmod/subtr-actor/verify-plugin-source.py index 60ecd2cb..1fe30aed 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( From a82f751422bfc6d68e0e7c56dda35a8c90b9bf6b Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 13 Jul 2026 16:27:37 -0700 Subject: [PATCH 12/12] Fix live stats opt-in module exports --- bakkesmod/subtr-actor/rust/src/lib.rs | 2 ++ .../subtr-actor/rust/src/lib_tests/live_graph_events.rs | 6 +++--- .../subtr-actor/rust/src/lib_tests/live_graph_exports.rs | 4 ++-- bakkesmod/subtr-actor/rust/src/lib_tests/live_graph_json.rs | 4 ++-- src/collector/stats/builtins.rs | 2 +- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/bakkesmod/subtr-actor/rust/src/lib.rs b/bakkesmod/subtr-actor/rust/src/lib.rs index d6f45e76..34917dd7 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 df510287..d97edefe 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 c45c80d1..1cf0a49f 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 358bed51..7b345dce 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/src/collector/stats/builtins.rs b/src/collector/stats/builtins.rs index fb2116f4..e4187c61 100644 --- a/src/collector/stats/builtins.rs +++ b/src/collector/stats/builtins.rs @@ -1193,7 +1193,7 @@ pub fn builtin_analysis_node_json( } "player_control_state" => { let state = graph_state::(graph, node_name)?; - json!({ "players": state.players }) + json!({ "players": player_stats_entries(&state.players) }) } "possession_state" => { let state = graph_state::(graph, node_name)?;