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:
+59
-32
@@ -44,6 +44,13 @@ impl Default for Skill {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[non_exhaustive]
|
||||
pub enum EventKind {
|
||||
Ranked,
|
||||
Scored { score_sigma: f64 },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Item {
|
||||
agent: Index,
|
||||
@@ -82,6 +89,7 @@ pub(crate) struct Event {
|
||||
teams: Vec<Team>,
|
||||
evidence: f64,
|
||||
weights: Vec<Vec<f64>>,
|
||||
kind: EventKind,
|
||||
}
|
||||
|
||||
impl Event {
|
||||
@@ -129,7 +137,14 @@ impl Event {
|
||||
) {
|
||||
let teams = self.within_priors(false, false, skills, agents);
|
||||
let result = self.outputs();
|
||||
let g = Game::ranked_with_arena(teams, &result, &self.weights, p_draw, arena);
|
||||
let g = match self.kind {
|
||||
EventKind::Ranked => {
|
||||
Game::ranked_with_arena(teams, &result, &self.weights, p_draw, arena)
|
||||
}
|
||||
EventKind::Scored { score_sigma } => {
|
||||
Game::scored_with_arena(teams, &result, &self.weights, score_sigma, arena)
|
||||
}
|
||||
};
|
||||
|
||||
for (t, team) in self.teams.iter_mut().enumerate() {
|
||||
for (i, item) in team.items.iter_mut().enumerate() {
|
||||
@@ -205,6 +220,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
composition: Vec<Vec<Vec<Index>>>,
|
||||
results: Vec<Vec<f64>>,
|
||||
weights: Vec<Vec<Vec<f64>>>,
|
||||
kinds: Vec<EventKind>,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
) {
|
||||
let mut unique = Vec::with_capacity(10);
|
||||
@@ -274,6 +290,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
teams,
|
||||
evidence: 0.0,
|
||||
weights,
|
||||
kind: kinds[e],
|
||||
}
|
||||
});
|
||||
|
||||
@@ -299,13 +316,22 @@ impl<T: Time> TimeSlice<T> {
|
||||
let teams = event.within_priors(false, false, &self.skills, agents);
|
||||
let result = event.outputs();
|
||||
|
||||
let g = Game::ranked_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&event.weights,
|
||||
self.p_draw,
|
||||
&mut self.arena,
|
||||
);
|
||||
let g = match event.kind {
|
||||
EventKind::Ranked => Game::ranked_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&event.weights,
|
||||
self.p_draw,
|
||||
&mut self.arena,
|
||||
),
|
||||
EventKind::Scored { score_sigma } => Game::scored_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&event.weights,
|
||||
score_sigma,
|
||||
&mut self.arena,
|
||||
),
|
||||
};
|
||||
|
||||
for (t, team) in event.teams.iter_mut().enumerate() {
|
||||
for (i, item) in team.items.iter_mut().enumerate() {
|
||||
@@ -474,21 +500,28 @@ impl<T: Time> TimeSlice<T> {
|
||||
// log_evidence is infrequent; a local arena avoids needing &mut self.
|
||||
let mut arena = ScratchArena::new();
|
||||
|
||||
let run_event = |event: &Event, arena: &mut ScratchArena| -> f64 {
|
||||
let teams = event.within_priors(online, forward, &self.skills, agents);
|
||||
let result = event.outputs();
|
||||
match event.kind {
|
||||
EventKind::Ranked => {
|
||||
Game::ranked_with_arena(teams, &result, &event.weights, self.p_draw, arena)
|
||||
.evidence
|
||||
.ln()
|
||||
}
|
||||
EventKind::Scored { score_sigma } => {
|
||||
Game::scored_with_arena(teams, &result, &event.weights, score_sigma, arena)
|
||||
.evidence
|
||||
.ln()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if targets.is_empty() {
|
||||
if online || forward {
|
||||
self.events
|
||||
.iter()
|
||||
.map(|event| {
|
||||
Game::ranked_with_arena(
|
||||
event.within_priors(online, forward, &self.skills, agents),
|
||||
&event.outputs(),
|
||||
&event.weights,
|
||||
self.p_draw,
|
||||
&mut arena,
|
||||
)
|
||||
.evidence
|
||||
.ln()
|
||||
})
|
||||
.map(|event| run_event(event, &mut arena))
|
||||
.sum()
|
||||
} else {
|
||||
self.events.iter().map(|event| event.evidence.ln()).sum()
|
||||
@@ -496,25 +529,14 @@ impl<T: Time> TimeSlice<T> {
|
||||
} else if online || forward {
|
||||
self.events
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, event)| {
|
||||
.filter(|event| {
|
||||
event
|
||||
.teams
|
||||
.iter()
|
||||
.flat_map(|team| &team.items)
|
||||
.any(|item| targets.contains(&item.agent))
|
||||
})
|
||||
.map(|(_, event)| {
|
||||
Game::ranked_with_arena(
|
||||
event.within_priors(online, forward, &self.skills, agents),
|
||||
&event.outputs(),
|
||||
&event.weights,
|
||||
self.p_draw,
|
||||
&mut arena,
|
||||
)
|
||||
.evidence
|
||||
.ln()
|
||||
})
|
||||
.map(|event| run_event(event, &mut arena))
|
||||
.sum()
|
||||
} else {
|
||||
self.events
|
||||
@@ -609,6 +631,7 @@ mod tests {
|
||||
],
|
||||
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
|
||||
vec![],
|
||||
vec![EventKind::Ranked; 3],
|
||||
&agents,
|
||||
);
|
||||
|
||||
@@ -685,6 +708,7 @@ mod tests {
|
||||
],
|
||||
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
|
||||
vec![],
|
||||
vec![EventKind::Ranked; 3],
|
||||
&agents,
|
||||
);
|
||||
|
||||
@@ -764,6 +788,7 @@ mod tests {
|
||||
],
|
||||
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
|
||||
vec![],
|
||||
vec![EventKind::Ranked; 3],
|
||||
&agents,
|
||||
);
|
||||
|
||||
@@ -795,6 +820,7 @@ mod tests {
|
||||
],
|
||||
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
|
||||
vec![],
|
||||
vec![EventKind::Ranked; 3],
|
||||
&agents,
|
||||
);
|
||||
|
||||
@@ -860,6 +886,7 @@ mod tests {
|
||||
],
|
||||
vec![vec![1.0, 0.0], vec![1.0, 0.0], vec![1.0, 0.0]],
|
||||
vec![],
|
||||
vec![EventKind::Ranked; 3],
|
||||
&agents,
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user