1use std::collections::BTreeMap;
12
13use rustev_contract::Document;
14use rustev_contract::decimal::Decimal;
15use rustev_contract::eval_report::MetricValue;
16use rustev_contract::ids::ContentDigest;
17use rustev_contract::limits::REPLAY_V1;
18use serde::{Deserialize, Serialize};
19
20use crate::repeat_config::TopK;
21use crate::repeat_stats::{Contribution, Figure, Form, MetricCases, run_stats};
22use crate::report::Evaluated;
23
24pub const EVAL_CASES: &str = "rustev.eval-cases/1";
25pub const COST_PER_1000_OBSERVED: &str = "cost_per_1000/observed";
28pub const COST_PER_1000_ESTIMATED: &str = "cost_per_1000/estimated";
29
30pub fn derived(metric: &str) -> bool {
32 metric == COST_PER_1000_OBSERVED || metric == COST_PER_1000_ESTIMATED
33}
34
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct EvalCases {
38 pub schema: String,
39 pub report: ContentDigest,
41 pub top_k: Option<TopK>,
44 pub cases: Vec<CaseMeasures>,
45}
46
47crate::doc::eval_document!(EvalCases, EVAL_CASES, REPLAY_V1);
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct CaseMeasures {
52 pub case: String,
53 pub run: Option<ContentDigest>,
56 pub outcome: String,
58 pub top_k: Option<Vec<String>>,
62 pub measures: BTreeMap<String, CaseMeasure>,
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case", deny_unknown_fields)]
70pub enum CaseMeasure {
71 Count {
72 num: u64,
73 den: u64,
74 },
75 Mean {
76 num: f64,
77 den: u64,
78 },
79 Sample {
80 value: f64,
81 },
82 Unknown {
84 reason: String,
85 },
86}
87
88impl CaseMeasure {
89 pub fn contribution(&self) -> Contribution {
90 match self {
91 CaseMeasure::Count { num, den } => Contribution::Count {
92 num: *num,
93 den: *den,
94 },
95 CaseMeasure::Mean { num, den } => Contribution::Mean {
96 num: *num,
97 den: *den,
98 },
99 CaseMeasure::Sample { value } => Contribution::Sample(*value),
100 CaseMeasure::Unknown { .. } => Contribution::Unknown,
101 }
102 }
103}
104
105pub fn form_of(metric: &str) -> Option<Form> {
110 match metric {
111 "coverage"
112 | "acceptance_coverage"
113 | "labeled_proposal_coverage"
114 | "error_among_accepted"
115 | "candidate_outcome_change" => return Some(Form::CountRatio),
116 "mean_log_loss" | COST_PER_1000_OBSERVED | COST_PER_1000_ESTIMATED => {
117 return Some(Form::Mean);
118 }
119 "brier" => return None,
124 _ => {}
125 }
126 let parts: Vec<&str> = metric.split('/').collect();
127 match parts.as_slice() {
128 ["reliability", _, _, "confidence"] => Some(Form::Mean),
129 ["reliability", _, _, "accuracy"] => Some(Form::CountRatio),
130 ["latency_ms" | "queue_ms", q] => {
131 let q = Decimal::parse(q.strip_prefix('p')?).ok()?;
132 Some(Form::Sample { quantile: q })
133 }
134 _ => None,
135 }
136}
137
138pub fn metric_cases(metric: &str, form: Form, runs: &[&EvalCases]) -> Result<MetricCases, String> {
143 let first = runs.first().map_or(&[][..], |r| r.cases.as_slice());
144 for r in runs {
145 if r.cases.len() != first.len() || r.cases.iter().zip(first).any(|(a, b)| a.case != b.case)
146 {
147 return Err("mixed-runs: the runs do not list the same cases in the same order".into());
148 }
149 }
150 let cases = (0..first.len())
151 .map(|c| {
152 runs.iter()
153 .map(|r| {
154 r.cases[c]
155 .measures
156 .get(metric)
157 .map(CaseMeasure::contribution)
158 })
159 .collect()
160 })
161 .collect();
162 Ok(MetricCases {
163 name: metric.into(),
164 form,
165 cases,
166 })
167}
168
169pub fn reconcile(run: &Evaluated, cases: &EvalCases) -> Result<(), String> {
176 let digest = run
177 .report
178 .record_digest()
179 .map_err(|_| "the report has no record digest".to_string())?;
180 if cases.schema != EVAL_CASES || cases.report != digest || run.detail.report != digest {
181 return Err("the case measures are not bound to this report".into());
182 }
183 if cases.cases.len() != run.detail.cases.len()
184 || cases
185 .cases
186 .iter()
187 .zip(&run.detail.cases)
188 .any(|(m, d)| m.case != d.case || m.outcome != d.outcome)
189 {
190 return Err("the case measures do not list the detail's cases and outcomes".into());
191 }
192 for c in &cases.cases {
193 if let Some(name) = c.measures.keys().find(|m| form_of(m).is_none()) {
194 return Err(format!(
195 "case {} has a measure for `{name}`, which has none",
196 c.case
197 ));
198 }
199 }
200 for m in &run.report.metrics {
201 let Some(form) = form_of(&m.name) else {
202 continue;
203 };
204 let metric = metric_cases(&m.name, form, &[cases])?;
205 let stats = run_stats(&metric, 1, false).map_err(|e| format!("`{}`: {e}", m.name))?;
206 let same = match (&stats.runs[0], &m.value) {
207 (Figure::Known(a), MetricValue::Measured { value, .. }) => {
208 a.to_bits() == value.to_bits()
209 }
210 (Figure::Unknown { .. }, MetricValue::Unknown { .. }) => true,
211 _ => false,
212 };
213 if !same {
214 return Err(format!(
215 "`{}` computed from the case measures is not the report's value",
216 m.name
217 ));
218 }
219 if let Some(count) = run.detail.count(&m.name) {
220 let (mut num, mut den) = (0u128, 0u128);
221 for per_run in &metric.cases {
222 if let Some(Contribution::Count { num: n, den: d }) = per_run[0] {
223 num += u128::from(n);
224 den += u128::from(d);
225 }
226 }
227 if (num, den) != (u128::from(count.numerator), u128::from(count.denominator)) {
228 return Err(format!("`{}` sums are not the detail's counts", m.name));
229 }
230 }
231 }
232 for c in &cases.cases {
235 if let Some(name) = c
236 .measures
237 .keys()
238 .find(|n| !derived(n) && !run.report.metrics.iter().any(|m| &m.name == *n))
239 {
240 return Err(format!(
241 "case {} has a measure for unreported `{name}`",
242 c.case
243 ));
244 }
245 }
246 Ok(())
247}