# 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<f64> 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<f64> }`.
   `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<f64>,
    },
}
```

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<I: IntoIterator<Item = f64>>(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<I: IntoIterator<Item = f64>>(
        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<T>,
    O: Observer<T>,
    K: Eq + std::hash::Hash + Clone,
{
    /// Per-team scores; uses HistoryBuilder::score_sigma default.
    pub fn scores<I: IntoIterator<Item = f64>>(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<I: IntoIterator<Item = f64>>(
        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).
