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}
420
421pub fn generation(runs: &[&RunRecord]) -> Vec<rustev_contract::eval_report::Metric> {
425 use rustev_contract::eval_report::{Metric, MetricValue};
426 use rustev_contract::generation_result::{TextCounts, TextOrigin, TextReason, TextSource};
427 use rustev_contract::run::CoreEvidence;
428 #[derive(Default)]
429 struct Counts {
430 fields: TextCounts,
431 cases: u64,
432 origins: [u64; 4],
433 cached: u64,
434 }
435 fn origin(c: &TextCounts) -> TextOrigin {
436 if c.generated + c.repaired + c.backstop == 0 {
437 TextOrigin::None
438 } else if c.backstop + c.absent == 0 {
439 TextOrigin::Generated
440 } else if c.generated + c.repaired == 0 {
441 TextOrigin::Backstop
442 } else {
443 TextOrigin::Partial
444 }
445 }
446 fn add(to: &mut TextCounts, from: &TextCounts) {
447 to.generated += from.generated;
448 to.repaired += from.repaired;
449 to.backstop += from.backstop;
450 to.absent += from.absent;
451 for (r, n) in &from.reasons {
452 *to.reasons.entry(*r).or_default() += n;
453 }
454 }
455 fn case(c: &mut Counts, fields: &TextCounts, cached: u64, origin: TextOrigin) {
456 add(&mut c.fields, fields);
457 c.cached += cached;
458 c.cases += 1;
459 c.origins[match origin {
460 TextOrigin::None => 0,
461 TextOrigin::Generated => 1,
462 TextOrigin::Backstop => 2,
463 TextOrigin::Partial => 3,
464 }] += 1;
465 }
466 let mut groups: BTreeMap<String, Counts> = BTreeMap::new();
467 for run in runs {
468 let CoreEvidence::Recorded(e) = &run.core else {
469 continue;
470 };
471 let Some(steps) = &e.generation else {
472 continue;
473 };
474 let mut total = TextCounts::default();
475 let mut hits = 0;
476 for s in steps {
477 let cached = s
478 .items
479 .iter()
480 .flat_map(|i| i.generated.values())
481 .filter(|f| matches!(f.source, TextSource::ServedFromCache { .. }))
482 .count() as u64;
483 case(
484 groups.entry(format!("text/step/{}", s.step)).or_default(),
485 &s.counts,
486 cached,
487 s.text_origin,
488 );
489 add(&mut total, &s.counts);
490 hits += cached;
491 }
492 case(
493 groups.entry("text".into()).or_default(),
494 &total,
495 hits,
496 origin(&total),
497 );
498 }
499 let mut metrics = vec![];
500 for (prefix, c) in groups {
501 let f = c.fields;
502 let slots = f.generated + f.repaired + f.backstop + f.absent;
503 let mut count = |name: &str, value: u64| {
504 metrics.push(Metric {
505 name: format!("{prefix}/{name}"),
506 value: MetricValue::Measured {
507 value: value as f64,
508 n: c.cases,
509 },
510 })
511 };
512 count("field_slots", slots);
513 count("origin/generated", f.generated);
514 count("origin/repaired", f.repaired);
515 count("origin/backstop", f.backstop);
516 count("origin/absent", f.absent);
517 count("served_from_cache", c.cached);
518 for (name, value) in ["none", "generated", "backstop", "partial"]
519 .into_iter()
520 .zip(c.origins)
521 {
522 count(&format!("text_origin/{name}"), value);
523 }
524 for reason in [
525 TextReason::ScreenBlocked,
526 TextReason::ScreenUnavailable,
527 TextReason::Deadline,
528 TextReason::RetriesExhausted,
529 TextReason::InvalidOutput,
530 TextReason::BackendUnavailable,
531 TextReason::BudgetExhausted,
532 TextReason::NotAttempted,
533 ] {
534 let name = serde_json::to_value(reason).expect("reason serializes");
535 count(
536 &format!("reason/{}", name.as_str().expect("reason name")),
537 f.reasons.get(&reason).copied().unwrap_or(0),
538 );
539 }
540 metrics.push(Metric {
541 name: format!("{prefix}/backstop_rate"),
542 value: if slots == 0 {
543 MetricValue::Unknown {
544 reason: "zero field slots".into(),
545 }
546 } else {
547 MetricValue::Measured {
548 value: (f.backstop + f.absent) as f64 / slots as f64,
549 n: slots,
550 }
551 },
552 });
553 }
554 metrics
555}