fix!: per-key queries report unknown keys instead of a plausible constant

Two accessors answered a question about a key the history had never seen
with a well-formed value indistinguishable from a real answer.

`log_evidence_for` filter_map'd unknown keys away. An empty target list
means "no restriction" downstream, so a list of *entirely* unknown keys
returned the whole-history evidence: measured on a two-cohort fixture,
`log_evidence_for(["typo"])` returned exactly `log_evidence()`. On the
one workload it is documented for — leave-one-out cross-validation —
that is the un-held-out score, a plausible number that silently
invalidates the comparison it was computed for. It now returns
`Err(UnknownKey)` naming the offending position.

`learning_curve` and `filtered_learning_curve` returned an empty `Vec`
both for a typo'd key and for a competitor who is registered but has not
played yet. They now return `Option`, so `None` is "never heard of it"
and `Some(vec![])` is "known, no appearances".

Tests carry a control case in each direction, so they cannot pass by
everything returning the same thing.

Closes #66, closes #70.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
2026-09-09 21:13:35 +02:00
co-authored by Claude Opus 5
parent 56e8220c86
commit e4a68ba1a7
10 changed files with 201 additions and 52 deletions
+61 -32
View File
@@ -745,19 +745,24 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
}
/// Learning curve for a single key: (time, posterior) pairs in time order.
///
/// `None` if the history has never seen the key; `Some(vec![])` if it is
/// registered but has no appearances. The two used to be the same empty
/// `Vec`, so a typo'd key was indistinguishable from a real competitor
/// awaiting their first game.
#[must_use]
pub fn learning_curve<Q>(&self, key: &Q) -> Vec<(T, Gaussian)>
pub fn learning_curve<Q>(&self, key: &Q) -> Option<Vec<(T, Gaussian)>>
where
K: std::borrow::Borrow<Q>,
Q: std::hash::Hash + Eq + ?Sized,
{
let Some(idx) = self.keys.get(key) else {
return Vec::new();
};
self.time_slices
.iter()
.filter_map(|ts| ts.skills.get(idx).map(|sk| (ts.time, sk.posterior())))
.collect()
let idx = self.keys.get(key)?;
Some(
self.time_slices
.iter()
.filter_map(|ts| ts.skills.get(idx).map(|sk| (ts.time, sk.posterior())))
.collect(),
)
}
/// Filtered learning curves for all competitors, keyed by user-facing key.
@@ -792,25 +797,27 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
/// discarding every posterior but the requested key's. N keys fetched
/// this way costs O(N * events); use `filtered_learning_curves` for
/// multi-key work instead — it computes the same pass once.
///
/// `None` for an unknown key, as with `learning_curve`.
#[must_use]
pub fn filtered_learning_curve<Q>(&self, key: &Q) -> Vec<(T, Gaussian)>
pub fn filtered_learning_curve<Q>(&self, key: &Q) -> Option<Vec<(T, Gaussian)>>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized,
{
let Some(idx) = self.keys.get(key) else {
return Vec::new();
};
let idx = self.keys.get(key)?;
self.filtered_pass()
.into_iter()
.filter_map(|(time, step)| {
step.posteriors
.iter()
.find(|(competitor, _)| *competitor == idx)
.map(|&(_, posterior)| (time, posterior))
})
.collect()
Some(
self.filtered_pass()
.into_iter()
.filter_map(|(time, step)| {
step.posteriors
.iter()
.find(|(competitor, _)| *competitor == idx)
.map(|&(_, posterior)| (time, posterior))
})
.collect(),
)
}
/// Sum per-slice evidence.
@@ -855,14 +862,36 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
/// Log-evidence restricted to time slices containing at least one of the
/// given keys. Useful for leave-one-out cross-validation.
#[must_use]
pub fn log_evidence_for<Q>(&self, keys: &[&Q]) -> f64
///
/// # Errors
///
/// `UnknownKey` for any key the history has never seen.
///
/// This used to `filter_map` unknown keys away, and an empty target list
/// means *no restriction* downstream — so a list of entirely unknown keys
/// returned the **whole-history** value. Measured on a two-cohort fixture,
/// `log_evidence_for(["typo"])` returned exactly `log_evidence()`. On the
/// one workload this is documented for, that silently yields the
/// un-held-out score: a plausible number that invalidates the comparison it
/// was computed for.
pub fn log_evidence_for<Q>(&self, keys: &[&Q]) -> Result<f64, InferenceError>
where
K: std::borrow::Borrow<Q>,
Q: std::hash::Hash + Eq + ?Sized,
Q: std::hash::Hash + Eq + ?Sized + std::fmt::Debug,
{
let targets: Vec<Index> = keys.iter().filter_map(|k| self.keys.get(*k)).collect();
self.log_evidence_internal(false, &targets)
let mut targets: Vec<Index> = Vec::with_capacity(keys.len());
for (member, key) in keys.iter().enumerate() {
let idx = self
.keys
.get(*key)
.ok_or_else(|| InferenceError::UnknownKey {
team: 0,
member,
key: format!("{key:?}"),
})?;
targets.push(idx);
}
Ok(self.log_evidence_internal(false, &targets))
}
/// Walk the slices in time order carrying forward messages only.
@@ -2961,8 +2990,8 @@ mod tests {
h.add_events(events).unwrap();
let _ = h.converge().unwrap();
let lc_a = h.learning_curve("a");
let lc_c = h.learning_curve("c");
let lc_a = h.learning_curve("a").unwrap();
let lc_c = h.learning_curve("c").unwrap();
let aj_e = lc_a.len();
let cj_e = lc_c.len();
@@ -3640,8 +3669,8 @@ mod tests {
];
h.add_events(events).unwrap();
let lc_a = h.learning_curve("a");
let lc_b = h.learning_curve("b");
let lc_a = h.learning_curve("a").unwrap();
let lc_b = h.learning_curve("b").unwrap();
assert_ulps_eq!(
lc_a[0].1,
@@ -3666,8 +3695,8 @@ mod tests {
let _ = h.converge().unwrap();
let lc_a = h.learning_curve("a");
let lc_b = h.learning_curve("b");
let lc_a = h.learning_curve("a").unwrap();
let lc_b = h.learning_curve("b").unwrap();
assert_ulps_eq!(lc_a[0].1, lc_a[0].1, epsilon = 1e-6);
assert_ulps_eq!(lc_b[0].1, lc_a[0].1, epsilon = 1e-6);