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()); +}