T4 (MarginFactor): scored outcomes via Gaussian-margin EP evidence

Adds soft Gaussian-observation evidence on the per-pair diff variable,
enabling continuous score margins as a richer alternative to ranks.

Public API:
- `Outcome::Scored([scores])` (non-breaking enum extension under
  `#[non_exhaustive]`).
- `Game::scored(teams, outcome, options)` constructor parallel to
  `Game::ranked`.
- `EventBuilder::scores([...])` fluent helper.
- `HistoryBuilder::score_sigma(σ)` knob (default 1.0, validated > 0).
- `GameOptions::score_sigma`.
- `EventKind` re-exported from `lib.rs` (annotated `#[non_exhaustive]`).
- New `InferenceError::InvalidParameter { name, value }` variant.

Internals:
- `MarginFactor` (`factor/margin.rs`): Gaussian observation factor that
  closes in one EP step; cavity-cached log-evidence mirrors `TruncFactor`.
- `BuiltinFactor::Margin` dispatch arm.
- `DiffFactor` enum in `game.rs` lets `Game::likelihoods` and the new
  `likelihoods_scored` share the per-pair link abstraction.
- Per-event `EventKind { Ranked, Scored { score_sigma } }` routed through
  `TimeSlice::add_events`, `iteration_direct`, and `log_evidence`.

Tests: 88 lib + 27 integration (4 new in `tests/scored.rs`); existing
goldens byte-identical.  Bench: `benches/scored.rs` baseline ~960µs for
60 events × 20-player pool with default convergence.

Plan: docs/superpowers/plans/2026-04-27-t4-margin-factor.md
Spec item marked Done.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 08:47:36 +02:00
parent 6bf3e7e294
commit 8b53cacd64
23 changed files with 3005 additions and 83 deletions

View File

