1use std::collections::BTreeMap;
6
7use rustev_contract::calibration::CalibrationArtifact;
8use rustev_contract::canonical::record_canonical_bytes;
9use rustev_contract::decimal::Decimal;
10use rustev_contract::definition::{RequiredKind, StepBody};
11use rustev_contract::judgment::{Judgment, OutValue, Outcome};
12use rustev_contract::output::RawOutput;
13use rustev_contract::plan::{BoundCalibration, Plan, PlanStepDetail};
14use rustev_contract::run::{Charge, RunRecord};
15use rustev_core::calibrate::{self, BindingCheck, CalibrationInput};
16use rustev_core::kinds::Distribution;
17
18use crate::config::{AdapterRules, Agreement};
19use crate::dataset::AdapterRef;
20
21pub trait TaskAdapter {
23 fn adapter(&self) -> AdapterRef;
24 fn rules(&self) -> AdapterRules;
28 fn check_label(&self, label: &str) -> Result<(), String>;
30 fn correct(&self, action: &str, params: &BTreeMap<String, OutValue>, label: &str) -> bool;
33}
34
35#[derive(Debug, Clone, PartialEq)]
37pub enum CaseOutcome {
38 Proposal {
40 key: String,
41 action: String,
42 params: BTreeMap<String, OutValue>,
43 },
44 Abstention { kind: String },
47}
48
49impl CaseOutcome {
50 pub fn of(j: &Judgment, agreement: &Agreement) -> CaseOutcome {
51 match &j.outcome {
52 Outcome::Propose { action, params } => {
53 let mut key = action.clone();
54 for name in &agreement.params {
55 let value = params
56 .get(name)
57 .and_then(|v| record_canonical_bytes(v).ok())
58 .map(|b| String::from_utf8_lossy(&b).into_owned())
59 .unwrap_or_else(|| {
60 if params.contains_key(name) {
61 "unrepresentable".into()
62 } else {
63 "absent".into()
64 }
65 });
66 key.push_str(&format!(";{name}={value}"));
67 }
68 CaseOutcome::Proposal {
69 key,
70 action: action.clone(),
71 params: params.clone(),
72 }
73 }
74 Outcome::Escalate { .. } => CaseOutcome::Abstention {
75 kind: "escalation".into(),
76 },
77 Outcome::Unresolved(u) | Outcome::UnresolvedAction { reason: u, .. } => {
78 CaseOutcome::Abstention {
79 kind: u.kind().name().into(),
80 }
81 }
82 Outcome::Refused { .. } => CaseOutcome::Abstention {
85 kind: "refused".into(),
86 },
87 Outcome::NeedsInput(_) => CaseOutcome::Abstention {
88 kind: "needs_input".into(),
89 },
90 }
91 }
92
93 pub fn name(&self) -> String {
94 match self {
95 CaseOutcome::Proposal { key, .. } => format!("proposal:{key}"),
96 CaseOutcome::Abstention { kind } => format!("abstention:{kind}"),
97 }
98 }
99}
100
101pub fn probabilities(
105 plan: &Plan,
106 calibrations: &[CalibrationArtifact],
107 step: &str,
108 raw: &RawOutput,
109) -> Result<Vec<(String, f64)>, String> {
110 let decl = plan
111 .definition
112 .steps
113 .iter()
114 .find(|s| s.id == step)
115 .and_then(|s| match &s.body {
116 StepBody::Semantic(d) => Some(d),
117 _ => None,
118 })
119 .ok_or("wrong-kind")?;
120 let Some(PlanStepDetail::Semantic(b)) =
121 plan.steps.iter().find(|s| s.id == step).map(|s| &s.detail)
122 else {
123 return Err("wrong-kind".into());
124 };
125 let tol = plan.definition.limits.distribution_tolerance;
126 let invalid = |e: String| format!("invalid-output: {e}");
127 let (options, masses): (Vec<String>, Vec<f64>) = match (b.requires, raw) {
128 (
129 RequiredKind::CalibratedProbability,
130 RawOutput::Logits(m) | RawOutput::Distribution(m),
131 ) => {
132 let BoundCalibration::Bound { id, .. } = &b.calibration else {
133 return Err("wrong-kind".into());
134 };
135 let art = calibrations
136 .iter()
137 .find(|c| rustev_contract::Identified::id(*c).ok().as_ref() == Some(id))
138 .ok_or("missing-calibration")?;
139 let input = match raw {
140 RawOutput::Logits(m) => CalibrationInput::Logits(m),
141 _ => CalibrationInput::Distribution(m),
142 };
143 let check = BindingCheck {
144 artifact: &b.artifact,
145 task: &decl.task,
146 question: step,
147 options: &decl.options,
148 };
149 let p = calibrate::apply(art, id, &check, input, tol)
150 .map_err(|e| invalid(e.to_string()))?;
151 (p.options().to_vec(), p.masses().to_vec())
152 }
153 (RequiredKind::Distribution | RequiredKind::OrdinalDistribution, RawOutput::Logits(m)) => {
154 let d = Distribution::from_logits(&decl.options, m, tol).map_err(|e| invalid(e.0))?;
155 (d.options().to_vec(), d.masses().to_vec())
156 }
157 (
158 RequiredKind::Distribution | RequiredKind::OrdinalDistribution,
159 RawOutput::Distribution(m),
160 ) => {
161 let d = Distribution::new(&decl.options, m, tol).map_err(|e| invalid(e.0))?;
162 (d.options().to_vec(), d.masses().to_vec())
163 }
164 _ => return Err("wrong-kind".into()),
165 };
166 Ok(options.into_iter().zip(masses).collect())
167}
168
169#[derive(Debug, Clone, PartialEq)]
171pub enum Measure {
172 Value { value: f64, n: u64 },
173 Unknown { reason: String },
174}
175
176impl Measure {
177 pub fn ratio(num: u64, den: u64) -> Measure {
178 if den == 0 {
179 Measure::Unknown {
180 reason: "zero denominator".into(),
181 }
182 } else {
183 Measure::finite(num as f64 / den as f64, den)
184 }
185 }
186
187 pub fn finite(value: f64, n: u64) -> Measure {
188 if value.is_finite() {
189 Measure::Value { value, n }
190 } else {
191 Measure::Unknown {
192 reason: "non-finite value".into(),
193 }
194 }
195 }
196}
197
198pub struct Scored<'a> {
200 pub case: &'a str,
201 pub probabilities: &'a [(String, f64)],
202 pub label: &'a str,
203}
204
205pub fn mean_log_loss(cases: &[Scored<'_>]) -> Measure {
208 if cases.is_empty() {
209 return Measure::Unknown {
210 reason: "zero denominator".into(),
211 };
212 }
213 let mut sum = 0.0;
214 for c in cases {
215 match log_loss_term(c) {
218 Ok(term) => sum += term,
219 Err(reason) => return Measure::Unknown { reason },
220 }
221 }
222 Measure::finite(sum / cases.len() as f64, cases.len() as u64)
223}
224
225pub fn log_loss_term(c: &Scored<'_>) -> Result<f64, String> {
228 match c
229 .probabilities
230 .iter()
231 .find(|(o, _)| o == c.label)
232 .map(|(_, p)| *p)
233 {
234 Some(p) if p > 0.0 => Ok(-rustev_core::math::ln(p)),
235 Some(_) => Err(format!(
236 "infinite loss: zero true-class probability at case {}",
237 c.case
238 )),
239 None => Err(format!("label is not an option at case {}", c.case)),
240 }
241}
242
243pub fn brier_term(c: &Scored<'_>) -> Result<f64, String> {
248 if !c.probabilities.iter().any(|(o, _)| o == c.label) {
249 return Err(format!("label is not an option at case {}", c.case));
250 }
251 let mut sum = 0.0;
252 for (o, p) in c.probabilities {
253 let y = if o == c.label { 1.0 } else { 0.0 };
254 sum += (p - y) * (p - y);
255 }
256 Ok(sum)
257}
258
259pub fn brier(cases: &[Scored<'_>]) -> Measure {
261 if cases.is_empty() {
262 return Measure::Unknown {
263 reason: "zero denominator".into(),
264 };
265 }
266 let mut sum = 0.0;
267 for c in cases {
268 if !c.probabilities.iter().any(|(o, _)| o == c.label) {
269 return Measure::Unknown {
270 reason: format!("label is not an option at case {}", c.case),
271 };
272 }
273 for (o, p) in c.probabilities {
274 let y = if o == c.label { 1.0 } else { 0.0 };
275 sum += (p - y) * (p - y);
276 }
277 }
278 Measure::finite(sum / cases.len() as f64, cases.len() as u64)
279}
280
281#[derive(Debug, Clone, PartialEq)]
283pub struct Bin {
284 pub lower: Decimal,
285 pub upper: Decimal,
286 pub count: u64,
287 pub mean_confidence: Measure,
289 pub accuracy: Measure,
290}
291
292pub fn reliability_bin<'a>(
297 probabilities: &'a [(String, f64)],
298 bounds: &[Decimal],
299) -> Option<(usize, f64, &'a str)> {
300 let n = bounds.len().saturating_sub(1);
301 let (top, conf) =
302 probabilities
303 .iter()
304 .fold(None::<(&str, f64)>, |best, (o, p)| match best {
305 Some((_, bp)) if bp >= *p => best,
306 _ => Some((o, *p)),
307 })?;
308 let i = (0..n).find(|&i| {
309 let lo = bounds[i].to_f64_nearest();
310 let hi = bounds[i + 1].to_f64_nearest();
311 conf >= lo && (conf < hi || i + 1 == n)
314 })?;
315 Some((i, conf, top))
316}
317
318pub fn reliability(cases: &[Scored<'_>], bounds: &[Decimal]) -> Vec<Bin> {
321 let n = bounds.len().saturating_sub(1);
322 let mut acc: Vec<(u64, f64, u64)> = vec![(0, 0.0, 0); n];
323 for c in cases {
324 if let Some((i, conf, top)) = reliability_bin(c.probabilities, bounds) {
325 acc[i].0 += 1;
326 acc[i].1 += conf;
327 acc[i].2 += u64::from(top == c.label);
328 }
329 }
330 acc.into_iter()
331 .enumerate()
332 .map(|(i, (count, conf, right))| Bin {
333 lower: bounds[i],
334 upper: bounds[i + 1],
335 count,
336 mean_confidence: if count == 0 {
337 Measure::Unknown {
338 reason: "empty bin".into(),
339 }
340 } else {
341 Measure::finite(conf / count as f64, count)
342 },
343 accuracy: Measure::ratio(right, count),
344 })
345 .collect()
346}
347
348pub fn nearest_rank(values: &[u64], q: Decimal) -> Measure {
350 if values.is_empty() {
351 return Measure::Unknown {
352 reason: "zero denominator".into(),
353 };
354 }
355 let mut v = values.to_vec();
356 v.sort_unstable();
357 let n = v.len() as i128;
358 let scaled = q.units() * n;
360 let one = 1_000_000_000i128;
361 let rank = ((scaled + one - 1) / one).clamp(1, n) as usize;
362 Measure::Value {
363 value: v[rank - 1] as f64,
364 n: v.len() as u64,
365 }
366}
367
368#[derive(Debug, Clone, PartialEq)]
370pub struct CostSeries {
371 pub observed_units: Measure,
372 pub estimated_units: Measure,
373 pub unknown_attempts: u64,
375 pub liability_units: u64,
376 pub backends: Vec<String>,
377}
378
379pub fn cost(runs: &[&RunRecord], units_comparable: bool) -> CostSeries {
380 let mut backends: Vec<String> = runs
381 .iter()
382 .flat_map(|r| r.requests.iter())
383 .flat_map(|q| q.attempts.iter().map(|a| a.backend_id.clone()))
384 .collect();
385 backends.sort();
386 backends.dedup();
387 let unknown_attempts = runs
388 .iter()
389 .flat_map(|r| r.requests.iter())
390 .flat_map(|q| q.attempts.iter())
391 .filter(|a| a.cost.charge == Charge::Unknown)
392 .count() as u64;
393 let liability_units = runs.iter().map(|r| r.cost.liability).sum();
394 let n = runs.len() as u64;
395 let total = |f: fn(&RunRecord) -> u64| -> Measure {
396 if n == 0 {
397 return Measure::Unknown {
398 reason: "zero denominator".into(),
399 };
400 }
401 if backends.len() > 1 && !units_comparable {
402 return Measure::Unknown {
403 reason: "units of different backends are not declared comparable".into(),
404 };
405 }
406 let sum: u128 = runs.iter().map(|r| u128::from(f(r))).sum();
407 Measure::Value {
408 value: sum as f64,
409 n,
410 }
411 };
412 CostSeries {
413 observed_units: total(|r| r.cost.observed),
414 estimated_units: total(|r| r.cost.estimated),
415 unknown_attempts,
416 liability_units,
417 backends,
418 }
419}