From 46625d247a93b0e58b990790e9a43aa67b8357c9 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Fri, 8 May 2026 16:05:27 +0200 Subject: [PATCH] docs: spec for per-event score_sigma override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outcome::Scored becomes a struct variant with an Option sigma field. None inherits HistoryBuilder::score_sigma; Some(s) overrides per event. Resolved at ingest time so EventKind::Scored stays a plain f64 and TimeSlice/run_chain need zero changes. New constructors Outcome::scores_with_sigma and EventBuilder::scores_with_sigma cover the override path; existing scores(..) keeps its signature with sigma=None internally. Breaking change to Outcome::Scored variant shape (tuple → struct); acceptable in 0.1.x. Closes the last item from the T4-MarginFactor deferred wishlist. --- ...2026-05-08-per-event-score-sigma-design.md | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-08-per-event-score-sigma-design.md diff --git a/docs/superpowers/specs/2026-05-08-per-event-score-sigma-design.md b/docs/superpowers/specs/2026-05-08-per-event-score-sigma-design.md new file mode 100644 index 0000000..e863351 --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-per-event-score-sigma-design.md @@ -0,0 +1,292 @@ +# 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).