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