Skip to main content

rustev_core/
evaluate.rs

1//! Staged evaluation of a compiled plan (spec 002, 3.7 and 3.10).
2//!
3//! `start` validates the snapshot at a supplied evaluation time and computes
4//! every exact step it can. `pending` lists semantic requests; `supply`
5//! accepts one output or runtime failure per request; `finish` returns the
6//! judgment and its evidence record. Nothing here performs I/O, reads a
7//! clock or calls a backend: the caller (a runtime, or a test) does.
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::fmt;
11
12#[path = "acquisition_eval.rs"]
13mod acquisition_eval;
14pub use acquisition_eval::PendingAcquisition;
15
16use rustev_contract::actionability::Party;
17use rustev_contract::canonical::canonical_value_bytes;
18use rustev_contract::definition::{
19    Candidates, Freshness, ItemSource, Operation, ProvenanceClass, ReasonKind, RequiredKind,
20};
21use rustev_contract::descriptor::OutputKind;
22use rustev_contract::evidence::{
23    CheckEvidence, CheckReason, CheckResult, Corroboration, CorroborationOutcome, EvidenceRecord,
24    InputEvidence, InputStatus, JoinEvidence, MatchStatus, MergeEvidence, ReadEvidence,
25    StepEvidence, StepStatus,
26};
27use rustev_contract::grade::{Basis, Grade, ValueGrade, ValueMeta};
28use rustev_contract::ids::SnapshotId;
29use rustev_contract::judgment::{
30    Derivation, Judgment, Lineage, Notice, NoticeKind, OutValue, Outcome, Unresolved,
31};
32use rustev_contract::output::{BackendOutputDoc, RawOutput};
33use rustev_contract::plan::{PlanExecution, PlanStepDetail};
34use rustev_contract::request::RequestDoc;
35use rustev_contract::scope::Scope as ReplayScope;
36use rustev_contract::screening::{
37    OnBlock, OnUnavailable, ScreenConsequence, ScreenVerdict, ScreeningEvidence, ScreeningItemId,
38};
39use rustev_contract::snapshot::{Entry, Snapshot};
40use rustev_contract::time::Timestamp;
41use rustev_contract::{Identified, schema};
42use serde_json::{Map, Value as Json, json};
43
44use crate::calibrate::{self, BindingCheck, CalibrationInput};
45use crate::compile::{Compiled, StepInfo};
46use crate::expr::{Ref, Scope};
47use crate::grading::{self, InputQuality, Meta};
48use crate::kinds::{Distribution, ModelScore, OrdinalLevel, ScoreScope, SelectedLabel};
49use crate::ops::{OpArgs, OpContext};
50use crate::policy::{self, Seen, View};
51use crate::quality;
52use crate::sem::{SemInstances, SemKind, SemValue};
53use crate::value::{Value, decode};
54
55/// Why evaluation could not start.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum StartError {
58    /// Reserved compatibility error for unavailable acquisition evaluators.
59    AcquisitionNotAvailable,
60    GenerationNotAvailable,
61    /// The snapshot's `schema` is not `rustev.snapshot/1`.
62    Schema(String),
63    /// An entry names a field the definition does not declare.
64    UndeclaredField(String),
65    /// A producer is only valid on model-derived entries.
66    InvalidProducer(String),
67    /// The snapshot has no canonical form (a fractional number).
68    NotCanonical(String),
69}
70
71impl fmt::Display for StartError {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            StartError::GenerationNotAvailable => {
75                write!(f, "generation evaluation is not available")
76            }
77            StartError::AcquisitionNotAvailable => {
78                write!(f, "staged acquisition evaluation is not available")
79            }
80            StartError::Schema(s) => write!(f, "snapshot schema {s:?}"),
81            StartError::UndeclaredField(s) => {
82                write!(f, "snapshot entry for undeclared field {s:?}")
83            }
84            StartError::InvalidProducer(s) => write!(f, "invalid_producer: {s}"),
85            StartError::NotCanonical(s) => write!(f, "snapshot has no canonical form: {s}"),
86        }
87    }
88}
89
90impl std::error::Error for StartError {}
91
92/// Why a supplied output was not accepted.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum SupplyError {
95    /// No such request is pending.
96    NotPending { step: String, instance: Vec<String> },
97    /// Only a runtime can report these reasons: backend unavailable,
98    /// invalid backend output, budget exhausted, deadline exceeded.
99    NotARuntimeReason(ReasonKind),
100}
101
102impl fmt::Display for SupplyError {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        match self {
105            SupplyError::NotPending { step, instance } => {
106                write!(f, "no pending request {step}{instance:?}")
107            }
108            SupplyError::NotARuntimeReason(k) => write!(f, "{} is not a runtime failure", k.name()),
109        }
110    }
111}
112
113impl std::error::Error for SupplyError {}
114
115/// Why a request identity was not computed (spec 004, 3.4.2).
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum RequestIdentityError {
118    /// No such request is pending.
119    NotPending { step: String, instance: Vec<String> },
120    /// The target is neither the primary (0) nor a fallback the plan binds
121    /// for this step.
122    UnknownTarget { step: String, target: u32 },
123    /// The projection is not UTF-8. Canonical JSON always is, so this is a
124    /// defect, reported rather than repaired.
125    ProjectionNotUtf8 { step: String },
126}
127
128impl fmt::Display for RequestIdentityError {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        match self {
131            RequestIdentityError::NotPending { step, instance } => {
132                write!(f, "no pending request {step}{instance:?}")
133            }
134            RequestIdentityError::UnknownTarget { step, target } => {
135                write!(f, "step {step} binds no target {target}")
136            }
137            RequestIdentityError::ProjectionNotUtf8 { step } => {
138                write!(f, "the projection of {step} is not UTF-8")
139            }
140        }
141    }
142}
143
144impl std::error::Error for RequestIdentityError {}
145
146/// `finish` was called while requests are pending.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct NotReady {
149    pub pending: usize,
150}
151
152/// One semantic request for a runtime to execute.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct SemanticRequest {
155    pub step: String,
156    pub instance: Vec<String>,
157    pub backend_id: String,
158    /// Canonical JSON: operation, task, question, options, candidates,
159    /// instance and projected values.
160    pub projection: Vec<u8>,
161}
162
163/// What a runtime reports for a request.
164#[derive(Debug, Clone, PartialEq)]
165pub enum Supplied {
166    Output(RawOutput),
167    Failed(Unresolved),
168}
169
170#[derive(Debug, Clone)]
171enum SemState {
172    Unresolved(Unresolved),
173    Instances {
174        values: SemInstances,
175        expected: BTreeSet<Vec<String>>,
176    },
177}
178
179/// What the runtime knows about a failed request's backend (spec 033, 3.9):
180/// who registered it and the determinism its descriptor declares. Recorded
181/// as the `backend` cause's origin and used for transience (3.6.4); plans
182/// without `actionability` ignore it, so their records keep their bytes.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub struct FailureContext {
185    pub origin: rustev_contract::actionability::Origin,
186    pub determinism: rustev_contract::definition::Determinism,
187}
188
189/// An evaluation in progress.
190pub struct Evaluation<'c> {
191    constructed: BTreeSet<String>,
192    skipped: BTreeMap<String, String>,
193    acquisitions: BTreeMap<String, acquisition_eval::State>,
194    widenings: BTreeMap<String, acquisition_eval::widening::State>,
195    compiled: &'c Compiled,
196    now: Timestamp,
197    snapshot_id: SnapshotId,
198    inputs_status: BTreeMap<String, Result<(), Unresolved>>,
199    input_values: BTreeMap<String, Value>,
200    input_evidence: BTreeMap<String, InputStatus>,
201    present: BTreeSet<String>,
202    exact: BTreeMap<String, Result<Value, Unresolved>>,
203    exact_values: BTreeMap<String, Value>,
204    semantic: BTreeMap<String, SemState>,
205    derivation: BTreeMap<String, Derivation>,
206    lineage: BTreeMap<String, Lineage>,
207    pending: BTreeMap<(String, Vec<String>), SemanticRequest>,
208    issued: u64,
209    screening: BTreeMap<(String, Vec<String>), ScreeningEvidence>,
210    screen_refusals: Vec<rustev_contract::actionability::Refusal>,
211    screen_inputs: BTreeMap<String, Value>,
212    notices: Vec<Notice>,
213    /// Plan 3 (spec 032): the value metadata of every input and step that
214    /// has a value.
215    metas: BTreeMap<String, Meta>,
216    input_quality: BTreeMap<String, InputQuality>,
217    step_quality: BTreeMap<String, StepQuality>,
218    /// Plan 3: policy reads whose requirement failed, by the input or step
219    /// they read (spec 032, 3.9.1).
220    policy_failures: BTreeMap<String, Unresolved>,
221    /// Spec 033: what the checks did.
222    checks: crate::actionable::CheckRun,
223    /// Spec 033: each input's observed classes: the used entry's class, or
224    /// every accepted entry's class when none was used (3.5.2).
225    observed: BTreeMap<String, Vec<ProvenanceClass>>,
226    /// Spec 033, 3.9: the backend context of each failed request.
227    failures: BTreeMap<(String, Vec<String>), FailureContext>,
228}
229
230/// What a plan 3 step records in evidence 2 beside its metadata.
231#[derive(Debug, Clone, Default)]
232struct StepQuality {
233    join: Option<Vec<JoinEvidence>>,
234    merge: Option<Vec<MergeEvidence>>,
235    corroboration: Option<Corroboration>,
236}
237
238// Projection trees retain positions through exact copies. Screening changes
239// rendering only; operators and policy continue to see the original values.
240#[derive(Clone)]
241struct ModelNode {
242    value: Json,
243    children: BTreeMap<String, ModelNode>,
244    screen: Option<ScreeningEvidence>,
245}
246impl ModelNode {
247    fn plain(value: Json) -> Self {
248        let children = match &value {
249            Json::Object(m) => m
250                .iter()
251                .map(|(k, v)| (k.clone(), Self::plain(v.clone())))
252                .collect(),
253            Json::Array(a) => a
254                .iter()
255                .enumerate()
256                .map(|(i, v)| (i.to_string(), Self::plain(v.clone())))
257                .collect(),
258            _ => BTreeMap::new(),
259        };
260        Self {
261            value,
262            children,
263            screen: None,
264        }
265    }
266    fn render(&self, unavailable: &mut BTreeSet<String>) -> Json {
267        if let Some(e) = &self.screen {
268            match e.consequence {
269                ScreenConsequence::DropField | ScreenConsequence::DropText => {
270                    return rustev_contract::screening::withheld_screen_blocked();
271                }
272                ScreenConsequence::FailClosed | ScreenConsequence::SkipGeneration => {
273                    unavailable.insert(e.field.clone());
274                }
275                _ => {}
276            }
277        }
278        match &self.value {
279            Json::Object(_) => Json::Object(
280                self.children
281                    .iter()
282                    .map(|(k, v)| (k.clone(), v.render(unavailable)))
283                    .collect(),
284            ),
285            Json::Array(a) => Json::Array(
286                (0..a.len())
287                    .map(|i| {
288                        // plain creates every indexed child; overlays replace
289                        // children but never remove them. Use a checked lookup
290                        // even though input cannot violate this invariant.
291                        self.children
292                            .get(&i.to_string())
293                            .map(|child| child.render(unavailable))
294                            .unwrap_or(Json::Null)
295                    })
296                    .collect(),
297            ),
298            other => other.clone(),
299        }
300    }
301    fn field(&self, path: &str) -> Option<&Self> {
302        path.split('/').try_fold(self, |v, f| v.children.get(f))
303    }
304    fn elements(&self) -> Vec<&Self> {
305        match &self.value {
306            Json::Array(a) => (0..a.len())
307                .filter_map(|i| self.children.get(&i.to_string()))
308                .collect(),
309            _ => self.children.values().collect(),
310        }
311    }
312    fn matching(&self, value: &Json) -> Option<&Self> {
313        (&self.value == value).then_some(self)
314    }
315    fn copied(value: Json, sources: &[Self]) -> Self {
316        if let Some(source) = sources.iter().find_map(|s| s.matching(&value)) {
317            return source.clone();
318        }
319        let mut result = Self::plain(value);
320        for child in result.children.values_mut() {
321            *child = Self::copied(child.value.clone(), sources);
322        }
323        result
324    }
325}
326
327fn validate_input(
328    name: &str,
329    decl: &rustev_contract::definition::InputDecl,
330    ty: &crate::value::Ty,
331    entries: &[&Entry],
332    now: Timestamp,
333) -> Result<(Value, &'static str, InputStatus), Unresolved> {
334    let accepted: Vec<&&Entry> = entries
335        .iter()
336        .filter(|e| decl.provenance.contains(&e.provenance))
337        .collect();
338    // 1. Conflict among accepted entries (canonical comparison).
339    if let Some(first) = accepted.first() {
340        let a = canonical_value_bytes(&first.value).ok();
341        if accepted
342            .iter()
343            .any(|e| canonical_value_bytes(&e.value).ok() != a)
344        {
345            return Err(Unresolved::conflict([name.to_string()]));
346        }
347    }
348    // 2. A future-dated accepted entry.
349    if accepted.iter().any(|e| e.as_of_ms > now) {
350        return Err(Unresolved::invalid(
351            [name.to_string()],
352            "as_of is after the evaluation time",
353        ));
354    }
355    // 3. No accepted entry.
356    let Some(newest) = accepted.iter().max_by_key(|e| e.as_of_ms) else {
357        return Err(Unresolved::missing([name.to_string()]));
358    };
359    // 4. Type.
360    let value =
361        decode(ty, &newest.value).map_err(|e| Unresolved::invalid([name.to_string()], e))?;
362    // 5. Freshness; equal to the maximum age is fresh.
363    if let Freshness::MaxAgeMs(max) = decl.freshness {
364        let age = now.since(newest.as_of_ms).map(|d| d.0).unwrap_or(0);
365        if age > max {
366            return Err(Unresolved::stale([name.to_string()]));
367        }
368    }
369    // The most derived accepted entry decides (spec 002, 3.12.2).
370    let class = if accepted
371        .iter()
372        .any(|e| e.provenance == ProvenanceClass::ModelDerived)
373    {
374        "model"
375    } else {
376        "exact"
377    };
378    Ok((
379        value,
380        class,
381        InputStatus::Resolved {
382            provenance: newest.provenance,
383            as_of_ms: newest.as_of_ms,
384            source: newest.source.clone(),
385        },
386    ))
387}
388
389/// The `OutValue` subset a remediation carries (spec 033, 3.6.1).
390fn remediation_value(v: &OutValue) -> Option<rustev_contract::actionability::RemediationValue> {
391    use rustev_contract::actionability::RemediationValue as R;
392    Some(match v {
393        OutValue::Bool(b) => R::Bool(*b),
394        OutValue::Integer(i) => R::Integer(*i),
395        OutValue::Decimal(d) => R::Decimal(*d),
396        OutValue::Text(t) => R::Text(t.clone()),
397        OutValue::Enum(e) => R::Enum(e.clone()),
398        OutValue::Timestamp(t) => R::Timestamp(*t),
399        OutValue::Duration(d) => R::Duration(*d),
400        OutValue::List(l) => R::List(l.iter().map(remediation_value).collect::<Option<_>>()?),
401        _ => return None,
402    })
403}
404
405/// Lower every grade of a metadata tree to at most `to`; never raise one
406/// (spec 033, 3.3).
407fn lower(m: &mut ValueMeta, to: Grade) {
408    m.grade = m.grade.combine(to.into());
409    for f in m.fields.iter_mut().flat_map(|f| f.values_mut()) {
410        lower(f, to);
411    }
412    for i in m.items.iter_mut().flatten() {
413        lower(i, to);
414    }
415}
416
417/// Merge the reasons of unresolved dependencies (spec 002, 3.10.4).
418fn merge(reasons: Vec<Unresolved>) -> Option<Unresolved> {
419    let first = reasons.first()?.clone();
420    Some(match &first {
421        Unresolved::MissingEvidence { .. } => {
422            Unresolved::missing(reasons.iter().flat_map(|r| match r {
423                Unresolved::MissingEvidence { fields } => fields.clone(),
424                _ => vec![],
425            }))
426        }
427        Unresolved::StaleEvidence { .. } => {
428            Unresolved::stale(reasons.iter().flat_map(|r| match r {
429                Unresolved::StaleEvidence { fields } => fields.clone(),
430                _ => vec![],
431            }))
432        }
433        _ => first,
434    })
435}
436
437fn combine(classes: impl IntoIterator<Item = Derivation>) -> Derivation {
438    if classes.into_iter().all(|c| c == Derivation::ExactDerived) {
439        Derivation::ExactDerived
440    } else {
441        Derivation::MixedDerived
442    }
443}
444
445impl<'c> Evaluation<'c> {
446    /// Validate `snapshot` at `now` and compute every exact step that can be
447    /// computed. Semantic requests become pending.
448    pub fn start(
449        compiled: &'c Compiled,
450        snapshot: &Snapshot,
451        now: Timestamp,
452    ) -> Result<Self, StartError> {
453        // A plan 3 accepts a snapshot 1 or 2, a plan 2 only a snapshot 1,
454        // so a grade claim is never silently dropped (spec 032, 3.1.4).
455        let v2 = compiled.info.v2;
456        let accepted =
457            snapshot.schema == schema::SNAPSHOT || (v2 && snapshot.schema == schema::SNAPSHOT_V2);
458        if !accepted {
459            return Err(StartError::Schema(
460                if snapshot.schema == schema::SNAPSHOT_V2 {
461                    format!(
462                        "snapshot_version: {} is not accepted by {}",
463                        schema::SNAPSHOT_V2,
464                        schema::PLAN
465                    )
466                } else {
467                    snapshot.schema.clone()
468                },
469            ));
470        }
471        if snapshot.schema == schema::SNAPSHOT && snapshot.entries.iter().any(|e| e.grade.is_some())
472        {
473            return Err(StartError::Schema(format!(
474                "snapshot_version: a {} entry claims a grade",
475                schema::SNAPSHOT
476            )));
477        }
478        if snapshot.schema == schema::SNAPSHOT
479            && snapshot.entries.iter().any(|e| e.producer.is_some())
480        {
481            return Err(StartError::Schema(
482                "snapshot_version: producer requires rustev.snapshot/2".into(),
483            ));
484        }
485        let info = &compiled.info;
486        for e in &snapshot.entries {
487            if !info.inputs.contains_key(&e.field) {
488                return Err(StartError::UndeclaredField(e.field.clone()));
489            }
490            if e.producer.is_some() && e.provenance != ProvenanceClass::ModelDerived {
491                return Err(StartError::InvalidProducer(e.field.clone()));
492            }
493        }
494        let snapshot_id = snapshot
495            .id()
496            .map_err(|e| StartError::NotCanonical(e.to_string()))?;
497        let mut ev = Evaluation {
498            constructed: BTreeSet::new(),
499            skipped: BTreeMap::new(),
500            acquisitions: BTreeMap::new(),
501            widenings: BTreeMap::new(),
502            compiled,
503            now,
504            snapshot_id,
505            inputs_status: BTreeMap::new(),
506            input_values: BTreeMap::new(),
507            input_evidence: BTreeMap::new(),
508            present: BTreeSet::new(),
509            exact: BTreeMap::new(),
510            exact_values: BTreeMap::new(),
511            semantic: BTreeMap::new(),
512            derivation: BTreeMap::new(),
513            lineage: BTreeMap::new(),
514            pending: BTreeMap::new(),
515            issued: 0,
516            screening: BTreeMap::new(),
517            screen_refusals: Vec::new(),
518            screen_inputs: BTreeMap::new(),
519            notices: compiled.plan.notices.clone(),
520            metas: BTreeMap::new(),
521            input_quality: BTreeMap::new(),
522            step_quality: BTreeMap::new(),
523            policy_failures: BTreeMap::new(),
524            checks: Default::default(),
525            observed: BTreeMap::new(),
526            failures: BTreeMap::new(),
527        };
528        for (name, (ty, decl)) in &info.inputs {
529            let entries: Vec<&Entry> = snapshot
530                .entries
531                .iter()
532                .filter(|e| &e.field == name)
533                .collect();
534            let key = format!("input:{name}");
535            let mut accepted: Vec<ProvenanceClass> = entries
536                .iter()
537                .filter(|e| decl.provenance.contains(&e.provenance))
538                .map(|e| e.provenance)
539                .collect();
540            accepted.sort();
541            accepted.dedup();
542            ev.observed.insert(name.clone(), accepted);
543            // Only a version 2 definition declares `actionability`, the one
544            // reader of `observed`; a version 1 input keeps its accepted
545            // classes, which nothing reads.
546            if v2 {
547                ev.start_input_v2(name, decl, &entries);
548                if let Some(InputStatus::Resolved { provenance, .. }) = ev.input_evidence.get(name)
549                {
550                    ev.observed.insert(name.clone(), vec![*provenance]);
551                }
552                continue;
553            }
554            match validate_input(name, decl, ty, &entries, now) {
555                Ok((v, class, evidence)) => {
556                    ev.inputs_status.insert(name.clone(), Ok(()));
557                    ev.input_values.insert(name.clone(), v);
558                    ev.input_evidence.insert(name.clone(), evidence);
559                    ev.present.insert(name.clone());
560                    ev.derivation.insert(
561                        key.clone(),
562                        if class == "model" {
563                            Derivation::ModelDerived
564                        } else {
565                            Derivation::ExactDerived
566                        },
567                    );
568                }
569                Err(u) => {
570                    ev.inputs_status.insert(name.clone(), Err(u.clone()));
571                    ev.input_evidence
572                        .insert(name.clone(), InputStatus::Unresolved(u));
573                    ev.derivation.insert(key.clone(), Derivation::ExactDerived);
574                }
575            }
576            ev.lineage.insert(
577                key,
578                Lineage {
579                    inputs: vec![name.clone()],
580                    steps: vec![],
581                },
582            );
583        }
584        // Checks run after input validation and before any step (spec 033,
585        // 3.4.1); a terminal outcome evaluates no step and issues no
586        // semantic request (3.4.6).
587        if compiled.plan.actionability.is_some() {
588            ev.run_checks();
589        }
590        ev.screen_inputs = ev.input_values.clone();
591        if ev.checks.terminal.is_none() {
592            ev.advance();
593        }
594        Ok(ev)
595    }
596
597    /// Spec 033, 3.3 and 3.4: every check in declaration order.
598    fn run_checks(&mut self) {
599        use crate::actionable::{PendingNotice, PendingRefusal, Terminal};
600        use rustev_contract::actionability::{
601            Address, CheckRecord, CheckStatus, Consequence, DefaultConsequence,
602        };
603        let Some(a) = self.compiled.plan.definition.actionability.as_ref() else {
604            return;
605        };
606        let mut relaxed: BTreeMap<String, String> = BTreeMap::new();
607        for c in &a.checks {
608            let record = |status| CheckRecord {
609                check: c.id.clone(),
610                subject: c.subject.clone(),
611                status,
612            };
613            if let Some(by) = relaxed.get(&c.id) {
614                self.checks
615                    .records
616                    .push(record(CheckStatus::Relaxed { by: by.clone() }));
617                continue;
618            }
619            if let Err(u) = self.status(&format!("input:{}", c.subject)) {
620                self.checks
621                    .records
622                    .push(record(CheckStatus::NotEvaluated { reason: u }));
623                continue;
624            }
625            let mut refs = Vec::new();
626            crate::expr::predicate_refs(&c.test, &mut refs);
627            // Only inputs can be unresolved here: the compiler refuses a
628            // check reading a step (3.8, `types`), and `now` and literals
629            // always resolve.
630            let mut reads: Vec<String> = refs
631                .iter()
632                .filter(|r| r.starts_with("input:"))
633                .map(|r| crate::expr::base_ref(r))
634                .collect();
635            reads.sort();
636            reads.dedup();
637            let unresolved = reads.iter().find_map(|r| self.status(r).err());
638            let tested = match unresolved {
639                Some(u) => Err(u),
640                None => crate::expr::eval_predicate(&c.test, &View::scope(self))
641                    .map_err(|f| Unresolved::invalid([c.subject.clone()], format!("{f:?}"))),
642            };
643            let passed = match tested {
644                Err(u) => {
645                    // The subject becomes unresolved with the reason, so no
646                    // failure is mistaken for a pass (3.4.3).
647                    let merged = merge(vec![u.clone()]).unwrap_or(u.clone());
648                    self.inputs_status
649                        .insert(c.subject.clone(), Err(merged.clone()));
650                    self.present.remove(&c.subject);
651                    self.checks
652                        .records
653                        .push(record(CheckStatus::Unevaluable { reason: merged }));
654                    continue;
655                }
656                Ok(p) => p,
657            };
658            if passed {
659                self.checks.records.push(record(CheckStatus::Passed));
660                continue;
661            }
662            // A resolved subject has a used entry; should it not, the check
663            // is recorded as not evaluated rather than silently skipped.
664            let class = match self.input_evidence.get(&c.subject) {
665                Some(InputStatus::Resolved { provenance, .. }) => *provenance,
666                _ => {
667                    self.checks.records.push(record(CheckStatus::NotEvaluated {
668                        reason: Unresolved::missing([c.subject.clone()]),
669                    }));
670                    continue;
671                }
672            };
673            let consequence = match c.on_fail.by_provenance.get(&class) {
674                Some(k) => k.clone(),
675                None => match &c.on_fail.default {
676                    DefaultConsequence::Consequence(k) => k.clone(),
677                    // Exhaustiveness (3.2.3) makes this unreachable; a check
678                    // without a consequence changes nothing.
679                    DefaultConsequence::None => continue,
680                },
681            };
682            self.checks.records.push(record(CheckStatus::Failed {
683                class,
684                consequence: consequence.kind().into(),
685            }));
686            let causes: Vec<rustev_contract::actionability::Cause> = reads
687                .iter()
688                .filter_map(|r| r.strip_prefix("input:"))
689                .map(|f| self.input_cause(f))
690                .collect();
691            let notice = |kind, subject: String, reason: &str, address: &Address| PendingNotice {
692                kind,
693                subject,
694                reason: reason.into(),
695                causes: causes.clone(),
696                address: address.clone(),
697            };
698            match &consequence {
699                Consequence::DropField { notice: n, address } => {
700                    // Absent for every reader; the entry stays in evidence.
701                    self.inputs_status.insert(
702                        c.subject.clone(),
703                        Err(Unresolved::missing([c.subject.clone()])),
704                    );
705                    self.input_values.remove(&c.subject);
706                    self.present.remove(&c.subject);
707                    self.metas.remove(&format!("input:{}", c.subject));
708                    self.checks.dropped.insert(c.subject.clone(), c.id.clone());
709                    self.checks.notices.push(notice(
710                        NoticeKind::FieldDropped,
711                        format!("input:{}", c.subject),
712                        n,
713                        address,
714                    ));
715                }
716                Consequence::Relax {
717                    group,
718                    notice: n,
719                    address,
720                } => {
721                    if let Some(g) = a.constraint_groups.iter().find(|g| &g.name == group) {
722                        for id in &g.checks {
723                            relaxed.entry(id.clone()).or_insert_with(|| c.id.clone());
724                        }
725                    }
726                    self.checks.notices.push(notice(
727                        NoticeKind::ConstraintsRelaxed,
728                        format!("group:{group}"),
729                        n,
730                        address,
731                    ));
732                }
733                Consequence::DowngradeGrade {
734                    to,
735                    notice: n,
736                    address,
737                } => {
738                    if let Some(m) = self.metas.get_mut(&format!("input:{}", c.subject)) {
739                        lower(&mut m.whole, *to);
740                        m.node = m.node.combine((*to).into());
741                    }
742                    self.checks.notices.push(notice(
743                        NoticeKind::GradeDowngraded,
744                        format!("input:{}", c.subject),
745                        n,
746                        address,
747                    ));
748                }
749                Consequence::ContinueWithNotice { notice: n, address } => {
750                    self.checks.notices.push(notice(
751                        NoticeKind::CheckNotice,
752                        format!("check:{}", c.id),
753                        n,
754                        address,
755                    ));
756                }
757                Consequence::Refuse {
758                    remediation,
759                    address,
760                } => {
761                    self.checks.refusals.push(PendingRefusal {
762                        check: c.id.clone(),
763                        subject: c.subject.clone(),
764                        causes: causes.clone(),
765                        remediation: remediation.clone(),
766                        address: address.clone(),
767                    });
768                    if self.checks.terminal.is_none() {
769                        self.checks.terminal = Some(Terminal::Refused);
770                    }
771                    if matches!(self.checks.terminal, Some(Terminal::Refused)) {
772                        self.checks.deciding_reads.extend(reads.iter().cloned());
773                    }
774                }
775                Consequence::Abstain => {
776                    if self.checks.terminal.is_none() {
777                        self.checks.terminal = Some(Terminal::Abstain {
778                            check: c.id.clone(),
779                        });
780                        self.checks.deciding_reads = reads.clone();
781                    }
782                }
783                Consequence::Escalate { reason } => {
784                    if self.checks.terminal.is_none() {
785                        self.checks.terminal = Some(Terminal::Escalate {
786                            check: c.id.clone(),
787                            reason: reason.clone(),
788                        });
789                        self.checks.deciding_reads = reads.clone();
790                    }
791                }
792            }
793        }
794        self.checks.deciding_reads.sort();
795        self.checks.deciding_reads.dedup();
796    }
797
798    /// An input cause (3.5.2): the observed class when an accepted entry
799    /// existed, else the classes the input accepts.
800    fn input_cause(&self, field: &str) -> rustev_contract::actionability::Cause {
801        use rustev_contract::actionability::{Cause, CauseProvenance};
802        // One observed class names it; otherwise (no accepted entry, or
803        // several of different classes with none used) the cause lists the
804        // declared classes, as 3.5.2 defines `accepted`, which bounds every
805        // class the field could have had. `conflict` lists each entry's
806        // observed class itself (3.5.6).
807        let provenance = match self.observed.get(field).map(Vec::as_slice) {
808            Some([one]) => CauseProvenance::Observed { class: *one },
809            _ => CauseProvenance::Accepted {
810                classes: self
811                    .compiled
812                    .info
813                    .inputs
814                    .get(field)
815                    .map(|(_, d)| {
816                        let mut v = d.provenance.clone();
817                        v.sort();
818                        v
819                    })
820                    .unwrap_or_default(),
821            },
822        };
823        Cause::Input {
824            field: field.into(),
825            provenance,
826        }
827    }
828
829    /// The causes of an unresolved reason (3.5.6).
830    fn reason_causes(
831        &self,
832        u: &Unresolved,
833        site: &str,
834    ) -> Vec<rustev_contract::actionability::Cause> {
835        use rustev_contract::actionability::{Cause, CauseProvenance, Origin};
836        match u {
837            Unresolved::Conflict { .. } => crate::actionable::reason_fields(u)
838                .into_iter()
839                .flat_map(|f| {
840                    let classes = self.observed.get(&f).cloned().unwrap_or_default();
841                    if classes.is_empty() {
842                        vec![self.input_cause(&f)]
843                    } else {
844                        classes
845                            .into_iter()
846                            .map(|class| Cause::Input {
847                                field: f.clone(),
848                                provenance: CauseProvenance::Observed { class },
849                            })
850                            .collect()
851                    }
852                })
853                .collect(),
854            Unresolved::MissingEvidence { .. }
855            | Unresolved::StaleEvidence { .. }
856            | Unresolved::InvalidInput { .. }
857            | Unresolved::InsufficientGrade { .. } => crate::actionable::reason_fields(u)
858                .into_iter()
859                .map(|f| self.input_cause(&f))
860                .collect(),
861            Unresolved::ScreenUnavailable { fields } => self
862                .screening
863                .iter()
864                .filter(|(_, e)| {
865                    fields.contains(&e.field)
866                        && matches!(e.verdict, ScreenVerdict::Unavailable { .. })
867                })
868                .map(|(key, e)| Cause::Backend {
869                    step: e.step.clone(),
870                    origin: self
871                        .failures
872                        .get(key)
873                        .map_or(Origin::Upstream, |c| c.origin),
874                })
875                .collect(),
876            Unresolved::Abstained { rule } => vec![Cause::Policy {
877                site: if site.starts_with("check:") {
878                    site.into()
879                } else {
880                    format!("rule:{rule}")
881                },
882            }],
883            Unresolved::BackendUnavailable { .. } | Unresolved::InvalidBackendOutput { .. } => {
884                vec![Cause::Backend {
885                    step: self.failed_step(u).unwrap_or_default(),
886                    // `upstream` unless the runtime noted a host-registered
887                    // backend (3.9.1).
888                    origin: self
889                        .failure_context(u)
890                        .map(|c| c.origin)
891                        .unwrap_or(Origin::Upstream),
892                }]
893            }
894            Unresolved::BudgetExhausted { resource } => vec![Cause::Budget {
895                resource: resource.clone(),
896            }],
897            Unresolved::DeadlineExceeded => vec![Cause::Deadline],
898            Unresolved::Unsupported { capability } => vec![Cause::Capability {
899                name: capability.clone(),
900            }],
901        }
902    }
903
904    /// The first failed request, in step and instance order, whose reason
905    /// is `u`'s kind (3.5.6).
906    fn failed_request(&self, u: &Unresolved) -> Option<(String, Vec<String>)> {
907        self.compiled
908            .info
909            .step_ids
910            .iter()
911            .find_map(|id| match self.semantic.get(id) {
912                Some(SemState::Unresolved(x)) if x.kind() == u.kind() => Some((id.clone(), vec![])),
913                Some(SemState::Instances { values, .. }) => values
914                    .iter()
915                    .find(|(_, v)| matches!(v, Err(x) if x.kind() == u.kind()))
916                    .map(|(k, _)| (id.clone(), k.clone())),
917                _ => None,
918            })
919    }
920
921    /// The first failed request's step, if any.
922    fn failed_step(&self, u: &Unresolved) -> Option<String> {
923        self.failed_request(u).map(|(s, _)| s)
924    }
925
926    /// The runtime's context of the first failed request, if it noted one.
927    fn failure_context(&self, u: &Unresolved) -> Option<FailureContext> {
928        self.failed_request(u)
929            .and_then(|k| self.failures.get(&k).copied())
930    }
931
932    /// The action of an unresolved reason at `site`, with a handler's
933    /// declared remediation and address when one decided (3.5.5).
934    fn reason_action(
935        &self,
936        u: &Unresolved,
937        site: &str,
938        declared: Option<(
939            &Option<rustev_contract::actionability::RemediationUse>,
940            &rustev_contract::actionability::Address,
941        )>,
942    ) -> rustev_contract::actionability::Action {
943        use rustev_contract::actionability::Address;
944        let step = self.failed_step(u);
945        // The failed request's backend, as the runtime noted it, else the
946        // step's bound backend (3.6.4).
947        let determinism = self.failure_context(u).map(|c| c.determinism).or_else(|| {
948            step.as_ref()
949                .and_then(|s| self.compiled.info.determinism.get(s))
950                .copied()
951        });
952        let transient = crate::actionable::transient(u, determinism);
953        let cc = self.caller_controlled();
954        let computed = Address::Computed;
955        let (address, remediation) = match declared {
956            Some((r, a)) => (a, r.as_ref().map(|r| self.resolve_remediation(r))),
957            None => (&computed, None),
958        };
959        crate::actionable::action(
960            self.reason_causes(u, site),
961            crate::actionable::ActionInputs {
962                caller_controlled: &cc,
963                address,
964                declared: remediation,
965                computed: crate::actionable::computed_remediation(u, transient, step.as_deref()),
966                transient,
967            },
968        )
969    }
970
971    fn caller_controlled(&self) -> Vec<ProvenanceClass> {
972        self.compiled
973            .plan
974            .definition
975            .actionability
976            .as_ref()
977            .map(|a| a.caller_controlled.clone())
978            .unwrap_or_default()
979    }
980
981    /// A declared or built-in remediation with its parameters evaluated, and
982    /// the party it is declared for; a parameter reading an unresolved
983    /// input makes it `none` (3.6.3).
984    fn resolve_remediation(
985        &self,
986        r: &rustev_contract::actionability::RemediationUse,
987    ) -> (rustev_contract::actionability::Remediation, Option<Party>) {
988        use rustev_contract::actionability::{Remediation, RemediationUse};
989        let declared = self
990            .compiled
991            .plan
992            .definition
993            .actionability
994            .as_ref()
995            .and_then(|a| a.remediations.iter().find(|d| d.code == r.code()));
996        let party = declared.map(|d| d.for_party);
997        let params = match r {
998            RemediationUse::Code(_) => BTreeMap::new(),
999            RemediationUse::WithParams(c) => c.params.clone(),
1000        };
1001        let mut values = BTreeMap::new();
1002        for (name, p) in &params {
1003            let v = match p {
1004                rustev_contract::definition::ParamSource::Lit(l) => {
1005                    Some(Value::from_literal(l).to_out())
1006                }
1007                rustev_contract::definition::ParamSource::Ref(x) => {
1008                    crate::expr::eval_ref(x, &View::scope(self))
1009                        .ok()
1010                        .map(|v| v.to_out())
1011                }
1012            };
1013            match v.and_then(|v| remediation_value(&v)) {
1014                Some(v) => {
1015                    values.insert(name.clone(), v);
1016                }
1017                None => return (Remediation::None, party),
1018            }
1019        }
1020        (
1021            Remediation::Some {
1022                code: r.code().into(),
1023                params: values,
1024            },
1025            party,
1026        )
1027    }
1028
1029    /// The evaluation time.
1030    pub fn now(&self) -> Timestamp {
1031        self.now
1032    }
1033
1034    /// Whether a screening refusal has decided this evaluation. Runtime
1035    /// drivers use this read-only hook to withdraw and cancel remaining work.
1036    pub fn screening_refused(&self) -> bool {
1037        !self.screen_refusals.is_empty()
1038    }
1039
1040    /// Pending semantic requests, in step order then instance order.
1041    pub fn pending(&self) -> Vec<SemanticRequest> {
1042        let rank: BTreeMap<&str, usize> = self
1043            .compiled
1044            .info
1045            .order
1046            .iter()
1047            .enumerate()
1048            .map(|(i, s)| (s.as_str(), i))
1049            .collect();
1050        let mut v: Vec<_> = self.pending.values().cloned().collect();
1051        v.sort_by(|a, b| {
1052            rank[a.step.as_str()]
1053                .cmp(&rank[b.step.as_str()])
1054                .then_with(|| a.instance.cmp(&b.instance))
1055        });
1056        v
1057    }
1058
1059    /// The request identity document of a pending request for one of its
1060    /// targets (spec 004, 3.4): 0 is the bound backend, `n` the plan's `n`th
1061    /// fallback for the step. The one function runtime capture and eval
1062    /// replay both use; a scope is valid by construction.
1063    pub fn request_document(
1064        &self,
1065        step: &str,
1066        instance: &[String],
1067        target: u32,
1068        scope: &ReplayScope,
1069    ) -> Result<RequestDoc, RequestIdentityError> {
1070        let not_pending = || RequestIdentityError::NotPending {
1071            step: step.into(),
1072            instance: instance.to_vec(),
1073        };
1074        let req = self
1075            .pending
1076            .get(&(step.to_string(), instance.to_vec()))
1077            .ok_or_else(not_pending)?;
1078        let plan = &self.compiled.plan;
1079        let Some(PlanStepDetail::Semantic(b)) =
1080            plan.steps.iter().find(|s| s.id == step).map(|s| &s.detail)
1081        else {
1082            // A pending request always belongs to a bound semantic step.
1083            return Err(not_pending());
1084        };
1085        let unknown = || RequestIdentityError::UnknownTarget {
1086            step: step.into(),
1087            target,
1088        };
1089        let (backend_id, artifact, descriptor) = if target == 0 {
1090            (&b.backend_id, &b.artifact, &b.descriptor)
1091        } else {
1092            let PlanExecution::Declared(d) = &plan.execution else {
1093                return Err(unknown());
1094            };
1095            let f = d
1096                .fallbacks
1097                .iter()
1098                .find(|f| f.step == step && f.target == target)
1099                .ok_or_else(unknown)?;
1100            (&f.backend_id, &f.artifact, &f.descriptor)
1101        };
1102        Ok(RequestDoc {
1103            schema: schema::REQUEST.into(),
1104            scope: scope.clone(),
1105            backend_id: backend_id.clone(),
1106            artifact: artifact.clone(),
1107            descriptor: descriptor.clone(),
1108            // The exact bytes, never a lossy repair.
1109            projection: String::from_utf8(req.projection.clone())
1110                .map_err(|_| RequestIdentityError::ProjectionNotUtf8 { step: step.into() })?,
1111            output: b.output.clone(),
1112            requires: b.requires,
1113            normalization: b.normalization,
1114        })
1115    }
1116
1117    /// Supply a backend output document; its artifact must be the bound one.
1118    pub fn supply_doc(&mut self, doc: &BackendOutputDoc) -> Result<(), SupplyError> {
1119        let bound = match self
1120            .compiled
1121            .plan
1122            .steps
1123            .iter()
1124            .find(|s| s.id == doc.step)
1125            .map(|s| &s.detail)
1126        {
1127            Some(PlanStepDetail::Semantic(b)) => Some(b.artifact.clone()),
1128            _ => None,
1129        };
1130        if bound.as_ref() != Some(&doc.artifact) {
1131            self.take(&doc.step, &doc.instance)?;
1132            return self.record(
1133                &doc.step,
1134                doc.instance.clone(),
1135                Err(Unresolved::InvalidBackendOutput {
1136                    detail: format!("artifact {} is not the bound artifact", doc.artifact),
1137                }),
1138            );
1139        }
1140        self.supply(
1141            &doc.step,
1142            &doc.instance,
1143            Supplied::Output(doc.output.clone()),
1144        )
1145    }
1146
1147    /// Record the backend context of a request the runtime is about to
1148    /// supply as failed (spec 033, 3.9). Only `backend_unavailable` and
1149    /// `invalid_backend_output` read it.
1150    pub fn note_failure(&mut self, step: &str, instance: &[String], context: FailureContext) {
1151        self.failures
1152            .insert((step.to_string(), instance.to_vec()), context);
1153    }
1154
1155    /// Supply one request's result.
1156    pub fn supply(
1157        &mut self,
1158        step: &str,
1159        instance: &[String],
1160        s: Supplied,
1161    ) -> Result<(), SupplyError> {
1162        if let Supplied::Failed(u) = &s {
1163            let k = u.kind();
1164            if !matches!(
1165                k,
1166                ReasonKind::BackendUnavailable
1167                    | ReasonKind::InvalidBackendOutput
1168                    | ReasonKind::BudgetExhausted
1169                    | ReasonKind::DeadlineExceeded
1170            ) {
1171                return Err(SupplyError::NotARuntimeReason(k));
1172            }
1173        }
1174        let req = self.take(step, instance)?;
1175        let value = match s {
1176            Supplied::Failed(u) => Err(u),
1177            Supplied::Output(raw) => self.validate_output(step, &req, &raw),
1178        };
1179        self.record(step, instance.to_vec(), value)
1180    }
1181
1182    /// What `supply` would record for `raw`, without consuming the request
1183    /// (spec 003, 3.2.4): `Ok(Ok(()))` for a valid output, `Ok(Err(_))` with
1184    /// the `invalid_backend_output` it would record, or `Err` when no such
1185    /// request is pending. A pure read; `supply` still validates.
1186    pub fn check_output(
1187        &self,
1188        step: &str,
1189        instance: &[String],
1190        raw: &RawOutput,
1191    ) -> Result<Result<(), Unresolved>, SupplyError> {
1192        let req = self
1193            .pending
1194            .get(&(step.to_string(), instance.to_vec()))
1195            .ok_or_else(|| SupplyError::NotPending {
1196                step: step.into(),
1197                instance: instance.to_vec(),
1198            })?;
1199        Ok(self.validate_output(step, req, raw).map(|_| ()))
1200    }
1201
1202    fn take(&mut self, step: &str, instance: &[String]) -> Result<SemanticRequest, SupplyError> {
1203        self.pending
1204            .remove(&(step.to_string(), instance.to_vec()))
1205            .ok_or_else(|| SupplyError::NotPending {
1206                step: step.into(),
1207                instance: instance.to_vec(),
1208            })
1209    }
1210
1211    fn record(
1212        &mut self,
1213        step: &str,
1214        instance: Vec<String>,
1215        value: Result<SemValue, Unresolved>,
1216    ) -> Result<(), SupplyError> {
1217        self.record_screen(step, &instance, &value);
1218        if let Some(SemState::Instances { values, .. }) = self.semantic.get_mut(step) {
1219            values.insert(instance, value);
1220        }
1221        if self.screen_refusals.is_empty() {
1222            self.advance();
1223        } else {
1224            self.pending.clear();
1225        }
1226        Ok(())
1227    }
1228
1229    fn validate_output(
1230        &self,
1231        step: &str,
1232        req: &SemanticRequest,
1233        raw: &RawOutput,
1234    ) -> Result<SemValue, Unresolved> {
1235        let bad = |d: String| Unresolved::InvalidBackendOutput { detail: d };
1236        let Some(StepInfo::Semantic {
1237            decl,
1238            binding: Some(b),
1239            calibration,
1240            kind,
1241            ..
1242        }) = self.compiled.info.steps.get(step)
1243        else {
1244            return Err(bad(format!("{step} is not a bound semantic step")));
1245        };
1246        let tol = self.compiled.plan.definition.limits.distribution_tolerance;
1247        let offered = match raw {
1248            RawOutput::TypedRecord { .. } => {
1249                return Err(bad("typed_record is not a decision value".into()));
1250            }
1251            RawOutput::TextRecord(_) => {
1252                return Err(bad("text_record is not a decision value".into()));
1253            }
1254            RawOutput::Screen { .. } => OutputKind::Screen,
1255            RawOutput::Label(_) => OutputKind::Label,
1256            RawOutput::Distribution(_) => OutputKind::Distribution,
1257            RawOutput::Logits(_) => OutputKind::Logits,
1258            RawOutput::Scores(_) => OutputKind::Scores,
1259        };
1260        if offered != b.output {
1261            return Err(bad(format!(
1262                "{offered:?} where the binding declares {:?}",
1263                b.output
1264            )));
1265        }
1266        let e = |k: crate::kinds::KindError| bad(k.0);
1267        match (b.requires, raw) {
1268            (
1269                RequiredKind::ScreenVerdict,
1270                RawOutput::Screen {
1271                    blocked,
1272                    categories,
1273                    ..
1274                },
1275            ) => {
1276                // Compilation rejects a screener without a profile (category 5).
1277                // Still reject inconsistent metadata without panicking.
1278                let Some(declared) = self.compiled.info.screen_categories.get(&b.backend_id) else {
1279                    return Err(bad("screening backend has no screen profile".into()));
1280                };
1281                let unique: BTreeSet<_> = categories.iter().collect();
1282                if unique.len() != categories.len()
1283                    || categories.iter().any(|c| !declared.contains(c))
1284                {
1285                    return Err(bad("invalid screening categories".into()));
1286                }
1287                let mut categories = categories.clone();
1288                categories.sort();
1289                Ok(SemValue::Screen {
1290                    blocked: *blocked,
1291                    categories,
1292                })
1293            }
1294            (RequiredKind::Label, RawOutput::Label(l)) => {
1295                let label = SelectedLabel::new(&decl.options, l).map_err(e)?;
1296                Ok(if decl.operation == Operation::Rubric {
1297                    SemValue::Ordinal(OrdinalLevel::from_label(label))
1298                } else {
1299                    SemValue::Label(label)
1300                })
1301            }
1302            (
1303                RequiredKind::Distribution | RequiredKind::OrdinalDistribution,
1304                RawOutput::Distribution(m) | RawOutput::Logits(m),
1305            ) => {
1306                let d = if offered == OutputKind::Logits {
1307                    Distribution::from_logits(&decl.options, m, tol)
1308                } else {
1309                    Distribution::new(&decl.options, m, tol)
1310                }
1311                .map_err(e)?;
1312                Ok(if *kind == SemKind::Ordinal(true) {
1313                    SemValue::Ordinal(OrdinalLevel::from_distribution(d))
1314                } else {
1315                    SemValue::Distribution(d)
1316                })
1317            }
1318            (
1319                RequiredKind::CalibratedProbability,
1320                RawOutput::Distribution(m) | RawOutput::Logits(m),
1321            ) => {
1322                let Some((art, cid)) = calibration else {
1323                    return Err(bad("no calibration is bound".into()));
1324                };
1325                let check = BindingCheck {
1326                    artifact: &b.artifact,
1327                    task: &decl.task,
1328                    question: step,
1329                    options: &decl.options,
1330                };
1331                let input = if offered == OutputKind::Logits {
1332                    CalibrationInput::Logits(m)
1333                } else {
1334                    CalibrationInput::Distribution(m)
1335                };
1336                calibrate::apply(art, cid, &check, input, tol)
1337                    .map(SemValue::Calibrated)
1338                    .map_err(|x| bad(x.to_string()))
1339            }
1340            (RequiredKind::Scores, RawOutput::Scores(m) | RawOutput::Logits(m)) => {
1341                let candidates = self.rank_candidates(decl);
1342                let keys: BTreeSet<&String> = m.keys().collect();
1343                let want: BTreeSet<&String> = candidates.iter().collect();
1344                if keys != want {
1345                    return Err(bad("scores are not keyed exactly by the candidates".into()));
1346                }
1347                let scope = ScoreScope {
1348                    plan: self.compiled.id.clone(),
1349                    step: step.to_string(),
1350                    instance: req.instance.clone(),
1351                };
1352                candidates
1353                    .iter()
1354                    .map(|c| ModelScore::new(scope.clone(), c.clone(), m[c]).map_err(e))
1355                    .collect::<Result<Vec<_>, _>>()
1356                    .map(SemValue::Scores)
1357            }
1358            _ => Err(bad("output does not match the required kind".into())),
1359        }
1360    }
1361
1362    fn is_screen_ref(&self, reference: &str) -> bool {
1363        self.compiled.plan.screening.as_ref().is_some_and(|p| {
1364            p.steps
1365                .iter()
1366                .any(|s| reference == format!("step:{}", s.step))
1367        })
1368    }
1369
1370    fn record_screen(&mut self, step: &str, key: &[String], value: &Result<SemValue, Unresolved>) {
1371        use rustev_contract::actionability::{Refusal, Remediation};
1372        use rustev_contract::screening::{ScreeningNotice, screen_text_digest};
1373        let Some(plan) = &self.compiled.plan.screening else {
1374            // This hook also receives ordinary semantic steps with no screening.
1375            return;
1376        };
1377        let Some(s) = plan.steps.iter().find(|s| s.step == step) else {
1378            // This hook also receives ordinary semantic steps with no screening.
1379            return;
1380        };
1381        if self.screening.contains_key(&(step.into(), key.to_vec())) {
1382            // Empty results are recorded twice; their consequence already exists.
1383            return;
1384        }
1385        let Some(entry) = plan.screening.entries.iter().find(|e| e.id == s.entry) else {
1386            // Lowering creates each step from its entry; evaluation never removes
1387            // entries. This cannot occur in a compiler-produced plan.
1388            return;
1389        };
1390        let projection = match self.compiled.info.steps.get(step) {
1391            Some(StepInfo::Semantic { decl, .. }) => {
1392                crate::compile::screening::request_projection(decl, key, &self.screen_inputs)
1393                    .ok()
1394                    .and_then(|bytes| serde_json::from_slice::<Json>(&bytes).ok())
1395            }
1396            _ => None,
1397        };
1398        let text = projection
1399            .as_ref()
1400            .and_then(|p| p.get("values"))
1401            .and_then(|v| v.get("text"))
1402            .and_then(Json::as_str);
1403        let invalid = Err(Unresolved::InvalidBackendOutput {
1404            detail: "invalid screening projection or verdict".into(),
1405        });
1406        let value = if text.is_none()
1407            || !matches!(
1408                value,
1409                Ok(SemValue::Screen { .. } | SemValue::ScreenEmpty) | Err(_)
1410            ) {
1411            &invalid
1412        } else {
1413            value
1414        };
1415        let (verdict, categories, consequence) = match value {
1416            Ok(SemValue::ScreenEmpty) => (ScreenVerdict::Empty, vec![], ScreenConsequence::None),
1417            Ok(SemValue::Screen {
1418                blocked: false,
1419                categories,
1420            }) => (
1421                ScreenVerdict::Pass,
1422                categories.clone(),
1423                ScreenConsequence::None,
1424            ),
1425            Ok(SemValue::Screen {
1426                blocked: true,
1427                categories,
1428            }) => (
1429                ScreenVerdict::Blocked,
1430                categories.clone(),
1431                match entry.on_block {
1432                    OnBlock::Refuse => ScreenConsequence::Refuse,
1433                    OnBlock::DropField => ScreenConsequence::DropField,
1434                    OnBlock::DropText => ScreenConsequence::DropText,
1435                    OnBlock::DropItem => ScreenConsequence::DropItem,
1436                },
1437            ),
1438            Err(reason) => (
1439                ScreenVerdict::Unavailable {
1440                    reason: reason.clone(),
1441                },
1442                vec![],
1443                match entry.on_unavailable {
1444                    OnUnavailable::FailClosed => ScreenConsequence::FailClosed,
1445                    OnUnavailable::SkipGeneration => ScreenConsequence::SkipGeneration,
1446                    OnUnavailable::FailOpen => ScreenConsequence::FailOpen,
1447                },
1448            ),
1449            // Non-screen values are normalized to `invalid` above.
1450            _ => (
1451                ScreenVerdict::Unavailable {
1452                    reason: Unresolved::InvalidBackendOutput {
1453                        detail: "not a screening verdict".into(),
1454                    },
1455                },
1456                vec![],
1457                ScreenConsequence::FailClosed,
1458            ),
1459        };
1460        let name = s
1461            .field
1462            .trim_start_matches("input:")
1463            .split('/')
1464            .next()
1465            .unwrap_or_default()
1466            .trim_end_matches("[]");
1467        if consequence == ScreenConsequence::Refuse {
1468            let subject = key.first().map_or_else(
1469                || s.field.clone(),
1470                |id| s.field.replace("[]", &format!("[{id}]")),
1471            );
1472            let action = crate::actionable::action(
1473                vec![self.input_cause(name)],
1474                crate::actionable::ActionInputs {
1475                    caller_controlled: &self.caller_controlled(),
1476                    address: &entry.address,
1477                    declared: None,
1478                    computed: Remediation::with("screen_blocked", []),
1479                    transient: false,
1480                },
1481            );
1482            self.screen_refusals.push(Refusal {
1483                check: format!("screen:{}", entry.id),
1484                subject,
1485                action,
1486            });
1487        }
1488        // compile::screening::validate requires a text or enum item_id field;
1489        // validate_input decodes snapshots against that type before evaluation.
1490        // Earlier drops can remove an item, but cannot change this shape.
1491        if consequence == ScreenConsequence::DropItem
1492            && let (ScreeningItemId::Field(field), Some(Value::List(items))) =
1493                (&entry.item_id, self.input_values.get_mut(name))
1494        {
1495            items.retain(|v| !matches!(v, Value::Record(m) if matches!(m.get(&field.field), Some(Value::Text(id) | Value::Enum(id)) if key.first() == Some(id))));
1496        }
1497        if consequence != ScreenConsequence::None {
1498            if let Some(n) = self.notices.iter_mut().find(|n| {
1499                n.screening.as_ref().is_some_and(|n| {
1500                    n.entry == entry.id && n.field == s.field && n.consequence == consequence
1501                })
1502            }) {
1503                if let Some(notice) = n.screening.as_mut() {
1504                    notice.items.extend(key.iter().cloned());
1505                    notice.items.sort();
1506                    notice.items.dedup();
1507                }
1508            } else {
1509                self.notices.push(Notice {
1510                    kind: NoticeKind::Screening,
1511                    subject: s.field.clone(),
1512                    reason: "screening consequence".into(),
1513                    action: None,
1514                    screening: Some(ScreeningNotice {
1515                        entry: entry.id.clone(),
1516                        field: s.field.clone(),
1517                        consequence,
1518                        items: key.to_vec(),
1519                    }),
1520                });
1521            }
1522        }
1523        self.screening.insert(
1524            (step.into(), key.to_vec()),
1525            ScreeningEvidence {
1526                entry: entry.id.clone(),
1527                field: s.field.clone(),
1528                item: key.first().cloned(),
1529                // A malformed projection has no text to hash. The empty digest
1530                // is accompanied by an invalid-output verdict, never `empty`.
1531                text_digest: screen_text_digest(text.unwrap_or_default()),
1532                verdict,
1533                categories,
1534                consequence,
1535                backend_id: s.binding.backend_id.clone(),
1536                artifact: s.binding.artifact.clone(),
1537                descriptor: s.binding.descriptor.clone(),
1538                step: step.into(),
1539            },
1540        );
1541    }
1542
1543    fn rank_candidates(&self, decl: &rustev_contract::definition::SemanticDecl) -> Vec<String> {
1544        match &decl.candidates {
1545            Candidates::From(r) => r
1546                .strip_prefix("step:")
1547                .and_then(|s| self.exact_values.get(s))
1548                .and_then(Value::ids)
1549                .unwrap_or_default(),
1550            Candidates::None => vec![],
1551        }
1552    }
1553
1554    fn dep_done(&self, r: &str) -> bool {
1555        match r.strip_prefix("step:") {
1556            Some(s) => self.step_done(s),
1557            None => true,
1558        }
1559    }
1560
1561    fn step_done(&self, s: &str) -> bool {
1562        if self.exact.contains_key(s) || self.skipped.contains_key(s) {
1563            return true;
1564        }
1565        match self.semantic.get(s) {
1566            Some(SemState::Unresolved(_)) => true,
1567            Some(SemState::Instances { values, expected }) => {
1568                expected.iter().all(|k| values.contains_key(k))
1569            }
1570            None => false,
1571        }
1572    }
1573
1574    /// Step-level status of a reference.
1575    fn status(&self, r: &str) -> Result<(), Unresolved> {
1576        if let Some(n) = r.strip_prefix("input:") {
1577            return self
1578                .inputs_status
1579                .get(n)
1580                .cloned()
1581                .unwrap_or_else(|| Err(Unresolved::missing([n.to_string()])));
1582        }
1583        let s = r.strip_prefix("step:").unwrap_or(r);
1584        if let Some(v) = self.exact.get(s) {
1585            return v.as_ref().map(|_| ()).map_err(Clone::clone);
1586        }
1587        match self.semantic.get(s) {
1588            Some(SemState::Unresolved(u)) => Err(u.clone()),
1589            Some(SemState::Instances { values, expected })
1590                if expected.len() == 1 && expected.contains(&Vec::new()) =>
1591            {
1592                match values.get(&Vec::new()) {
1593                    Some(Err(u)) => Err(u.clone()),
1594                    _ => Ok(()),
1595                }
1596            }
1597            _ => Ok(()),
1598        }
1599    }
1600
1601    fn lineage_of(&self, deps: &[String]) -> Lineage {
1602        let mut inputs = BTreeSet::new();
1603        let mut steps = BTreeSet::new();
1604        for d in deps {
1605            if let Some(n) = d.strip_prefix("input:") {
1606                inputs.insert(n.to_string());
1607            } else if let Some(s) = d.strip_prefix("step:") {
1608                steps.insert(s.to_string());
1609                if let Some(l) = self.lineage.get(d) {
1610                    inputs.extend(l.inputs.iter().cloned());
1611                    steps.extend(l.steps.iter().cloned());
1612                }
1613            }
1614        }
1615        Lineage {
1616            inputs: inputs.into_iter().collect(),
1617            steps: steps.into_iter().collect(),
1618        }
1619    }
1620
1621    fn advance(&mut self) {
1622        let compiled = self.compiled;
1623        let info = &compiled.info;
1624        loop {
1625            let mut progressed = false;
1626            for id in &info.order {
1627                if matches!(info.steps.get(id), Some(StepInfo::Generate))
1628                    || self.exact.contains_key(id)
1629                    || self.semantic.contains_key(id)
1630                    || self.skipped.contains_key(id)
1631                    || self.acquisitions.contains_key(id)
1632                    || self.widenings.contains_key(id)
1633                {
1634                    continue;
1635                }
1636                let deps = &info.deps[id];
1637                if !deps.iter().all(|d| self.dep_done(d)) && !self.acquisition_guard_terminal(id) {
1638                    continue;
1639                }
1640                let skipped = deps.iter().find_map(|d| {
1641                    let guard = self.skipped.get(d.strip_prefix("step:")?)?;
1642                    let propagate = match &info.steps[id] {
1643                        StepInfo::Acquisition(_) => false,
1644                        StepInfo::Exact { args, .. } => !crate::compile::probe_only(args, d),
1645                        _ => true,
1646                    };
1647                    propagate.then(|| guard.clone())
1648                });
1649                if let Some(guard) = skipped {
1650                    self.skipped.insert(id.clone(), guard);
1651                    self.lineage
1652                        .insert(format!("step:{id}"), self.lineage_of(deps));
1653                    progressed = true;
1654                    continue;
1655                }
1656                progressed = true;
1657                let key = format!("step:{id}");
1658                let lineage = self.lineage_of(deps);
1659                match &info.steps[id] {
1660                    StepInfo::Generate => unreachable!("generation evaluation refused at start"),
1661                    // Acquisition lineage is stored by the shared insertion after this match.
1662                    StepInfo::Acquisition(a) => self.start_acquisition(id, a),
1663                    StepInfo::Exact { args, .. } => {
1664                        if matches!(args, OpArgs::ComposeKey(_))
1665                            || deps.iter().any(|d| self.constructed.contains(d))
1666                        {
1667                            self.constructed.insert(key.clone());
1668                        }
1669                        let derivation = combine(deps.iter().map(|d| {
1670                            self.derivation
1671                                .get(d)
1672                                .copied()
1673                                .unwrap_or(Derivation::ExactDerived)
1674                        }));
1675                        let v2 = info.v2;
1676                        let unresolved = if matches!(args, OpArgs::FieldPresence(_)) {
1677                            None
1678                        } else {
1679                            merge(
1680                                deps.iter()
1681                                    .filter(|d| !self.is_screen_ref(d))
1682                                    .filter_map(|d| self.status(d).err())
1683                                    .collect(),
1684                            )
1685                        };
1686                        let result = match unresolved {
1687                            Some(u) => Err(u),
1688                            None if v2 => {
1689                                let (r, meta, q) = self.eval_exact_v2(id, args, &lineage.inputs);
1690                                if let Some(m) = meta {
1691                                    self.metas.insert(key.clone(), m);
1692                                }
1693                                self.step_quality.insert(id.clone(), q);
1694                                r
1695                            }
1696                            None => self.eval_op(id, args, &lineage.inputs),
1697                        };
1698                        if let Ok(v) = &result {
1699                            self.exact_values.insert(id.clone(), v.clone());
1700                        } else {
1701                            self.constructed.remove(&key);
1702                        }
1703                        self.exact.insert(id.clone(), result);
1704                        self.derivation.insert(key.clone(), derivation);
1705                    }
1706                    StepInfo::Corroborate { decl, .. } => {
1707                        // Corroboration never changes a derivation class: the
1708                        // step's is its claim's (spec 032, 2.1).
1709                        let (claim, _) = quality::claim_path(decl);
1710                        let derivation = self
1711                            .derivation
1712                            .get(&claim)
1713                            .copied()
1714                            .unwrap_or(Derivation::ExactDerived);
1715                        if self.constructed.contains(&claim) {
1716                            self.constructed.insert(key.clone());
1717                        }
1718                        let (result, meta, q) = self.eval_corroborate(id, decl);
1719                        // As for exact steps: only a value is constructed.
1720                        if let Ok(v) = &result {
1721                            self.exact_values.insert(id.clone(), v.clone());
1722                        } else {
1723                            self.constructed.remove(&key);
1724                        }
1725                        if let Some(m) = meta {
1726                            self.metas.insert(key.clone(), m);
1727                        }
1728                        self.step_quality.insert(id.clone(), q);
1729                        self.exact.insert(id.clone(), result);
1730                        self.derivation.insert(key.clone(), derivation);
1731                    }
1732                    StepInfo::Semantic { decl, .. } => {
1733                        self.derivation
1734                            .insert(key.clone(), Derivation::ModelDerived);
1735                        let state = self.start_semantic(id, deps);
1736                        self.semantic.insert(id.clone(), state);
1737                        if info.v2 {
1738                            // A backend output is never better than
1739                            // `estimated`; an ungraded projection stays
1740                            // ungraded (spec 032, 3.2.3).
1741                            let mut m = grading::combine_reads(
1742                                &quality::semantic_reads(decl),
1743                                &self.metas,
1744                                Basis::Computed,
1745                            );
1746                            m.grade = match m.grade.graded() {
1747                                Some(g) => g.min(Grade::Estimated).into(),
1748                                None => ValueGrade::Ungraded,
1749                            };
1750                            let node = m.grade;
1751                            self.metas.insert(key.clone(), Meta { whole: m, node });
1752                        }
1753                    }
1754                }
1755                self.lineage.insert(key, lineage);
1756            }
1757            if !progressed {
1758                break;
1759            }
1760        }
1761    }
1762
1763    /// Validate one plan 3 input (spec 032, 3.3 and 3.8).
1764    fn start_input_v2(
1765        &mut self,
1766        name: &str,
1767        decl: &rustev_contract::definition::InputDecl,
1768        entries: &[&Entry],
1769    ) {
1770        let compiled = self.compiled;
1771        let key = format!("input:{name}");
1772        let grading = compiled.info.bound.inputs.get(name);
1773        let (res, q) = grading::validate_input(name, decl, entries, grading, self.now);
1774        match res {
1775            Ok((v, derived, used)) => {
1776                self.inputs_status.insert(name.into(), Ok(()));
1777                self.input_values.insert(name.into(), v);
1778                self.input_evidence.insert(
1779                    name.into(),
1780                    InputStatus::Resolved {
1781                        provenance: used.provenance,
1782                        as_of_ms: used.as_of_ms,
1783                        source: used.source.clone(),
1784                    },
1785                );
1786                self.present.insert(name.into());
1787                self.derivation.insert(
1788                    key.clone(),
1789                    match derived {
1790                        grading::Derived::Model => Derivation::ModelDerived,
1791                        grading::Derived::Exact => Derivation::ExactDerived,
1792                    },
1793                );
1794                if let Some(m) = &q.meta {
1795                    self.metas.insert(key.clone(), m.clone());
1796                }
1797            }
1798            Err(u) => {
1799                self.inputs_status.insert(name.into(), Err(u.clone()));
1800                self.input_evidence
1801                    .insert(name.into(), InputStatus::Unresolved(u));
1802                self.derivation
1803                    .insert(key.clone(), Derivation::ExactDerived);
1804            }
1805        }
1806        self.input_quality.insert(name.into(), q);
1807        self.lineage.insert(
1808            key,
1809            Lineage {
1810                inputs: vec![name.to_string()],
1811                steps: vec![],
1812            },
1813        );
1814    }
1815
1816    /// The value and metadata a reference names, or why it has none.
1817    fn value_and_meta(&self, r: &str) -> Result<(Value, ValueMeta), Unresolved> {
1818        let read = quality::whole_read(r)
1819            .ok_or_else(|| Unresolved::invalid([], format!("{r} reads nothing")))?;
1820        self.status(&read.base)?;
1821        let v = crate::expr::eval_ref(r, &View::scope(self)).map_err(|f| match f {
1822            crate::expr::Fault::Absent(p) | crate::expr::Fault::Missing(p) => {
1823                Unresolved::missing([p])
1824            }
1825            crate::expr::Fault::Conflict(c) => Unresolved::conflict([c]),
1826            crate::expr::Fault::Invalid(d) => Unresolved::invalid([], d),
1827        })?;
1828        Ok((v, grading::read_meta(&read, &self.metas)))
1829    }
1830
1831    /// Evaluate a plan 3 exact step: its requirements and graded hard gates
1832    /// first (spec 032, 3.9.1), then the operator, then its metadata.
1833    fn eval_exact_v2(
1834        &self,
1835        id: &str,
1836        args: &OpArgs,
1837        lineage_inputs: &[String],
1838    ) -> (Result<Value, Unresolved>, Option<Meta>, StepQuality) {
1839        let info = &self.compiled.info;
1840        let none = StepQuality::default();
1841        let reads = quality::op_reads(args);
1842        for r in &reads {
1843            if View::skipped(self, &r.base) && crate::compile::probe_only(args, &r.base) {
1844                continue;
1845            }
1846            let ceiling = quality::read_ceiling(r, &info.trees);
1847            let meta = grading::read_meta(r, &self.metas);
1848            if let Err(u) = grading::check_read(r, &ceiling, &meta, self.now) {
1849                return (Err(u), None, none);
1850            }
1851        }
1852        let mut here = grading::combine_reads(&reads, &self.metas, Basis::Computed);
1853        if self.constructed.contains(&format!("step:{id}")) {
1854            here.grade = ValueGrade::Hint;
1855        }
1856        match args {
1857            OpArgs::JoinKeyed(a) => {
1858                let joined = (|| {
1859                    let (lv, lm) = self.value_and_meta(&a.left)?;
1860                    let (rv, rm) = self.value_and_meta(&a.right)?;
1861                    // Named as a missing reason names it: without its kind.
1862                    let left = a.left.split_once(':').map_or(a.left.as_str(), |(_, p)| p);
1863                    let key_path = format!("{left}/*/{}", a.key);
1864                    grading::join((&lv, &lm), &a.key, &key_path, (&rv, &rm), here.clone())
1865                        .map_err(|d| Unresolved::invalid(lineage_inputs.iter().cloned(), d))
1866                })();
1867                match joined {
1868                    Ok((v, whole)) => {
1869                        let join = match &v {
1870                            Value::Joined(entries) => entries
1871                                .iter()
1872                                .map(|e| JoinEvidence {
1873                                    id: e.id.clone(),
1874                                    status: match e.matched {
1875                                        crate::value::JoinMatch::Matched(_) => MatchStatus::Matched,
1876                                        crate::value::JoinMatch::Unmatched { .. } => {
1877                                            MatchStatus::Unmatched
1878                                        }
1879                                        crate::value::JoinMatch::MatchedAbsent(_) => {
1880                                            MatchStatus::MatchedAbsent
1881                                        }
1882                                    },
1883                                })
1884                                .collect(),
1885                            _ => vec![],
1886                        };
1887                        let node = here.grade;
1888                        (
1889                            Ok(v),
1890                            Some(Meta { whole, node }),
1891                            StepQuality {
1892                                join: Some(join),
1893                                ..Default::default()
1894                            },
1895                        )
1896                    }
1897                    Err(u) => (Err(u), None, none),
1898                }
1899            }
1900            OpArgs::MergeFields(m) => {
1901                let merged = (|| {
1902                    let (bv, bm) = self.value_and_meta(&m.base)?;
1903                    let (ov, om) = self.value_and_meta(&m.overlay)?;
1904                    grading::merge(m, id, (&bv, &bm), (&ov, &om))
1905                        .map_err(|d| Unresolved::invalid(lineage_inputs.iter().cloned(), d))
1906                })();
1907                match merged {
1908                    Ok((v, whole, evidence)) => {
1909                        let node = whole.grade;
1910                        (
1911                            Ok(v),
1912                            Some(Meta { whole, node }),
1913                            StepQuality {
1914                                merge: Some(evidence),
1915                                ..Default::default()
1916                            },
1917                        )
1918                    }
1919                    Err(u) => (Err(u), None, none),
1920                }
1921            }
1922            _ => {
1923                let result = self.eval_op(id, args, lineage_inputs);
1924                let node = here.grade;
1925                let meta = result.is_ok().then_some(Meta { whole: here, node });
1926                (result, meta, none)
1927            }
1928        }
1929    }
1930
1931    /// Evaluate a corroboration step (spec 032, 3.7).
1932    fn eval_corroborate(
1933        &self,
1934        id: &str,
1935        decl: &rustev_contract::definition::CorroborateDecl,
1936    ) -> (Result<Value, Unresolved>, Option<Meta>, StepQuality) {
1937        use rustev_contract::definition::{CorroborationMode, OnContradiction, OnFalse};
1938        let info = &self.compiled.info;
1939        let claim_path = quality::claim_path(decl);
1940        // An unresolved claim makes the step unresolved with its reason.
1941        let (claim, claim_meta) = match self.value_and_meta(&decl.claim) {
1942            Ok(x) => x,
1943            Err(u) => return (Err(u), None, StepQuality::default()),
1944        };
1945        let scope = View::scope(self);
1946        let mut checks = Vec::new();
1947        let mut contradicting: Vec<String> = Vec::new();
1948        for (check, reads) in decl.by.iter().zip(quality::check_reads(decl)) {
1949            let mut reason: Option<CheckReason> = None;
1950            let mut records = Vec::new();
1951            for r in &reads {
1952                let name = r.name();
1953                let (digest, grade) = match self.value_and_meta(&name) {
1954                    Ok((v, m)) => (Some(grading::digest(&v.to_json())), m.grade),
1955                    Err(u) => {
1956                        reason.get_or_insert(CheckReason::Unresolved(u));
1957                        (None, ValueGrade::Ungraded)
1958                    }
1959                };
1960                let meta = grading::read_meta(r, &self.metas);
1961                if reason.is_none() {
1962                    let ceiling = quality::read_ceiling(r, &info.trees);
1963                    if let Err(u) = grading::check_read(r, &ceiling, &meta, self.now) {
1964                        reason = Some(CheckReason::Unresolved(u));
1965                    }
1966                }
1967                // A check counts only if what it reads has at least the
1968                // target grade; the claim itself is not its own evidence.
1969                if reason.is_none()
1970                    && !quality::reads_claim(r, &claim_path)
1971                    && meta.grade.graded().is_none_or(|g| g < decl.upgrade_to)
1972                {
1973                    reason = Some(CheckReason::BelowGrade { read: name.clone() });
1974                }
1975                records.push(ReadEvidence {
1976                    reference: name,
1977                    digest,
1978                    grade,
1979                });
1980            }
1981            let (result, reason) = match reason {
1982                Some(r) => (CheckResult::NoEvidence, r),
1983                None => match crate::expr::eval_predicate(&check.predicate, &scope) {
1984                    Ok(true) => (CheckResult::Supports, CheckReason::True),
1985                    Ok(false) => (
1986                        match check.on_false {
1987                            OnFalse::Contradicts => CheckResult::Contradicts,
1988                            OnFalse::NoEvidence => CheckResult::NoEvidence,
1989                        },
1990                        CheckReason::False(check.on_false),
1991                    ),
1992                    Err(crate::expr::Fault::Absent(p)) => (
1993                        CheckResult::NoEvidence,
1994                        CheckReason::Unresolved(Unresolved::missing([p])),
1995                    ),
1996                    Err(f) => (
1997                        CheckResult::NoEvidence,
1998                        CheckReason::Unresolved(Unresolved::invalid([], format!("{f:?}"))),
1999                    ),
2000                },
2001            };
2002            if result == CheckResult::Contradicts {
2003                contradicting.extend(records.iter().map(|r| r.reference.clone()));
2004            }
2005            checks.push(CheckEvidence {
2006                result,
2007                reason,
2008                reads: records,
2009            });
2010        }
2011        let supports = checks
2012            .iter()
2013            .filter(|c| c.result == CheckResult::Supports)
2014            .count();
2015        let outcome = if !contradicting.is_empty() {
2016            CorroborationOutcome::Contradicted
2017        } else if match decl.mode {
2018            CorroborationMode::All => supports == checks.len(),
2019            CorroborationMode::Any => supports > 0,
2020        } {
2021            CorroborationOutcome::Corroborated
2022        } else {
2023            CorroborationOutcome::Uncorroborated
2024        };
2025        let claim_name = if claim_path.1.is_empty() {
2026            claim_path.0.clone()
2027        } else {
2028            format!("{}/{}", claim_path.0, claim_path.1.join("/"))
2029        };
2030        let (result, meta) = match (outcome, decl.on_contradiction) {
2031            (CorroborationOutcome::Corroborated, _) => {
2032                let m = grading::raise(&claim_meta, decl.upgrade_to, id);
2033                (Ok(claim.clone()), Some(m))
2034            }
2035            (CorroborationOutcome::Uncorroborated, _) => {
2036                (Ok(claim.clone()), Some(claim_meta.clone()))
2037            }
2038            (CorroborationOutcome::Contradicted, OnContradiction::Conflict) => {
2039                let mut facts = vec![claim_name];
2040                facts.extend(contradicting);
2041                (Err(Unresolved::conflict(facts)), None)
2042            }
2043            (CorroborationOutcome::Contradicted, OnContradiction::TreatAsHint) => {
2044                (Ok(claim.clone()), Some(grading::lower_to_hint(&claim_meta)))
2045            }
2046            (CorroborationOutcome::Contradicted, OnContradiction::Abstain) => (
2047                Err(Unresolved::Abstained {
2048                    rule: id.to_string(),
2049                }),
2050                None,
2051            ),
2052        };
2053        let record = Corroboration {
2054            claim: decl.claim.clone(),
2055            claim_digest: grading::digest(&claim.to_json()),
2056            grade_before: claim_meta.grade,
2057            grade_after: meta.as_ref().map(|m| m.grade).unwrap_or(claim_meta.grade),
2058            outcome,
2059            checks,
2060        };
2061        (
2062            result,
2063            meta.map(|whole| {
2064                let node = whole.grade;
2065                Meta { whole, node }
2066            }),
2067            StepQuality {
2068                corroboration: Some(record),
2069                ..Default::default()
2070            },
2071        )
2072    }
2073
2074    fn eval_op(
2075        &self,
2076        _id: &str,
2077        args: &OpArgs,
2078        lineage_inputs: &[String],
2079    ) -> Result<Value, Unresolved> {
2080        let semantic: BTreeMap<String, SemInstances> = self
2081            .semantic
2082            .iter()
2083            .filter_map(|(k, v)| match v {
2084                SemState::Instances { values, .. } => Some((k.clone(), values.clone())),
2085                SemState::Unresolved(_) => None,
2086            })
2087            .collect();
2088        let binds: BTreeMap<String, Vec<String>> = self
2089            .compiled
2090            .info
2091            .steps
2092            .iter()
2093            .filter_map(|(k, s)| match s {
2094                StepInfo::Semantic { decl, .. } => Some((
2095                    k.clone(),
2096                    decl.for_each.iter().map(|f| f.bind.clone()).collect(),
2097                )),
2098                StepInfo::Exact { .. }
2099                | StepInfo::Corroborate { .. }
2100                | StepInfo::Acquisition(_)
2101                | StepInfo::Generate => None,
2102            })
2103            .collect();
2104        let cx = OpContext {
2105            scope: Scope {
2106                constructed: Some(&self.constructed),
2107                now: self.now,
2108                inputs: &self.input_values,
2109                steps: &self.exact_values,
2110                present: &self.present,
2111                item: None,
2112                v2: self.compiled.info.v2,
2113            },
2114            semantic: &semantic,
2115            inputs_status: &self.inputs_status,
2116            lineage_inputs,
2117            binds: &binds,
2118        };
2119        args.eval(&cx)
2120    }
2121
2122    fn start_semantic(&mut self, id: &str, deps: &[String]) -> SemState {
2123        let compiled = self.compiled;
2124        let detail = compiled
2125            .plan
2126            .steps
2127            .iter()
2128            .find(|s| s.id == id)
2129            .map(|s| &s.detail);
2130        if let Some(PlanStepDetail::Unsupported { capability }) = detail {
2131            return SemState::Unresolved(Unresolved::Unsupported {
2132                capability: capability.clone(),
2133            });
2134        }
2135        if let Some(u) = merge(
2136            deps.iter()
2137                .filter(|d| !self.is_screen_ref(d))
2138                .filter_map(|d| self.status(d).err())
2139                .collect(),
2140        ) {
2141            return SemState::Unresolved(u);
2142        }
2143        let Some(StepInfo::Semantic {
2144            decl,
2145            binding: Some(binding),
2146            ..
2147        }) = compiled.info.steps.get(id)
2148        else {
2149            return SemState::Unresolved(Unresolved::Unsupported {
2150                capability: id.to_string(),
2151            });
2152        };
2153        // Instance keys: the cartesian product of the fan-out id lists.
2154        let mut keys: Vec<Vec<String>> = vec![vec![]];
2155        for f in &decl.for_each {
2156            let ids = f
2157                .over
2158                .strip_prefix("step:")
2159                .and_then(|s| self.exact_values.get(s))
2160                .and_then(Value::ids)
2161                .unwrap_or_default();
2162            keys = keys
2163                .into_iter()
2164                .flat_map(|k| {
2165                    ids.iter()
2166                        .map(move |i| [k.clone(), vec![i.clone()]].concat())
2167                })
2168                .collect();
2169        }
2170        let candidates = self.rank_candidates(decl);
2171        let mut values: SemInstances = BTreeMap::new();
2172        let mut expected = BTreeSet::new();
2173        let limits = &compiled.plan.definition.limits;
2174        let mut truncated = 0u64;
2175        for key in keys {
2176            expected.insert(key.clone());
2177            let projection = match self.projection(decl, &key, &candidates) {
2178                Ok(p) => p,
2179                Err(u) => {
2180                    values.insert(key, Err(u));
2181                    continue;
2182                }
2183            };
2184            if decl.requires == RequiredKind::ScreenVerdict {
2185                let empty = serde_json::from_slice::<Json>(&projection)
2186                    .is_ok_and(|envelope| envelope["values"]["text"] == "");
2187                if empty {
2188                    let value = Ok(SemValue::ScreenEmpty);
2189                    self.record_screen(id, &key, &value);
2190                    values.insert(key, value);
2191                    continue;
2192                }
2193            }
2194            if projection.len() as u64 > binding.max_projection_bytes {
2195                // The compiler's bound is an upper bound; exceeding it is a
2196                // defect, reported rather than dispatched.
2197                values.insert(
2198                    key,
2199                    Err(Unresolved::BudgetExhausted {
2200                        resource: "projection_bytes".into(),
2201                    }),
2202                );
2203                continue;
2204            }
2205            if self.issued >= limits.max_semantic_requests {
2206                truncated += 1;
2207                values.insert(
2208                    key,
2209                    Err(Unresolved::BudgetExhausted {
2210                        resource: "semantic_requests".into(),
2211                    }),
2212                );
2213                continue;
2214            }
2215            self.issued += 1;
2216            self.pending.insert(
2217                (id.to_string(), key.clone()),
2218                SemanticRequest {
2219                    step: id.to_string(),
2220                    instance: key,
2221                    backend_id: binding.backend_id.clone(),
2222                    projection,
2223                },
2224            );
2225        }
2226        if truncated > 0 {
2227            self.notices.push(Notice {
2228                screening: None,
2229                kind: NoticeKind::Truncation,
2230                subject: format!("step:{id}"),
2231                reason: format!("{truncated} request(s) not issued: max_semantic_requests reached"),
2232                action: None,
2233            });
2234        }
2235        for (key, value) in &values {
2236            self.record_screen(id, key, value);
2237        }
2238        SemState::Instances { values, expected }
2239    }
2240
2241    fn model_node(&self, reference: &str) -> ModelNode {
2242        let (base, path) = reference
2243            .split_once('/')
2244            .map_or((reference, None), |(b, p)| (b, Some(p)));
2245        let mut node = if let Some(name) = base.strip_prefix("input:") {
2246            let mut node = ModelNode::plain(
2247                self.input_values
2248                    .get(name)
2249                    .map(Value::to_json)
2250                    .unwrap_or(Json::Null),
2251            );
2252            for e in self.screening.values() {
2253                let (input, field) = e
2254                    .field
2255                    .trim_start_matches("input:")
2256                    .split_once('/')
2257                    .map_or((e.field.trim_start_matches("input:"), None), |(i, f)| {
2258                        (i, Some(f))
2259                    });
2260                if input.trim_end_matches("[]") != name {
2261                    continue;
2262                }
2263                let target = if let Some(item) = &e.item {
2264                    let Some(plan) = self.compiled.plan.screening.as_ref() else {
2265                        // Evidence is inserted only by record_screen with this plan.
2266                        continue;
2267                    };
2268                    let Some(entry) = plan.screening.entries.iter().find(|s| s.id == e.entry)
2269                    else {
2270                        // record_screen copies an existing entry id; entries are immutable.
2271                        continue;
2272                    };
2273                    let ScreeningItemId::Field(id) = &entry.item_id else {
2274                        // Lowering creates item keys only for list entries, whose
2275                        // item_id field is required by compilation.
2276                        continue;
2277                    };
2278                    node.children
2279                        .values_mut()
2280                        .find(|v| v.value.get(&id.field).and_then(Json::as_str) == Some(item))
2281                } else {
2282                    Some(&mut node)
2283                };
2284                // A missing item was already removed by drop_item. A missing
2285                // field cannot contain text to expose: validated records have
2286                // their declared fields and consequences never remove fields.
2287                if let Some(target) = target {
2288                    let target = match field {
2289                        Some(f) => target.children.get_mut(f),
2290                        None => Some(target),
2291                    };
2292                    if let Some(target) = target {
2293                        target.screen = Some(e.clone());
2294                    }
2295                }
2296            }
2297            node
2298        } else if let Some(id) = base.strip_prefix("step:") {
2299            let output = self
2300                .exact_values
2301                .get(id)
2302                .map(Value::to_json)
2303                .unwrap_or(Json::Null);
2304            match self.compiled.info.steps.get(id) {
2305                Some(StepInfo::Corroborate { decl, .. }) => self.model_node(&decl.claim),
2306                Some(StepInfo::Exact { args, .. }) => {
2307                    let sources: Vec<ModelNode> = match args {
2308                        OpArgs::WindowFilter(a) => self
2309                            .model_node(&a.list)
2310                            .elements()
2311                            .into_iter()
2312                            .cloned()
2313                            .collect(),
2314                        OpArgs::Extremum(a) => self
2315                            .model_node(&a.list)
2316                            .elements()
2317                            .into_iter()
2318                            .filter_map(|n| n.field(&a.field).cloned())
2319                            .collect(),
2320                        OpArgs::FilterWithReasons(a) => self
2321                            .model_node(&a.list)
2322                            .elements()
2323                            .into_iter()
2324                            .filter_map(|n| n.field(&a.id_field).cloned())
2325                            .collect(),
2326                        OpArgs::TopK(a) => self
2327                            .model_node(&a.list)
2328                            .elements()
2329                            .into_iter()
2330                            .filter_map(|n| n.field(&a.id_field).cloned())
2331                            .collect(),
2332                        OpArgs::WeightedRank(a) => vec![self.model_node(&a.candidates)],
2333                        // A composed key copies its parts' text, as `copies`
2334                        // in compile/screening.rs discloses.
2335                        OpArgs::ComposeKey(a) => {
2336                            let mut refs = vec![];
2337                            for part in &a.parts {
2338                                crate::expr::expr_refs(&part.value, &mut refs);
2339                            }
2340                            refs.iter().map(|r| self.model_node(r)).collect()
2341                        }
2342                        OpArgs::JoinKeyed(a) => {
2343                            let left = self.model_node(&a.left);
2344                            let right = self.model_node(&a.right);
2345                            let mut node = ModelNode::plain(output);
2346                            for (index, target) in &mut node.children {
2347                                if let Some(source) = left.children.get(index) {
2348                                    let key = source.children.get(&a.key).unwrap_or(source);
2349                                    if key.value == target.value["id"] {
2350                                        target.children.insert("id".into(), key.clone());
2351                                    }
2352                                }
2353                                if let Some(id) = target.value["id"].as_str()
2354                                    && let Some(source) = right.children.get(id)
2355                                {
2356                                    target.children.insert("match".into(), source.clone());
2357                                }
2358                            }
2359                            return match path {
2360                                Some(p) => node
2361                                    .field(p)
2362                                    .cloned()
2363                                    .unwrap_or_else(|| ModelNode::plain(Json::Null)),
2364                                None => node,
2365                            };
2366                        }
2367                        OpArgs::MergeFields(a) => {
2368                            // The merge record identifies the selected side per field;
2369                            // equal text on the other side must not affect withholding.
2370                            let mut node = ModelNode::plain(output);
2371                            let base = self.model_node(&a.base);
2372                            let overlay = self.model_node(&a.overlay);
2373                            if let Some(records) =
2374                                self.step_quality.get(id).and_then(|q| q.merge.as_ref())
2375                            {
2376                                for r in records {
2377                                    if let rustev_contract::evidence::MergedField::Taken {
2378                                        side,
2379                                        ..
2380                                    } = r.result
2381                                    {
2382                                        let source = match side {
2383                                            rustev_contract::grade::MergeSide::Base => &base,
2384                                            rustev_contract::grade::MergeSide::Overlay => &overlay,
2385                                        };
2386                                        let (source, target) = if r.item.is_empty() {
2387                                            (Some(source), Some(&mut node))
2388                                        } else {
2389                                            (
2390                                                overlay.children.iter().find(|(_, n)| n.value["id"].as_str() == Some(&r.item))
2391                                                    .and_then(|(i, _)| source.children.get(i))
2392                                                    .and_then(|n| if side == rustev_contract::grade::MergeSide::Overlay { n.children.get("match") } else { Some(n) }),
2393                                                overlay.children.iter().find(|(_, n)| n.value["id"].as_str() == Some(&r.item))
2394                                                    .and_then(|(i, _)| node.children.get_mut(i)),
2395                                            )
2396                                        };
2397                                        if let (Some(source), Some(target)) = (source, target)
2398                                            && let Some(field) = source.children.get(&r.field)
2399                                        {
2400                                            target.children.insert(r.field.clone(), field.clone());
2401                                        }
2402                                    }
2403                                }
2404                            }
2405                            return match path {
2406                                Some(p) => node
2407                                    .field(p)
2408                                    .cloned()
2409                                    .unwrap_or_else(|| ModelNode::plain(Json::Null)),
2410                                None => node,
2411                            };
2412                        }
2413                        OpArgs::CountWhere(_)
2414                        | OpArgs::TimestampDiff(_)
2415                        | OpArgs::Compare(_)
2416                        | OpArgs::Arith(_)
2417                        | OpArgs::FxConvert(_)
2418                        | OpArgs::MemberOf(_)
2419                        | OpArgs::FieldPresence(_) => vec![],
2420                    };
2421                    if matches!(args, OpArgs::FilterWithReasons(_) | OpArgs::TopK(_)) {
2422                        let mut node = ModelNode::plain(output);
2423                        for name in ["eligible", "ids"] {
2424                            if let Some(ids) = node.children.get_mut(name) {
2425                                *ids = ModelNode::copied(ids.value.clone(), &sources);
2426                            }
2427                        }
2428                        if let Some(excluded) = node.children.get_mut("excluded") {
2429                            for item in excluded.children.values_mut() {
2430                                if let Some(id) = item.children.get_mut("id") {
2431                                    *id = ModelNode::copied(id.value.clone(), &sources);
2432                                }
2433                            }
2434                        }
2435                        node
2436                    } else {
2437                        ModelNode::copied(output, &sources)
2438                    }
2439                }
2440                _ => ModelNode::plain(output),
2441            }
2442        } else {
2443            ModelNode::plain(Json::Null)
2444        };
2445        if let Some(path) = path {
2446            node = node
2447                .field(path)
2448                .cloned()
2449                .unwrap_or_else(|| ModelNode::plain(Json::Null));
2450        }
2451        node
2452    }
2453
2454    fn projection(
2455        &self,
2456        decl: &rustev_contract::definition::SemanticDecl,
2457        key: &[String],
2458        candidates: &[String],
2459    ) -> Result<Vec<u8>, Unresolved> {
2460        if decl.requires == rustev_contract::definition::RequiredKind::ScreenVerdict {
2461            return crate::compile::screening::request_projection(decl, key, &self.screen_inputs);
2462        }
2463        if self.compiled.plan.screening.is_none() {
2464            return self.legacy_projection(decl, key, candidates);
2465        }
2466        let mut values = Map::new();
2467        let mut instance = Map::new();
2468        let mut unavailable = BTreeSet::new();
2469        for (f, id) in decl.for_each.iter().zip(key) {
2470            instance.insert(f.bind.clone(), Json::String(id.clone()));
2471        }
2472        for r in &decl.project {
2473            let node = if let Some(bind) = r.strip_prefix("bind:") {
2474                match decl.for_each.iter().zip(key).find(|(f, _)| f.bind == bind) {
2475                    Some((f, id)) => match &f.items {
2476                        ItemSource::List { list, id_field } => self
2477                            .model_node(list)
2478                            .children
2479                            .values()
2480                            .find(|n| n.value.get(id_field).and_then(Json::as_str) == Some(id))
2481                            .cloned()
2482                            .ok_or_else(|| {
2483                                Unresolved::invalid(
2484                                    [list.clone()],
2485                                    format!("no item with id {id:?}"),
2486                                )
2487                            })?,
2488                        ItemSource::None => ModelNode::plain(Json::Null),
2489                    },
2490                    None => ModelNode::plain(Json::Null),
2491                }
2492            } else {
2493                self.model_node(r)
2494            };
2495            values.insert(r.clone(), node.render(&mut unavailable));
2496        }
2497        // Id lists and candidates also carry text, even when not explicitly projected.
2498        for (f, id) in decl.for_each.iter().zip(key) {
2499            let ids = self.model_node(&f.over);
2500            let ids = ids
2501                .children
2502                .get("eligible")
2503                .or_else(|| ids.children.get("ids"))
2504                .unwrap_or(&ids);
2505            for n in ids
2506                .elements()
2507                .into_iter()
2508                .filter(|n| n.value.as_str() == Some(id))
2509            {
2510                n.render(&mut unavailable);
2511            }
2512        }
2513        if let Candidates::From(r) = &decl.candidates {
2514            self.model_node(r).render(&mut unavailable);
2515        }
2516        if !unavailable.is_empty() {
2517            return Err(Unresolved::ScreenUnavailable {
2518                fields: unavailable.into_iter().collect(),
2519            });
2520        }
2521        let envelope = json!({
2522            "candidates": candidates,
2523            "instance": instance,
2524            "operation": decl.operation,
2525            "options": decl.options,
2526            "question": decl.question,
2527            "task": decl.task,
2528            "values": values,
2529        });
2530        canonical_value_bytes(&envelope).map_err(|e| Unresolved::invalid([], e.to_string()))
2531    }
2532
2533    fn legacy_projection(
2534        &self,
2535        decl: &rustev_contract::definition::SemanticDecl,
2536        key: &[String],
2537        candidates: &[String],
2538    ) -> Result<Vec<u8>, Unresolved> {
2539        if decl.requires == rustev_contract::definition::RequiredKind::ScreenVerdict {
2540            return crate::compile::screening::request_projection(decl, key, &self.screen_inputs);
2541        }
2542        let mut values = Map::new();
2543        let mut instance = Map::new();
2544        for (f, id) in decl.for_each.iter().zip(key) {
2545            instance.insert(f.bind.clone(), Json::String(id.clone()));
2546        }
2547        for r in &decl.project {
2548            let v = match Ref::parse(r) {
2549                Ok(Ref::Input(n, _)) => self.input_values.get(&n).map(Value::to_json),
2550                Ok(Ref::Step(_)) if self.compiled.info.v2 => {
2551                    crate::expr::eval_ref(r, &View::scope(self))
2552                        .ok()
2553                        .map(|v| v.to_json())
2554                }
2555                Ok(Ref::Step(n)) => self.exact_values.get(&n).map(Value::to_json),
2556                Ok(Ref::Bind(b)) => {
2557                    let pos = decl.for_each.iter().position(|f| f.bind == b);
2558                    match pos.map(|p| (&decl.for_each[p], &key[p])) {
2559                        Some((f, id)) => match &f.items {
2560                            ItemSource::List { list, id_field } => {
2561                                let name = list.trim_start_matches("input:");
2562                                let found = match self.input_values.get(name) {
2563                                    Some(Value::List(items)) => items.iter().find(|it| match it {
2564                                        Value::Record(m) => matches!(m.get(id_field), Some(Value::Text(s) | Value::Enum(s)) if s == id),
2565                                        _ => false,
2566                                    }),
2567                                    _ => None,
2568                                };
2569                                match found {
2570                                    Some(it) => Some(it.to_json()),
2571                                    None => {
2572                                        return Err(Unresolved::invalid(
2573                                            [name.to_string()],
2574                                            format!("no item with id {id:?}"),
2575                                        ));
2576                                    }
2577                                }
2578                            }
2579                            ItemSource::None => None,
2580                        },
2581                        None => None,
2582                    }
2583                }
2584                _ => None,
2585            };
2586            values.insert(r.clone(), v.unwrap_or(Json::Null));
2587        }
2588        let envelope = json!({
2589            "candidates": candidates,
2590            "instance": instance,
2591            "operation": decl.operation,
2592            "options": decl.options,
2593            "question": decl.question,
2594            "task": decl.task,
2595            "values": values,
2596        });
2597        canonical_value_bytes(&envelope).map_err(|e| Unresolved::invalid([], e.to_string()))
2598    }
2599
2600    /// Finish: evaluate the policy and build the judgment and evidence.
2601    pub fn finish(self, decision_id: &str) -> Result<(Judgment, EvidenceRecord), NotReady> {
2602        if !self.compiled.info.generation.is_empty() {
2603            return Err(NotReady {
2604                pending: self.pending.len().max(1),
2605            });
2606        }
2607        self.finish_decision(decision_id)
2608    }
2609
2610    /// Freeze the decision and enter the pure generation stage. Its `pending`
2611    /// requests are separate from decision requests and can never affect policy.
2612    pub fn begin_generation(
2613        self,
2614        decision_id: &str,
2615    ) -> Result<
2616        crate::generation_eval::GenerationEvaluation<'c>,
2617        crate::generation_eval::GenerationError,
2618    > {
2619        let compiled = self.compiled;
2620        let issued = self.issued;
2621        let inputs = self.input_values.clone();
2622        let exact = self.exact_values.clone();
2623        let lineage = self.lineage.clone();
2624        let (judgment, evidence) = self
2625            .finish_decision(decision_id)
2626            .map_err(crate::generation_eval::GenerationError::NotReady)?;
2627        crate::generation_eval::GenerationEvaluation::start(
2628            compiled,
2629            judgment,
2630            evidence,
2631            (inputs, exact),
2632            lineage,
2633            issued,
2634        )
2635    }
2636
2637    fn finish_decision(
2638        mut self,
2639        decision_id: &str,
2640    ) -> Result<(Judgment, EvidenceRecord), NotReady> {
2641        if !self.pending.is_empty() || !self.pending_acquisitions().is_empty() {
2642            return Err(NotReady {
2643                pending: self.pending.len() + self.pending_acquisitions().len(),
2644            });
2645        }
2646        let compiled = self.compiled;
2647        let v2 = compiled.info.v2;
2648        let actionable = compiled.plan.actionability.is_some() || compiled.plan.screening.is_some();
2649        // A terminal check decides without the policy (spec 033, 3.4.6).
2650        let (outcome, reads, trace, site, handler) = if !self.screen_refusals.is_empty() {
2651            let refusals = self.screen_refusals.clone();
2652            let addressed_to = Party::combine(refusals.iter().map(|r| r.action.addressed_to));
2653            let reads = self
2654                .screening
2655                .values()
2656                .filter(|e| e.consequence == ScreenConsequence::Refuse)
2657                .map(|e| format!("step:{}", e.step))
2658                .collect();
2659            (
2660                Outcome::Refused {
2661                    refusals,
2662                    addressed_to,
2663                },
2664                reads,
2665                vec!["screening".into()],
2666                None,
2667                None,
2668            )
2669        } else {
2670            match self.checks.terminal.clone() {
2671                Some(t) => {
2672                    use crate::actionable::Terminal;
2673                    let (outcome, trace) = match &t {
2674                        Terminal::Refused => (
2675                            Outcome::Refused {
2676                                refusals: vec![],
2677                                addressed_to: Party::None,
2678                            },
2679                            self.checks
2680                                .refusals
2681                                .iter()
2682                                .map(|r| format!("check:{}", r.check))
2683                                .collect(),
2684                        ),
2685                        Terminal::Abstain { check } => (
2686                            Outcome::Unresolved(Unresolved::Abstained {
2687                                rule: check.clone(),
2688                            }),
2689                            vec![format!("check:{check}")],
2690                        ),
2691                        Terminal::Escalate { check, reason } => (
2692                            Outcome::Escalate {
2693                                reason: reason.clone(),
2694                            },
2695                            vec![format!("check:{check}")],
2696                        ),
2697                    };
2698                    let site = trace.first().cloned();
2699                    (
2700                        outcome,
2701                        self.checks.deciding_reads.clone(),
2702                        trace,
2703                        site,
2704                        None,
2705                    )
2706                }
2707                None => {
2708                    if v2 {
2709                        // A policy read below its requirement makes the input or
2710                        // step it reads unresolved for the policy, so its handlers
2711                        // apply (spec 032, 3.9.1 and 3.9.4).
2712                        let mut failures = BTreeMap::new();
2713                        for (_, c) in quality::policy_conds(&compiled.plan.definition) {
2714                            let mut reads = Vec::new();
2715                            quality::cond_reads(c, &mut reads);
2716                            for r in &reads {
2717                                if failures.contains_key(&r.base) || self.status(&r.base).is_err() {
2718                                    continue;
2719                                }
2720                                let ceiling = quality::read_ceiling(r, &compiled.info.trees);
2721                                let meta = grading::read_meta(r, &self.metas);
2722                                if let Err(u) = grading::check_read(r, &ceiling, &meta, self.now) {
2723                                    failures.insert(r.base.clone(), u);
2724                                }
2725                            }
2726                        }
2727                        self.policy_failures = failures;
2728                    }
2729                    let result = policy::evaluate(&compiled.plan.definition.policy, &self);
2730                    (
2731                        result.outcome,
2732                        result.reads,
2733                        result.trace,
2734                        result.site,
2735                        result.handler,
2736                    )
2737                }
2738            }
2739        };
2740        let derivation = if reads.is_empty() {
2741            Derivation::ExactDerived
2742        } else {
2743            combine(reads.iter().map(|r| {
2744                // The judgment is computed exactly from what it read.
2745                match self.derivation.get(r) {
2746                    Some(Derivation::ExactDerived) => Derivation::ExactDerived,
2747                    _ => Derivation::MixedDerived,
2748                }
2749            }))
2750        };
2751        let lineage = self.lineage_of(&reads);
2752        let mut notices = self.notices.clone();
2753        let outcome = if actionable {
2754            notices = self.actionable_notices(notices);
2755            self.actionable_outcome(outcome, site.as_deref(), handler)
2756        } else {
2757            outcome
2758        };
2759        notices.sort();
2760        notices.dedup();
2761        let judgment = Judgment {
2762            schema: if actionable {
2763                schema::JUDGMENT_V2
2764            } else {
2765                schema::JUDGMENT
2766            }
2767            .into(),
2768            plan_id: compiled.id.clone(),
2769            snapshot_id: self.snapshot_id.clone(),
2770            evaluation_time_ms: self.now,
2771            outcome,
2772            derivation,
2773            lineage,
2774            notices,
2775            trace,
2776        };
2777        let inputs = self
2778            .input_evidence
2779            .iter()
2780            .map(|(k, v)| {
2781                let q = self.input_quality.get(k).cloned().unwrap_or_default();
2782                InputEvidence {
2783                    field: k.clone(),
2784                    status: v.clone(),
2785                    meta: q.meta.map(|m| m.whole),
2786                    presence: q.presence,
2787                    entries: q.entries,
2788                    resolution: q.resolution,
2789                    dropped: self.checks.dropped.get(k).map(|check| {
2790                        rustev_contract::evidence::DroppedBy {
2791                            check: check.clone(),
2792                        }
2793                    }),
2794                }
2795            })
2796            .collect();
2797        let mut steps = Vec::new();
2798        for id in &compiled.info.step_ids {
2799            if let Some(guard) = self.skipped.get(id) {
2800                steps.push(StepEvidence::new(
2801                    id.clone(),
2802                    vec![],
2803                    StepStatus::Skipped {
2804                        guard: guard.clone(),
2805                    },
2806                ));
2807                continue;
2808            }
2809            let derivation = self
2810                .derivation
2811                .get(&format!("step:{id}"))
2812                .copied()
2813                .unwrap_or(Derivation::ExactDerived);
2814            match (
2815                &compiled.info.steps[id],
2816                self.exact.get(id),
2817                self.semantic.get(id),
2818            ) {
2819                (StepInfo::Exact { ty, .. } | StepInfo::Corroborate { ty, .. }, Some(r), _) => {
2820                    steps.push(StepEvidence::new(
2821                        id.clone(),
2822                        vec![],
2823                        match r {
2824                            Ok(_) => StepStatus::Resolved {
2825                                kind: ty.name(),
2826                                derivation,
2827                            },
2828                            Err(u) => StepStatus::Unresolved(u.clone()),
2829                        },
2830                    ))
2831                }
2832                (StepInfo::Acquisition(a), Some(r), _) => steps.push(StepEvidence::new(
2833                    id.clone(),
2834                    vec![],
2835                    match r {
2836                        Ok(_) => StepStatus::Resolved {
2837                            kind: a.ty.name(),
2838                            derivation,
2839                        },
2840                        Err(u) => StepStatus::Unresolved(u.clone()),
2841                    },
2842                )),
2843                (StepInfo::Semantic { kind, .. }, _, Some(SemState::Instances { values, .. })) => {
2844                    for (k, v) in values {
2845                        steps.push(StepEvidence::new(
2846                            id.clone(),
2847                            k.clone(),
2848                            match v {
2849                                Ok(_) => StepStatus::Resolved {
2850                                    kind: if self.is_screen_ref(&format!("step:{id}")) {
2851                                        "screen_verdict".into()
2852                                    } else {
2853                                        kind.name().into()
2854                                    },
2855                                    derivation,
2856                                },
2857                                Err(u) => StepStatus::Unresolved(u.clone()),
2858                            },
2859                        ));
2860                    }
2861                }
2862                (_, _, Some(SemState::Unresolved(u))) => steps.push(StepEvidence::new(
2863                    id.clone(),
2864                    vec![],
2865                    StepStatus::Unresolved(u.clone()),
2866                )),
2867                _ => {}
2868            }
2869        }
2870        if v2 {
2871            // Evidence 2: each step's metadata and its join, merge or
2872            // corroboration record (spec 032, 3.10.1).
2873            for s in &mut steps {
2874                s.constructed = self.constructed.contains(&format!("step:{}", s.step));
2875                let key = format!("step:{}", s.step);
2876                s.meta = self.metas.get(&key).map(|m| m.whole.clone());
2877                if s.instance.is_empty()
2878                    && let Some(q) = self.step_quality.get(&s.step)
2879                {
2880                    s.join = q.join.clone();
2881                    s.merge = q.merge.clone();
2882                    s.corroboration = q.corroboration.clone();
2883                }
2884            }
2885        }
2886        let screening = compiled.plan.screening.as_ref().map(|p| {
2887            p.steps
2888                .iter()
2889                .flat_map(|s| {
2890                    self.screening
2891                        .iter()
2892                        .filter(|((id, _), _)| id == &s.step)
2893                        .map(|(_, e)| e.clone())
2894                })
2895                .collect()
2896        });
2897        let evidence = EvidenceRecord {
2898            generation: None,
2899            acquisitions: self.acquisition_evidence(),
2900            screening: screening.filter(|entries: &Vec<ScreeningEvidence>| !entries.is_empty()),
2901            schema: if v2 {
2902                schema::EVIDENCE_V2
2903            } else {
2904                schema::EVIDENCE
2905            }
2906            .into(),
2907            decision_id: decision_id.into(),
2908            plan_id: compiled.id.clone(),
2909            definition_id: compiled.plan.definition_id.clone(),
2910            snapshot_id: self.snapshot_id.clone(),
2911            evaluation_time_ms: self.now,
2912            inputs,
2913            steps,
2914            judgment: judgment.clone(),
2915            checks: compiled
2916                .plan
2917                .actionability
2918                .is_some()
2919                .then(|| self.checks.records.clone()),
2920        };
2921        Ok((judgment, evidence))
2922    }
2923
2924    /// Every notice with its action (spec 033, 3.5.4): the existing kinds
2925    /// from their causes, the checks' from theirs.
2926    fn actionable_notices(&self, notices: Vec<Notice>) -> Vec<Notice> {
2927        use rustev_contract::actionability::{Address, Cause, Remediation, RemediationValue};
2928        let cc = self.caller_controlled();
2929        let computed = Address::Computed;
2930        let mut out: Vec<Notice> = notices
2931            .into_iter()
2932            .map(|mut n| {
2933                let name = n
2934                    .subject
2935                    .strip_prefix("step:")
2936                    .unwrap_or(&n.subject)
2937                    .to_string();
2938                let (causes, remediation) = match n.kind {
2939                    NoticeKind::Truncation => (
2940                        vec![Cause::Budget {
2941                            resource: "semantic_requests".into(),
2942                        }],
2943                        Remediation::with(
2944                            "raise_budget",
2945                            [(
2946                                "resource".to_string(),
2947                                RemediationValue::Text("semantic_requests".into()),
2948                            )],
2949                        ),
2950                    ),
2951                    NoticeKind::UncalibratedThreshold | NoticeKind::Fallback => (
2952                        vec![Cause::Capability { name: name.clone() }],
2953                        Remediation::with(
2954                            "provide_capability",
2955                            [("name".to_string(), RemediationValue::Text(name))],
2956                        ),
2957                    ),
2958                    // The checks' kinds are not among the policy's notices;
2959                    // they are built with their own causes below.
2960                    NoticeKind::FieldDropped
2961                    | NoticeKind::ConstraintsRelaxed
2962                    | NoticeKind::GradeDowngraded
2963                    | NoticeKind::CheckNotice
2964                    | NoticeKind::Screening => return n,
2965                };
2966                n.action = Some(crate::actionable::action(
2967                    causes,
2968                    crate::actionable::ActionInputs {
2969                        caller_controlled: &cc,
2970                        address: &computed,
2971                        declared: None,
2972                        computed: remediation,
2973                        transient: false,
2974                    },
2975                ));
2976                n
2977            })
2978            .collect();
2979        for p in &self.checks.notices {
2980            out.push(Notice {
2981                screening: None,
2982                kind: p.kind,
2983                subject: p.subject.clone(),
2984                reason: p.reason.clone(),
2985                action: Some(crate::actionable::action(
2986                    p.causes.clone(),
2987                    crate::actionable::ActionInputs {
2988                        caller_controlled: &cc,
2989                        address: &p.address,
2990                        declared: None,
2991                        computed: Remediation::None,
2992                        transient: false,
2993                    },
2994                )),
2995            });
2996        }
2997        out
2998    }
2999
3000    /// A judgment 2 outcome (spec 033, 3.5.1): every unresolved outcome,
3001    /// refusal and request for input carries its action.
3002    fn actionable_outcome(
3003        &self,
3004        outcome: Outcome,
3005        site: Option<&str>,
3006        handler: Option<usize>,
3007    ) -> Outcome {
3008        use rustev_contract::actionability::{Action, Cause, Override, Refusal, Remediation};
3009        let cc = self.caller_controlled();
3010        match outcome {
3011            outcome @ Outcome::Refused { .. } if !self.screen_refusals.is_empty() => outcome,
3012            Outcome::Refused { .. } => {
3013                let refusals: Vec<Refusal> = self
3014                    .checks
3015                    .refusals
3016                    .iter()
3017                    .map(|r| Refusal {
3018                        check: r.check.clone(),
3019                        subject: r.subject.clone(),
3020                        action: crate::actionable::action(
3021                            r.causes.clone(),
3022                            crate::actionable::ActionInputs {
3023                                caller_controlled: &cc,
3024                                address: &r.address,
3025                                declared: Some(self.resolve_remediation(&r.remediation)),
3026                                computed: Remediation::None,
3027                                transient: false,
3028                            },
3029                        ),
3030                    })
3031                    .collect();
3032                let addressed_to = Party::combine(refusals.iter().map(|r| r.action.addressed_to));
3033                Outcome::Refused {
3034                    refusals,
3035                    addressed_to,
3036                }
3037            }
3038            Outcome::Unresolved(u) => {
3039                let site = site.map(str::to_string).unwrap_or_else(|| match &u {
3040                    Unresolved::Abstained { rule } => format!("rule:{rule}"),
3041                    _ => "policy".into(),
3042                });
3043                let declared = handler
3044                    .and_then(|i| self.compiled.plan.definition.policy.on_unresolved.get(i))
3045                    .and_then(|h| match &h.action {
3046                        rustev_contract::definition::HandlerAction::PropagateWith {
3047                            remediation,
3048                            address,
3049                        } => Some((remediation, address)),
3050                        _ => None,
3051                    });
3052                let action = self.reason_action(&u, &site, declared);
3053                Outcome::UnresolvedAction { reason: u, action }
3054            }
3055            Outcome::NeedsInput(mut n) => {
3056                let declared = self
3057                    .compiled
3058                    .plan
3059                    .definition
3060                    .actionability
3061                    .as_ref()
3062                    .map(|a| a.questions.clone())
3063                    .unwrap_or_default();
3064                // The compiler refused undeclared question ids (3.7.1), so
3065                // none is dropped; the digest covers exactly the questions
3066                // the outcome carries either way.
3067                n.questions = std::mem::take(&mut n.questions)
3068                    .into_iter()
3069                    .filter_map(|q| declared.iter().find(|d| d.id == q.id).cloned())
3070                    .collect();
3071                let ids: Vec<String> = n.questions.iter().map(|q| q.id.clone()).collect();
3072                let site = site.unwrap_or("policy").to_string();
3073                let bytes = rustev_contract::canonical::record_canonical_bytes(&json!({
3074                    "plan_id": self.compiled.id.to_string(),
3075                    "snapshot_id": self.snapshot_id.to_string(),
3076                    "evaluation_time_ms": self.now,
3077                    "site": site,
3078                    "fields": n.fields,
3079                    "questions": ids,
3080                }))
3081                // An object of strings, an integer and string lists always
3082                // has canonical bytes; a failure is a defect, not a digest.
3083                .expect("continuation inputs have canonical bytes");
3084                let digest = rustev_contract::canonical::tagged_digest(
3085                    rustev_contract::actionability::CONTINUATION_TAG,
3086                    &bytes,
3087                );
3088                // A tagged digest is a digest by construction; a failure is a
3089                // defect, never a placeholder continuation.
3090                n.continuation = serde_json::from_value(json!(digest))
3091                    .expect("a tagged digest parses as a digest");
3092                let causes: Vec<Cause> = n
3093                    .fields
3094                    .iter()
3095                    .map(|f| {
3096                        let classes = self
3097                            .compiled
3098                            .info
3099                            .inputs
3100                            .get(f)
3101                            .map(|(_, d)| {
3102                                let mut v: Vec<ProvenanceClass> = d
3103                                    .provenance
3104                                    .iter()
3105                                    .filter(|c| cc.contains(c))
3106                                    .copied()
3107                                    .collect();
3108                                v.sort();
3109                                v
3110                            })
3111                            .unwrap_or_default();
3112                        Cause::Input {
3113                            field: f.clone(),
3114                            provenance: rustev_contract::actionability::CauseProvenance::Accepted {
3115                                classes,
3116                            },
3117                        }
3118                    })
3119                    .collect();
3120                // Addressed to the caller by construction (3.7.2).
3121                n.action = Action {
3122                    addressed_to: Party::Caller,
3123                    computed: Party::Caller,
3124                    override_: Override::None,
3125                    remediation: Remediation::with(
3126                        "provide_fields",
3127                        [(
3128                            "fields".to_string(),
3129                            Remediation::texts(n.fields.iter().cloned()),
3130                        )],
3131                    ),
3132                    caused_by: crate::actionable::sort_causes(causes),
3133                    transient: false,
3134                };
3135                Outcome::NeedsInput(n)
3136            }
3137            other => other,
3138        }
3139    }
3140
3141    /// The value of an exact step, if resolved.
3142    pub fn exact_value(&self, step: &str) -> Option<&Value> {
3143        self.exact_values.get(step)
3144    }
3145
3146    /// The status of an input or step reference (`input:x`, `step:y`).
3147    pub fn status_of(&self, r: &str) -> Result<(), Unresolved> {
3148        self.status(r)
3149    }
3150}
3151
3152impl View for Evaluation<'_> {
3153    fn skipped(&self, r: &str) -> bool {
3154        self.skipped.contains_key(
3155            crate::expr::base_ref_v2(r)
3156                .strip_prefix("step:")
3157                .unwrap_or(r),
3158        )
3159    }
3160    fn base(&self, r: &str) -> String {
3161        if self.compiled.info.v2 {
3162            crate::expr::base_ref_v2(r)
3163        } else {
3164            crate::expr::base_ref(r)
3165        }
3166    }
3167
3168    fn provenance(&self, r: &str) -> Result<Vec<ProvenanceClass>, Unresolved> {
3169        let base = View::base(self, r);
3170        self.status(&base)?;
3171        if let Some(n) = base.strip_prefix("input:") {
3172            return match self.input_evidence.get(n) {
3173                Some(InputStatus::Resolved { provenance, .. }) => Ok(vec![*provenance]),
3174                _ => Err(Unresolved::missing([n.to_string()])),
3175            };
3176        }
3177        // A plan 3 step value's provenance set (spec 032, 3.2).
3178        Ok(self
3179            .metas
3180            .get(&base)
3181            .map(|m| m.whole.provenance.clone())
3182            .unwrap_or_default())
3183    }
3184
3185    fn get(&self, r: &str) -> Result<Seen<'_>, Unresolved> {
3186        if self.compiled.info.v2 {
3187            if let Some(u) = self.policy_failures.get(&self.base(r)) {
3188                return Err(u.clone());
3189            }
3190            // A version 2 path may reach a `null` field or an absent value,
3191            // which is missing evidence for that path (spec 032, 3.4.4).
3192            let base = self.base(r);
3193            if base != r && base.starts_with("step:") {
3194                self.status(&base)?;
3195                let (id, path) = crate::expr::split_step(r.trim_start_matches("step:"));
3196                let mut v = self
3197                    .exact_values
3198                    .get(&id)
3199                    .ok_or_else(|| Unresolved::invalid([], format!("{r} has no value")))?;
3200                for f in &path {
3201                    v = match v {
3202                        Value::Record(m) => m
3203                            .get(f)
3204                            .ok_or_else(|| Unresolved::invalid([], format!("no field {f:?}")))?,
3205                        _ => return Err(Unresolved::invalid([], format!("no field {f:?}"))),
3206                    };
3207                    if matches!(v, Value::Null | Value::Absent(_)) {
3208                        return Err(Unresolved::missing([r
3209                            .trim_start_matches("step:")
3210                            .to_string()]));
3211                    }
3212                }
3213                return Ok(Seen::Exact(v));
3214            }
3215        }
3216        if let Some(rest) = r.strip_prefix("input:") {
3217            let mut parts = rest.split('/');
3218            let n = parts.next().unwrap_or_default();
3219            self.status(&format!("input:{n}"))?;
3220            let mut v = self
3221                .input_values
3222                .get(n)
3223                .ok_or_else(|| Unresolved::missing([n.to_string()]))?;
3224            for f in parts {
3225                v = match v {
3226                    Value::Record(m) => m.get(f).ok_or_else(|| {
3227                        Unresolved::invalid([n.to_string()], format!("no field {f:?}"))
3228                    })?,
3229                    _ => {
3230                        return Err(Unresolved::invalid(
3231                            [n.to_string()],
3232                            format!("no field {f:?}"),
3233                        ));
3234                    }
3235                };
3236                // Only plan 3 values can be `null` or absent.
3237                if matches!(v, Value::Null | Value::Absent(_)) {
3238                    return Err(Unresolved::missing([rest.to_string()]));
3239                }
3240            }
3241            return Ok(Seen::Exact(v));
3242        }
3243        let s = r.strip_prefix("step:").unwrap_or(r);
3244        self.status(r)?;
3245        if let Some(v) = self.exact_values.get(s) {
3246            return Ok(Seen::Exact(v));
3247        }
3248        match self.semantic.get(s) {
3249            Some(SemState::Instances { values, .. }) => match values.get(&Vec::new()) {
3250                Some(Ok(v)) => Ok(Seen::Semantic(v)),
3251                Some(Err(u)) => Err(u.clone()),
3252                None => Err(Unresolved::invalid([], format!("{r} fans out"))),
3253            },
3254            Some(SemState::Unresolved(u)) => Err(u.clone()),
3255            None => Err(Unresolved::invalid([], format!("{r} has no value"))),
3256        }
3257    }
3258
3259    fn scope(&self) -> Scope<'_> {
3260        Scope {
3261            constructed: Some(&self.constructed),
3262            now: self.now,
3263            inputs: &self.input_values,
3264            steps: &self.exact_values,
3265            present: &self.present,
3266            item: None,
3267            v2: self.compiled.info.v2,
3268        }
3269    }
3270}
3271
3272#[cfg(test)]
3273mod screening_hardening_tests {
3274    use super::*;
3275    use rustev_contract::definition::{Definition, StepBody, StepDecl};
3276    use rustev_contract::descriptor::{
3277        BackendDescriptor, InputExcess, InputLimit, OperationSupport, ScreenProfile,
3278    };
3279    use rustev_contract::output::ScreenUnits;
3280    use rustev_contract::{Document, schema};
3281    use serde_json::json;
3282
3283    // Synthetic fixtures. Corruption below is deliberately internal: callers
3284    // cannot mutate Info or the accepted screening inputs of an evaluation.
3285    fn fixture() -> (Compiled, Snapshot, Timestamp) {
3286        let mut d =
3287            Definition::parse(include_bytes!("testdata/screening.definition.json")).unwrap();
3288        let backend = BackendDescriptor {
3289            schema: schema::BACKEND.into(),
3290            backend_id: "screen".into(),
3291            artifact: rustev_contract::ids::ArtifactId::parse(&format!(
3292                "sha256:{}",
3293                "b".repeat(64)
3294            ))
3295            .unwrap(),
3296            operations: vec![
3297                OperationSupport {
3298                    operation: Operation::Proposition,
3299                    output: OutputKind::Screen,
3300                    max_options: 2,
3301                    isolation: None,
3302                    screen: Some(ScreenProfile {
3303                        policy_version: "synthetic/1".into(),
3304                        categories: vec![],
3305                        max_text_bytes: 48,
3306                        text_unit_bytes: 16,
3307                    }),
3308                },
3309                OperationSupport {
3310                    operation: Operation::Classify,
3311                    output: OutputKind::Label,
3312                    max_options: 2,
3313                    isolation: None,
3314                    screen: None,
3315                },
3316            ],
3317            input_limit: InputLimit {
3318                max_bytes: 65536,
3319                on_excess: InputExcess::Refuse,
3320            },
3321            determinism: rustev_contract::definition::Determinism::Bitwise,
3322        };
3323        let c = crate::compile(&d, std::slice::from_ref(&backend), &[]).unwrap();
3324        let StepInfo::Semantic { decl, .. } = c.info.steps.get("screen:caller_text:0").unwrap()
3325        else {
3326            panic!("screen step")
3327        };
3328        let mut model = decl.clone();
3329        model.operation = Operation::Classify;
3330        model.requires = RequiredKind::Label;
3331        model.project = vec!["input:request".into()];
3332        d.steps.push(StepDecl {
3333            id: "model".into(),
3334            body: StepBody::Semantic(*model),
3335        });
3336        let c = crate::compile(&d, &[backend], &[]).unwrap();
3337        let now = Timestamp::from_ms(1_790_164_800_000).unwrap();
3338        let snapshot = Snapshot {
3339            schema: schema::SNAPSHOT.into(),
3340            entries: vec![
3341                Entry {
3342                    field: "request".into(),
3343                    value: json!({"text":"unsafe text","behaviour_text":""}),
3344                    provenance: ProvenanceClass::UserSupplied,
3345                    as_of_ms: now,
3346                    source: "synthetic".into(),
3347                    grade: None,
3348                    producer: None,
3349                },
3350                Entry {
3351                    field: "candidates".into(),
3352                    value: json!([]),
3353                    provenance: ProvenanceClass::ThirdParty,
3354                    as_of_ms: now,
3355                    source: "synthetic".into(),
3356                    grade: None,
3357                    producer: None,
3358                },
3359            ],
3360        };
3361        (c, snapshot, now)
3362    }
3363
3364    fn assert_unavailable(ev: Evaluation<'_>) {
3365        assert!(ev.pending().is_empty());
3366        assert_eq!(
3367            ev.status_of("step:model"),
3368            Err(Unresolved::ScreenUnavailable {
3369                fields: vec!["input:request/text".into()]
3370            })
3371        );
3372        let (judgment, evidence) = ev.finish("corrupted-screen").unwrap();
3373        assert!(!matches!(judgment.outcome, Outcome::Refused { .. }));
3374        let entry = evidence
3375            .screening
3376            .unwrap()
3377            .into_iter()
3378            .find(|e| e.entry == "caller_text")
3379            .unwrap();
3380        assert!(matches!(
3381            entry.verdict,
3382            ScreenVerdict::Unavailable {
3383                reason: Unresolved::InvalidBackendOutput { .. }
3384            }
3385        ));
3386        assert_eq!(entry.consequence, ScreenConsequence::FailClosed);
3387    }
3388
3389    fn blocked() -> Supplied {
3390        Supplied::Output(RawOutput::Screen {
3391            blocked: true,
3392            categories: vec![],
3393            units: ScreenUnits::NotReported,
3394        })
3395    }
3396
3397    #[test]
3398    fn legacy_screen_projection_preserves_original_inputs() {
3399        let (c, snapshot, now) = fixture();
3400        let mut ev = Evaluation::start(&c, &snapshot, now).unwrap();
3401        let StepInfo::Semantic { decl, .. } = c.info.steps.get("screen:caller_text:0").unwrap()
3402        else {
3403            panic!("screen step")
3404        };
3405        let original = ev.legacy_projection(decl, &[], &[]).unwrap();
3406        ev.input_values.remove("request");
3407        assert_eq!(ev.legacy_projection(decl, &[], &[]).unwrap(), original);
3408    }
3409
3410    #[test]
3411    fn missing_profile_is_invalid_output_without_panicking() {
3412        let (mut c, snapshot, now) = fixture();
3413        c.info.screen_categories.clear();
3414        let mut ev = Evaluation::start(&c, &snapshot, now).unwrap();
3415        ev.supply("screen:caller_text:0", &[], blocked()).unwrap();
3416        assert_unavailable(ev);
3417    }
3418
3419    #[test]
3420    fn corrupted_projection_cannot_bypass_refusing_entry() {
3421        for non_string in [false, true] {
3422            let (c, snapshot, now) = fixture();
3423            let mut ev = Evaluation::start(&c, &snapshot, now).unwrap();
3424            let Some(Value::Record(fields)) = ev.screen_inputs.get_mut("request") else {
3425                panic!("request record")
3426            };
3427            if non_string {
3428                fields.insert("text".into(), Value::List(vec![]));
3429            } else {
3430                fields.remove("text");
3431            }
3432            ev.supply("screen:caller_text:0", &[], blocked()).unwrap();
3433            assert_unavailable(ev);
3434        }
3435    }
3436}