# Per-Event `score_sigma` Override ## Summary Let users specify a per-event noise override on `Outcome::Scored`. Today every scored event in a `History` shares the single `HistoryBuilder::score_sigma` value (default `1.0`); a user who wants to say "this match was a clean blowout, trust the margin more" or "this one was a disrupted scrappy game, trust it less" has no way to do so. The override is resolved at ingest time and stored as a plain `f64` on the existing `EventKind::Scored { score_sigma }` payload, so `TimeSlice` and `run_chain` need zero changes. The work is purely on the public API surface: `Outcome::Scored` becomes a struct variant with an `Option sigma` field; two builder methods on `Outcome` and `EventBuilder` cover the explicit-override path. ## Background `Outcome::Scored(SmallVec<[f64; 4]>)` is the public per-team-score variant (`src/outcome.rs:20`). It's constructed via `Outcome::scores(I)` (`src/outcome.rs:44`) or `EventBuilder::scores(I)` (`src/event_builder.rs:79`). When `History::add_events` ingests a Scored outcome, it always uses the history-wide default: ```rust // src/history.rs:735-740 crate::Outcome::Scored(scores) => { kinds.push(EventKind::Scored { score_sigma: self.score_sigma, }); scores.to_vec() } ``` The downstream `EventKind::Scored { score_sigma: f64 }` (`src/time_slice.rs:51`) is already per-event-shaped — every Event carries its own copy. The constraint is purely at the ingest boundary. This was flagged as deferred tech debt during the T4-MarginFactor work: "EventKind::Scored.score_sigma payload is always history-wide today; per-event override deferred." ## Scope ### What ships 1. `Outcome::Scored` becomes a struct variant: `Scored { scores: SmallVec<[f64; 4]>, sigma: Option }`. `None` = use history default; `Some(s)` = override. 2. New constructor `Outcome::scores_with_sigma(scores, sigma)` on `Outcome`. Existing `Outcome::scores(I)` keeps the same shape but builds with `sigma: None`. 3. New builder method `EventBuilder::scores_with_sigma(scores, sigma)` on `EventBuilder`. 4. `History::add_events` resolves `sigma.unwrap_or(self.score_sigma)` when converting an `Outcome::Scored` to `EventKind::Scored`. 5. Mechanical pattern-match updates at every site that destructures `Outcome::Scored(...)` as a tuple. Estimate ~5–10 sites across `src/`, `tests/`, `examples/`, `benches/`. ### What does not ship - No change to `EventKind::Scored` (already per-event). - No change to `TimeSlice` or `run_chain`. - No change to `Game::scored` standalone API (it still takes `score_sigma` via `GameOptions::score_sigma`). - No deprecation of `HistoryBuilder::score_sigma` — the history-wide default is still useful as a common-case fallback. ## Design ### `Outcome` enum change ```rust // src/outcome.rs #[derive(Clone, Debug)] pub enum Outcome { Ranked(SmallVec<[u32; 4]>), Scored { scores: SmallVec<[f64; 4]>, /// Per-event noise override. `None` means inherit /// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`. sigma: Option, }, } ``` The variant shape changes from tuple to struct. Pattern matches that extract the scores switch from `Outcome::Scored(scores)` to `Outcome::Scored { scores, .. }` (or `{ scores, sigma }` where the sigma is needed). ### `Outcome` constructors ```rust impl Outcome { /// Per-team continuous scores; uses HistoryBuilder::score_sigma default. pub fn scores>(scores: I) -> Self { Self::Scored { scores: scores.into_iter().collect(), sigma: None, } } /// Per-team scores with explicit per-event noise override. /// /// `sigma` must be > 0.0; debug_assert. pub fn scores_with_sigma>( scores: I, sigma: f64, ) -> Self { debug_assert!(sigma > 0.0, "score_sigma must be > 0.0 (got {sigma})"); Self::Scored { scores: scores.into_iter().collect(), sigma: Some(sigma), } } } ``` `Outcome::scores(I)` keeps the existing function signature exactly — its only behavioural change is the internal struct construction. The existing `as_scores()`, `team_count()`, etc. accessors keep their public signatures (they return `Option<&[f64]>` and `usize`); their internal pattern matches update mechanically. ### `EventBuilder` method ```rust impl<'h, T, D, O, K> EventBuilder<'h, T, D, O, K> where T: Time, D: Drift, O: Observer, K: Eq + std::hash::Hash + Clone, { /// Per-team scores; uses HistoryBuilder::score_sigma default. pub fn scores>(mut self, scores: I) -> Self { self.event.outcome = crate::Outcome::scores(scores); self } /// Per-team scores with explicit per-event noise override. pub fn scores_with_sigma>( mut self, scores: I, sigma: f64, ) -> Self { self.event.outcome = crate::Outcome::scores_with_sigma(scores, sigma); self } } ``` The existing `.scores(...)` builder method stays — its body changes trivially because `Outcome::scores(I)` still has the same signature. `.scores_with_sigma(...)` is the new method. ### Sigma resolution In `History::add_events` at `src/history.rs:735`: ```rust crate::Outcome::Scored { scores, sigma } => { let resolved = sigma.unwrap_or(self.score_sigma); debug_assert!( resolved > 0.0, "resolved score_sigma must be > 0.0 (got {resolved})" ); kinds.push(EventKind::Scored { score_sigma: resolved, }); scores.to_vec() } ``` Resolution at ingest time means downstream code keeps a plain `f64`. No `Option` propagates further. ### Validation - `Outcome::scores_with_sigma(_, sigma)` debug-asserts `sigma > 0.0` at construction. - `History::add_events` debug-asserts the resolved sigma is `> 0.0` (catches both inherited and overridden paths). - `HistoryBuilder::score_sigma(s)` keeps its existing positive assertion. The default sigma at the History level (`1.0`) is positive, so an event with `sigma = None` against a default-built History always passes the resolved-sigma assertion trivially. ### Pattern-match update inventory Every site that destructures `Outcome::Scored(_)` as a tuple needs updating. Known sites: - `src/outcome.rs`: the `team_count()`, `as_scores()`, `as_ranks()` match arms (`src/outcome.rs:51`, `:58`, `:64`). - `src/history.rs:735`: the conversion arm (this is also where the resolution rule lands). - Any test in `src/outcome.rs` test mod that constructs `Outcome::Scored(...)` literally. - Any callsite in `src/`, `tests/`, `examples/`, `benches/`, `src/game.rs` that pattern-matches the variant. The compiler surfaces every site at `cargo build`. Locating them is mechanical. ## Testing strategy ### Regression net Existing 100 lib + 27 integration tests are the bit-equal regression net for the `sigma = None` path. Every existing test that uses `Outcome::scores(...)` or `EventBuilder::scores(...)` should continue to produce identical posteriors — the resolved sigma equals the history default (which equals what the hardcoded path produced). ### New tests Three additions in the `src/history.rs` test module: 1. **`outcome_scores_default_sigma_uses_history_default`** — build a History with `score_sigma(0.5)`, add a 2-team event via `Outcome::scores([3.0, 1.0])` (no override), capture posteriors. Build a second History identical except using `Outcome::scores_with_sigma([3.0, 1.0], 0.5)` (override matches default). Assert posteriors are bit-equal across the two paths. 2. **`outcome_scores_with_sigma_overrides_history_default`** — build a History with `score_sigma(0.5)`, add an event via `Outcome::scores_with_sigma([3.0, 1.0], 2.0)`. Build a second History with `score_sigma(2.0)` and add the same event via `Outcome::scores([3.0, 1.0])`. Assert posteriors are bit-equal. Then build a third History with `score_sigma(0.5)` and add via `Outcome::scores([3.0, 1.0])` (no override). Assert this third one's posteriors differ measurably from the override path (max diff > 1e-6) — proves the override actually changes inference. 3. **`event_builder_scores_with_sigma_threading`** — same shape as #2 but constructed via the fluent builder `h.event(0).team(["a"]).team(["b"]).scores_with_sigma([3.0, 1.0], 2.0).commit()`. Proves the builder method works end-to-end. ### Pattern-match update test impact Existing tests in `src/outcome.rs` that construct `Outcome::Scored(...)` literally need updating to the struct shape. Mechanical change; no new tests required. ## Verification gates ```bash cargo +nightly fmt cargo clippy --all-targets -- -D warnings cargo test --lib cargo test ``` Test count grows by 3. ## Risks - **Public API breaking change.** `Outcome::Scored` variant shape changes from tuple to struct. Any downstream consumer pattern-matching on the tuple form breaks. In a 0.1.x crate this is acceptable; flag it in the commit message. - **Mechanical breadth.** The pattern-match updates touch several files. They're all caught by the compiler so the risk is low, but the diff will look bigger than the actual logical change. - **Two ways to do the same thing.** `Outcome::scores_with_sigma(..)` and `EventBuilder::scores_with_sigma(..)` both produce the same outcome. This is intentional — the constructor is the underlying primitive; the builder method is the ergonomic wrapper. Same pattern as the existing `Outcome::scores(..)` / `EventBuilder::scores(..)` pair. ## Out-of-scope follow-ups - Per-event override of other config currently history-wide (`p_draw`, drift, beta) — same architectural pattern would apply but each is its own design decision. - Validation upgrade from `debug_assert!` to a `Result` at the Outcome construction boundary. - Schedule trait integration with `run_chain`, `Residual` schedule, `SynergyFactor` (still pending from the larger spec).