feat(api): add fluent history.event(t).team(...).commit() builder
Third tier of the ingestion API (spec Section 4). Powers one-off events with irregular shapes where neither record_winner (too simple) nor typed add_events (too verbose) fits cleanly. EventBuilder accumulates teams, weights, and outcome. Supports: - .team([keys]) — add a team - .weights([w..]) — per-member weights on the most-recently-added team - .ranking([ranks]) — explicit per-team ranks - .winner(i) — convenience: team i wins, others tied - .draw() — all teams tied - .commit() — finalize into an Event<T, K> and delegate to add_events Part of T2 of docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
94
src/event_builder.rs
Normal file
94
src/event_builder.rs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
use smallvec::SmallVec;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
InferenceError, Outcome,
|
||||||
|
drift::Drift,
|
||||||
|
event::{Event, Member, Team},
|
||||||
|
history::History,
|
||||||
|
observer::Observer,
|
||||||
|
time::Time,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct EventBuilder<'h, T, D, O, K>
|
||||||
|
where
|
||||||
|
T: Time,
|
||||||
|
D: Drift<T>,
|
||||||
|
O: Observer<T>,
|
||||||
|
K: Eq + std::hash::Hash + Clone,
|
||||||
|
{
|
||||||
|
history: &'h mut History<T, D, O, K>,
|
||||||
|
event: Event<T, K>,
|
||||||
|
current_team_idx: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
{
|
||||||
|
pub(crate) fn new(history: &'h mut History<T, D, O, K>, time: T) -> Self {
|
||||||
|
Self {
|
||||||
|
history,
|
||||||
|
event: Event {
|
||||||
|
time,
|
||||||
|
teams: SmallVec::new(),
|
||||||
|
outcome: Outcome::Ranked(SmallVec::new()),
|
||||||
|
},
|
||||||
|
current_team_idx: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a team by its member keys (weight 1.0 each, no prior overrides).
|
||||||
|
pub fn team<I: IntoIterator<Item = K>>(mut self, keys: I) -> Self {
|
||||||
|
let members: SmallVec<[Member<K>; 4]> = keys.into_iter().map(Member::new).collect();
|
||||||
|
self.event.teams.push(Team { members });
|
||||||
|
self.current_team_idx = Some(self.event.teams.len() - 1);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set per-member weights for the most recently added team.
|
||||||
|
///
|
||||||
|
/// Panics in debug builds if called before `.team(...)` or if the length
|
||||||
|
/// doesn't match the team's member count.
|
||||||
|
pub fn weights<I: IntoIterator<Item = f64>>(mut self, weights: I) -> Self {
|
||||||
|
let idx = self
|
||||||
|
.current_team_idx
|
||||||
|
.expect(".weights(...) called before any .team(...)");
|
||||||
|
let ws: Vec<f64> = weights.into_iter().collect();
|
||||||
|
let team = &mut self.event.teams[idx];
|
||||||
|
debug_assert_eq!(
|
||||||
|
ws.len(),
|
||||||
|
team.members.len(),
|
||||||
|
"weights length must match team size"
|
||||||
|
);
|
||||||
|
for (m, w) in team.members.iter_mut().zip(ws) {
|
||||||
|
m.weight = w;
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set explicit ranks per team (length must equal number of teams).
|
||||||
|
pub fn ranking<I: IntoIterator<Item = u32>>(mut self, ranks: I) -> Self {
|
||||||
|
self.event.outcome = Outcome::ranking(ranks);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark team `winner_idx` as winner; others tied for last.
|
||||||
|
pub fn winner(mut self, winner_idx: u32) -> Self {
|
||||||
|
self.event.outcome = Outcome::winner(winner_idx, self.event.teams.len() as u32);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All teams tied.
|
||||||
|
pub fn draw(mut self) -> Self {
|
||||||
|
self.event.outcome = Outcome::draw(self.event.teams.len() as u32);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit the event to the history.
|
||||||
|
pub fn commit(self) -> Result<(), InferenceError> {
|
||||||
|
self.history.add_events(std::iter::once(self.event))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -535,6 +535,11 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start a fluent event builder for a single match at `time`.
|
||||||
|
pub fn event(&mut self, time: T) -> crate::event_builder::EventBuilder<'_, T, D, O, K> {
|
||||||
|
crate::event_builder::EventBuilder::new(self, time)
|
||||||
|
}
|
||||||
|
|
||||||
/// Bulk-ingest typed events.
|
/// Bulk-ingest typed events.
|
||||||
pub fn add_events<I>(&mut self, events: I) -> Result<(), InferenceError>
|
pub fn add_events<I>(&mut self, events: I) -> Result<(), InferenceError>
|
||||||
where
|
where
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ mod convergence;
|
|||||||
pub mod drift;
|
pub mod drift;
|
||||||
mod error;
|
mod error;
|
||||||
mod event;
|
mod event;
|
||||||
|
mod event_builder;
|
||||||
pub(crate) mod factor;
|
pub(crate) mod factor;
|
||||||
mod game;
|
mod game;
|
||||||
pub mod gaussian;
|
pub mod gaussian;
|
||||||
@@ -31,6 +32,7 @@ pub use convergence::{ConvergenceOptions, ConvergenceReport};
|
|||||||
pub use drift::{ConstantDrift, Drift};
|
pub use drift::{ConstantDrift, Drift};
|
||||||
pub use error::InferenceError;
|
pub use error::InferenceError;
|
||||||
pub use event::{Event, Member, Team};
|
pub use event::{Event, Member, Team};
|
||||||
|
pub use event_builder::EventBuilder;
|
||||||
pub use game::Game;
|
pub use game::Game;
|
||||||
pub use gaussian::Gaussian;
|
pub use gaussian::Gaussian;
|
||||||
pub use history::History;
|
pub use history::History;
|
||||||
|
|||||||
@@ -82,3 +82,63 @@ fn add_events_rejects_mismatched_outcome_ranks() {
|
|||||||
let err = h.add_events(events).unwrap_err();
|
let err = h.add_events(events).unwrap_err();
|
||||||
assert!(matches!(err, InferenceError::MismatchedShape { .. }));
|
assert!(matches!(err, InferenceError::MismatchedShape { .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fluent_event_builder_basic() {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(25.0)
|
||||||
|
.sigma(25.0 / 3.0)
|
||||||
|
.beta(25.0 / 6.0)
|
||||||
|
.p_draw(0.0)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
h.event(1)
|
||||||
|
.team(["alice", "bob"])
|
||||||
|
.weights([1.0, 0.7])
|
||||||
|
.team(["carol"])
|
||||||
|
.ranking([1, 0])
|
||||||
|
.commit()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let report = h.converge().unwrap();
|
||||||
|
assert!(report.converged);
|
||||||
|
assert!(h.lookup(&"alice").is_some());
|
||||||
|
assert!(h.lookup(&"bob").is_some());
|
||||||
|
assert!(h.lookup(&"carol").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fluent_event_builder_winner_convenience() {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(25.0)
|
||||||
|
.sigma(25.0 / 3.0)
|
||||||
|
.beta(25.0 / 6.0)
|
||||||
|
.p_draw(0.0)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
h.event(1)
|
||||||
|
.team(["alice"])
|
||||||
|
.team(["bob"])
|
||||||
|
.winner(0)
|
||||||
|
.commit()
|
||||||
|
.unwrap();
|
||||||
|
h.converge().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fluent_event_builder_draw() {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(25.0)
|
||||||
|
.sigma(25.0 / 3.0)
|
||||||
|
.beta(25.0 / 6.0)
|
||||||
|
.p_draw(0.25)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
h.event(1)
|
||||||
|
.team(["alice"])
|
||||||
|
.team(["bob"])
|
||||||
|
.draw()
|
||||||
|
.commit()
|
||||||
|
.unwrap();
|
||||||
|
h.converge().unwrap();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user