Skip to main content

rustev_eval/
origin.rs

1//! Origin and evidence-quality metrics (spec 041, 3.5).
2//!
3//! Each family reads only the evidence its owning spec puts into the
4//! decision's `rustev.evidence/2` record, with that spec's vocabulary, and
5//! adds nothing to it. Every metric is a count ratio (3.11.1): a case
6//! contributes its own counts of fields, outcomes or decisions. A case whose
7//! plan lacks the construct is excluded and counted `not_in_plan`, so a plan
8//! without it makes every such metric unknown (3.5.3), never zero.
9
10use 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
27/// Exclusion reasons of the origin metrics (3.5).
28pub mod reason {
29    /// The case's plan has no such construct (3.5.3).
30    pub const NOT_IN_PLAN: &str = "not_in_plan";
31    /// The case has a proposal but no core evidence record.
32    pub const NO_EVIDENCE: &str = "no_evidence";
33    /// Corroboration: no step ended `corroborated` or `contradicted`.
34    pub const NO_OUTCOME: &str = "no_outcome";
35    /// Corroboration: an `uncorroborated` outcome, outside the denominator.
36    pub const UNCORROBORATED: &str = "uncorroborated";
37    /// Screening: no non-empty value was screened by this screener.
38    pub const NO_SCREENED_FIELD: &str = "no_screened_field";
39    /// Screening: an empty value, which sends no request (spec 035, 3.6.1).
40    pub const EMPTY: &str = "empty";
41    /// Acquisition tier: the decision did not reach the widening step.
42    pub const NOT_RUN: &str = "not_run";
43    /// Acquisition tier: every tier was skipped by its guard. Text
44    /// backstop: every generate step was skipped (spec 037).
45    pub const SKIPPED: &str = "skipped";
46    /// Text backstop: a generate step ran with no field slot.
47    pub const NO_SLOT: &str = "no_slot";
48    /// Extraction: the case's extract step has no result for the field.
49    pub const NO_RESULT: &str = "no_result";
50    /// Extraction accuracy: the gold record states nothing for the field.
51    pub const NO_GOLD: &str = "no_gold";
52    /// Extraction accuracy: the value is absent, which absence_rate counts.
53    pub const ABSENT: &str = "absent";
54    /// Extraction accuracy: spec 038 keeps only the digest of this field's
55    /// type, so the value cannot be compared (R-19).
56    pub const VALUE_NOT_RETAINED: &str = "value_not_retained";
57    /// Extraction accuracy: the value and gold kinds do not pair (3.3.3).
58    pub const TYPE_MISMATCH: &str = "type_mismatch";
59}
60
61/// One comparable proposal case as the origin metrics see it.
62#[derive(Clone, Copy)]
63pub struct OriginCase<'a> {
64    pub plan: &'a Plan,
65    /// None when the core produced no record.
66    pub evidence: Option<&'a EvidenceRecord>,
67}
68
69/// The origin metrics of the declared families, in declaration order;
70/// `fields` are the adapter's field declarations (3.4.7). Each
71/// entry of `cases` belongs to the same index of `scope`: some for a
72/// proposal, none for a case outside every new metric. A family listed
73/// twice, which the configuration check refuses, is reported once.
74///
75/// # Panics
76///
77/// If `scope` and `cases` differ in length.
78pub 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
104/// The case's evidence, or the reason it is excluded: outside the metrics,
105/// no construct in its plan, or no record.
106fn 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
123/// `origin/corroboration/hint_disagreement_rate`: `contradicted` outcomes
124/// over `corroborated` and `contradicted` ones (3.5.2).
125fn 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
162/// Every screener the evaluated plans bind, by `backend_id`, sorted.
163fn 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
173/// Per screener, `block_rate` then `unavailable_rate`: blocked (or
174/// unavailable) screened values over screened values. An empty value sends
175/// no request (spec 035, 3.6.1), so it is outside the denominator and
176/// counted `empty`.
177fn 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
233/// The widening steps the evaluated plans declare, in first-seen plan order.
234fn 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
246/// The index of the last tier the step ran: its record lists every tier,
247/// and a tier that was skipped or not needed did not run (spec 036, 3.5).
248fn stopped_at(e: &AcquisitionEvidence) -> Option<usize> {
249    e.tiers
250        .iter()
251        .rposition(|t| !matches!(t.outcome, TierOutcome::Skipped | TierOutcome::NotNeeded))
252}
253
254/// Per widening step, `tier/<n>` for every tier index n its records list,
255/// then `exhausted`: decisions that stopped at tier n (that ran out of
256/// tiers) over decisions that ran the step.
257fn 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
324/// Every text reason, in spec 037's order, for the per-reason rates.
325const 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
349/// One proposal's field slots over every generate step that ran: their
350/// count, the backstop slots by reason, and its aggregate text origin in
351/// spec 037's terms (3.11), or why the case is excluded.
352struct 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    // Spec 037's aggregate: `backstop` when no slot was produced and at
385    // least one is a backstop, `partial` when some were and some were not.
386    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
396/// `backstop_rate` (and per reason): backstop slots over every field slot
397/// of the proposal's generate steps that ran; `partial_rate` and
398/// `full_backstop_rate`: proposals whose aggregate text origin is partial
399/// (backstop) over proposals with a generate step that ran.
400fn 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
446/// Whether a retained extraction value equals a gold value, kind by kind
447/// as 3.3.3 pairs them: booleans, integers, strings (decimal, date and
448/// enum values are retained as their canonical strings) and lists of enum
449/// values against a path.
450fn 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
463/// `accuracy`: present values matching gold over present values whose gold
464/// is stated, where gold `"absent"` makes any present value wrong; and
465/// `absence_rate`: absent values over the step's results for the field.
466/// Both details count the measured values by origin (`origin:extracted`,
467/// `origin:fallback`).
468fn 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}