From aff3fb948db92cec35b9dfd92acca0887f4bdb79 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 27 Aug 2026 17:48:50 +0200 Subject: [PATCH] refactor!: replace emptiness-as-sentinel with Option for results and weights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third of the five remaining items on #23. `add_events_with_prior` and `TimeSlice::add_events` took `Vec>` and `Vec>>` where an empty vec meant "not supplied" — so an empty outer vec and a genuinely empty event list were the same value, and every reader had to know which. Both are now `Option`, and the "not supplied" branches read as `None` arms rather than `is_empty()` checks. Two things fell out of the change that a sentinel would have hidden: The tie pre-check iterated `results` directly. Under `Option` it needs `.iter().flatten()`, which makes explicit that a `None` results list has no ties to reject — previously an empty vec silently skipped the same loop and looked identical to "checked, found nothing". `MismatchedShape.got` could no longer be `results.len()`, because at the point of the error there may be no vec to take a length from. It is now computed as `map_or(0, Vec::len)` before the error is built. MSRV note: my first draft used let-chains for the two validations. Those need Rust 1.88 and this crate pins 1.85 — it compiled locally on 1.98 and would have failed only in the MSRV CI job. Rewritten with `is_some_and`, and verified by installing 1.85.0 and building against it rather than by assuming the removal was complete. Breaking: `TimeSlice::add_events` is public. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc --- benches/batch.rs | 2 +- src/history.rs | 56 ++++++++++++++++++++++++++++------------------- src/time_slice.rs | 41 +++++++++++++++++----------------- 3 files changed, 55 insertions(+), 44 deletions(-) diff --git a/benches/batch.rs b/benches/batch.rs index b87dc06..2f30794 100644 --- a/benches/batch.rs +++ b/benches/batch.rs @@ -36,7 +36,7 @@ fn criterion_benchmark(criterion: &mut Criterion) { let kinds = vec![EventKind::Ranked; composition.len()]; let mut time_slice = TimeSlice::new(1, P_DRAW, ConvergenceOptions::default()); - time_slice.add_events(composition, results, weights, kinds, &agents); + time_slice.add_events(composition, Some(results), Some(weights), kinds, &agents); criterion.bench_function("Batch::iteration", |b| { b.iter(|| time_slice.iteration(0, &agents)) diff --git a/src/history.rs b/src/history.rs index cd90079..746ca84 100644 --- a/src/history.rs +++ b/src/history.rs @@ -651,17 +651,22 @@ impl, O: Observer, K: Eq + Hash + Clone> History>>, - results: Vec>, + results: Option>>, times: Vec, - weights: Vec>>, + weights: Option>>>, kinds: Vec, mut priors: HashMap>, ) -> Result<(), InferenceError> { - if !results.is_empty() && results.len() != composition.len() { + if results + .as_ref() + .is_some_and(|r| r.len() != composition.len()) + { + let got = results.as_ref().map_or(0, Vec::len); + return Err(InferenceError::MismatchedShape { kind: "results", expected: composition.len(), - got: results.len(), + got, }); } if times.len() != composition.len() { @@ -671,11 +676,16 @@ impl, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History>(); - let results = if results.is_empty() { - Vec::new() - } else { - (i..j).map(|e| results[o[e]].clone()).collect::>() - }; + let results = results + .as_ref() + .map(|results| (i..j).map(|e| results[o[e]].clone()).collect::>()); - let weights = if weights.is_empty() { - Vec::new() - } else { - (i..j).map(|e| weights[o[e]].clone()).collect::>() - }; + let weights = weights + .as_ref() + .map(|weights| (i..j).map(|e| weights[o[e]].clone()).collect::>()); let kinds_chunk: Vec = (i..j).map(|e| kinds[o[e]]).collect(); @@ -861,9 +867,9 @@ impl, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History TimeSlice { pub fn add_events>( &mut self, composition: Vec>>, - results: Vec>, - weights: Vec>>, + results: Option>>, + weights: Option>>>, kinds: Vec, agents: &CompetitorStore, ) { @@ -328,22 +328,21 @@ impl TimeSlice { Team { items, - output: if results.is_empty() { - (event.len() - (t + 1)) as f64 - } else { - results[e][t] + output: match &results { + Some(results) => results[e][t], + // No explicit result: rank by position, first team best. + None => (event.len() - (t + 1)) as f64, }, } }) .collect::>(); - let weights = if weights.is_empty() { - teams + let weights = match &weights { + Some(weights) => weights[e].clone(), + None => teams .iter() .map(|team| vec![1.0; team.items.len()]) - .collect::>() - } else { - weights[e].clone() + .collect::>(), }; Event { @@ -828,8 +827,8 @@ mod tests { vec![vec![c], vec![d]], vec![vec![e], vec![f]], ], - vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]], - vec![], + Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]), + None, vec![EventKind::Ranked; 3], &agents, ); @@ -905,8 +904,8 @@ mod tests { vec![vec![a], vec![c]], vec![vec![b], vec![c]], ], - vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]], - vec![], + Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]), + None, vec![EventKind::Ranked; 3], &agents, ); @@ -985,8 +984,8 @@ mod tests { vec![vec![a], vec![c]], vec![vec![b], vec![c]], ], - vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]], - vec![], + Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]), + None, vec![EventKind::Ranked; 3], &agents, ); @@ -1017,8 +1016,8 @@ mod tests { vec![vec![a], vec![c]], vec![vec![b], vec![c]], ], - vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]], - vec![], + Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]), + None, vec![EventKind::Ranked; 3], &agents, ); @@ -1088,8 +1087,8 @@ mod tests { vec![vec![c], vec![d]], vec![vec![a], vec![c]], ], - vec![vec![1.0, 0.0], vec![1.0, 0.0], vec![1.0, 0.0]], - vec![], + Some(vec![vec![1.0, 0.0], vec![1.0, 0.0], vec![1.0, 0.0]]), + None, vec![EventKind::Ranked; 3], &agents, );