Skip to main content

rustev_core/
compile.rs

1//! The plan compiler (spec 002, 3.9).
2//!
3//! Phases run in order and stop at the first refusal: (a) parse and bound,
4//! (b) structure, (c) resolve operators, calibrations and sources, (d)
5//! graph, (e) types, (e2) grades (plan 3 only, spec 032 3.9), (f) bind
6//! backends, fallbacks and calibration artifacts, (g) budget, (h) policy,
7//! (i) emit. Within a phase, steps are judged in declaration
8//! order, then policy items in declaration order.
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::fmt;
12
13use rustev_contract::calibration::CalibrationArtifact;
14use rustev_contract::canonical::canonical_value_bytes;
15use rustev_contract::decimal::Decimal;
16use rustev_contract::definition::{
17    ArtifactPin, BindingChoice, CalibrationRef, Candidates, Cond, Definition, ExcessPolicy,
18    Fallback, Freshness, HandlerAction, InputDecl, ItemSource, Operation, OutcomeDecl, ParamSource,
19    ProvenanceClass, ReasonKind, ReasonSet, RequiredKind, SemanticDecl, StepBody, TypeDecl,
20    UncalibratedThreshold,
21};
22use rustev_contract::descriptor::{BackendDescriptor, InputExcess, OutputKind};
23use rustev_contract::execution::ExecutionPolicy;
24use rustev_contract::ids::{CalibrationId, DescriptorId, PlanId};
25use rustev_contract::judgment::{Derivation, Notice, NoticeKind};
26use rustev_contract::plan::{
27    BoundCalibration, Normalization, Plan, PlanExecution, PlanStep, PlanStepDetail,
28    SemanticBinding, StaticGrade,
29};
30use rustev_contract::source::SourceDescriptor;
31use rustev_contract::{Document, Identified, schema};
32use serde_json::json;
33
34use crate::calibrate;
35use crate::expr::{Ref, StepTy, TyEnv, predicate_refs, predicate_structure, type_predicate};
36use crate::ops::{self, OpArgs};
37use crate::quality;
38use crate::sem::SemKind;
39use crate::value::{Ty, Value};
40
41pub(crate) mod screening;
42
43/// The compiler identity recorded in every plan.
44pub const COMPILER: &str = concat!("rustev-core/", env!("CARGO_PKG_VERSION"));
45
46/// Refusal categories (spec 002, 3.9.5).
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
48pub enum Category {
49    Parse = 1,
50    UnknownOperator = 2,
51    KindMismatch = 3,
52    Cycle = 4,
53    NoCapableBackend = 5,
54    UncalibratedThreshold = 6,
55    Budget = 7,
56    UnhandledUnresolved = 8,
57    InvalidFallback = 9,
58    CalibrationBinding = 10,
59    InvalidDefinition = 11,
60    /// An execution policy that is not valid (spec 003, 3.2.3).
61    InvalidExecution = 12,
62    /// A requirement or hard gate reads a value whose static ceiling is
63    /// below the required grade (spec 032, 3.9.3; phase e2).
64    GradeRequirement = 13,
65    /// A requirement or hard gate reads a `hint` no corroboration raises
66    /// (spec 032, 3.9.3; phase e2).
67    UncorroboratedHint = 14,
68    /// A graded input's sources do not bind (spec 032, 3.3.4; phase c).
69    SourceBinding = 15,
70    /// A check leaves an accepted class of its subject without a branch or
71    /// default (spec 033, 3.2.3; phase h2).
72    UncoveredProvenance = 16,
73    /// A declaration would address the caller where the caller cannot act
74    /// (spec 033, 3.8.2; phases b and h2).
75    CallerCannotAct = 17,
76    /// Invalid screening declaration (spec 035, 3.5.3).
77    InvalidScreening = 18,
78    /// A model projection carries uncovered untrusted text (spec 035, 3.5.5).
79    UnscreenedText = 19,
80    InvalidAcquisition = 20,
81    SourceMapping = 21,
82    InvalidGeneration = 22,
83    Template = 23,
84    ModelOutputFlow = 24,
85}
86
87/// Why one candidate backend cannot serve a step.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum ShortfallReason {
90    UnknownBackend,
91    ScreenProfile,
92    ScreenTextLimit {
93        max: u64,
94        needed: u64,
95    },
96    ScreenInputExcess,
97    Operation(Operation),
98    OutputKind {
99        offered: OutputKind,
100        required: RequiredKind,
101    },
102    Options {
103        max: u64,
104        needed: u64,
105    },
106    InputLimit {
107        max: u64,
108        needed: u64,
109    },
110    Determinism {
111        offered: String,
112        required: String,
113    },
114    ArtifactPin {
115        pinned: String,
116        offered: String,
117    },
118    OutputLimit {
119        max: u64,
120        needed: u64,
121    },
122    Language {
123        required: String,
124    },
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct Shortfall {
129    pub backend_id: String,
130    pub reasons: Vec<ShortfallReason>,
131}
132
133/// A typed compilation refusal naming its subject and requirement.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct Refusal {
136    pub category: Category,
137    /// `step:<id>`, `input:<name>`, `policy:<item>`, `definition` or `limits`.
138    pub subject: String,
139    pub detail: String,
140    /// For categories 5 and 9: each candidate's shortfall.
141    pub shortfalls: Vec<Shortfall>,
142}
143
144impl fmt::Display for Refusal {
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        write!(
147            f,
148            "refused ({:?}, category {}) at {}: {}",
149            self.category, self.category as u8, self.subject, self.detail
150        )?;
151        for s in &self.shortfalls {
152            write!(f, "; {}: {:?}", s.backend_id, s.reasons)?;
153        }
154        Ok(())
155    }
156}
157
158impl std::error::Error for Refusal {}
159
160fn refuse<T>(
161    category: Category,
162    subject: impl Into<String>,
163    detail: impl Into<String>,
164) -> Result<T, Refusal> {
165    Err(Refusal {
166        category,
167        subject: subject.into(),
168        detail: detail.into(),
169        shortfalls: vec![],
170    })
171}
172
173/// What the compiler knows about each step, kept for evaluation.
174#[derive(Debug, Clone)]
175pub(crate) enum StepInfo {
176    Generate,
177    Acquisition(Box<crate::acquisition::Acquisition>),
178    Exact {
179        args: OpArgs,
180        ty: Ty,
181    },
182    Semantic {
183        decl: Box<SemanticDecl>,
184        kind: SemKind,
185        /// `None` when fallback `unsupported` was taken.
186        binding: Option<Box<SemanticBinding>>,
187        calibration: Option<(CalibrationArtifact, CalibrationId)>,
188    },
189    /// Plan 3: a corroboration step; its value is the claim's.
190    Corroborate {
191        decl: Box<rustev_contract::definition::CorroborateDecl>,
192        ty: Ty,
193    },
194}
195
196#[derive(Debug, Clone)]
197pub(crate) struct Info {
198    pub screen_categories: BTreeMap<String, Vec<String>>,
199    pub generation: BTreeMap<String, crate::generation::Checked>,
200    pub inputs: BTreeMap<String, (Ty, InputDecl)>,
201    /// Declaration order.
202    pub step_ids: Vec<String>,
203    pub steps: BTreeMap<String, StepInfo>,
204    /// Direct dependencies (`input:`/`step:` references) of each step, in
205    /// argument order.
206    pub deps: BTreeMap<String, Vec<String>>,
207    pub order: Vec<String>,
208    /// A plan 3 (spec 032).
209    pub v2: bool,
210    /// Plan 3: the bound sources and each graded input's grading.
211    #[allow(dead_code)] // read by plan 3 evaluation, spec 032 increment 3
212    pub bound: quality::Bound,
213    /// Plan 3: the static ceiling tree of every input and step.
214    #[allow(dead_code)] // read by plan 3 evaluation, spec 032 increment 3
215    pub trees: BTreeMap<String, quality::Ceil>,
216    /// The declared determinism of each semantic step's bound backend, for
217    /// the transience of `invalid_backend_output` (spec 033, 3.6.4).
218    pub determinism: BTreeMap<String, rustev_contract::definition::Determinism>,
219}
220
221/// A plan with everything evaluation needs.
222#[derive(Debug, Clone)]
223pub struct Compiled {
224    pub plan: Plan,
225    pub id: PlanId,
226    pub(crate) info: Info,
227}
228
229impl Compiled {
230    /// Canonical plan bytes (spec 002, 3.3).
231    pub fn canonical(&self) -> Vec<u8> {
232        // A compiled plan always has a canonical form: it carries no floats.
233        self.plan.canonical().unwrap_or_default()
234    }
235
236    /// Load a plan document by recompiling its embedded definition, with its
237    /// embedded execution policy, against the same descriptors and
238    /// calibrations; refused unless the result is byte-identical to the
239    /// document.
240    pub fn load(
241        plan: &Plan,
242        descriptors: &[BackendDescriptor],
243        calibrations: &[CalibrationArtifact],
244    ) -> Result<Compiled, Refusal> {
245        Self::load_with_sources(plan, descriptors, calibrations, &[])
246    }
247
248    /// [`Compiled::load`] with source descriptors, for a plan 3 that binds
249    /// sources (spec 032, 3.3.4).
250    pub fn load_with_sources(
251        plan: &Plan,
252        descriptors: &[BackendDescriptor],
253        calibrations: &[CalibrationArtifact],
254        sources: &[SourceDescriptor],
255    ) -> Result<Compiled, Refusal> {
256        recompile(plan, descriptors, calibrations, sources).map_err(|e| match e {
257            Recompiled::Refused(r) => r,
258            Recompiled::Differs => Refusal {
259                category: Category::Parse,
260                subject: "plan".into(),
261                detail: "the plan document does not match its recompiled definition".into(),
262                shortfalls: vec![],
263            },
264        })
265    }
266
267    /// [`Compiled::load`] with a typed diagnostic (spec 004, 5.2): a changed
268    /// compiler or exact-operator registry, a binding dependency not
269    /// supplied, or a plan that does not recompile identically under this
270    /// compiler. It shares `load`'s recompile and equality check; there is
271    /// no recompile-and-accept fallback. The dependency list is read from
272    /// the plan document itself, so a caller verifies the document's digest
273    /// or identity first: a tampered plan naming a bogus descriptor reports
274    /// that descriptor missing, not a mismatch.
275    pub fn load_checked(
276        plan: &Plan,
277        descriptors: &[BackendDescriptor],
278        calibrations: &[CalibrationArtifact],
279    ) -> Result<Compiled, LoadError> {
280        Self::load_checked_with_sources(plan, descriptors, calibrations, &[])
281    }
282
283    /// [`Compiled::load_checked`] with source descriptors, for a plan 3
284    /// that binds sources (spec 032, 3.3.4); without them such a plan is a
285    /// mismatch carrying the category 15 refusal.
286    pub fn load_checked_with_sources(
287        plan: &Plan,
288        descriptors: &[BackendDescriptor],
289        calibrations: &[CalibrationArtifact],
290        sources: &[SourceDescriptor],
291    ) -> Result<Compiled, LoadError> {
292        #[cfg(not(feature = "parameterized"))]
293        crate::unparameterized::plan(plan).map_err(|refusal| LoadError::PlanMismatch {
294            refusal: Some(refusal),
295        })?;
296        // A plan 2 records `rustev.exact/1`; a plan 3 the lowest registry it
297        // needs (spec 032, 3.1.3).
298        let registry_known = plan.registry == schema::EXACT_REGISTRY
299            || (plan.schema == schema::PLAN_V3 && plan.registry == schema::EXACT_REGISTRY_V2);
300        if plan.compiler != COMPILER || !registry_known {
301            return Err(LoadError::CompilerChanged {
302                recorded_compiler: plan.compiler.clone(),
303                current_compiler: COMPILER,
304                recorded_registry: plan.registry.clone(),
305                current_registry: schema::EXACT_REGISTRY,
306            });
307        }
308        let have_d: BTreeSet<DescriptorId> =
309            descriptors.iter().filter_map(|d| d.id().ok()).collect();
310        let have_c: BTreeSet<CalibrationId> =
311            calibrations.iter().filter_map(|c| c.id().ok()).collect();
312        let mut needed: Vec<(String, &DescriptorId)> = vec![];
313        for step in &plan.steps {
314            if let PlanStepDetail::Generate(b) = &step.detail {
315                needed.push((b.backend_id.clone(), &b.descriptor));
316            }
317            if let PlanStepDetail::Semantic(b) = &step.detail {
318                needed.push((b.backend_id.clone(), &b.descriptor));
319                if let BoundCalibration::Bound { id, .. } = &b.calibration
320                    && !have_c.contains(id)
321                {
322                    return Err(LoadError::MissingCalibration {
323                        step: step.id.clone(),
324                        calibration: id.clone(),
325                    });
326                }
327            }
328        }
329        if let PlanExecution::Declared(d) = &plan.execution {
330            needed.extend(
331                d.fallbacks
332                    .iter()
333                    .map(|f| (f.backend_id.clone(), &f.descriptor)),
334            );
335        }
336        for (backend_id, descriptor) in needed {
337            if !have_d.contains(descriptor) {
338                return Err(LoadError::MissingDescriptor {
339                    backend_id,
340                    descriptor: descriptor.clone(),
341                });
342            }
343        }
344        recompile(plan, descriptors, calibrations, sources).map_err(|e| LoadError::PlanMismatch {
345            refusal: match e {
346                Recompiled::Refused(r) => Some(r),
347                Recompiled::Differs => None,
348            },
349        })
350    }
351}
352
353/// Why [`Compiled::load_checked`] refused a plan (spec 004, 3.3.2).
354#[derive(Debug, Clone, PartialEq, Eq)]
355pub enum LoadError {
356    /// The plan was compiled by another compiler or registry version.
357    CompilerChanged {
358        recorded_compiler: String,
359        current_compiler: &'static str,
360        recorded_registry: String,
361        current_registry: &'static str,
362    },
363    /// A binding names a descriptor that was not supplied.
364    MissingDescriptor {
365        backend_id: String,
366        descriptor: DescriptorId,
367    },
368    /// A step binds a calibration artifact that was not supplied.
369    MissingCalibration {
370        step: String,
371        calibration: CalibrationId,
372    },
373    /// Same compiler, yet the plan does not recompile to identical bytes:
374    /// refused on recompilation, or recompiled differently.
375    PlanMismatch { refusal: Option<Refusal> },
376}
377
378impl fmt::Display for LoadError {
379    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
380        match self {
381            LoadError::CompilerChanged {
382                recorded_compiler,
383                current_compiler,
384                recorded_registry,
385                current_registry,
386            } => write!(
387                f,
388                "compiler changed: recorded {recorded_compiler} ({recorded_registry}), \
389                 current {current_compiler} ({current_registry})"
390            ),
391            LoadError::MissingDescriptor {
392                backend_id,
393                descriptor,
394            } => write!(f, "descriptor {descriptor} of {backend_id} not supplied"),
395            LoadError::MissingCalibration { step, calibration } => {
396                write!(f, "calibration {calibration} of step {step} not supplied")
397            }
398            LoadError::PlanMismatch { refusal: Some(r) } => {
399                write!(f, "plan does not recompile: {r}")
400            }
401            LoadError::PlanMismatch { refusal: None } => {
402                f.write_str("plan does not match its recompiled definition")
403            }
404        }
405    }
406}
407
408impl std::error::Error for LoadError {}
409
410enum Recompiled {
411    Refused(Refusal),
412    Differs,
413}
414
415/// The one recompile-and-compare both loaders use.
416fn recompile(
417    plan: &Plan,
418    descriptors: &[BackendDescriptor],
419    calibrations: &[CalibrationArtifact],
420    sources: &[SourceDescriptor],
421) -> Result<Compiled, Recompiled> {
422    let execution = match &plan.execution {
423        PlanExecution::None => None,
424        PlanExecution::Declared(d) => Some(&d.policy),
425    };
426    #[cfg(not(feature = "parameterized"))]
427    crate::unparameterized::plan(plan).map_err(Recompiled::Refused)?;
428    #[cfg(feature = "parameterized")]
429    if let Some(record) = &plan.instantiation {
430        crate::instantiate::validate_record(plan, record).map_err(|detail| {
431            Recompiled::Refused(Refusal {
432                category: Category::Parse,
433                subject: "plan".into(),
434                detail,
435                shortfalls: vec![],
436            })
437        })?;
438    }
439    let templates: Vec<_> = plan
440        .steps
441        .iter()
442        .filter_map(|s| match &s.detail {
443            PlanStepDetail::Generate(g) => Some(g.template.clone()),
444            _ => None,
445        })
446        .collect();
447    let mut c = compile_generation(
448        &plan.definition,
449        descriptors,
450        calibrations,
451        sources,
452        execution,
453        plan.instantiation.is_some(),
454        &templates,
455    )
456    .map_err(Recompiled::Refused)?;
457    #[cfg(feature = "parameterized")]
458    {
459        c.plan.instantiation = plan.instantiation.clone();
460    }
461    c.id = c.plan.id().map_err(|e| {
462        Recompiled::Refused(Refusal {
463            category: Category::Parse,
464            subject: "plan".into(),
465            detail: e.to_string(),
466            shortfalls: vec![],
467        })
468    })?;
469    if plan.canonical().ok() != Some(c.canonical()) {
470        #[cfg(feature = "parameterized")]
471        if plan.instantiation.is_some() {
472            return Err(Recompiled::Refused(Refusal {
473                category: Category::Parse,
474                subject: "plan".into(),
475                detail: "instantiated plan differs from its recompilation".into(),
476                shortfalls: vec![],
477            }));
478        }
479        return Err(Recompiled::Differs);
480    }
481    Ok(c)
482}
483
484/// Parse a definition document and compile it.
485pub fn compile_bytes(
486    bytes: &[u8],
487    descriptors: &[BackendDescriptor],
488    calibrations: &[CalibrationArtifact],
489) -> Result<Compiled, Refusal> {
490    #[cfg(not(feature = "parameterized"))]
491    crate::unparameterized::definition(bytes)?;
492    let def = Definition::parse(bytes).map_err(|e| Refusal {
493        category: Category::Parse,
494        subject: "definition".into(),
495        detail: e.to_string(),
496        shortfalls: vec![],
497    })?;
498    compile(&def, descriptors, calibrations)
499}
500
501fn step_ref(r: &str) -> Option<&str> {
502    r.strip_prefix("step:")
503}
504
505fn is_semver(v: &str) -> bool {
506    let parts: Vec<&str> = v.split('.').collect();
507    parts.len() == 3
508        && parts.iter().all(|p| {
509            !p.is_empty()
510                && p.bytes().all(|b| b.is_ascii_digit())
511                && (p.len() == 1 || !p.starts_with('0'))
512        })
513}
514
515fn valid_name(s: &str) -> bool {
516    !s.is_empty() && !s.contains(':') && !s.chars().any(char::is_whitespace)
517}
518
519fn check_type_decl(t: &TypeDecl) -> Result<(), String> {
520    match t {
521        TypeDecl::Text { max_bytes } if *max_bytes == 0 => {
522            Err("text max_bytes must be positive".into())
523        }
524        TypeDecl::Enum { options } => {
525            let set: BTreeSet<_> = options.iter().collect();
526            if options.is_empty()
527                || set.len() != options.len()
528                || options.iter().any(String::is_empty)
529            {
530                Err("enum options must be non-empty and unique".into())
531            } else {
532                Ok(())
533            }
534        }
535        TypeDecl::List { item, .. } => check_type_decl(item),
536        TypeDecl::Nullable { ty } => check_type_decl(ty),
537        TypeDecl::Map { value, .. } => check_type_decl(value),
538        TypeDecl::Record { fields, .. } => {
539            let set: BTreeSet<_> = fields.iter().map(|f| &f.name).collect();
540            if fields.is_empty()
541                || set.len() != fields.len()
542                || fields.iter().any(|f| f.name.is_empty())
543            {
544                return Err("record fields must be non-empty and unique".into());
545            }
546            fields.iter().try_for_each(|f| check_type_decl(&f.ty))
547        }
548        _ => Ok(()),
549    }
550}
551
552/// Whether `required` is a valid kind for `op` (spec 002, 3.9.3).
553fn kind_valid_for(op: Operation, required: RequiredKind) -> bool {
554    use RequiredKind as R;
555    match op {
556        Operation::Classify => matches!(
557            required,
558            R::Label | R::Distribution | R::CalibratedProbability
559        ),
560        Operation::Proposition => matches!(
561            required,
562            R::Label | R::Distribution | R::CalibratedProbability
563        ),
564        Operation::Rubric => matches!(required, R::Label | R::OrdinalDistribution),
565        Operation::Rank => matches!(required, R::Scores),
566        Operation::Generate | Operation::Extract => false,
567    }
568}
569
570fn satisfies(output: OutputKind, required: RequiredKind) -> bool {
571    use OutputKind as O;
572    use RequiredKind as R;
573    match required {
574        R::ScreenVerdict => output == O::Screen,
575        R::TextRecord | R::TypedRecord => false,
576        R::Label => output == O::Label,
577        R::Distribution | R::CalibratedProbability | R::OrdinalDistribution => {
578            matches!(output, O::Distribution | O::Logits)
579        }
580        R::Scores => matches!(output, O::Scores | O::Logits),
581    }
582}
583
584fn sem_kind(op: Operation, required: RequiredKind) -> SemKind {
585    match (op, required) {
586        (Operation::Rubric, RequiredKind::Label) => SemKind::Ordinal(false),
587        // Screening values are opaque to authored consumers; verdict
588        // evaluation is spec 035's. A text record is refused before kind
589        // analysis, so its arm is a total placeholder.
590        (_, RequiredKind::ScreenVerdict | RequiredKind::TextRecord | RequiredKind::TypedRecord) => {
591            SemKind::Label
592        }
593        (_, RequiredKind::Label) => SemKind::Label,
594        (_, RequiredKind::Distribution) => SemKind::Distribution,
595        (_, RequiredKind::CalibratedProbability) => SemKind::Calibrated,
596        (_, RequiredKind::OrdinalDistribution) => SemKind::Ordinal(true),
597        (_, RequiredKind::Scores) => SemKind::Scores,
598    }
599}
600
601/// Why `b` cannot serve semantic step `d` with `required`, or the output
602/// kind it would serve it with (spec 002, 3.9.4; spec 003, 3.2.3 for
603/// runtime fallback targets).
604pub(crate) fn check_candidate(
605    d: &SemanticDecl,
606    b: &BackendDescriptor,
607    required: RequiredKind,
608    options_needed: u64,
609    bound: u64,
610) -> Result<OutputKind, Vec<ShortfallReason>> {
611    let mut reasons = Vec::new();
612    let supports: Vec<_> = b
613        .operations
614        .iter()
615        .filter(|o| o.operation == d.operation)
616        .collect();
617    if supports.is_empty() {
618        reasons.push(ShortfallReason::Operation(d.operation));
619    }
620    let chosen = supports.iter().find(|o| {
621        satisfies(o.output.clone(), required)
622            && (required != RequiredKind::ScreenVerdict || d.operation == Operation::Proposition)
623    });
624    match (chosen, supports.first()) {
625        (None, Some(o)) => reasons.push(ShortfallReason::OutputKind {
626            offered: o.output.clone(),
627            required,
628        }),
629        (Some(o), _) if o.max_options < options_needed => reasons.push(ShortfallReason::Options {
630            max: o.max_options,
631            needed: options_needed,
632        }),
633        _ => {}
634    }
635    if bound > b.input_limit.max_bytes && b.input_limit.on_excess == InputExcess::Refuse {
636        reasons.push(ShortfallReason::InputLimit {
637            max: b.input_limit.max_bytes,
638            needed: bound,
639        });
640    }
641    if b.determinism < d.backend.min_determinism {
642        reasons.push(ShortfallReason::Determinism {
643            offered: format!("{:?}", b.determinism),
644            required: format!("{:?}", d.backend.min_determinism),
645        });
646    }
647    if let ArtifactPin::Pinned(a) = &d.backend.artifact
648        && a != &b.artifact
649    {
650        reasons.push(ShortfallReason::ArtifactPin {
651            pinned: a.to_string(),
652            offered: b.artifact.to_string(),
653        });
654    }
655    match chosen {
656        Some(o) if reasons.is_empty() => Ok(o.output.clone()),
657        _ => Err(reasons),
658    }
659}
660
661pub(crate) fn condition_refs(c: &Cond, out: &mut Vec<String>) {
662    match c {
663        Cond::Always => {}
664        Cond::TopLabel { step, .. }
665        | Cond::MassAtLeast { step, .. }
666        | Cond::TopMassBelow { step, .. }
667        | Cond::ExpectationAtLeast { step, .. } => out.push(format!("step:{step}")),
668        Cond::Exact(p) => predicate_refs(p, out),
669        Cond::Empty(r) | Cond::Nonempty(r) => out.push(r.clone()),
670        Cond::All(cs) | Cond::Any(cs) => cs.iter().for_each(|c| condition_refs(c, out)),
671        Cond::Not(c) => condition_refs(c, out),
672        Cond::ProvenanceWithin { reference, .. } => out.push(reference.clone()),
673    }
674}
675
676pub(crate) fn outcome_refs(o: &OutcomeDecl, out: &mut Vec<String>) {
677    match o {
678        OutcomeDecl::Propose { params, .. } => {
679            for p in params {
680                if let ParamSource::Ref(r) = &p.value {
681                    out.push(r.clone());
682                }
683            }
684        }
685        OutcomeDecl::Escalate { .. } => {}
686        OutcomeDecl::MissingEvidenceFrom { step } => out.push(format!("step:{step}")),
687        // It names fields for the caller to supply; it reads none of them
688        // (spec 033, 3.7), so no handler coverage applies to them. Only a
689        // version 2 definition declares it: `first_construct` refuses it in
690        // version 1, so no version 1 plan reaches this arm.
691        OutcomeDecl::NeedsInput { .. } => {}
692    }
693}
694
695/// Compile a definition against supplied backend descriptors and calibration
696/// artifacts, with no execution policy (`execution: none`). Pure: no I/O, no
697/// clock.
698pub fn compile(
699    def: &Definition,
700    descriptors: &[BackendDescriptor],
701    calibrations: &[CalibrationArtifact],
702) -> Result<Compiled, Refusal> {
703    compile_inner(def, descriptors, calibrations, &[], None)
704}
705
706/// Compile with context source descriptors beside backend descriptors and
707/// calibrations (spec 032, 3.3.4), and optionally an execution policy. A
708/// version 1 definition ignores supplied sources, as an unbound backend
709/// descriptor changes nothing.
710pub fn compile_with_sources(
711    def: &Definition,
712    descriptors: &[BackendDescriptor],
713    calibrations: &[CalibrationArtifact],
714    sources: &[SourceDescriptor],
715    execution: Option<&ExecutionPolicy>,
716) -> Result<Compiled, Refusal> {
717    compile_inner(def, descriptors, calibrations, sources, execution)
718}
719
720/// Compile with a runtime execution policy (spec 003, 3.2.3). The policy is
721/// validated after every step is bound and embedded in the plan, so it is
722/// part of the `PlanId`.
723pub fn compile_with(
724    def: &Definition,
725    descriptors: &[BackendDescriptor],
726    calibrations: &[CalibrationArtifact],
727    execution: &ExecutionPolicy,
728) -> Result<Compiled, Refusal> {
729    compile_inner(def, descriptors, calibrations, &[], Some(execution))
730}
731
732fn compile_inner(
733    def: &Definition,
734    descriptors: &[BackendDescriptor],
735    calibrations: &[CalibrationArtifact],
736    sources: &[SourceDescriptor],
737    execution: Option<&ExecutionPolicy>,
738) -> Result<Compiled, Refusal> {
739    compile_instantiated(def, descriptors, calibrations, sources, execution, false)
740}
741
742/// Only the supplied-template version guard differs for an instantiated definition.
743pub(crate) fn compile_instantiated(
744    def: &Definition,
745    descriptors: &[BackendDescriptor],
746    calibrations: &[CalibrationArtifact],
747    sources: &[SourceDescriptor],
748    execution: Option<&ExecutionPolicy>,
749    #[cfg_attr(not(feature = "parameterized"), allow(unused_variables))] instantiated: bool,
750) -> Result<Compiled, Refusal> {
751    compile_generation(
752        def,
753        descriptors,
754        calibrations,
755        sources,
756        execution,
757        instantiated,
758        &[],
759    )
760}
761
762/// Compile generation with supplied prompt documents, alongside ordinary dependencies.
763pub fn compile_with_templates(
764    def: &Definition,
765    descriptors: &[BackendDescriptor],
766    calibrations: &[CalibrationArtifact],
767    sources: &[SourceDescriptor],
768    execution: Option<&ExecutionPolicy>,
769    templates: &[rustev_contract::generation::PromptTemplate],
770) -> Result<Compiled, Refusal> {
771    compile_generation(
772        def,
773        descriptors,
774        calibrations,
775        sources,
776        execution,
777        false,
778        templates,
779    )
780}
781
782fn compile_generation(
783    def: &Definition,
784    descriptors: &[BackendDescriptor],
785    calibrations: &[CalibrationArtifact],
786    sources: &[SourceDescriptor],
787    execution: Option<&ExecutionPolicy>,
788    instantiated: bool,
789    templates: &[rustev_contract::generation::PromptTemplate],
790) -> Result<Compiled, Refusal> {
791    #[cfg(not(feature = "parameterized"))]
792    let _ = instantiated;
793    use Category as C;
794
795    // (a) Parse and bound: schema, and decoding of registered operators'
796    // arguments. Unknown operators are left to phase (c). A version 2
797    // definition compiles only to plan 3 (spec 032, 3.1.3).
798    #[cfg(not(feature = "parameterized"))]
799    crate::unparameterized::typed_definition(def)?;
800    let v2 = def.schema == schema::DEFINITION_V2;
801    if def.schema != schema::DEFINITION && !v2 {
802        return refuse(
803            C::Parse,
804            "definition",
805            format!(
806                "schema {:?} is neither {} nor {}",
807                def.schema,
808                schema::DEFINITION,
809                schema::DEFINITION_V2
810            ),
811        );
812    }
813    let decode = if v2 { ops::decode_v2 } else { ops::decode };
814    let mut decoded: BTreeMap<String, OpArgs> = BTreeMap::new();
815    for s in &def.steps {
816        if let StepBody::Exact(e) = &s.body {
817            match decode(&e.op, e.version, &e.args) {
818                Some(Ok(a)) => {
819                    decoded.insert(s.id.clone(), a);
820                }
821                Some(Err(msg)) => {
822                    return refuse(
823                        C::Parse,
824                        format!("step:{}", s.id),
825                        format!("arguments of {}@{}: {msg}", e.op, e.version),
826                    );
827                }
828                None => {}
829            }
830        }
831    }
832    for d in descriptors {
833        if d.schema != schema::BACKEND {
834            return refuse(
835                C::Parse,
836                format!("backend:{}", d.backend_id),
837                "descriptor schema is not rustev.backend/1",
838            );
839        }
840    }
841    for c in calibrations {
842        if c.schema != schema::CALIBRATION {
843            return refuse(
844                C::Parse,
845                "calibration",
846                "calibration schema is not rustev.calibration/1",
847            );
848        }
849    }
850
851    if def
852        .limits
853        .acquisition
854        .as_ref()
855        .is_some_and(|b| !(1..=4096).contains(&b.max_calls))
856    {
857        return refuse(C::Parse, "limits", "acquisition.max_calls outside 1..=4096");
858    }
859    if !v2 && def.limits.acquisition.is_some() {
860        return refuse(
861            C::InvalidDefinition,
862            "limits",
863            "requires-definition-2: acquisition budget",
864        );
865    }
866    // Screening is a definition 2 construct (spec 035, section 2).
867    if !v2 && def.screening.is_some() {
868        return refuse(
869            C::InvalidDefinition,
870            "screening",
871            "requires-definition-2: screening needs rustev.definition/2",
872        );
873    }
874    // (b) Structure. Authored screening verdicts are reserved (spec 035, 3.3.2).
875    for s in &def.steps {
876        if let StepBody::Semantic(d) = &s.body
877            && d.requires == RequiredKind::ScreenVerdict
878        {
879            return refuse(
880                C::InvalidDefinition,
881                format!("step:{}", s.id),
882                "screen_verdict is reserved for lowered screening steps",
883            );
884        }
885    }
886    // Spec 036: no plan holds an `acquire` or `widen` step until the core
887    // increment compiles them; that increment adds their plan step detail
888    // and the `acquisitions` member (3.10). A version 2 definition compiles
889    // only to `rustev.plan/3` (3.10.4), never to `rustev.plan/2`.
890    for s in &def.steps {
891        if !v2
892            && matches!(
893                s.body,
894                StepBody::Acquire(_)
895                    | StepBody::Widen(_)
896                    | StepBody::Generate(_)
897                    | StepBody::Extract(_)
898                    | StepBody::AcceptModelDerived(_)
899            )
900        {
901            return refuse(
902                C::InvalidDefinition,
903                format!("step:{}", s.id),
904                "requires-definition-2",
905            );
906        }
907    }
908    for s in &def.steps {
909        if matches!(
910            s.body,
911            StepBody::Extract(_) | StepBody::AcceptModelDerived(_)
912        ) {
913            return refuse(
914                C::InvalidDefinition,
915                format!("step:{}", s.id),
916                "extraction-not-compiled",
917            );
918        }
919    }
920    for s in &def.steps {
921        crate::acquisition::structure(&s.id, &s.body)?;
922    }
923    if !v2
924        && def.policy.on_unresolved.iter().any(|h| {
925            matches!(&h.reasons,
926        ReasonSet::Only(rs) if rs.contains(&ReasonKind::Skipped))
927        })
928    {
929        return refuse(
930            C::InvalidDefinition,
931            "policy",
932            "requires-definition-2: skipped",
933        );
934    }
935    // A construct of spec 033 needs a version 2 definition
936    // (spec 033, 3.1.1); its own structure is checked after the policy's.
937    if !v2 && let Some((subject, construct)) = crate::actionable::first_construct(def) {
938        return refuse(
939            C::InvalidDefinition,
940            subject,
941            format!(
942                "requires-definition-2: {construct} needs {}",
943                schema::DEFINITION_V2
944            ),
945        );
946    }
947    // A version 1 definition carries no construct of spec
948    // 032 other than an operator outside `rustev.exact/1`, which phase (c)
949    // refuses as before (spec 032, 3.1.3).
950    if v2 {
951        #[cfg(feature = "parameterized")]
952        if instantiated {
953            quality::structure_with_origin(def, &decoded, true)?;
954        } else {
955            quality::structure(def, &decoded)?;
956        }
957        #[cfg(not(feature = "parameterized"))]
958        quality::structure(def, &decoded)?;
959    } else if let Some((subject, construct)) = first_v2_construct(def, &decoded) {
960        return refuse(
961            C::InvalidDefinition,
962            subject,
963            format!(
964                "requires_definition_2: {construct} needs {}",
965                schema::DEFINITION_V2
966            ),
967        );
968    }
969    crate::generation::isolation(def, execution)?;
970    crate::generation::structure(def)?;
971    // Supplied backend ids must be unique.
972    let mut backend_ids = BTreeSet::new();
973    for d in descriptors {
974        if !backend_ids.insert(d.backend_id.as_str()) {
975            return refuse(
976                C::InvalidDefinition,
977                format!("backend:{}", d.backend_id),
978                "two descriptors share a backend id",
979            );
980        }
981    }
982    let inv = |subject: String, detail: String| -> Result<(), Refusal> {
983        refuse(C::InvalidDefinition, subject, detail)
984    };
985    if def.name.is_empty() || def.package.is_empty() {
986        inv(
987            "definition".into(),
988            "name and package must be non-empty".into(),
989        )?;
990    }
991    if !is_semver(&def.version) {
992        inv(
993            "definition".into(),
994            format!("version {:?} is not MAJOR.MINOR.PATCH", def.version),
995        )?;
996    }
997    let mut inputs: BTreeMap<String, (Ty, InputDecl)> = BTreeMap::new();
998    for i in &def.inputs {
999        let subject = format!("input:{}", i.name);
1000        if !valid_name(&i.name) || inputs.contains_key(&i.name) {
1001            inv(
1002                subject.clone(),
1003                "input names must be unique, non-empty, without ':' or whitespace".into(),
1004            )?;
1005        }
1006        let classes: BTreeSet<_> = i.provenance.iter().collect();
1007        if i.provenance.is_empty() || classes.len() != i.provenance.len() {
1008            inv(
1009                subject.clone(),
1010                "accepted provenance classes must be non-empty and unique".into(),
1011            )?;
1012        }
1013        check_type_decl(&i.ty).or_else(|e| inv(subject, e))?;
1014        inputs.insert(i.name.clone(), (Ty::from_decl(&i.ty), i.clone()));
1015    }
1016    let ids: Vec<String> = def.steps.iter().map(|s| s.id.clone()).collect();
1017    let known = |r: &str| -> Result<(), String> {
1018        match Ref::parse(r)? {
1019            Ref::Input(n, _) if !inputs.contains_key(&n) => Err(format!("unknown input {n:?}")),
1020            // A version 2 step reference may carry a field path.
1021            Ref::Step(n) if v2 && !ids.contains(&crate::expr::split_step(&n).0) => {
1022                Err(format!("unknown step {n:?}"))
1023            }
1024            Ref::Step(n) if !v2 && !ids.contains(&n) => Err(format!("unknown step {n:?}")),
1025            _ => Ok(()),
1026        }
1027    };
1028    let options_of: BTreeMap<&str, &Vec<String>> = def
1029        .steps
1030        .iter()
1031        .filter_map(|s| match &s.body {
1032            StepBody::Extract(_) | StepBody::AcceptModelDerived(_) => {
1033                // Refused earlier by extraction-not-compiled.
1034                None
1035            }
1036            StepBody::Semantic(d) => Some((s.id.as_str(), &d.options)),
1037            StepBody::Exact(_)
1038            | StepBody::Corroborate(_)
1039            | StepBody::Acquire(_)
1040            | StepBody::Widen(_)
1041            | StepBody::Generate(_) => None,
1042        })
1043        .collect();
1044    for (i, s) in def.steps.iter().enumerate() {
1045        let subject = format!("step:{}", s.id);
1046        if !valid_name(&s.id) || ids[..i].contains(&s.id) {
1047            inv(
1048                subject.clone(),
1049                "step ids must be unique, non-empty, without ':' or whitespace".into(),
1050            )?;
1051        }
1052        match &s.body {
1053            StepBody::Extract(_) | StepBody::AcceptModelDerived(_) => {
1054                // Refused earlier by extraction-not-compiled.
1055            }
1056            StepBody::Generate(_) => {}
1057            // Refused above outside plan 3, whose own structure is checked by
1058            // `quality::structure`; here only the references are resolved.
1059            StepBody::Acquire(_) | StepBody::Widen(_) => {
1060                for r in crate::acquisition::refs(&s.body) {
1061                    known(&r).or_else(|e| inv(subject.clone(), e))?;
1062                }
1063            }
1064            StepBody::Corroborate(c) => {
1065                let mut refs = vec![c.claim.clone()];
1066                c.by.iter()
1067                    .for_each(|k| predicate_refs(&k.predicate, &mut refs));
1068                for r in refs {
1069                    known(&r).or_else(|e| inv(subject.clone(), e))?;
1070                }
1071            }
1072            StepBody::Exact(_) => {
1073                if let Some(a) = decoded.get(&s.id) {
1074                    a.structure().or_else(|e| inv(subject.clone(), e))?;
1075                    for r in a.all_ref_strings() {
1076                        known(&r).or_else(|e| inv(subject.clone(), e))?;
1077                    }
1078                }
1079            }
1080            StepBody::Semantic(d) => {
1081                if d.task.is_empty() || d.question.is_empty() {
1082                    inv(
1083                        subject.clone(),
1084                        "task and question must be non-empty".into(),
1085                    )?;
1086                }
1087                let set: BTreeSet<_> = d.options.iter().collect();
1088                if set.len() != d.options.len() || d.options.iter().any(String::is_empty) {
1089                    inv(
1090                        subject.clone(),
1091                        "options must be unique and non-empty".into(),
1092                    )?;
1093                }
1094                match d.operation {
1095                    Operation::Classify if d.options.len() < 2 => inv(
1096                        subject.clone(),
1097                        "classify needs at least two options".into(),
1098                    )?,
1099                    Operation::Proposition if d.options != ["false", "true"] => inv(
1100                        subject.clone(),
1101                        "proposition options must be [\"false\", \"true\"]".into(),
1102                    )?,
1103                    Operation::Rubric if d.options.len() < 2 => {
1104                        inv(subject.clone(), "rubric needs at least two levels".into())?
1105                    }
1106                    Operation::Rank if !d.options.is_empty() => {
1107                        inv(subject.clone(), "rank takes candidates, not options".into())?
1108                    }
1109                    _ => {}
1110                }
1111                match (&d.candidates, d.operation) {
1112                    (Candidates::From(r), Operation::Rank) => {
1113                        if step_ref(r).is_none() {
1114                            inv(subject.clone(), format!("candidates {r:?} must be a step"))?;
1115                        }
1116                        known(r).or_else(|e| inv(subject.clone(), e))?;
1117                    }
1118                    (Candidates::None, Operation::Rank) => {
1119                        inv(subject.clone(), "rank needs candidates".into())?
1120                    }
1121                    (Candidates::From(_), _) => {
1122                        inv(subject.clone(), "only rank takes candidates".into())?
1123                    }
1124                    (Candidates::None, _) => {}
1125                }
1126                if !kind_valid_for(d.operation, d.requires) {
1127                    inv(
1128                        subject.clone(),
1129                        format!("{:?} cannot require {:?}", d.operation, d.requires),
1130                    )?;
1131                }
1132                if d.for_each.len() > 2 {
1133                    inv(subject.clone(), "at most two fan-out indices".into())?;
1134                }
1135                let mut binds = BTreeSet::new();
1136                for f in &d.for_each {
1137                    if !valid_name(&f.bind) || !binds.insert(f.bind.clone()) {
1138                        inv(
1139                            subject.clone(),
1140                            format!("bind {:?} must be unique and valid", f.bind),
1141                        )?;
1142                    }
1143                    if step_ref(&f.over).is_none() {
1144                        inv(
1145                            subject.clone(),
1146                            format!("for_each over {:?} must be a step", f.over),
1147                        )?;
1148                    }
1149                    known(&f.over).or_else(|e| inv(subject.clone(), e))?;
1150                    if let ItemSource::List { list, .. } = &f.items {
1151                        known(list).or_else(|e| inv(subject.clone(), e))?;
1152                    }
1153                }
1154                for r in &d.project {
1155                    match Ref::parse(r) {
1156                        Ok(Ref::Bind(b)) if !binds.contains(&b) => {
1157                            inv(subject.clone(), format!("unknown bind {b:?}"))?
1158                        }
1159                        Ok(Ref::Bind(_)) => {}
1160                        Ok(Ref::Input(_, p)) if p.is_empty() => {
1161                            known(r).or_else(|e| inv(subject.clone(), e))?
1162                        }
1163                        Ok(Ref::Step(_)) => known(r).or_else(|e| inv(subject.clone(), e))?,
1164                        Ok(_) => inv(subject.clone(), format!("{r:?} cannot be projected"))?,
1165                        Err(e) => inv(subject.clone(), e)?,
1166                    }
1167                }
1168                if matches!(d.calibration, CalibrationRef::Id(_))
1169                    && d.requires != RequiredKind::CalibratedProbability
1170                {
1171                    inv(
1172                        subject.clone(),
1173                        "a calibration is bound only to a calibrated_probability step".into(),
1174                    )?;
1175                }
1176                if let BindingChoice::Backend(b) = &d.backend.binding
1177                    && b.is_empty()
1178                {
1179                    inv(subject.clone(), "binding names no backend".into())?;
1180                }
1181                if let Fallback::Kind { reason, .. } = &d.fallback
1182                    && reason.is_empty()
1183                {
1184                    inv(subject.clone(), "a fallback states its reason".into())?;
1185                }
1186            }
1187        }
1188    }
1189    if def.limits.distribution_tolerance <= Decimal::ZERO
1190        || def.limits.distribution_tolerance > Decimal::parse("0.1").unwrap_or(Decimal::ZERO)
1191    {
1192        inv(
1193            "limits".into(),
1194            "distribution_tolerance must be in (0, 0.1]".into(),
1195        )?;
1196    }
1197    // Policy structure.
1198    let policy = &def.policy;
1199    for (i, h) in policy.on_unresolved.iter().enumerate() {
1200        let subject = format!("policy:on_unresolved[{i}]");
1201        match Ref::parse(&h.target) {
1202            Ok(Ref::Input(_, p)) if p.is_empty() => {
1203                known(&h.target).or_else(|e| inv(subject.clone(), e))?
1204            }
1205            Ok(Ref::Step(_)) => known(&h.target).or_else(|e| inv(subject.clone(), e))?,
1206            _ => inv(
1207                subject.clone(),
1208                format!("handler target {:?} must be an input or step", h.target),
1209            )?,
1210        }
1211        if matches!(&h.reasons, ReasonSet::Only(v) if v.is_empty()) {
1212            inv(subject.clone(), "handler covers no reason".into())?;
1213        }
1214        if matches!(&h.action, HandlerAction::Escalate { reason } if reason.is_empty()) {
1215            inv(subject, "an escalation states its reason".into())?;
1216        }
1217    }
1218    let mut rule_ids = BTreeSet::new();
1219    let mut proposed_params: BTreeSet<String> = BTreeSet::new();
1220    for r in &policy.rules {
1221        let subject = format!("policy:rule:{}", r.id);
1222        if !valid_name(&r.id) || !rule_ids.insert(r.id.clone()) {
1223            inv(subject.clone(), "rule ids must be unique and valid".into())?;
1224        }
1225        cond_structure(&r.when, &options_of, &known).or_else(|e| inv(subject.clone(), e))?;
1226        let mut refs = Vec::new();
1227        outcome_refs(&r.then, &mut refs);
1228        for x in &refs {
1229            known(x).or_else(|e| inv(subject.clone(), e))?;
1230        }
1231        match &r.then {
1232            OutcomeDecl::Propose { action, params } => {
1233                let names: BTreeSet<_> = params.iter().map(|p| &p.name).collect();
1234                if action.is_empty()
1235                    || names.len() != params.len()
1236                    || params.iter().any(|p| p.name.is_empty())
1237                {
1238                    inv(
1239                        subject.clone(),
1240                        "a proposal has an action and unique parameter names".into(),
1241                    )?;
1242                }
1243                proposed_params.extend(params.iter().map(|p| p.name.clone()));
1244            }
1245            OutcomeDecl::Escalate { reason } if reason.is_empty() => {
1246                inv(subject.clone(), "an escalation states its reason".into())?
1247            }
1248            _ => {}
1249        }
1250    }
1251    match policy.rules.last() {
1252        Some(r) if r.when == Cond::Always => {}
1253        _ => inv(
1254            "policy".into(),
1255            "the last rule's condition must be `always`".into(),
1256        )?,
1257    }
1258    let mut adj_ids = BTreeSet::new();
1259    for a in &policy.adjustments {
1260        let subject = format!("policy:adjustment:{}", a.id);
1261        if !valid_name(&a.id) || !adj_ids.insert(a.id.clone()) {
1262            inv(
1263                subject.clone(),
1264                "adjustment ids must be unique and valid".into(),
1265            )?;
1266        }
1267        let scale: BTreeSet<_> = a.scale.iter().collect();
1268        if a.scale.len() < 2 || scale.len() != a.scale.len() {
1269            inv(
1270                subject.clone(),
1271                "a scale has at least two unique positions".into(),
1272            )?;
1273        }
1274        if !proposed_params.contains(&a.param) {
1275            inv(
1276                subject.clone(),
1277                format!("no proposal has parameter {:?}", a.param),
1278            )?;
1279        }
1280        cond_structure(&a.when, &options_of, &known).or_else(|e| inv(subject, e))?;
1281    }
1282    // Spec 033's structure (3.8.1), after the policy's: checks, groups,
1283    // remediations, questions and the policy constructs it adds.
1284    if v2 {
1285        let mut taken: BTreeSet<String> = ids.iter().cloned().collect();
1286        taken.extend(rule_ids.iter().cloned());
1287        taken.extend(adj_ids.iter().cloned());
1288        crate::actionable::structure(def, &inputs, &taken)?;
1289    }
1290
1291    let authored = def;
1292    let screens = screening::validate(def)?;
1293    let lowered = screening::lower(def, &screens, &mut decoded);
1294    let def = &lowered;
1295    let ids: Vec<String> = def.steps.iter().map(|s| s.id.clone()).collect();
1296
1297    // (c) Resolve operators and calibrations.
1298    let calibration_ids: Vec<(CalibrationId, &CalibrationArtifact)> = calibrations
1299        .iter()
1300        .map(|c| c.id().map(|id| (id, c)))
1301        .collect::<Result<_, _>>()
1302        .map_err(|e| Refusal {
1303            category: C::Parse,
1304            subject: "calibration".into(),
1305            detail: e.to_string(),
1306            shortfalls: vec![],
1307        })?;
1308    let mut resolved_cal: BTreeMap<String, (CalibrationArtifact, CalibrationId)> = BTreeMap::new();
1309    for s in &def.steps {
1310        let subject = format!("step:{}", s.id);
1311        match &s.body {
1312            StepBody::Extract(_) | StepBody::AcceptModelDerived(_) => {
1313                // Refused earlier by extraction-not-compiled.
1314            }
1315            StepBody::Generate(_) => {}
1316            StepBody::Acquire(_) | StepBody::Widen(_) => {
1317                crate::acquisition::resolve(&s.id, &s.body, sources)?;
1318            }
1319            StepBody::Corroborate(_) => {}
1320            StepBody::Exact(e) => {
1321                let (registered, registry) = if v2 {
1322                    (
1323                        ops::is_registered_v2(&e.op, e.version),
1324                        schema::EXACT_REGISTRY_V2,
1325                    )
1326                } else {
1327                    (ops::is_registered(&e.op, e.version), schema::EXACT_REGISTRY)
1328                };
1329                if !registered {
1330                    return refuse(
1331                        C::UnknownOperator,
1332                        subject,
1333                        format!("{}@{} is not in {registry}", e.op, e.version),
1334                    );
1335                }
1336            }
1337            StepBody::Semantic(d) => {
1338                if d.requires != RequiredKind::CalibratedProbability {
1339                    continue;
1340                }
1341                let CalibrationRef::Id(cid) = &d.calibration else {
1342                    return refuse(
1343                        C::CalibrationBinding,
1344                        subject,
1345                        "a calibrated_probability step names no calibration",
1346                    );
1347                };
1348                let Some((_, art)) = calibration_ids.iter().find(|(id, _)| id == cid) else {
1349                    return refuse(
1350                        C::CalibrationBinding,
1351                        subject,
1352                        format!("calibration {cid} was not supplied"),
1353                    );
1354                };
1355                calibrate::check_static(art, &d.task, &s.id, &d.options).map_err(|e| Refusal {
1356                    category: C::CalibrationBinding,
1357                    subject: subject.clone(),
1358                    detail: e.to_string(),
1359                    shortfalls: vec![],
1360                })?;
1361                resolved_cal.insert(s.id.clone(), ((*art).clone(), cid.clone()));
1362            }
1363        }
1364    }
1365
1366    let prompts = crate::generation::resolve(def, templates)?;
1367
1368    // (c) continued: bind every source a graded input names (spec 032,
1369    // 3.3.4). A version 1 definition binds none.
1370    let mut bound = if v2 {
1371        quality::bind_sources(def, sources)?
1372    } else {
1373        quality::Bound::default()
1374    };
1375
1376    // (d) Graph.
1377    let mut deps: BTreeMap<String, Vec<String>> = BTreeMap::new();
1378    for s in &def.steps {
1379        let mut refs: Vec<String> = match &s.body {
1380            StepBody::Extract(_) | StepBody::AcceptModelDerived(_) => {
1381                // Refused earlier by extraction-not-compiled.
1382                Vec::new()
1383            }
1384            StepBody::Generate(g) => crate::generation::dependencies(g, def),
1385            StepBody::Acquire(_) | StepBody::Widen(_) => crate::acquisition::refs(&s.body),
1386            StepBody::Corroborate(c) => corroborate_refs(c),
1387            StepBody::Exact(_) => decoded[&s.id].refs(),
1388            StepBody::Semantic(d) => {
1389                let mut v = Vec::new();
1390                if let Candidates::From(r) = &d.candidates {
1391                    v.push(r.clone());
1392                }
1393                for f in &d.for_each {
1394                    v.push(f.over.clone());
1395                    if let ItemSource::List { list, .. } = &f.items {
1396                        v.push(list.clone());
1397                    }
1398                }
1399                v.extend(
1400                    d.project
1401                        .iter()
1402                        .filter(|r| !r.starts_with("bind:"))
1403                        .cloned(),
1404                );
1405                v
1406            }
1407        };
1408        if v2 {
1409            // A version 2 step reference's dependency is the step itself.
1410            refs = refs.iter().map(|r| base_ref_v2(r)).collect();
1411        }
1412        let mut seen = BTreeSet::new();
1413        refs.retain(|r| seen.insert(r.clone()));
1414        deps.insert(s.id.clone(), refs);
1415    }
1416    screening::dependencies(authored, &screens, &decoded, &mut deps);
1417    let order = topo_order(&ids, &deps)?;
1418
1419    // (e) Types, in topological order.
1420    let input_tys: BTreeMap<String, Ty> = inputs
1421        .iter()
1422        .map(|(k, (t, _))| (k.clone(), t.clone()))
1423        .collect();
1424    let mut step_tys: BTreeMap<String, StepTy> = BTreeMap::new();
1425    let mut infos: BTreeMap<String, StepInfo> = BTreeMap::new();
1426    let mut projection_bounds: BTreeMap<String, u64> = BTreeMap::new();
1427    let mut max_requests: BTreeMap<String, u64> = BTreeMap::new();
1428    let by_id: BTreeMap<&str, &rustev_contract::definition::StepDecl> =
1429        def.steps.iter().map(|s| (s.id.as_str(), s)).collect();
1430    let mut acquisition_derivations = BTreeMap::new();
1431    for id in &order {
1432        let s = by_id[id.as_str()];
1433        let subject = format!("step:{id}");
1434        let env = TyEnv {
1435            inputs: &input_tys,
1436            steps: &step_tys,
1437            item: None,
1438            v2,
1439        };
1440        let km = |e: String| Refusal {
1441            category: C::KindMismatch,
1442            subject: subject.clone(),
1443            detail: e,
1444            shortfalls: vec![],
1445        };
1446        match &s.body {
1447            StepBody::Extract(_) | StepBody::AcceptModelDerived(_) => {
1448                // Refused earlier by extraction-not-compiled.
1449            }
1450            StepBody::Generate(_) => {
1451                infos.insert(id.clone(), StepInfo::Generate);
1452            }
1453            StepBody::Acquire(_) | StepBody::Widen(_) => {
1454                let a = crate::acquisition::compile(
1455                    id,
1456                    &s.body,
1457                    sources,
1458                    &env,
1459                    &acquisition_derivations,
1460                    def,
1461                )?;
1462                step_tys.insert(id.clone(), StepTy::Exact(a.ty.clone()));
1463                infos.insert(id.clone(), StepInfo::Acquisition(Box::new(a)));
1464            }
1465            StepBody::Corroborate(c) => {
1466                // The step's value is the claim (spec 032, 3.7.5).
1467                let ty = crate::expr::ref_ty(&c.claim, &env).map_err(km)?;
1468                for check in &c.by {
1469                    type_predicate(&check.predicate, &env).map_err(km)?;
1470                }
1471                step_tys.insert(id.clone(), StepTy::Exact(ty.clone()));
1472                infos.insert(
1473                    id.clone(),
1474                    StepInfo::Corroborate {
1475                        decl: Box::new(c.clone()),
1476                        ty,
1477                    },
1478                );
1479            }
1480            StepBody::Exact(_) => {
1481                let args = decoded[id].clone();
1482                if let OpArgs::MergeFields(m) = &args {
1483                    // A per-item merge reads a join over its own base list.
1484                    let overlay = m
1485                        .overlay
1486                        .strip_prefix("step:")
1487                        .map(|s| crate::expr::split_step(s).0);
1488                    if let Some(OpArgs::JoinKeyed(j)) =
1489                        overlay.as_deref().and_then(|o| decoded.get(o))
1490                        && j.left != m.base
1491                    {
1492                        return refuse(
1493                            C::InvalidDefinition,
1494                            subject,
1495                            format!("the overlay joins {}, not the base list {}", j.left, m.base),
1496                        );
1497                    }
1498                    if let Some(e) = args.merge_rule_error(&env) {
1499                        return refuse(C::InvalidDefinition, subject, e);
1500                    }
1501                }
1502                let ty = args.type_check(&env).map_err(km)?;
1503                step_tys.insert(id.clone(), StepTy::Exact(ty.clone()));
1504                infos.insert(id.clone(), StepInfo::Exact { args, ty });
1505            }
1506            StepBody::Semantic(d) => {
1507                let mut requests: u64 = 1;
1508                let mut instance_bytes: u64 = 0;
1509                let mut bind_items = BTreeMap::new();
1510                for f in &d.for_each {
1511                    let t = crate::expr::ref_ty(&f.over, &env).map_err(km)?;
1512                    let (max, id_bytes) = Value::id_bounds(&t)
1513                        .ok_or_else(|| km(format!("{} yields {}, not ids", f.over, t.name())))?;
1514                    requests = requests.saturating_mul(max);
1515                    instance_bytes = instance_bytes
1516                        .saturating_add(2 + 6 * f.bind.len() as u64)
1517                        .saturating_add(2 + 6 * id_bytes)
1518                        .saturating_add(2);
1519                    if let ItemSource::List { list, id_field } = &f.items {
1520                        match crate::expr::ref_ty(list, &env).map_err(km)? {
1521                            Ty::List { item, .. } => match item.as_ref() {
1522                                Ty::Record(fields)
1523                                    if fields.iter().any(|(n, t)| {
1524                                        n == id_field && matches!(t, Ty::Text { .. } | Ty::Enum(_))
1525                                    }) =>
1526                                {
1527                                    bind_items.insert(f.bind.clone(), (*item).clone());
1528                                }
1529                                _ => {
1530                                    return Err(km(format!(
1531                                        "{list} has no text id field {id_field:?}"
1532                                    )));
1533                                }
1534                            },
1535                            t => return Err(km(format!("{list} is {}, not a list", t.name()))),
1536                        }
1537                    }
1538                }
1539                let mut candidate_bytes = 0u64;
1540                if let Candidates::From(r) = &d.candidates {
1541                    let t = crate::expr::ref_ty(r, &env).map_err(km)?;
1542                    let (max, id_bytes) = Value::id_bounds(&t)
1543                        .ok_or_else(|| km(format!("{r} yields {}, not ids", t.name())))?;
1544                    candidate_bytes = max.saturating_mul(2 + 6 * id_bytes + 1);
1545                }
1546                let mut value_bytes = 0u64;
1547                for r in &d.project {
1548                    let t = match Ref::parse(r).map_err(km)? {
1549                        Ref::Bind(b) if d.requires == RequiredKind::ScreenVerdict => {
1550                            let (bind, field) = b
1551                                .split_once('/')
1552                                .ok_or_else(|| km("invalid lowered field".into()))?;
1553                            match bind_items.get(bind) {
1554                                Some(Ty::Record(fields)) => fields
1555                                    .iter()
1556                                    .find(|(name, _)| name == field)
1557                                    .map(|(_, ty)| ty.clone())
1558                                    .ok_or_else(|| km("unknown lowered field".into()))?,
1559                                _ => return Err(km("invalid lowered item".into())),
1560                            }
1561                        }
1562                        Ref::Bind(b) => bind_items.get(&b).cloned().ok_or_else(|| {
1563                            km(format!("bind {b:?} has no item source to project"))
1564                        })?,
1565                        Ref::Input(n, path) => crate::expr::ref_ty(
1566                            &format!("input:{}", [vec![n], path].concat().join("/")),
1567                            &env,
1568                        )
1569                        .map_err(km)?,
1570                        Ref::Step(n) => {
1571                            let base = if v2 { crate::expr::split_step(&n).0 } else { n };
1572                            match step_tys.get(&base) {
1573                                Some(StepTy::Semantic(..)) => {
1574                                    return Err(km(format!(
1575                                        "{r} is semantic; only exact values are projected"
1576                                    )));
1577                                }
1578                                Some(StepTy::Exact(_)) => {}
1579                                None => return Err(km(format!("{r} is not a declared step"))),
1580                            }
1581                            let t = crate::expr::ref_ty(r, &env).map_err(km)?;
1582                            if matches!(
1583                                t,
1584                                Ty::Filtered { .. } | Ty::Shortlist { .. } | Ty::Ranking { .. }
1585                            ) {
1586                                return Err(km(format!(
1587                                    "{r} is a filter, shortlist or ranking summary; project its ids through for_each instead"
1588                                )));
1589                            }
1590                            t
1591                        }
1592                        _ => return Err(km(format!("{r} cannot be projected"))),
1593                    };
1594                    value_bytes = value_bytes
1595                        .saturating_add(2 + 6 * r.len() as u64 + 1)
1596                        .saturating_add(t.max_json_bytes())
1597                        .saturating_add(1);
1598                }
1599                let envelope = json!({
1600                    "candidates": [], "instance": {}, "operation": d.operation, "options": d.options,
1601                    "question": d.question, "task": d.task, "values": {},
1602                });
1603                let base = canonical_value_bytes(&envelope)
1604                    .map(|b| b.len() as u64)
1605                    .unwrap_or(u64::MAX);
1606                let bound = base
1607                    .saturating_add(candidate_bytes)
1608                    .saturating_add(instance_bytes)
1609                    .saturating_add(value_bytes);
1610                let bound = screens
1611                    .iter()
1612                    .find(|f| &f.step == id)
1613                    .map_or(bound, |f| screening::projection_bound(f.max_bytes));
1614                projection_bounds.insert(id.clone(), bound);
1615                max_requests.insert(id.clone(), requests);
1616                let kind = sem_kind(d.operation, d.requires);
1617                let binds: Vec<String> = d.for_each.iter().map(|f| f.bind.clone()).collect();
1618                step_tys.insert(id.clone(), StepTy::Semantic(kind, binds));
1619                infos.insert(
1620                    id.clone(),
1621                    StepInfo::Semantic {
1622                        decl: Box::new(d.clone()),
1623                        kind,
1624                        binding: None,
1625                        calibration: resolved_cal.get(id).cloned(),
1626                    },
1627                );
1628            }
1629        }
1630        acquisition_derivations = static_derivations(
1631            def,
1632            &inputs,
1633            &order[..=order.iter().position(|x| x == id).unwrap()],
1634            &deps,
1635            &infos,
1636        );
1637    }
1638
1639    screening::coverage(authored, &screens, &decoded)?;
1640    let mut generated = crate::generation::type_check(
1641        def,
1642        &prompts,
1643        &input_tys,
1644        &step_tys,
1645        &acquisition_derivations,
1646        &deps,
1647    )?;
1648
1649    // (e) continued: spec 033's checks and remediation parameters (3.8.1).
1650    crate::actionable::types(def, &input_tys, v2)?;
1651
1652    // (e2) Grades (spec 032, 3.9.3): the static ceiling of every input path
1653    // and step value, then every requirement and hard gate, steps in
1654    // declaration order, then policy items.
1655    let mut trees: BTreeMap<String, quality::Ceil> = BTreeMap::new();
1656    let mut grade_table: Vec<StaticGrade> = Vec::new();
1657    if v2 {
1658        for (name, (_, decl)) in &inputs {
1659            trees.insert(
1660                format!("input:{name}"),
1661                quality::input_tree(&decl.ty, bound.inputs.get(name), None),
1662            );
1663        }
1664        for id in &order {
1665            let tree = match &infos[id] {
1666                StepInfo::Generate => {
1667                    quality::Ceil::leaf(rustev_contract::grade::ValueGrade::Ungraded, false)
1668                }
1669                StepInfo::Acquisition(a) => a.tree.clone(),
1670                StepInfo::Exact { args, .. } => quality::step_tree(args, &trees),
1671                StepInfo::Semantic { decl, .. } => quality::semantic_tree(decl, &trees),
1672                StepInfo::Corroborate { decl, .. } => quality::corroborate_tree(decl, &trees),
1673            };
1674            trees.insert(format!("step:{id}"), tree);
1675        }
1676        for s in &def.steps {
1677            let subject = format!("step:{}", s.id);
1678            let found = match &infos[&s.id] {
1679                StepInfo::Acquisition(a) => a
1680                    .reads
1681                    .iter()
1682                    .find_map(|r| quality::requirement_refusal(&subject, r, &trees)),
1683                StepInfo::Exact { args, .. } => quality::op_reads(args)
1684                    .iter()
1685                    .filter(|r| r.gate || r.require.is_some())
1686                    .find_map(|r| quality::requirement_refusal(&subject, r, &trees)),
1687                StepInfo::Corroborate { decl, .. } => {
1688                    quality::corroborate_refusal(&subject, decl, &trees)
1689                }
1690                StepInfo::Generate | StepInfo::Semantic { .. } => None,
1691            };
1692            if let Some(r) = found {
1693                return Err(r);
1694            }
1695        }
1696        for (subject, c) in quality::policy_conds(def) {
1697            let mut reads = Vec::new();
1698            quality::cond_reads(c, &mut reads);
1699            if let Some(r) = reads
1700                .iter()
1701                .find_map(|r| quality::requirement_refusal(&subject, r, &trees))
1702            {
1703                return Err(r);
1704            }
1705        }
1706        if !bound.inputs.is_empty()
1707            || infos
1708                .values()
1709                .any(|i| matches!(i, StepInfo::Acquisition(_)))
1710        {
1711            for name in inputs.keys() {
1712                let key = format!("input:{name}");
1713                trees[&key].walk(&key, &mut grade_table);
1714            }
1715            for s in &def.steps {
1716                let key = format!("step:{}", s.id);
1717                trees[&key].walk(&key, &mut grade_table);
1718            }
1719        }
1720    }
1721
1722    // (f) Bind backends, fallbacks and calibration artifacts.
1723    let mut sorted: Vec<&BackendDescriptor> = descriptors.iter().collect();
1724    sorted.sort_by(|a, b| a.backend_id.as_bytes().cmp(b.backend_id.as_bytes()));
1725
1726    let mut notices: Vec<Notice> = Vec::new();
1727    let mut details: BTreeMap<String, PlanStepDetail> = BTreeMap::new();
1728    let mut determinism = BTreeMap::new();
1729    let mut bind_ctx: BTreeMap<String, (u64, u64)> = BTreeMap::new();
1730    for s in &def.steps {
1731        let subject = format!("step:{}", s.id);
1732        let d = match &s.body {
1733            StepBody::Extract(_) | StepBody::AcceptModelDerived(_) => {
1734                // Refused earlier by extraction-not-compiled.
1735                continue;
1736            }
1737            StepBody::Generate(g) => {
1738                let binding = crate::generation::bind(&s.id, g, &generated[&s.id], &sorted)?;
1739                if let Some(checked) = generated.get_mut(&s.id) {
1740                    checked.binding = binding.clone();
1741                }
1742                details.insert(s.id.clone(), PlanStepDetail::Generate(Box::new(binding)));
1743                continue;
1744            }
1745            StepBody::Semantic(d) => d,
1746            StepBody::Exact(e) => {
1747                details.insert(
1748                    s.id.clone(),
1749                    PlanStepDetail::Exact {
1750                        op: e.op.clone(),
1751                        version: e.version,
1752                    },
1753                );
1754                continue;
1755            }
1756            StepBody::Acquire(_) | StepBody::Widen(_) => {
1757                details.insert(
1758                    s.id.clone(),
1759                    if matches!(s.body, StepBody::Acquire(_)) {
1760                        PlanStepDetail::Acquire
1761                    } else {
1762                        PlanStepDetail::Widen
1763                    },
1764                );
1765                continue;
1766            }
1767            StepBody::Corroborate(_) => {
1768                details.insert(s.id.clone(), PlanStepDetail::Corroborate);
1769                continue;
1770            }
1771        };
1772        let bound = projection_bounds[&s.id];
1773        let options_needed = match &d.candidates {
1774            Candidates::From(r) => match &step_tys[step_ref(r).unwrap_or_default()] {
1775                StepTy::Exact(t) => Value::id_bounds(t).map(|(m, _)| m).unwrap_or(0),
1776                StepTy::Semantic(..) => 0,
1777            },
1778            Candidates::None => d.options.len() as u64,
1779        };
1780        bind_ctx.insert(s.id.clone(), (options_needed, bound));
1781        let candidates: Vec<&BackendDescriptor> = match &d.backend.binding {
1782            BindingChoice::Backend(b) => match sorted.iter().find(|x| &x.backend_id == b) {
1783                Some(x) => vec![*x],
1784                None => {
1785                    return Err(Refusal {
1786                        category: C::NoCapableBackend,
1787                        subject,
1788                        detail: format!("bound backend {b:?} was not supplied"),
1789                        shortfalls: vec![Shortfall {
1790                            backend_id: b.clone(),
1791                            reasons: vec![ShortfallReason::UnknownBackend],
1792                        }],
1793                    });
1794                }
1795            },
1796            BindingChoice::Auto => sorted.clone(),
1797        };
1798        let consumers_ok = |k: SemKind| consumers_accept(def, &s.id, k);
1799        if let Fallback::Kind { requires, .. } = &d.fallback {
1800            if !kind_valid_for(d.operation, *requires) {
1801                return refuse(
1802                    C::InvalidFallback,
1803                    subject,
1804                    format!("{:?} cannot fall back to {:?}", d.operation, requires),
1805                );
1806            }
1807            if *requires == RequiredKind::CalibratedProbability {
1808                return refuse(
1809                    C::InvalidFallback,
1810                    subject,
1811                    "a fallback cannot require calibration",
1812                );
1813            }
1814            if let Err(e) = consumers_ok(sem_kind(d.operation, *requires)) {
1815                return refuse(
1816                    C::InvalidFallback,
1817                    subject,
1818                    format!("consumers cannot accept the fallback kind: {e}"),
1819                );
1820            }
1821        }
1822        let evaluate =
1823            |required: RequiredKind| -> (Option<(&BackendDescriptor, OutputKind)>, Vec<Shortfall>) {
1824                let mut shortfalls = Vec::new();
1825                for b in &candidates {
1826                    match screening::candidate(
1827                        screens.iter().find(|f| f.step == s.id),
1828                        d,
1829                        b,
1830                        required,
1831                        options_needed,
1832                        bound,
1833                    ) {
1834                        Ok(o) => return (Some((*b, o)), vec![]),
1835                        Err(reasons) => shortfalls.push(Shortfall {
1836                            backend_id: b.backend_id.clone(),
1837                            reasons,
1838                        }),
1839                    }
1840                }
1841                (None, shortfalls)
1842            };
1843        let (found, shortfalls) = evaluate(d.requires);
1844        let (backend, output, requires, fallback_taken) = match (found, &d.fallback) {
1845            (Some((b, o)), _) => (b, o, d.requires, false),
1846            (None, Fallback::None) => {
1847                return Err(Refusal {
1848                    category: C::NoCapableBackend,
1849                    subject,
1850                    detail: format!(
1851                        "no backend satisfies {:?} {:?}, and no fallback is declared",
1852                        d.operation, d.requires
1853                    ),
1854                    shortfalls,
1855                });
1856            }
1857            (None, Fallback::Unsupported) => {
1858                let capability = format!("{:?}:{:?}", d.operation, d.requires).to_lowercase();
1859                notices.push(Notice {
1860                    screening: None,
1861                    kind: NoticeKind::Fallback,
1862                    subject: subject.clone(),
1863                    reason: format!("unsupported: {capability}"),
1864                    action: None,
1865                });
1866                details.insert(s.id.clone(), PlanStepDetail::Unsupported { capability });
1867                continue;
1868            }
1869            (None, Fallback::Kind { requires, reason }) => match evaluate(*requires) {
1870                (Some((b, o)), _) => {
1871                    notices.push(Notice {
1872                        screening: None,
1873                        kind: NoticeKind::Fallback,
1874                        subject: subject.clone(),
1875                        reason: reason.clone(),
1876                        action: None,
1877                    });
1878                    (b, o, *requires, true)
1879                }
1880                (None, fb_shortfalls) => {
1881                    return Err(Refusal {
1882                        category: C::InvalidFallback,
1883                        subject,
1884                        detail: format!(
1885                            "neither {:?} nor the fallback {:?} is satisfied",
1886                            d.requires, requires
1887                        ),
1888                        shortfalls: fb_shortfalls,
1889                    });
1890                }
1891            },
1892        };
1893        determinism.insert(s.id.clone(), backend.determinism);
1894        let calibration = match (requires, resolved_cal.get(&s.id)) {
1895            (RequiredKind::CalibratedProbability, Some((art, cid))) => {
1896                if art.binding.artifact != backend.artifact {
1897                    return refuse(
1898                        C::CalibrationBinding,
1899                        subject,
1900                        format!(
1901                            "calibration is bound to {}, the backend is {}",
1902                            art.binding.artifact, backend.artifact
1903                        ),
1904                    );
1905                }
1906                BoundCalibration::Bound {
1907                    id: cid.clone(),
1908                    binding: art.binding.clone(),
1909                }
1910            }
1911            _ => BoundCalibration::None,
1912        };
1913        let normalization = match (&output, requires) {
1914            (
1915                OutputKind::Logits,
1916                RequiredKind::Distribution | RequiredKind::OrdinalDistribution,
1917            ) => Normalization::Softmax1,
1918            _ => Normalization::None,
1919        };
1920        let descriptor_id = backend.id().map_err(|e| Refusal {
1921            category: C::Parse,
1922            subject: subject.clone(),
1923            detail: e.to_string(),
1924            shortfalls: vec![],
1925        })?;
1926        let binding = SemanticBinding {
1927            backend_id: backend.backend_id.clone(),
1928            artifact: backend.artifact.clone(),
1929            descriptor: descriptor_id,
1930            output,
1931            requires,
1932            normalization,
1933            calibration,
1934            fallback_taken,
1935            max_requests: max_requests[&s.id],
1936            max_projection_bytes: bound,
1937        };
1938        if let Some(StepInfo::Semantic {
1939            binding: b,
1940            kind,
1941            calibration: cal,
1942            ..
1943        }) = infos.get_mut(&s.id)
1944        {
1945            *kind = sem_kind(d.operation, requires);
1946            if requires != RequiredKind::CalibratedProbability {
1947                *cal = None;
1948            }
1949            *b = Some(Box::new(binding.clone()));
1950            step_tys.insert(
1951                s.id.clone(),
1952                StepTy::Semantic(*kind, d.for_each.iter().map(|f| f.bind.clone()).collect()),
1953            );
1954        }
1955        details.insert(s.id.clone(), PlanStepDetail::Semantic(Box::new(binding)));
1956    }
1957
1958    // (f) continued: the execution policy, once every step is bound
1959    // (spec 003, 3.2.3).
1960    let plan_execution = match execution {
1961        None => PlanExecution::None,
1962        Some(policy) => crate::execution::validate(policy, def, &sorted, &details, &bind_ctx)?,
1963    };
1964
1965    // (g) Budget.
1966    screening::budget(authored, &screens, descriptors)?;
1967    crate::acquisition::budget(def, sources, execution)?;
1968    let mut total: u64 = crate::generation::budget(def, &details)?;
1969    for s in &def.steps {
1970        if let Some(PlanStepDetail::Semantic(b)) = details.get(&s.id) {
1971            total = total.saturating_add(b.max_requests);
1972            if b.max_projection_bytes > def.limits.max_projection_bytes {
1973                return refuse(
1974                    C::Budget,
1975                    format!("step:{}", s.id),
1976                    format!(
1977                        "worst-case projection {} bytes exceeds max_projection_bytes {}",
1978                        b.max_projection_bytes, def.limits.max_projection_bytes
1979                    ),
1980                );
1981            }
1982        }
1983    }
1984    if total > def.limits.max_semantic_requests {
1985        match def.limits.on_excess {
1986            ExcessPolicy::Refuse => {
1987                return refuse(
1988                    C::Budget,
1989                    "limits",
1990                    format!(
1991                        "worst-case {total} semantic requests exceed max_semantic_requests {} without truncate_visible",
1992                        def.limits.max_semantic_requests
1993                    ),
1994                );
1995            }
1996            ExcessPolicy::TruncateVisible => notices.push(Notice {
1997                screening: None,
1998                kind: NoticeKind::Truncation,
1999                subject: "limits".into(),
2000                reason: format!(
2001                    "worst case {total} requests; at most {} are issued",
2002                    def.limits.max_semantic_requests
2003                ),
2004                action: None,
2005            }),
2006        }
2007    }
2008
2009    // (g) continued: per-item and per-field evidence 2 records fit
2010    // `RECORD_V1` (spec 032, 3.10.4).
2011    if v2 && let Err(detail) = quality::evidence_bound(def, &infos) {
2012        return refuse(C::Budget, "limits", format!("evidence-bound: {detail}"));
2013    }
2014
2015    // (h) Policy.
2016    let mut reasons = possible_reasons(def, &inputs, &order, &infos, &details);
2017    if v2 {
2018        extend_reasons_v2(def, &inputs, &order, &infos, &deps, &trees, &mut reasons);
2019    }
2020    check_policy(def, &input_tys, &step_tys, &reasons, &mut notices, v2)?;
2021
2022    // (h2) Exhaustiveness and caller addressing, and the static table
2023    // (spec 033, 3.8.2 and 3.8.3).
2024    let actionability = crate::actionable::phase_h2(def, &inputs, &deps, &reasons)?;
2025
2026    // (i) Emit.
2027    let derivations = static_derivations(def, &inputs, &order, &deps, &infos);
2028    // A plan records the lowest registry containing every operator,
2029    // predicate and reference form it uses (spec 032, 3.1.3).
2030    let registry = if v2 && uses_exact_v2(def, &decoded) {
2031        schema::EXACT_REGISTRY_V2
2032    } else {
2033        schema::EXACT_REGISTRY
2034    };
2035    let definition_id = authored.id().map_err(|e| Refusal {
2036        category: C::Parse,
2037        subject: "definition".into(),
2038        detail: e.to_string(),
2039        shortfalls: vec![],
2040    })?;
2041    let acquisitions: Vec<_> = def
2042        .steps
2043        .iter()
2044        .filter_map(|s| match &infos[&s.id] {
2045            StepInfo::Acquisition(a) => {
2046                use rustev_contract::acquisition_record::AcquisitionPossibleOutcome as O;
2047                let mut plan = a.plan.clone();
2048                for (reason, outcome) in [
2049                    (ReasonKind::Skipped, O::Skipped),
2050                    (ReasonKind::InsufficientGrade, O::InsufficientGrade),
2051                ] {
2052                    if reasons[&format!("step:{}", s.id)].contains(&reason)
2053                        && !plan.outcomes.contains(&outcome)
2054                    {
2055                        plan.outcomes.push(outcome);
2056                    }
2057                }
2058                Some(plan)
2059            }
2060            _ => None,
2061        })
2062        .collect();
2063    for a in &acquisitions {
2064        for b in &a.sources {
2065            if !bound.sources.iter().any(|s| s.name == b.source.name) {
2066                bound.sources.push(b.source.clone());
2067            }
2068        }
2069    }
2070    bound.sources.sort_by(|a, b| a.name.cmp(&b.name));
2071    let plan = Plan {
2072        acquisitions: (!acquisitions.is_empty()).then_some(acquisitions),
2073        schema: if v2 { schema::PLAN_V3 } else { schema::PLAN }.into(),
2074        compiler: COMPILER.into(),
2075        registry: registry.into(),
2076        definition_id,
2077        definition: authored.clone(),
2078        order: order.clone(),
2079        steps: def
2080            .steps
2081            .iter()
2082            .map(|s| PlanStep {
2083                id: s.id.clone(),
2084                derivation: derivations[&s.id],
2085                detail: details[&s.id].clone(),
2086            })
2087            .collect(),
2088        notices,
2089        execution: plan_execution,
2090        sources: (!bound.sources.is_empty()).then(|| bound.sources.clone()),
2091        grades: (!grade_table.is_empty()).then_some(grade_table),
2092        actionability,
2093        instantiation: None,
2094        screening: screening::plan(authored, &screens, &details),
2095    };
2096    let id = plan.id().map_err(|e| Refusal {
2097        category: C::Parse,
2098        subject: "plan".into(),
2099        detail: e.to_string(),
2100        shortfalls: vec![],
2101    })?;
2102    Ok(Compiled {
2103        plan,
2104        id,
2105        info: Info {
2106            screen_categories: descriptors
2107                .iter()
2108                .filter_map(|d| {
2109                    d.operations
2110                        .iter()
2111                        .find_map(|op| op.screen.as_ref())
2112                        .map(|p| (d.backend_id.clone(), p.categories.clone()))
2113                })
2114                .collect(),
2115            generation: generated,
2116            inputs,
2117            step_ids: ids,
2118            steps: infos,
2119            deps,
2120            order,
2121            v2,
2122            bound,
2123            trees,
2124            determinism,
2125        },
2126    })
2127}
2128
2129/// Whether a version 2 definition uses an operator, predicate or reference
2130/// form only `rustev.exact/2` has.
2131fn uses_exact_v2(def: &Definition, decoded: &BTreeMap<String, OpArgs>) -> bool {
2132    decoded
2133        .values()
2134        .any(|a| matches!(a, OpArgs::JoinKeyed(_) | OpArgs::MergeFields(_)) || a.uses_v2())
2135        || def.steps.iter().any(|s| match &s.body {
2136            StepBody::Acquire(_) | StepBody::Widen(_) => true,
2137            StepBody::Corroborate(c) => {
2138                c.by.iter()
2139                    .any(|k| crate::expr::predicate_uses_v2(&k.predicate))
2140            }
2141            _ => false,
2142        })
2143        || quality::policy_conds(def).any(|(_, c)| first_cond_v2(c))
2144}
2145
2146fn first_cond_v2(c: &Cond) -> bool {
2147    match c {
2148        Cond::Exact(p) => crate::expr::predicate_uses_v2(p),
2149        Cond::All(cs) | Cond::Any(cs) => cs.iter().any(first_cond_v2),
2150        Cond::Not(c) => first_cond_v2(c),
2151        _ => false,
2152    }
2153}
2154
2155fn base_ref_v2(r: &str) -> String {
2156    crate::expr::base_ref_v2(r)
2157}
2158
2159/// Whether values of a version 2 type can be absent or `null`.
2160fn may_be_absent(t: &TypeDecl) -> bool {
2161    match t {
2162        TypeDecl::Nullable { .. } => true,
2163        TypeDecl::Record {
2164            present_when: Some(_),
2165            ..
2166        } => true,
2167        TypeDecl::Record { fields, .. } => fields.iter().any(|f| may_be_absent(&f.ty)),
2168        TypeDecl::List { item, .. } => may_be_absent(item),
2169        TypeDecl::Map { value, .. } => may_be_absent(value),
2170        _ => false,
2171    }
2172}
2173
2174/// The reasons spec 032 adds (3.4.4, 3.7.5, 3.8.1 and 3.9.4): reading an
2175/// absent value is `missing_evidence`; a requirement or graded hard gate
2176/// can be `insufficient_grade`; a corroboration can be `conflict` or
2177/// `abstained`; `abstain` on disagreement is `abstained`.
2178#[allow(clippy::too_many_arguments)]
2179fn extend_reasons_v2(
2180    def: &Definition,
2181    inputs: &BTreeMap<String, (Ty, InputDecl)>,
2182    order: &[String],
2183    infos: &BTreeMap<String, StepInfo>,
2184    deps: &BTreeMap<String, Vec<String>>,
2185    trees: &BTreeMap<String, quality::Ceil>,
2186    reasons: &mut BTreeMap<String, BTreeSet<ReasonKind>>,
2187) {
2188    use ReasonKind as K;
2189    let graded_gate = |r: &quality::Read| {
2190        r.require.is_some() || (r.gate && quality::read_ceiling(r, trees).own.graded().is_some())
2191    };
2192    let mut absent: BTreeSet<String> = BTreeSet::new();
2193    for (name, (_, decl)) in inputs {
2194        let key = format!("input:{name}");
2195        if may_be_absent(&decl.ty) {
2196            absent.insert(key.clone());
2197        }
2198        if matches!(
2199            decl.on_disagreement,
2200            Some(rustev_contract::definition::Disagreement::Abstain)
2201        ) {
2202            reasons.entry(key).or_default().insert(K::Abstained);
2203        }
2204    }
2205    for id in order {
2206        let key = format!("step:{id}");
2207        let mut add: BTreeSet<ReasonKind> = BTreeSet::new();
2208        match &infos[id] {
2209            StepInfo::Exact { args, .. } => {
2210                let reads = quality::op_reads(args);
2211                let reads_absent = reads.iter().any(|r| absent.contains(&r.base));
2212                match args {
2213                    OpArgs::JoinKeyed(_) | OpArgs::MergeFields(_) => {
2214                        absent.insert(key.clone());
2215                    }
2216                    // These exclude an absent item instead (3.4.4).
2217                    OpArgs::FilterWithReasons(_) | OpArgs::TopK(_) | OpArgs::FieldPresence(_) => {}
2218                    _ if reads_absent => {
2219                        add.insert(K::MissingEvidence);
2220                    }
2221                    _ => {}
2222                }
2223                if reads.iter().any(graded_gate) {
2224                    add.insert(K::InsufficientGrade);
2225                }
2226                if !matches!(args, OpArgs::FieldPresence(_)) {
2227                    for d in &deps[id] {
2228                        let mut inherited = reasons.get(d).cloned().unwrap_or_default();
2229                        if probe_only(args, d) {
2230                            inherited.remove(&K::Skipped);
2231                        }
2232                        add.extend(inherited);
2233                    }
2234                }
2235            }
2236            StepInfo::Corroborate { decl, .. } => {
2237                let (claim, _) = quality::claim_path(decl);
2238                if absent.contains(&claim) {
2239                    absent.insert(key.clone());
2240                }
2241                add.extend(reasons.get(&claim).cloned().unwrap_or_default());
2242                // A check reading an absent or unresolved value is
2243                // `no_evidence`, and the claim then passes unchanged
2244                // (spec 032, 3.7 items 3 and 5): a check's reads add no
2245                // reason of their own to the step.
2246                match decl.on_contradiction {
2247                    rustev_contract::definition::OnContradiction::Conflict => {
2248                        add.insert(K::Conflict);
2249                    }
2250                    rustev_contract::definition::OnContradiction::Abstain => {
2251                        add.insert(K::Abstained);
2252                    }
2253                    rustev_contract::definition::OnContradiction::TreatAsHint => {}
2254                }
2255            }
2256            StepInfo::Generate => {}
2257            StepInfo::Acquisition(_) | StepInfo::Semantic { .. } => {
2258                for d in &deps[id] {
2259                    add.extend(reasons.get(d).cloned().unwrap_or_default());
2260                }
2261            }
2262        }
2263        reasons.entry(key).or_default().extend(add);
2264    }
2265    for (_, c) in quality::policy_conds(def) {
2266        let mut reads = Vec::new();
2267        quality::cond_reads(c, &mut reads);
2268        for r in &reads {
2269            if graded_gate(r) {
2270                reasons
2271                    .entry(r.base.clone())
2272                    .or_default()
2273                    .insert(K::InsufficientGrade);
2274            }
2275            if absent.contains(&r.base) {
2276                reasons
2277                    .entry(r.base.clone())
2278                    .or_default()
2279                    .insert(K::MissingEvidence);
2280            }
2281        }
2282    }
2283}
2284
2285/// The first construct of spec 032 in a version 1 definition, as `(subject,
2286/// construct)`, judged inputs, then steps, then policy items, each in
2287/// declaration order.
2288fn first_v2_construct(
2289    def: &Definition,
2290    decoded: &BTreeMap<String, OpArgs>,
2291) -> Option<(String, &'static str)> {
2292    fn type_v2(t: &TypeDecl) -> Option<&'static str> {
2293        match t {
2294            TypeDecl::Nullable { .. } => Some("a nullable type"),
2295            TypeDecl::Map { .. } => Some("a map type"),
2296            TypeDecl::Record {
2297                present_when: Some(_),
2298                ..
2299            } => Some("a presence predicate"),
2300            TypeDecl::Record { fields, .. } => fields.iter().find_map(|f| type_v2(&f.ty)),
2301            TypeDecl::List { item, .. } => type_v2(item),
2302            _ => None,
2303        }
2304    }
2305    fn cond_v2(c: &Cond) -> bool {
2306        match c {
2307            Cond::Exact(p) => crate::expr::predicate_uses_v2(p),
2308            Cond::All(cs) | Cond::Any(cs) => cs.iter().any(cond_v2),
2309            Cond::Not(c) => cond_v2(c),
2310            _ => false,
2311        }
2312    }
2313    for i in &def.inputs {
2314        let subject = format!("input:{}", i.name);
2315        if i.evidence.is_some() {
2316            return Some((subject, "an evidence declaration"));
2317        }
2318        if i.on_disagreement.is_some() {
2319            return Some((subject, "a disagreement rule"));
2320        }
2321        if let Some(c) = type_v2(&i.ty) {
2322            return Some((subject, c));
2323        }
2324    }
2325    for s in &def.steps {
2326        let subject = format!("step:{}", s.id);
2327        match &s.body {
2328            StepBody::Extract(_) => return Some((subject, "an extraction step")),
2329            StepBody::AcceptModelDerived(_) => {
2330                return Some((subject, "an accept_model_derived step"));
2331            }
2332            StepBody::Generate(_) => return Some((subject, "a generate step")),
2333            StepBody::Acquire(_) | StepBody::Widen(_) => {
2334                return Some((subject, "an acquisition step"));
2335            }
2336            StepBody::Corroborate(_) => return Some((subject, "a corroborate step")),
2337            StepBody::Exact(_) => {
2338                if decoded.get(&s.id).is_some_and(OpArgs::uses_v2) {
2339                    return Some((subject, "a rustev.exact/2 predicate or graded reference"));
2340                }
2341            }
2342            StepBody::Semantic(_) => {}
2343        }
2344    }
2345    let policy = &def.policy;
2346    for r in &policy.rules {
2347        if cond_v2(&r.when) {
2348            return Some((
2349                format!("policy:rule:{}", r.id),
2350                "a rustev.exact/2 predicate or graded reference",
2351            ));
2352        }
2353    }
2354    for a in &policy.adjustments {
2355        if cond_v2(&a.when) {
2356            return Some((
2357                format!("policy:adjustment:{}", a.id),
2358                "a rustev.exact/2 predicate or graded reference",
2359            ));
2360        }
2361    }
2362    None
2363}
2364
2365/// The references a corroboration step reads: its claim and every check.
2366fn corroborate_refs(c: &rustev_contract::definition::CorroborateDecl) -> Vec<String> {
2367    let mut out = vec![c.claim.clone()];
2368    for check in &c.by {
2369        predicate_refs(&check.predicate, &mut out);
2370    }
2371    out.into_iter()
2372        .filter(|r| r.starts_with("input:") || r.starts_with("step:"))
2373        .map(|r| crate::expr::base_ref(&r))
2374        .collect()
2375}
2376
2377fn cond_structure(
2378    c: &Cond,
2379    options_of: &BTreeMap<&str, &Vec<String>>,
2380    known: &dyn Fn(&str) -> Result<(), String>,
2381) -> Result<(), String> {
2382    let unit = Decimal::from_i64(1);
2383    let semantic_opts = |step: &str| -> Result<&Vec<String>, String> {
2384        known(&format!("step:{step}"))?;
2385        options_of
2386            .get(step)
2387            .copied()
2388            .ok_or_else(|| format!("{step:?} is not a semantic step"))
2389    };
2390    match c {
2391        Cond::Always => Ok(()),
2392        // Validated with the rest of spec 033's constructs (3.8).
2393        Cond::ProvenanceWithin { reference, .. } => known(reference),
2394        Cond::TopLabel { step, label } => {
2395            if !semantic_opts(step)?.contains(label) {
2396                return Err(format!("{label:?} is not an option of {step:?}"));
2397            }
2398            Ok(())
2399        }
2400        Cond::MassAtLeast {
2401            step,
2402            option,
2403            threshold,
2404            uncalibrated_threshold,
2405        } => {
2406            if !semantic_opts(step)?.contains(option) {
2407                return Err(format!("{option:?} is not an option of {step:?}"));
2408            }
2409            if *threshold < Decimal::ZERO || *threshold > unit {
2410                return Err("a probability threshold is in [0, 1]".into());
2411            }
2412            if matches!(uncalibrated_threshold, UncalibratedThreshold::Declared { reason } if reason.is_empty())
2413            {
2414                return Err("an uncalibrated threshold states its reason".into());
2415            }
2416            Ok(())
2417        }
2418        Cond::TopMassBelow {
2419            step,
2420            threshold,
2421            uncalibrated_threshold,
2422        } => {
2423            semantic_opts(step)?;
2424            if *threshold < Decimal::ZERO || *threshold > unit {
2425                return Err("a probability threshold is in [0, 1]".into());
2426            }
2427            if matches!(uncalibrated_threshold, UncalibratedThreshold::Declared { reason } if reason.is_empty())
2428            {
2429                return Err("an uncalibrated threshold states its reason".into());
2430            }
2431            Ok(())
2432        }
2433        Cond::ExpectationAtLeast { step, .. } => semantic_opts(step).map(|_| ()),
2434        Cond::Exact(p) => {
2435            predicate_structure(p)?;
2436            let mut refs = Vec::new();
2437            predicate_refs(p, &mut refs);
2438            refs.iter().try_for_each(|r| known(r))
2439        }
2440        Cond::Empty(r) | Cond::Nonempty(r) => known(r),
2441        Cond::All(cs) | Cond::Any(cs) => cs
2442            .iter()
2443            .try_for_each(|c| cond_structure(c, options_of, known)),
2444        Cond::Not(c) => cond_structure(c, options_of, known),
2445    }
2446}
2447
2448/// Kahn's algorithm with ties by declaration order; a cycle is refused
2449/// naming every step that could not be ordered.
2450fn topo_order(
2451    ids: &[String],
2452    deps: &BTreeMap<String, Vec<String>>,
2453) -> Result<Vec<String>, Refusal> {
2454    let step_deps: BTreeMap<&str, BTreeSet<&str>> = ids
2455        .iter()
2456        .map(|id| {
2457            (
2458                id.as_str(),
2459                deps[id].iter().filter_map(|r| step_ref(r)).collect(),
2460            )
2461        })
2462        .collect();
2463    let mut done: BTreeSet<&str> = BTreeSet::new();
2464    let mut order = Vec::with_capacity(ids.len());
2465    loop {
2466        let next = ids.iter().find(|id| {
2467            !done.contains(id.as_str()) && step_deps[id.as_str()].iter().all(|d| done.contains(d))
2468        });
2469        match next {
2470            Some(id) => {
2471                done.insert(id);
2472                order.push(id.clone());
2473            }
2474            None => break,
2475        }
2476    }
2477    if order.len() == ids.len() {
2478        return Ok(order);
2479    }
2480    let stuck: Vec<String> = ids
2481        .iter()
2482        .filter(|id| !done.contains(id.as_str()))
2483        .cloned()
2484        .collect();
2485    refuse(
2486        Category::Cycle,
2487        format!("step:{}", stuck[0]),
2488        format!("the step graph has a cycle among {}", stuck.join(", ")),
2489    )
2490}
2491
2492/// Whether every consumer of a semantic step accepts `kind`.
2493fn consumers_accept(def: &Definition, step: &str, kind: SemKind) -> Result<(), String> {
2494    fn walk(c: &Cond, step: &str, kind: SemKind) -> Result<(), String> {
2495        match c {
2496            Cond::TopLabel { step: s, .. } if s == step => {
2497                if kind == SemKind::Scores {
2498                    return Err("top_label needs labels".into());
2499                }
2500                Ok(())
2501            }
2502            Cond::MassAtLeast { step: s, .. } | Cond::TopMassBelow { step: s, .. } if s == step => {
2503                if kind.has_mass() {
2504                    Ok(())
2505                } else {
2506                    Err(format!(
2507                        "a probability threshold cannot read {}",
2508                        kind.name()
2509                    ))
2510                }
2511            }
2512            Cond::ExpectationAtLeast { step: s, .. } if s == step => {
2513                if matches!(kind, SemKind::Ordinal(_)) {
2514                    Ok(())
2515                } else {
2516                    Err(format!("an expectation cannot read {}", kind.name()))
2517                }
2518            }
2519            Cond::All(cs) | Cond::Any(cs) => cs.iter().try_for_each(|c| walk(c, step, kind)),
2520            Cond::Not(c) => walk(c, step, kind),
2521            _ => Ok(()),
2522        }
2523    }
2524    for r in &def.policy.rules {
2525        walk(&r.when, step, kind)?;
2526    }
2527    for a in &def.policy.adjustments {
2528        walk(&a.when, step, kind)?;
2529    }
2530    for s in &def.steps {
2531        if let StepBody::Exact(e) = &s.body
2532            && let Some(Ok(OpArgs::WeightedRank(w))) = ops::decode(&e.op, e.version, &e.args)
2533        {
2534            for c in &w.components {
2535                match &c.source {
2536                    ops::Source::PropositionMean { step: s2, .. }
2537                        if step_ref(s2) == Some(step) && !kind.has_mass() =>
2538                    {
2539                        return Err(format!("proposition_mean cannot read {}", kind.name()));
2540                    }
2541                    ops::Source::RubricExpectation { step: s2 }
2542                        if step_ref(s2) == Some(step) && !matches!(kind, SemKind::Ordinal(_)) =>
2543                    {
2544                        return Err(format!("rubric_expectation cannot read {}", kind.name()));
2545                    }
2546                    _ => {}
2547                }
2548            }
2549        }
2550    }
2551    Ok(())
2552}
2553
2554/// Reasons each input and step can be unresolved with (spec 002, 3.11.2).
2555fn possible_reasons(
2556    def: &Definition,
2557    inputs: &BTreeMap<String, (Ty, InputDecl)>,
2558    order: &[String],
2559    infos: &BTreeMap<String, StepInfo>,
2560    details: &BTreeMap<String, PlanStepDetail>,
2561) -> BTreeMap<String, BTreeSet<ReasonKind>> {
2562    use ReasonKind as K;
2563    let mut out: BTreeMap<String, BTreeSet<ReasonKind>> = BTreeMap::new();
2564    for (name, (_, decl)) in inputs {
2565        let mut r: BTreeSet<_> = [K::MissingEvidence, K::InvalidInput, K::Conflict].into();
2566        if matches!(decl.freshness, Freshness::MaxAgeMs(_)) {
2567            r.insert(K::StaleEvidence);
2568        }
2569        out.insert(format!("input:{name}"), r);
2570    }
2571    // A check can make its subject unresolved (spec 033, 3.4.3), so every
2572    // step reading it inherits those reasons too.
2573    crate::actionable::extend_reasons(def, &mut out);
2574    for id in order {
2575        let mut r = BTreeSet::new();
2576        match (&infos[id], &details[id]) {
2577            (StepInfo::Generate, _) => {}
2578            (StepInfo::Acquisition(a), _) => {
2579                r.extend([
2580                    K::MissingEvidence,
2581                    K::StaleEvidence,
2582                    K::InvalidInput,
2583                    K::BackendUnavailable,
2584                    K::InvalidBackendOutput,
2585                    K::BudgetExhausted,
2586                    K::DeadlineExceeded,
2587                ]);
2588                if a.plan.outcomes.contains(
2589                    &rustev_contract::acquisition_record::AcquisitionPossibleOutcome::Skipped,
2590                ) {
2591                    r.insert(K::Skipped);
2592                }
2593                if a.plan.outcomes.contains(&rustev_contract::acquisition_record::AcquisitionPossibleOutcome::InsufficientGrade) { r.insert(K::InsufficientGrade); }
2594            }
2595            (_, PlanStepDetail::Unsupported { .. }) => {
2596                r.insert(K::Unsupported);
2597            }
2598            // An unresolved claim makes the step unresolved with the claim's
2599            // reason (spec 032, 3.7.6); the rest is `extend_reasons_v2`.
2600            (StepInfo::Corroborate { decl, .. }, _) => {
2601                let (claim, _) = quality::claim_path(decl);
2602                r.extend(out.get(&claim).cloned().unwrap_or_default());
2603            }
2604            (StepInfo::Exact { args, .. }, _) => {
2605                if let OpArgs::FieldPresence(fp) = args {
2606                    for i in &fp.inputs {
2607                        let mut s = out.get(i).cloned().unwrap_or_default();
2608                        s.remove(&K::MissingEvidence);
2609                        r.extend(s);
2610                    }
2611                } else {
2612                    for d in args.refs() {
2613                        let mut inherited = out.get(&d).cloned().unwrap_or_default();
2614                        if probe_only(args, &d) {
2615                            inherited.remove(&K::Skipped);
2616                        }
2617                        r.extend(inherited);
2618                    }
2619                }
2620                r.extend(args.own_reasons());
2621            }
2622            (StepInfo::Semantic { decl, .. }, _) => {
2623                let mut refs: Vec<String> = decl
2624                    .project
2625                    .iter()
2626                    .filter(|p| !p.starts_with("bind:"))
2627                    .cloned()
2628                    .collect();
2629                for f in &decl.for_each {
2630                    refs.push(f.over.clone());
2631                    if let ItemSource::List { list, .. } = &f.items {
2632                        refs.push(list.clone());
2633                    }
2634                }
2635                if let Candidates::From(c) = &decl.candidates {
2636                    refs.push(c.clone());
2637                }
2638                for d in refs {
2639                    r.extend(out.get(&d).cloned().unwrap_or_default());
2640                }
2641                if decl.for_each.is_empty() {
2642                    r.extend([
2643                        K::BackendUnavailable,
2644                        K::InvalidBackendOutput,
2645                        K::BudgetExhausted,
2646                        K::DeadlineExceeded,
2647                    ]);
2648                } else {
2649                    // An item record that cannot be found for an id.
2650                    r.insert(K::InvalidInput);
2651                }
2652            }
2653        }
2654        out.insert(format!("step:{id}"), r);
2655    }
2656    out
2657}
2658
2659// Presence probes consume the existence of a value, not a skipped value.
2660pub(crate) fn predicate_value_refs(
2661    p: &rustev_contract::definition::Predicate,
2662    out: &mut Vec<String>,
2663) {
2664    use rustev_contract::definition::Predicate as P;
2665    match p {
2666        P::Present(_) | P::IsConstructed { .. } => {}
2667        P::All(ps) | P::Any(ps) => ps.iter().for_each(|p| predicate_value_refs(p, out)),
2668        P::Not(p) => predicate_value_refs(p, out),
2669        p => crate::expr::predicate_refs(p, out),
2670    }
2671}
2672fn condition_value_refs(c: &Cond, out: &mut Vec<String>) {
2673    match c {
2674        Cond::Exact(p) => predicate_value_refs(p, out),
2675        Cond::All(cs) | Cond::Any(cs) => cs.iter().for_each(|c| condition_value_refs(c, out)),
2676        Cond::Not(c) => condition_value_refs(c, out),
2677        c => condition_refs(c, out),
2678    }
2679}
2680pub(crate) fn probe_only(args: &OpArgs, target: &str) -> bool {
2681    if let OpArgs::Compare(c) = args {
2682        let mut values = vec![];
2683        predicate_value_refs(&c.predicate, &mut values);
2684        return !values.iter().any(|r| crate::expr::base_ref_v2(r) == target);
2685    }
2686    false
2687}
2688
2689/// Refs a condition reads, with whether each read is under a `not`.
2690fn cond_reads(c: &Cond, negated: bool, out: &mut Vec<(String, bool)>) {
2691    match c {
2692        Cond::Not(inner) => cond_reads(inner, !negated, out),
2693        Cond::All(cs) | Cond::Any(cs) => cs.iter().for_each(|c| cond_reads(c, negated, out)),
2694        other => {
2695            let mut refs = Vec::new();
2696            condition_refs(other, &mut refs);
2697            out.extend(refs.into_iter().map(|r| (r, negated)));
2698        }
2699    }
2700}
2701
2702fn check_policy(
2703    def: &Definition,
2704    input_tys: &BTreeMap<String, Ty>,
2705    step_tys: &BTreeMap<String, StepTy>,
2706    reasons: &BTreeMap<String, BTreeSet<ReasonKind>>,
2707    notices: &mut Vec<Notice>,
2708    v2: bool,
2709) -> Result<(), Refusal> {
2710    use Category as C;
2711    let base = |r: &str| {
2712        if v2 {
2713            crate::expr::base_ref_v2(r)
2714        } else {
2715            crate::expr::base_ref(r)
2716        }
2717    };
2718    let env = TyEnv {
2719        inputs: input_tys,
2720        steps: step_tys,
2721        item: None,
2722        v2,
2723    };
2724    let policy = &def.policy;
2725
2726    // Kinds and thresholds, rules then adjustments.
2727    let items: Vec<(String, &Cond, Option<&OutcomeDecl>)> = policy
2728        .rules
2729        .iter()
2730        .map(|r| (format!("policy:rule:{}", r.id), &r.when, Some(&r.then)))
2731        .chain(
2732            policy
2733                .adjustments
2734                .iter()
2735                .map(|a| (format!("policy:adjustment:{}", a.id), &a.when, None)),
2736        )
2737        .collect();
2738    for (subject, cond, outcome) in &items {
2739        check_cond(cond, &env, subject, notices)?;
2740        if let Some(o) = outcome {
2741            match o {
2742                OutcomeDecl::Propose { params, .. } => {
2743                    for p in params {
2744                        if let ParamSource::Ref(r) = &p.value {
2745                            match crate::expr::ref_ty(r, &env) {
2746                                Ok(_) => {}
2747                                Err(e) => {
2748                                    return refuse(
2749                                        C::KindMismatch,
2750                                        subject.clone(),
2751                                        format!("parameter {:?}: {e}", p.name),
2752                                    );
2753                                }
2754                            }
2755                        }
2756                    }
2757                }
2758                OutcomeDecl::MissingEvidenceFrom { step } => match step_tys.get(step) {
2759                    Some(StepTy::Exact(Ty::List { item, .. }))
2760                        if matches!(item.as_ref(), Ty::Text { .. }) => {}
2761                    _ => {
2762                        return refuse(
2763                            C::KindMismatch,
2764                            subject.clone(),
2765                            format!("{step:?} does not list field names"),
2766                        );
2767                    }
2768                },
2769                OutcomeDecl::Escalate { .. } | OutcomeDecl::NeedsInput { .. } => {}
2770            }
2771        }
2772    }
2773
2774    // Unresolved coverage (category 8).
2775    let mut covered: BTreeMap<&str, BTreeSet<ReasonKind>> = BTreeMap::new();
2776    let mut unmet: BTreeSet<&str> = BTreeSet::new();
2777    for h in &policy.on_unresolved {
2778        let set: BTreeSet<ReasonKind> = match &h.reasons {
2779            ReasonSet::Any => ReasonKind::ALL.into_iter().collect(),
2780            ReasonSet::Only(v) => v.iter().copied().collect(),
2781        };
2782        if !matches!(
2783            h.action,
2784            HandlerAction::AsUnmet | HandlerAction::Escalate { .. }
2785        ) && set.contains(&ReasonKind::Skipped)
2786            && reasons
2787                .get(&h.target)
2788                .is_some_and(|r| r.contains(&ReasonKind::Skipped))
2789        {
2790            return refuse(
2791                C::UnhandledUnresolved,
2792                &h.target,
2793                "skipped_not_propagatable",
2794            );
2795        }
2796        if h.action == HandlerAction::AsUnmet {
2797            unmet.insert(&h.target);
2798        }
2799        covered.entry(&h.target).or_default().extend(set);
2800    }
2801    let mut reads: Vec<(String, String, bool, bool)> = Vec::new();
2802    for (subject, cond, outcome) in &items {
2803        let mut r = Vec::new();
2804        cond_reads(cond, false, &mut r);
2805        let mut values = vec![];
2806        condition_value_refs(cond, &mut values);
2807        reads.extend(
2808            r.into_iter()
2809                .filter(|(x, _)| x.starts_with("input:") || x.starts_with("step:"))
2810                .map(|(x, neg)| {
2811                    let target = base(&x);
2812                    let probe = !values.iter().any(|v| base(v) == target);
2813                    (subject.clone(), target, neg, probe)
2814                }),
2815        );
2816        if let Some(o) = outcome {
2817            let mut refs = Vec::new();
2818            outcome_refs(o, &mut refs);
2819            reads.extend(
2820                refs.into_iter()
2821                    .map(|x| (subject.clone(), base(&x), false, false)),
2822            );
2823        }
2824    }
2825    for (subject, target, negated, probe) in &reads {
2826        let mut possible = reasons.get(target).cloned().unwrap_or_default();
2827        if *probe {
2828            possible.remove(&ReasonKind::Skipped);
2829        }
2830        let have = covered.get(target.as_str()).cloned().unwrap_or_default();
2831        let missing: Vec<&str> = possible.difference(&have).map(|k| k.name()).collect();
2832        if !missing.is_empty() {
2833            return refuse(
2834                C::UnhandledUnresolved,
2835                subject.clone(),
2836                format!(
2837                    "{target} can be unresolved with {} and no handler covers it",
2838                    missing.join(", ")
2839                ),
2840            );
2841        }
2842        if *negated && unmet.contains(target.as_str()) {
2843            return refuse(
2844                C::UnhandledUnresolved,
2845                subject.clone(),
2846                format!("`not` over {target}, which is handled as_unmet"),
2847            );
2848        }
2849    }
2850    if let Some(last) = policy.rules.last() {
2851        let mut refs = Vec::new();
2852        outcome_refs(&last.then, &mut refs);
2853        if let Some(r) = refs
2854            .iter()
2855            .map(|r| base(r))
2856            .find(|r| unmet.contains(r.as_str()))
2857        {
2858            return refuse(
2859                C::UnhandledUnresolved,
2860                format!("policy:rule:{}", last.id),
2861                format!("the last rule reads {r}, which is handled as_unmet"),
2862            );
2863        }
2864    }
2865    Ok(())
2866}
2867
2868fn check_cond(
2869    c: &Cond,
2870    env: &TyEnv<'_>,
2871    subject: &str,
2872    notices: &mut Vec<Notice>,
2873) -> Result<(), Refusal> {
2874    use Category as C;
2875    let sem = |step: &str| -> Result<SemKind, Refusal> {
2876        match env.steps.get(step) {
2877            Some(StepTy::Semantic(k, binds)) if binds.is_empty() => Ok(*k),
2878            Some(StepTy::Semantic(..)) => refuse(
2879                C::KindMismatch,
2880                subject,
2881                format!("{step:?} fans out; the policy reads single values"),
2882            ),
2883            _ => refuse(
2884                C::KindMismatch,
2885                subject,
2886                format!("{step:?} is not a semantic step"),
2887            ),
2888        }
2889    };
2890    let threshold = |step: &str,
2891                     u: &UncalibratedThreshold,
2892                     notices: &mut Vec<Notice>|
2893     -> Result<(), Refusal> {
2894        match sem(step)? {
2895            SemKind::Calibrated => Ok(()),
2896            SemKind::Distribution => match u {
2897                UncalibratedThreshold::Declared { reason } => {
2898                    notices.push(Notice {
2899                        screening: None,
2900                        kind: NoticeKind::UncalibratedThreshold,
2901                        subject: subject.to_string(),
2902                        reason: reason.clone(),
2903                        action: None,
2904                    });
2905                    Ok(())
2906                }
2907                UncalibratedThreshold::NotDeclared => refuse(
2908                    C::UncalibratedThreshold,
2909                    subject,
2910                    format!(
2911                        "a probability threshold reads the uncalibrated distribution {step:?} without uncalibrated_threshold"
2912                    ),
2913                ),
2914            },
2915            k => refuse(
2916                C::KindMismatch,
2917                subject,
2918                format!(
2919                    "a probability threshold cannot read {} ({step:?})",
2920                    k.name()
2921                ),
2922            ),
2923        }
2924    };
2925    match c {
2926        Cond::Always | Cond::ProvenanceWithin { .. } => Ok(()),
2927        Cond::TopLabel { step, .. } => match sem(step)? {
2928            SemKind::Scores => refuse(
2929                C::KindMismatch,
2930                subject,
2931                format!("top_label cannot read scores ({step:?})"),
2932            ),
2933            _ => Ok(()),
2934        },
2935        Cond::MassAtLeast {
2936            step,
2937            uncalibrated_threshold,
2938            ..
2939        }
2940        | Cond::TopMassBelow {
2941            step,
2942            uncalibrated_threshold,
2943            ..
2944        } => threshold(step, uncalibrated_threshold, notices),
2945        Cond::ExpectationAtLeast { step, .. } => match sem(step)? {
2946            SemKind::Ordinal(_) => Ok(()),
2947            k => refuse(
2948                C::KindMismatch,
2949                subject,
2950                format!("an expectation cannot read {} ({step:?})", k.name()),
2951            ),
2952        },
2953        Cond::Exact(p) => type_predicate(p, env).or_else(|e| refuse(C::KindMismatch, subject, e)),
2954        Cond::Empty(r) | Cond::Nonempty(r) => match crate::expr::ref_ty(r, env) {
2955            Ok(
2956                Ty::List { .. } | Ty::Filtered { .. } | Ty::Shortlist { .. } | Ty::Ranking { .. },
2957            ) => Ok(()),
2958            Ok(t) => refuse(C::KindMismatch, subject, format!("empty() of {}", t.name())),
2959            Err(e) => refuse(C::KindMismatch, subject, e),
2960        },
2961        Cond::All(cs) | Cond::Any(cs) => cs
2962            .iter()
2963            .try_for_each(|c| check_cond(c, env, subject, notices)),
2964        Cond::Not(c) => check_cond(c, env, subject, notices),
2965    }
2966}
2967
2968/// Static derivation classes (spec 002, 3.12.3).
2969fn static_derivations(
2970    def: &Definition,
2971    inputs: &BTreeMap<String, (Ty, InputDecl)>,
2972    order: &[String],
2973    deps: &BTreeMap<String, Vec<String>>,
2974    infos: &BTreeMap<String, StepInfo>,
2975) -> BTreeMap<String, Derivation> {
2976    let semantic: BTreeSet<&str> = def
2977        .steps
2978        .iter()
2979        .filter(|s| matches!(s.body, StepBody::Semantic(_) | StepBody::Generate(_)))
2980        .map(|s| s.id.as_str())
2981        .collect();
2982    let mut out: BTreeMap<String, Derivation> = BTreeMap::new();
2983    for id in order {
2984        if let Some(StepInfo::Acquisition(a)) = infos.get(id) {
2985            out.insert(id.clone(), a.plan.derivation);
2986            continue;
2987        }
2988        if semantic.contains(id.as_str()) {
2989            out.insert(id.clone(), Derivation::ModelDerived);
2990            continue;
2991        }
2992        // Corroboration never changes a derivation class (spec 032, 2.1):
2993        // the step's class is its claim's.
2994        let claim_only;
2995        let reads: &[String] = match infos.get(id) {
2996            Some(StepInfo::Corroborate { decl, .. }) => {
2997                claim_only = vec![quality::claim_path(decl).0];
2998                &claim_only
2999            }
3000            _ => &deps[id],
3001        };
3002        let all_exact = reads.iter().all(|r| {
3003            if let Some(n) = r.strip_prefix("input:") {
3004                !inputs
3005                    .get(n)
3006                    .is_some_and(|(_, d)| d.provenance.contains(&ProvenanceClass::ModelDerived))
3007            } else if let Some(s) = step_ref(r) {
3008                out.get(s) == Some(&Derivation::ExactDerived)
3009            } else {
3010                true
3011            }
3012        });
3013        out.insert(
3014            id.clone(),
3015            if all_exact {
3016                Derivation::ExactDerived
3017            } else {
3018                Derivation::MixedDerived
3019            },
3020        );
3021    }
3022    out
3023}
3024
3025#[cfg(test)]
3026mod tests {
3027    use super::*;
3028
3029    #[test]
3030    fn v2_construct_describes_extraction_and_acceptance_separately() {
3031        let mut def: Definition =
3032            serde_json::from_str(include_str!("testdata/support-routing.definition.json")).unwrap();
3033        for (fixture, description) in [
3034            (
3035                include_str!("testdata/extract-step.json"),
3036                "an extraction step",
3037            ),
3038            (
3039                include_str!("testdata/accept-step.json"),
3040                "an accept_model_derived step",
3041            ),
3042        ] {
3043            def.steps = vec![serde_json::from_str(fixture).unwrap()];
3044            assert_eq!(
3045                first_v2_construct(&def, &BTreeMap::new()),
3046                Some((format!("step:{}", def.steps[0].id), description)),
3047            );
3048        }
3049    }
3050}