From dc2d2e50bc6ec9bb4fb0c990ddc452032730f1f6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 07:25:39 +0530 Subject: [PATCH] fix(response): size the probability-sum tolerance to the option count The OpenRouter System One endpoint rounds each probability to two decimals, so a 21-option Choice legitimately answers with probabilities summing to 0.99 and the fixed 1e-6 tolerance rejected 3-8% of many-option searches. Allow half a unit in the second decimal per option, floored at the original tolerance so two-option answers are held as before, and name the sum and option count in the error. Co-authored-by: Medulla --- crates/tinyjevclient/src/response/mod.rs | 21 +++++++++++++++++++-- crates/tinyjevclient/src/response/test.rs | 17 +++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/tinyjevclient/src/response/mod.rs b/crates/tinyjevclient/src/response/mod.rs index 2c1d3cf..6f999c4 100644 --- a/crates/tinyjevclient/src/response/mod.rs +++ b/crates/tinyjevclient/src/response/mod.rs @@ -166,14 +166,31 @@ fn validate_distribution( validate_probability(*probability, name)?; } let sum: f64 = probabilities.values().sum(); - if (sum - 1.0).abs() > PROBABILITY_TOLERANCE { + if (sum - 1.0).abs() > distribution_tolerance(probabilities.len()) { return Err(Error::invalid_response(format!( - "{name} probabilities must sum to one" + "{name} probabilities must sum to one (sum {sum:.6} over {} options)", + probabilities.len() ))); } Ok(()) } +/// How far a distribution's sum may stray from one. +/// +/// Providers serialise each probability with a fixed number of decimals, so +/// the rounding error grows with the number of options. Measured on the +/// `OpenRouter` System One endpoint (2026-09): two decimals per option, and a +/// 21-option Choice answered with probabilities summing to 0.99. The +/// tolerance is therefore half a unit in the second decimal per option, +/// floored at `PROBABILITY_TOLERANCE` so a two-option answer is held as +/// tightly as before. It is a bound on rounding, not on the model: a +/// distribution that is off by more than that is still rejected. +fn distribution_tolerance(options: usize) -> f64 { + // A Choice holds at most 255 options, so the cast is exact. + let options = f64::from(u32::try_from(options).unwrap_or(u32::MAX)); + PROBABILITY_TOLERANCE.max(options * 0.005) +} + fn validate_probability(value: f64, name: &str) -> Result<()> { if !value.is_finite() || !(0.0..=1.0).contains(&value) { return Err(Error::invalid_response(format!( diff --git a/crates/tinyjevclient/src/response/test.rs b/crates/tinyjevclient/src/response/test.rs index f69ce67..e151d20 100644 --- a/crates/tinyjevclient/src/response/test.rs +++ b/crates/tinyjevclient/src/response/test.rs @@ -230,3 +230,20 @@ fn accepts_the_exact_score_rounding_boundary() { score.score = 0.05; boundary.validate_for(&request()).unwrap(); } + +#[test] +fn many_option_distributions_tolerate_per_option_rounding() { + // 21 options rounded to two decimals summing to 0.99: what the OpenRouter + // endpoint answers. Rejected before the size-aware tolerance. + let mut probabilities = std::collections::BTreeMap::new(); + for i in 0..20 { + probabilities.insert(format!("o{i}"), 0.04); + } + probabilities.insert("o20".to_owned(), 0.19); + assert!((probabilities.values().sum::() - 0.99).abs() < 1e-9); + assert!(validate_distribution(&probabilities, "choice").is_ok()); + + // Two options are still held tightly. + let two = std::collections::BTreeMap::from([("a".to_owned(), 0.6), ("b".to_owned(), 0.39)]); + assert!(validate_distribution(&two, "choice").is_err()); +}