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