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 ["ranking", _, spec] => {
132 if ["hit@", "short@", "backfill@p"]
133 .iter()
134 .any(|p| spec.starts_with(p))
135 {
136 Some(Form::CountRatio)
137 } else {
138 Some(Form::Mean)
139 }
140 }
141 ["origin", ..] => Some(Form::CountRatio),
143 ["agreement", _, "exact" | "adjacent"]
145 | ["model", "agreement", _, "exact" | "adjacent"] => Some(Form::CountRatio),
146 ["agreement", _, "mean_abs_diff"] | ["model", "agreement", _, "mean_abs_diff"] => {
147 Some(Form::Mean)
148 }
149 ["latency_ms" | "queue_ms", q] => {
150 let q = Decimal::parse(q.strip_prefix('p')?).ok()?;
151 Some(Form::Sample { quantile: q })
152 }
153 _ => None,
154 }
155}
156
157pub fn form_in(metric: &str, runs: &[&EvalCases]) -> Option<Form> {
163 if let Some(form) = form_of(metric) {
164 return Some(form);
165 }
166 let by_measures = ["constraint/", "rubric/", "model/rubric/"];
167 if !by_measures.iter().any(|p| metric.starts_with(p)) {
168 return None;
169 }
170 runs.iter()
171 .flat_map(|r| &r.cases)
172 .find_map(|c| match c.measures.get(metric)? {
173 CaseMeasure::Count { .. } => Some(Form::CountRatio),
174 CaseMeasure::Mean { .. } => Some(Form::Mean),
175 CaseMeasure::Sample { .. } | CaseMeasure::Unknown { .. } => None,
176 })
177}
178
179pub fn metric_cases(metric: &str, form: Form, runs: &[&EvalCases]) -> Result<MetricCases, String> {
184 let first = runs.first().map_or(&[][..], |r| r.cases.as_slice());
185 for r in runs {
186 if r.cases.len() != first.len() || r.cases.iter().zip(first).any(|(a, b)| a.case != b.case)
187 {
188 return Err("mixed-runs: the runs do not list the same cases in the same order".into());
189 }
190 }
191 let cases = (0..first.len())
192 .map(|c| {
193 runs.iter()
194 .map(|r| {
195 r.cases[c]
196 .measures
197 .get(metric)
198 .map(CaseMeasure::contribution)
199 })
200 .collect()
201 })
202 .collect();
203 Ok(MetricCases {
204 name: metric.into(),
205 form,
206 cases,
207 })
208}
209
210pub fn reconcile(run: &Evaluated, cases: &EvalCases) -> Result<(), String> {
217 let digest = run
218 .report
219 .record_digest()
220 .map_err(|_| "the report has no record digest".to_string())?;
221 if cases.schema != EVAL_CASES || cases.report != digest || run.detail.report != digest {
222 return Err("the case measures are not bound to this report".into());
223 }
224 if cases.cases.len() != run.detail.cases.len()
225 || cases
226 .cases
227 .iter()
228 .zip(&run.detail.cases)
229 .any(|(m, d)| m.case != d.case || m.outcome != d.outcome)
230 {
231 return Err("the case measures do not list the detail's cases and outcomes".into());
232 }
233 for c in &cases.cases {
234 if let Some(name) = c.measures.keys().find(|m| form_in(m, &[cases]).is_none()) {
235 return Err(format!(
236 "case {} has a measure for `{name}`, which has none",
237 c.case
238 ));
239 }
240 }
241 for m in &run.report.metrics {
242 let Some(form) = form_in(&m.name, &[cases]) else {
243 continue;
244 };
245 let metric = metric_cases(&m.name, form, &[cases])?;
246 let stats = run_stats(&metric, 1, false).map_err(|e| format!("`{}`: {e}", m.name))?;
247 let same = match (&stats.runs[0], &m.value) {
248 (Figure::Known(a), MetricValue::Measured { value, .. }) => {
249 a.to_bits() == value.to_bits()
250 }
251 (Figure::Unknown { .. }, MetricValue::Unknown { .. }) => true,
252 _ => false,
253 };
254 if !same {
255 return Err(format!(
256 "`{}` computed from the case measures is not the report's value",
257 m.name
258 ));
259 }
260 if let (Form::CountRatio, Some(count)) = (&metric.form, run.detail.count(&m.name)) {
263 let (mut num, mut den) = (0u128, 0u128);
264 for per_run in &metric.cases {
265 if let Some(Contribution::Count { num: n, den: d }) = per_run[0] {
266 num += u128::from(n);
267 den += u128::from(d);
268 }
269 }
270 if (num, den) != (u128::from(count.numerator), u128::from(count.denominator)) {
271 return Err(format!("`{}` sums are not the detail's counts", m.name));
272 }
273 }
274 }
275 for c in &cases.cases {
278 if let Some(name) = c
279 .measures
280 .keys()
281 .find(|n| !derived(n) && !run.report.metrics.iter().any(|m| &m.name == *n))
282 {
283 return Err(format!(
284 "case {} has a measure for unreported `{name}`",
285 c.case
286 ));
287 }
288 }
289 Ok(())
290}