1use std::collections::BTreeSet;
11
12use rustev_contract::acquisition_record::{
13 AcquisitionEvidence, AcquisitionKind, AcquisitionOutcome, AcquisitionStop, TierOutcome,
14};
15use rustev_contract::evidence::{CorroborationOutcome, EvidenceRecord};
16use rustev_contract::extraction_results::ExtractionOrigin;
17use rustev_contract::generation_result::{FieldOrigin, GenerationState, TextReason};
18use rustev_contract::plan::{Plan, PlanStepDetail};
19use rustev_contract::screening::ScreenVerdict;
20
21use crate::eval_cases::CaseMeasure;
22use crate::gold::{FieldGold, GoldValue};
23use crate::measured::{Builder, NewMetric, Scope};
24use crate::measures::FieldDecl;
25use crate::scoring::OriginFamily;
26
27pub mod reason {
29 pub const NOT_IN_PLAN: &str = "not_in_plan";
31 pub const NO_EVIDENCE: &str = "no_evidence";
33 pub const NO_OUTCOME: &str = "no_outcome";
35 pub const UNCORROBORATED: &str = "uncorroborated";
37 pub const NO_SCREENED_FIELD: &str = "no_screened_field";
39 pub const EMPTY: &str = "empty";
41 pub const NOT_RUN: &str = "not_run";
43 pub const SKIPPED: &str = "skipped";
46 pub const NO_SLOT: &str = "no_slot";
48 pub const NO_RESULT: &str = "no_result";
50 pub const NO_GOLD: &str = "no_gold";
52 pub const ABSENT: &str = "absent";
54 pub const VALUE_NOT_RETAINED: &str = "value_not_retained";
57 pub const TYPE_MISMATCH: &str = "type_mismatch";
59}
60
61#[derive(Clone, Copy)]
63pub struct OriginCase<'a> {
64 pub plan: &'a Plan,
65 pub evidence: Option<&'a EvidenceRecord>,
67}
68
69pub fn origin_metrics(
79 families: &[OriginFamily],
80 fields: &[FieldDecl],
81 scope: &[Scope<'_>],
82 cases: &[Option<OriginCase<'_>>],
83) -> Vec<NewMetric> {
84 assert_eq!(scope.len(), cases.len(), "one origin case per scope entry");
85 let total = scope.len() as u64;
86 let mut out = vec![];
87 let mut seen = BTreeSet::new();
88 for family in families.iter().filter(|f| seen.insert(**f)) {
89 match family {
90 OriginFamily::Corroboration => out.push(corroboration(scope, cases, total)),
91 OriginFamily::Screening => out.extend(screening(scope, cases, total)),
92 OriginFamily::AcquisitionTier => out.extend(acquisition_tier(scope, cases, total)),
93 OriginFamily::TextBackstop => out.extend(text_backstop(scope, cases, total)),
94 OriginFamily::Extraction => {
95 for f in fields {
96 out.extend(extraction(f, scope, cases, total));
97 }
98 }
99 }
100 }
101 out
102}
103
104fn evidence<'a>(
107 s: &Scope<'_>,
108 case: &Option<OriginCase<'a>>,
109 in_plan: impl Fn(&Plan) -> bool,
110) -> Result<&'a EvidenceRecord, String> {
111 match (s, case) {
112 (Scope::Out(why), _) => Err(why.clone()),
113 (Scope::Proposal { .. }, None) => Err(reason::NO_EVIDENCE.into()),
114 (Scope::Proposal { .. }, Some(c)) if !in_plan(c.plan) => Err(reason::NOT_IN_PLAN.into()),
115 (Scope::Proposal { .. }, Some(c)) => c.evidence.ok_or_else(|| reason::NO_EVIDENCE.into()),
116 }
117}
118
119fn has_detail(plan: &Plan, f: impl Fn(&PlanStepDetail) -> bool) -> bool {
120 plan.steps.iter().any(|s| f(&s.detail))
121}
122
123fn corroboration(scope: &[Scope<'_>], cases: &[Option<OriginCase<'_>>], total: u64) -> NewMetric {
126 let mut b = Builder::new(
127 "origin/corroboration/hint_disagreement_rate".into(),
128 false,
129 scope.len(),
130 );
131 for (s, c) in scope.iter().zip(cases) {
132 let e = match evidence(s, c, |p| {
133 has_detail(p, |d| matches!(d, PlanStepDetail::Corroborate))
134 }) {
135 Ok(e) => e,
136 Err(why) => {
137 b.push(Err(why.as_str()));
138 continue;
139 }
140 };
141 let (mut num, mut den, mut other) = (0u64, 0u64, 0u64);
142 for o in e.steps.iter().filter_map(|s| s.corroboration.as_ref()) {
143 match o.outcome {
144 CorroborationOutcome::Contradicted => {
145 num += 1;
146 den += 1;
147 }
148 CorroborationOutcome::Corroborated => den += 1,
149 CorroborationOutcome::Uncorroborated => other += 1,
150 }
151 }
152 b.count(reason::UNCORROBORATED, other);
153 b.push(if den == 0 {
154 Err(reason::NO_OUTCOME)
155 } else {
156 Ok(CaseMeasure::Count { num, den })
157 });
158 }
159 b.finish(total)
160}
161
162fn screeners<'a>(cases: &[Option<OriginCase<'a>>]) -> BTreeSet<&'a str> {
164 cases
165 .iter()
166 .flatten()
167 .filter_map(|c| c.plan.screening.as_ref())
168 .flat_map(|s| &s.steps)
169 .map(|s| s.binding.backend_id.as_str())
170 .collect()
171}
172
173fn screening(scope: &[Scope<'_>], cases: &[Option<OriginCase<'_>>], total: u64) -> Vec<NewMetric> {
178 let mut out = vec![];
179 for screener in screeners(cases) {
180 for (metric, hit) in [
181 (
182 "block_rate",
183 (|v: &ScreenVerdict| matches!(v, ScreenVerdict::Blocked))
184 as fn(&ScreenVerdict) -> bool,
185 ),
186 ("unavailable_rate", |v: &ScreenVerdict| {
187 matches!(v, ScreenVerdict::Unavailable { .. })
188 }),
189 ] {
190 let mut b = Builder::new(
191 format!("origin/screening/{screener}/{metric}"),
192 false,
193 scope.len(),
194 );
195 for (s, c) in scope.iter().zip(cases) {
196 let binds = |p: &Plan| {
197 p.screening
198 .as_ref()
199 .is_some_and(|s| s.steps.iter().any(|s| s.binding.backend_id == screener))
200 };
201 let e = match evidence(s, c, binds) {
202 Ok(e) => e,
203 Err(why) => {
204 b.push(Err(why.as_str()));
205 continue;
206 }
207 };
208 let (mut num, mut den, mut empty) = (0u64, 0u64, 0u64);
209 for f in e.screening.iter().flatten() {
210 if f.backend_id != screener {
211 continue;
212 }
213 if f.verdict == ScreenVerdict::Empty {
214 empty += 1;
215 continue;
216 }
217 den += 1;
218 num += u64::from(hit(&f.verdict));
219 }
220 b.count(reason::EMPTY, empty);
221 b.push(if den == 0 {
222 Err(reason::NO_SCREENED_FIELD)
223 } else {
224 Ok(CaseMeasure::Count { num, den })
225 });
226 }
227 out.push(b.finish(total));
228 }
229 }
230 out
231}
232
233fn widen_steps<'a>(cases: &[Option<OriginCase<'a>>]) -> Vec<&'a str> {
235 let mut steps: Vec<&str> = vec![];
236 for c in cases.iter().flatten() {
237 for a in c.plan.acquisitions.iter().flatten() {
238 if a.kind == AcquisitionKind::Widen && !steps.contains(&a.step.as_str()) {
239 steps.push(&a.step);
240 }
241 }
242 }
243 steps
244}
245
246fn stopped_at(e: &AcquisitionEvidence) -> Option<usize> {
249 e.tiers
250 .iter()
251 .rposition(|t| !matches!(t.outcome, TierOutcome::Skipped | TierOutcome::NotNeeded))
252}
253
254fn acquisition_tier(
258 scope: &[Scope<'_>],
259 cases: &[Option<OriginCase<'_>>],
260 total: u64,
261) -> Vec<NewMetric> {
262 let mut out = vec![];
263 for step in widen_steps(cases) {
264 let record = |e: &EvidenceRecord| -> Option<AcquisitionEvidence> {
265 e.acquisitions
266 .iter()
267 .flatten()
268 .find(|a| a.step == step && a.kind == AcquisitionKind::Widen)
269 .cloned()
270 };
271 let tiers = cases
272 .iter()
273 .flatten()
274 .filter_map(|c| c.evidence.and_then(record))
275 .map(|a| a.tiers.len())
276 .max()
277 .unwrap_or(0);
278 let names = (0..tiers)
279 .map(|n| (format!("tier/{n}"), Some(n)))
280 .chain([("exhausted".to_string(), None)]);
281 for (name, tier) in names {
282 let mut b = Builder::new(
283 format!("origin/acquisition_tier/{step}/{name}"),
284 false,
285 scope.len(),
286 );
287 for (s, c) in scope.iter().zip(cases) {
288 let declares = |p: &Plan| {
289 p.acquisitions
290 .iter()
291 .flatten()
292 .any(|a| a.step == step && a.kind == AcquisitionKind::Widen)
293 };
294 let e = match evidence(s, c, declares) {
295 Ok(e) => e,
296 Err(why) => {
297 b.push(Err(why.as_str()));
298 continue;
299 }
300 };
301 let Some(a) = record(e) else {
302 b.push(Err(reason::NOT_RUN));
303 continue;
304 };
305 if matches!(a.outcome, AcquisitionOutcome::Skipped { .. }) {
306 b.push(Err(reason::SKIPPED));
307 continue;
308 }
309 let hit = match tier {
310 Some(n) => stopped_at(&a) == Some(n),
311 None => a.stopped_by == AcquisitionStop::TiersExhausted,
312 };
313 b.push(Ok(CaseMeasure::Count {
314 num: u64::from(hit),
315 den: 1,
316 }));
317 }
318 out.push(b.finish(total));
319 }
320 }
321 out
322}
323
324const TEXT_REASONS: [TextReason; 8] = [
326 TextReason::ScreenBlocked,
327 TextReason::ScreenUnavailable,
328 TextReason::Deadline,
329 TextReason::RetriesExhausted,
330 TextReason::InvalidOutput,
331 TextReason::BackendUnavailable,
332 TextReason::BudgetExhausted,
333 TextReason::NotAttempted,
334];
335
336fn text_reason(r: TextReason) -> &'static str {
337 match r {
338 TextReason::ScreenBlocked => "screen_blocked",
339 TextReason::ScreenUnavailable => "screen_unavailable",
340 TextReason::Deadline => "deadline",
341 TextReason::RetriesExhausted => "retries_exhausted",
342 TextReason::InvalidOutput => "invalid_output",
343 TextReason::BackendUnavailable => "backend_unavailable",
344 TextReason::BudgetExhausted => "budget_exhausted",
345 TextReason::NotAttempted => "not_attempted",
346 }
347}
348
349struct Slots {
353 slots: u64,
354 backstop: Vec<TextReason>,
355 partial: bool,
356 full_backstop: bool,
357}
358
359fn slots(e: &EvidenceRecord) -> Result<Slots, &'static str> {
360 let steps = e.generation.as_deref().unwrap_or_default();
361 let ran: Vec<_> = steps
362 .iter()
363 .filter(|s| s.state == GenerationState::Ran)
364 .collect();
365 if ran.is_empty() {
366 return Err(reason::SKIPPED);
367 }
368 let (mut produced, mut backstop, mut absent) = (0u64, vec![], 0u64);
369 for f in ran
370 .iter()
371 .flat_map(|s| &s.items)
372 .flat_map(|i| i.generated.values())
373 {
374 match &f.origin {
375 FieldOrigin::Generated | FieldOrigin::Repaired => produced += 1,
376 FieldOrigin::Backstop { reason } => backstop.push(*reason),
377 FieldOrigin::Absent { .. } => absent += 1,
378 }
379 }
380 let slots = produced + backstop.len() as u64 + absent;
381 if slots == 0 {
382 return Err(reason::NO_SLOT);
383 }
384 let full_backstop = produced == 0 && !backstop.is_empty();
387 let partial = produced > 0 && (!backstop.is_empty() || absent > 0);
388 Ok(Slots {
389 slots,
390 backstop,
391 partial,
392 full_backstop,
393 })
394}
395
396fn text_backstop(
401 scope: &[Scope<'_>],
402 cases: &[Option<OriginCase<'_>>],
403 total: u64,
404) -> Vec<NewMetric> {
405 let in_plan = |p: &Plan| has_detail(p, |d| matches!(d, PlanStepDetail::Generate(_)));
406 let per_case: Vec<Result<Slots, String>> = scope
407 .iter()
408 .zip(cases)
409 .map(|(s, c)| evidence(s, c, in_plan).and_then(|e| slots(e).map_err(String::from)))
410 .collect();
411 let metric = |name: String, contribution: &dyn Fn(&Slots) -> CaseMeasure| {
412 let mut b = Builder::new(format!("origin/text_backstop/{name}"), false, scope.len());
413 for r in &per_case {
414 b.push(match r {
415 Ok(s) => Ok(contribution(s)),
416 Err(why) => Err(why.as_str()),
417 });
418 }
419 b.finish(total)
420 };
421 let mut out = vec![metric("backstop_rate".into(), &|s| CaseMeasure::Count {
422 num: s.backstop.len() as u64,
423 den: s.slots,
424 })];
425 for r in TEXT_REASONS {
426 out.push(metric(format!("backstop_rate/{}", text_reason(r)), &|s| {
427 CaseMeasure::Count {
428 num: s.backstop.iter().filter(|b| **b == r).count() as u64,
429 den: s.slots,
430 }
431 }));
432 }
433 out.push(metric("partial_rate".into(), &|s| CaseMeasure::Count {
434 num: u64::from(s.partial),
435 den: 1,
436 }));
437 out.push(metric("full_backstop_rate".into(), &|s| {
438 CaseMeasure::Count {
439 num: u64::from(s.full_backstop),
440 den: 1,
441 }
442 }));
443 out
444}
445
446fn matches_gold(value: &serde_json::Value, gold: &GoldValue) -> Result<bool, &'static str> {
451 use serde_json::Value;
452 match (value, gold) {
453 (Value::Bool(v), GoldValue::Bool(g)) => Ok(v == g),
454 (Value::Number(v), GoldValue::Integer(g)) => Ok(v.as_i64() == Some(*g)),
455 (Value::String(v), GoldValue::Text(g)) => Ok(v == g),
456 (Value::Array(v), GoldValue::Path(g)) => {
457 Ok(v.len() == g.len() && v.iter().zip(g).all(|(a, b)| a.as_str() == Some(b.as_str())))
458 }
459 _ => Err(reason::TYPE_MISMATCH),
460 }
461}
462
463fn extraction(
469 f: &FieldDecl,
470 scope: &[Scope<'_>],
471 cases: &[Option<OriginCase<'_>>],
472 total: u64,
473) -> Vec<NewMetric> {
474 let in_plan = |p: &Plan| {
475 p.steps
476 .iter()
477 .any(|s| s.id == f.step && matches!(s.detail, PlanStepDetail::Extract(_)))
478 };
479 let name = |m: &str| format!("origin/extraction/{}/{m}", f.name);
480 let mut accuracy = Builder::new(name("accuracy"), false, scope.len());
481 let mut absence = Builder::new(name("absence_rate"), false, scope.len());
482 for (s, c) in scope.iter().zip(cases) {
483 let e = match evidence(s, c, in_plan) {
484 Ok(e) => e,
485 Err(why) => {
486 accuracy.push(Err(why.as_str()));
487 absence.push(Err(why.as_str()));
488 continue;
489 }
490 };
491 let Some(field) = e
492 .extraction
493 .iter()
494 .flatten()
495 .filter(|x| x.step == f.step)
496 .flat_map(|x| &x.fields)
497 .find(|x| x.name == f.field)
498 else {
499 accuracy.push(Err(reason::NO_RESULT));
500 absence.push(Err(reason::NO_RESULT));
501 continue;
502 };
503 let origin = match field.origin {
504 ExtractionOrigin::Extracted => "origin:extracted",
505 ExtractionOrigin::Fallback => "origin:fallback",
506 ExtractionOrigin::Absent { .. } => {
507 absence.push(Ok(CaseMeasure::Count { num: 1, den: 1 }));
508 accuracy.push(Err(reason::ABSENT));
509 continue;
510 }
511 };
512 absence.count(origin, 1);
513 absence.push(Ok(CaseMeasure::Count { num: 0, den: 1 }));
514 let Scope::Proposal { case, .. } = s else {
515 unreachable!("a case with evidence is a proposal")
516 };
517 let gold = case.gold.as_ref().and_then(|g| g.field(&f.name));
518 let correct = match (gold, field.value()) {
519 (None, _) => Err(reason::NO_GOLD),
520 (Some(FieldGold::Absent), _) => Ok(false),
521 (Some(FieldGold::Value(_)), None) => Err(reason::VALUE_NOT_RETAINED),
522 (Some(FieldGold::Value(g)), Some(v)) => matches_gold(v, g),
523 };
524 match correct {
525 Ok(hit) => {
526 accuracy.count(origin, 1);
527 accuracy.push(Ok(CaseMeasure::Count {
528 num: u64::from(hit),
529 den: 1,
530 }));
531 }
532 Err(why) => accuracy.push(Err(why)),
533 }
534 }
535 vec![accuracy.finish(total), absence.finish(total)]
536}