@@ -13,7 +13,7 @@ use crate::{
sort_time,
storage::CompetitorStore,
time::Time,
time_slice::{self, TimeSlice},
time_slice::{self, EventKind, TimeSlice},
tuple_gt, tuple_max,
};
@@ -30,6 +30,7 @@ pub struct HistoryBuilder<
drift: D,
p_draw: f64,
online: bool,
score_sigma: f64,
convergence: ConvergenceOptions,
observer: O,
_time: PhantomData<T>,
@@ -60,6 +61,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
beta: self.beta,
p_draw: self.p_draw,
online: self.online,
score_sigma: self.score_sigma,
convergence: self.convergence,
observer: self.observer,
_time: self._time,
@@ -77,6 +79,15 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
self
}
pub fn score_sigma(mut self, score_sigma: f64) -> Self {
assert!(
score_sigma > 0.0,
"score_sigma must be positive (got {score_sigma})"
);
self.score_sigma = score_sigma;
self
}
pub fn convergence(mut self, opts: ConvergenceOptions) -> Self {
self.convergence = opts;
self
@@ -90,6 +101,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
drift: self.drift,
p_draw: self.p_draw,
online: self.online,
score_sigma: self.score_sigma,
convergence: self.convergence,
observer,
_time: self._time,
@@ -109,6 +121,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
drift: self.drift,
p_draw: self.p_draw,
online: self.online,
score_sigma: self.score_sigma,
convergence: self.convergence,
observer: self.observer,
}
@@ -124,6 +137,7 @@ impl Default for HistoryBuilder<i64, ConstantDrift, NullObserver, &'static str>
drift: ConstantDrift(GAMMA),
p_draw: P_DRAW,
online: false,
score_sigma: 1.0,
convergence: ConvergenceOptions::default(),
observer: NullObserver,
_time: PhantomData,
@@ -148,6 +162,7 @@ pub struct History<
drift: D,
p_draw: f64,
online: bool,
score_sigma: f64,
convergence: ConvergenceOptions,
observer: O,
}
@@ -174,6 +189,7 @@ impl<K: Eq + Hash + Clone> History<i64, ConstantDrift, NullObserver, K> {
drift: ConstantDrift(GAMMA),
p_draw: P_DRAW,
online: false,
score_sigma: 1.0,
convergence: ConvergenceOptions::default(),
observer: NullObserver,
_time: PhantomData,
@@ -450,6 +466,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
results: Vec<Vec<f64>>,
times: Vec<T>,
weights: Vec<Vec<Vec<f64>>>,
kinds: Vec<EventKind>,
mut priors: HashMap<Index, Rating<T, D>>,
) -> Result<(), InferenceError> {
if !results.is_empty() && results.len() != composition.len() {
@@ -473,6 +490,13 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
got: weights.len(),
});
}
if kinds.len() != composition.len() {
return Err(InferenceError::MismatchedShape {
kind: "kinds",
expected: composition.len(),
got: kinds.len(),
});
}
competitor::clean(self.agents.values_mut(), true);
@@ -557,9 +581,11 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
(i..j).map(|e| weights[o[e]].clone()).collect::<Vec<_>>()
};
let kinds_chunk: Vec<EventKind> = (i..j).map(|e| kinds[o[e]]).collect();
if self.time_slices.len() > k && self.time_slices[k].time == t {
let time_slice = &mut self.time_slices[k];
time_slice.add_events(composition, results, weights, &self.agents);
time_slice.add_events(composition, results, weights, kinds_chunk, &self.agents);
for agent_idx in time_slice.skills.keys() {
let agent = self.agents.get_mut(agent_idx).unwrap();
@@ -569,7 +595,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
}
} else {
let mut time_slice = TimeSlice::new(t, self.p_draw);
time_slice.add_events(composition, results, weights, &self.agents);
time_slice.add_events(composition, results, weights, kinds_chunk, &self.agents);
self.time_slices.insert(k, time_slice);
@@ -626,6 +652,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
vec![vec![1.0, 0.0]],
vec![time],
vec![],
vec![EventKind::Ranked],
HashMap::new(),
)
}
@@ -642,6 +669,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
vec![vec![0.0, 0.0]],
vec![time],
vec![],
vec![EventKind::Ranked],
HashMap::new(),
)
}
@@ -666,15 +694,15 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
let mut results: Vec<Vec<f64>> = Vec::with_capacity(events.len());
let mut times: Vec<T> = Vec::with_capacity(events.len());
let mut weights: Vec<Vec<Vec<f64>>> = Vec::with_capacity(events.len());
let mut kinds: Vec<EventKind> = Vec::with_capacity(events.len());
let mut priors: HashMap<Index, Rating<T, D>> = HashMap::new();
for ev in events {
let ranks = ev.outcome.as_ranks();
if ranks.len() != ev.teams.len() {
if ev.outcome.team_count() != ev.teams.len() {
return Err(InferenceError::MismatchedShape {
kind: "outcome ranks vs teams",
kind: "outcome vs teams",
expected: ev.teams.len(),
got: ranks.len(),
got: ev.outcome.team_count(),
});
}
@@ -698,13 +726,24 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
composition.push(event_comp);
weights.push(event_weights);
let max_rank = ranks.iter().copied().max().unwrap_or(0) as f64;
let inverted: Vec<f64> = ranks.iter().map(|&r| max_rank - r as f64).collect();
results.push(inverted);
let event_result: Vec<f64> = match &ev.outcome {
crate::Outcome::Ranked(ranks) => {
let max_rank = ranks.iter().copied().max().unwrap_or(0) as f64;
kinds.push(EventKind::Ranked);
ranks.iter().map(|&r| max_rank - r as f64).collect()
}
crate::Outcome::Scored(scores) => {
kinds.push(EventKind::Scored {
score_sigma: self.score_sigma,
});
scores.to_vec()
}
};
results.push(event_result);
times.push(ev.time);
}
self.add_events_with_prior(composition, results, times, weights, priors)
self.add_events_with_prior(composition, results, times, weights, kinds, priors)
}
}
@@ -1666,4 +1705,10 @@ mod tests {
assert!(report.iterations < 30);
assert!(report.final_step.0 <= 1e-6);
}
#[test]
#[should_panic(expected = "score_sigma must be positive")]
fn history_builder_rejects_zero_score_sigma() {
let _ = History::builder().score_sigma(0.0).build();
}
}