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