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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user