Skip to main content

lemma/planning/
execution_plan.rs

1//! Execution plan for evaluated specs
2//!
3//! Provides a complete self-contained execution plan ready for the evaluator.
4//! The plan holds an expanded shared normal-form graph in a dense table
5//! ([`ExecutionPlan::normal_forms`]) addressed by [`NormalFormId`], plus all data —
6//! no spec structure needed during evaluation.
7//!
8//! Reliability model:
9//! - [`Show`] is the IO contract surface for consumers (data and rule outputs).
10//!   IO compatibility is the consumer-facing guarantee.
11
12use crate::literals::Value;
13use crate::parsing::ast::{DateTimeValue, EffectiveDate, LemmaSpec};
14use crate::parsing::source::{Source, SourceType};
15use crate::planning::graph::Graph;
16use crate::planning::graph::ResolvedSpecTypes;
17use crate::planning::normalize::{
18    data_path_result_type, NormalForm, NormalFormId, NormalFormInterner, NormalizeContext,
19    NormalizedRule,
20};
21use crate::planning::semantics::{
22    value_kind_matches_spec, ComparisonComputation, DataDefinition, DataPath, LemmaType,
23    LiteralValue, ReferenceEnd, ReferenceTarget, RulePath, TypeSpecification, ValueKind,
24};
25use crate::planning::show_expression::{project_depends_on_rules, show_branches_from};
26use crate::planning::spec_set::LemmaSpecSet;
27use crate::planning::unit_family::FamilyUnitCatalog;
28use crate::result_value::RuleResultValue;
29use crate::Error;
30use indexmap::{IndexMap, IndexSet};
31use serde::{Deserialize, Serialize};
32use std::collections::{BTreeMap, HashMap, HashSet};
33use std::sync::Arc;
34
35pub use crate::planning::show_expression::{
36    ShowBranch, ShowConversionTarget, ShowExpression, ShowRule,
37};
38
39/// A complete execution plan ready for the evaluator
40///
41/// Contains drift-free normal-form equations in a table, named rule roots, and all data.
42/// Self-contained structure - no spec lookups required during evaluation.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ExecutionPlan {
45    /// Main spec name
46    pub spec_name: String,
47
48    /// Optional commentary from the `"""..."""` block in the spec source.
49    pub commentary: Option<String>,
50
51    /// Per-data data in definition order: value, type-only, or spec reference.
52    pub data: IndexMap<DataPath, DataDefinition>,
53
54    /// Dense table of interned NormalForm cells — the expanded shared graph.
55    /// Index = [`NormalFormId`].
56    pub(crate) normal_forms: Vec<NormalForm>,
57
58    /// Named rules → root of each rule in [`Self::normal_forms`].
59    /// Insertion order is planning topo order (deps before consumers).
60    pub rules: IndexMap<RulePath, ExecutableRule>,
61
62    /// Data→data [`DataDefinition::Reference`] paths in dependency order for
63    /// evaluation prepop (chained reference → reference → data). Empty when the
64    /// plan has none. Rule-target references are not included.
65    pub data_reference_order: Vec<DataPath>,
66
67    /// Spec metadata, in declaration order.
68    pub meta: IndexMap<String, Value>,
69
70    /// Main-spec types from planning. [`ResolvedSpecTypes::unit_index`] is expression-scope
71    /// units (local types plus direct `uses` imports). Rule-result unit maps use
72    /// [`Self::family_units`]; Show data inputs stay declared-only.
73    pub resolved_types: ResolvedSpecTypes,
74
75    /// Precomputed measure/ratio family unit expansion for rule results and show rule schemas.
76    pub family_units: Arc<FamilyUnitCatalog>,
77
78    /// Reverse index: canonical-form unit signature `Vec<(unit_name, exponent)>` →
79    /// (unit_name, owning type). Built from expression-scope units during planning so
80    /// cross-type Multiply/Divide arithmetic can deterministically resolve a combined
81    /// signature back to a single named unit. Ambiguous signatures (the same key matched
82    /// by units in two distinct types) are rejected at planning time.
83    pub signature_index: crate::computation::arithmetic::SignatureIndex,
84
85    pub effective: EffectiveDate,
86
87    /// Declared temporal window `[effective_from, effective_to)` of the LemmaSpec
88    /// version this plan was built from. Filled by [`attach_show_cache`].
89    pub effective_from: Option<DateTimeValue>,
90    pub effective_to: Option<DateTimeValue>,
91
92    /// All loaded temporal versions for this spec name (same list on every slice).
93    /// Shared via [`Arc`] so every slice of a set points at one allocation.
94    /// Filled by [`attach_show_cache`].
95    pub versions: Arc<[ShowVersion]>,
96
97    /// Source start line of the LemmaSpec version this plan was built from.
98    pub start_line: usize,
99
100    /// Source type of the LemmaSpec version this plan was built from.
101    pub source_type: Option<SourceType>,
102
103    /// Per [`Self::data`] position: positions in [`Self::rules`] of local rules that
104    /// transitively need that slot (through rule references), in alphabetical rule-name order.
105    /// Non-promptable slots (references, imports) have empty lists: leaves are mapped to
106    /// their promptable target before recording. `len() == data.len()`.
107    /// Built in [`build_execution_plan`].
108    pub(crate) needed_by_rules: Vec<Vec<u32>>,
109
110    /// Prefill/suggestion [`BoundValueKind`]s for show, keyed by data path.
111    /// Built once at plan time; [`RuleResultValue`] is expanded at show.
112    pub(crate) data_display: IndexMap<DataPath, ShowDataCache>,
113
114    /// Show rule graph (type, branches, depends_on_rules), keyed by local rule path.
115    /// Built in [`build_execution_plan`].
116    pub(crate) show_rules: IndexMap<RulePath, ShowRule>,
117
118    /// Every [`DataDefinition::Reference`] path → where its chain ends.
119    /// Copied from [`Graph::reference_ends`] in [`build_execution_plan`].
120    /// Missing key after planning is a bug.
121    pub(crate) reference_ends: IndexMap<DataPath, ReferenceEnd>,
122
123    /// Precomputed `input_key` → `DataPath` index for [`RunData::resolve`].
124    /// Built once at plan time so every `Engine::run` call avoids rebuilding it.
125    pub(crate) input_key_index: IndexMap<String, DataPath>,
126
127    /// Precomputed `input_key` → `RulePath` for [`Self::show_rules`].
128    /// Built once so [`Self::get_rule`] is O(1) without allocating per candidate.
129    pub(crate) show_rule_index: IndexMap<String, RulePath>,
130
131    /// Every [`Self::data`] path → its [`LeafKind::DataPath`] cell in
132    /// [`Self::normal_forms`]. Built once so evaluation writes bindings into
133    /// the value table by [`NormalFormId`] instead of a parallel path map.
134    pub(crate) data_leaf: IndexMap<DataPath, NormalFormId>,
135}
136
137/// Plan-time prefill/suggestion for one data path (show cache).
138///
139/// Stores domain [`BoundValueKind`] — postcard-safe. [`RuleResultValue`] is built at
140/// show time (JSON / API boundary), not snapshotted.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub(crate) struct ShowDataCache {
143    pub fill: Option<crate::planning::semantics::BoundValueKind>,
144    pub suggestion: Option<crate::planning::semantics::BoundValueKind>,
145}
146
147/// A named rule's root in the normal-form table, plus declaration metadata.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct ExecutableRule {
150    /// Unique identifier for this rule
151    pub path: RulePath,
152
153    /// Root of this rule in the shared [`ExecutionPlan::normal_forms`] graph.
154    pub normal_form: NormalFormId,
155
156    /// Source location for error messages (always present for rules from parsed specs)
157    pub source: Source,
158
159    /// Type of the rule as written: the lowered source expression typed at
160    /// planning, equal to validation's inferred type. The root cell at
161    /// `normal_form` has the same value type but may carry a different name
162    /// after rewrites (`x + 0` → `x`), so the API type reads this field.
163    pub rule_type: Arc<LemmaType>,
164}
165
166impl ExecutableRule {
167    pub fn name(&self) -> &str {
168        &self.path.rule
169    }
170}
171
172/// Position of a rule in [`ExecutionPlan::rules`]; indexes the evaluation
173/// context's `rule_values` table. Plain `Copy` so the evaluation walk can
174/// unwind to a rule without cloning its [`RulePath`].
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub(crate) struct RuleIndex(usize);
177
178impl RuleIndex {
179    pub(crate) fn index(self) -> usize {
180        self.0
181    }
182}
183
184/// Select the plan whose half-open `[effective, next.effective)` covers `instant`
185/// (greatest key `<= instant` in the map).
186pub(crate) fn plan_at<'a>(
187    plans: &'a BTreeMap<EffectiveDate, ExecutionPlan>,
188    instant: &EffectiveDate,
189) -> Option<&'a ExecutionPlan> {
190    plans
191        .range(..=instant.clone())
192        .next_back()
193        .map(|(_, plan)| plan)
194}
195
196/// Builds an execution plan from a Graph for one temporal slice.
197///
198/// `interner` is shared across every slice of the enclosing spec set for one
199/// planning pass: cons keys are span-insensitive, so identical subexpressions
200/// in different slices share cells. Each slice still extracts its own dense
201/// `normal_forms` table via [`NormalFormInterner::extract_reachable`].
202pub(crate) fn build_execution_plan(
203    graph: &Graph<'_>,
204    resolved_types: ResolvedSpecTypes,
205    effective: &EffectiveDate,
206    limits: &crate::limits::ResourceLimits,
207    interner: &mut NormalFormInterner,
208) -> Result<ExecutionPlan, Vec<Error>> {
209    let rule_order = graph.rule_order();
210
211    let main_spec = graph.main_spec();
212    let data = graph.build_data(&resolved_types.resolved)?;
213
214    // Planning gate: every data-target reference and plain data declaration
215    // must carry a fully resolved type. Rule-target references are exempt:
216    // they deliberately ship `Undetermined` so runtime veto propagation
217    // surfaces the target rule's veto reason directly. A residual
218    // `Undetermined` anywhere else would violate the invariant evaluation
219    // and show consumers rely on — report it instead of shipping the plan.
220    let undetermined_errors: Vec<Error> = data
221        .iter()
222        .filter_map(|(path, definition)| {
223            let (resolved_type, source) = match definition {
224                DataDefinition::TypeDeclaration {
225                    resolved_type,
226                    source,
227                    ..
228                } => (resolved_type, source),
229                DataDefinition::Reference {
230                    target: ReferenceTarget::Data(_),
231                    resolved_type,
232                    source,
233                    ..
234                } => (resolved_type, source),
235                DataDefinition::Reference {
236                    target: ReferenceTarget::Rule(_),
237                    ..
238                }
239                | DataDefinition::Value { .. }
240                | DataDefinition::Import { .. } => return None,
241            };
242            if resolved_type.is_undetermined() {
243                Some(Error::validation(
244                    format!("could not determine the type of '{path}'"),
245                    Some(source.clone()),
246                    None::<String>,
247                ))
248            } else {
249                None
250            }
251        })
252        .collect();
253    if !undetermined_errors.is_empty() {
254        return Err(undetermined_errors);
255    }
256
257    let signature_index = resolved_types.signature_index.clone();
258
259    let family_units = Arc::new(FamilyUnitCatalog::build(&resolved_types.unit_index));
260
261    let reference_ends = graph.reference_ends();
262    let rule_types: HashMap<RulePath, Arc<LemmaType>> = graph
263        .rules()
264        .iter()
265        .map(|(path, node)| (path.clone(), Arc::clone(&node.rule_type)))
266        .collect();
267    let mut rules: IndexMap<RulePath, ExecutableRule> = IndexMap::new();
268    let mut completed_rules: HashMap<RulePath, NormalFormId> = HashMap::new();
269    let mut show_rules: IndexMap<RulePath, ShowRule> = IndexMap::new();
270
271    // Local rules plus depends_on_rules closure = Show.rules universe.
272    let mut reachable: HashSet<RulePath> = HashSet::new();
273    let mut stack: Vec<RulePath> = graph
274        .rules()
275        .keys()
276        .filter(|path| path.segments.is_empty())
277        .cloned()
278        .collect();
279    while let Some(path) = stack.pop() {
280        if !reachable.insert(path.clone()) {
281            continue;
282        }
283        let node = graph
284            .rules()
285            .get(&path)
286            .expect("BUG: reachable rule path missing from graph.rules");
287        for dep in &node.depends_on_rules {
288            if !reachable.contains(dep) {
289                stack.push(dep.clone());
290            }
291        }
292    }
293    let show_keys: HashSet<String> = reachable.iter().map(|path| path.input_key()).collect();
294
295    for rule_path in rule_order {
296        if !reachable.contains(rule_path) {
297            continue;
298        }
299        let rule_node = graph.rules().get(rule_path).expect(
300            "bug: rule from topological sort not in graph - validation should have caught this",
301        );
302
303        let measure_scope = crate::planning::typing::MeasureScope {
304            unit_index: &resolved_types.unit_index,
305            signature_index: &resolved_types.signature_index,
306        };
307        let normalize_ctx = NormalizeContext {
308            data: &data,
309            measure_scope,
310            max_normalized_expression_nodes: limits.max_normalized_expression_nodes,
311            max_normal_form_depth: limits.max_normal_form_depth,
312        };
313        let normalized = crate::planning::normalize::build_normalized_rule(
314            &normalize_ctx,
315            &completed_rules,
316            &rule_types,
317            reference_ends,
318            &rule_node.branches,
319            Some(rule_node.source.clone()),
320            interner,
321        )
322        .map_err(|error| vec![error])?;
323        let NormalizedRule { body, source_type } = normalized;
324        assert_eq!(
325            &source_type, &rule_node.rule_type,
326            "BUG: rule '{}' typed differently by planning and validation",
327            rule_path.rule
328        );
329        assert!(
330            interner
331                .result_type(body)
332                .same_value_type(&rule_node.rule_type),
333            "BUG: rewrites changed the value type of rule '{}': {:?} from {:?}",
334            rule_path.rule,
335            interner.result_type(body),
336            rule_node.rule_type
337        );
338        completed_rules.insert(rule_path.clone(), body);
339
340        show_rules.insert(
341            rule_path.clone(),
342            ShowRule {
343                lemma_type: family_units.rule_type_for_show(rule_node.rule_type.as_ref()),
344                path: rule_path.segments.clone(),
345                branches: show_branches_from(&rule_node.branches, &show_keys),
346                depends_on_rules: project_depends_on_rules(&rule_node.depends_on_rules, &show_keys),
347            },
348        );
349
350        rules.insert(
351            rule_path.clone(),
352            ExecutableRule {
353                path: rule_path.clone(),
354                normal_form: body,
355                source: rule_node.source.clone(),
356                rule_type: source_type,
357            },
358        );
359    }
360
361    let root_ids: Vec<NormalFormId> = rules.values().map(|rule| rule.normal_form).collect();
362    let (normal_forms, remapped_roots) = interner.extract_reachable(&root_ids);
363    for (rule, remapped) in rules.values_mut().zip(remapped_roots) {
364        rule.normal_form = remapped;
365    }
366
367    let mut plan = ExecutionPlan {
368        spec_name: main_spec.name.clone(),
369        commentary: main_spec.commentary.clone(),
370        data,
371        normal_forms,
372        rules,
373        data_reference_order: graph.data_reference_order().to_vec(),
374        meta: main_spec
375            .meta_fields
376            .iter()
377            .map(|f| (f.key.clone(), f.value.clone()))
378            .collect(),
379        resolved_types,
380        family_units,
381        signature_index,
382        effective: effective.clone(),
383        // Filled by attach_show_cache after this returns.
384        effective_from: None,
385        effective_to: None,
386        versions: Arc::from(Vec::new().into_boxed_slice()),
387        start_line: 1,
388        source_type: None,
389        needed_by_rules: Vec::new(),
390        data_display: IndexMap::new(),
391        show_rules,
392        // Filled below after validation succeeds.
393        reference_ends: IndexMap::new(),
394        input_key_index: IndexMap::new(),
395        show_rule_index: IndexMap::new(),
396        data_leaf: IndexMap::new(),
397    };
398
399    let mut plan_errors = validate_literal_data_against_types(&plan);
400    if let Err(error) = validate_unit_conversion_targets(&plan) {
401        plan_errors.push(error);
402    }
403    if !plan_errors.is_empty() {
404        return Err(plan_errors);
405    }
406
407    plan.reference_ends = graph.reference_ends().clone();
408    plan.input_key_index = plan
409        .data
410        .keys()
411        .map(|path| (path.input_key(), path.clone()))
412        .collect();
413    plan.show_rule_index = plan
414        .show_rules
415        .keys()
416        .map(|path| (path.input_key(), path.clone()))
417        .collect();
418    plan.data_leaf = ensure_data_leaves(&mut plan.normal_forms, &plan.data);
419    for (path, &id) in &plan.data_leaf {
420        debug_assert_eq!(
421            plan.result_type(id).as_ref(),
422            data_path_result_type(&plan.data, path).as_ref(),
423            "BUG: DataPath leaf result_type drift for {path}"
424        );
425    }
426    plan.needed_by_rules = plan.build_needed_by_rules();
427    Ok(plan)
428}
429
430/// Ensure every `plan.data` path has a [`LeafKind::DataPath`] cell in `normal_forms`
431/// and return the path → id index. Expression lowering already creates leaves for
432/// referenced paths; unused paths get orphan leaves appended so evaluation can
433/// still store bindings/defaults/reference copies in the value table.
434///
435/// Rule reference cells share a body's Kind, so a `Leaf(DataPath)` cell may carry
436/// `rule_ref`. Those cells hold the referenced rule's value in the value table and
437/// must never be the binding slot for the path.
438fn ensure_data_leaves(
439    normal_forms: &mut Vec<NormalForm>,
440    data: &IndexMap<DataPath, DataDefinition>,
441) -> IndexMap<DataPath, NormalFormId> {
442    use crate::planning::normalize::LeafKind;
443    use crate::planning::normalize::NormalFormKind;
444
445    let mut data_leaf: IndexMap<DataPath, NormalFormId> = IndexMap::new();
446    for (index, cell) in normal_forms.iter().enumerate() {
447        if cell.rule_ref.is_some() {
448            continue;
449        }
450        if let NormalFormKind::Leaf(LeafKind::DataPath(path)) = &cell.kind {
451            data_leaf.insert(path.clone(), NormalFormId::from_index(index));
452        }
453    }
454    for path in data.keys() {
455        if data_leaf.contains_key(path) {
456            continue;
457        }
458        let id = NormalFormId::from_index(normal_forms.len());
459        normal_forms.push(NormalForm {
460            kind: NormalFormKind::Leaf(LeafKind::DataPath(path.clone())),
461            result_type: data_path_result_type(data, path),
462            fold_types: Vec::new(),
463            source: None,
464            origin: None,
465            rule_ref: None,
466        });
467        data_leaf.insert(path.clone(), id);
468    }
469    data_leaf
470}
471
472/// Fill show/response cache fields that are pure functions of the plan and its
473/// owning LemmaSpec / LemmaSpecSet. Called once after [`build_execution_plan`]
474/// succeeds so [`Engine::show`] / [`Engine::run`] only look up the plan.
475pub(crate) fn attach_show_cache(
476    plan: &mut ExecutionPlan,
477    lemma_spec_set: &LemmaSpecSet,
478    spec: &LemmaSpec,
479    versions: &Arc<[ShowVersion]>,
480) {
481    let (effective_from, effective_to) = lemma_spec_set.effective_range(spec);
482    plan.effective_from = effective_from;
483    plan.effective_to = effective_to;
484    plan.versions = Arc::clone(versions);
485    plan.start_line = spec.start_line;
486    plan.source_type = spec.source_type.clone();
487    plan.data_display = build_data_display(plan);
488}
489
490fn build_data_display(plan: &ExecutionPlan) -> IndexMap<DataPath, ShowDataCache> {
491    let mut out = IndexMap::new();
492    for (path, data) in &plan.data {
493        if data.schema_type().is_none() || matches!(data, DataDefinition::Reference { .. }) {
494            continue;
495        }
496        let fill = match data {
497            DataDefinition::Value {
498                value,
499                resolved_type,
500                ..
501            } => Some(crate::planning::semantics::BoundValueKind {
502                value: value.value.clone(),
503                unit: resolved_type.unit().map(Arc::from),
504            }),
505            _ => data.bound_fill().cloned(),
506        };
507        let suggestion = data.bound_suggestion().cloned();
508        if fill.is_some() || suggestion.is_some() {
509            out.insert(path.clone(), ShowDataCache { fill, suggestion });
510        }
511    }
512    out
513}
514
515/// Whether a show bound value is the fill or the suggestion for a data path.
516#[derive(Debug, Clone, Copy, PartialEq, Eq)]
517pub(crate) enum ShowBoundRole {
518    Fill,
519    Suggestion,
520}
521
522pub(crate) fn show_bound_result_value(
523    bound: &crate::planning::semantics::BoundValueKind,
524    schema_type: &crate::planning::semantics::LemmaType,
525    input_key: &str,
526    role: ShowBoundRole,
527) -> crate::result_value::RuleResultValue {
528    let (fill, suggestion) = match role {
529        ShowBoundRole::Fill => (Some(bound), None),
530        ShowBoundRole::Suggestion => (None, Some(bound)),
531    };
532    let display_type = crate::planning::semantics::lemma_type_with_display_binding(
533        schema_type,
534        None,
535        fill,
536        suggestion,
537    );
538    crate::result_value::type_scoped_result_value_from_literal(&bound.to_literal(), &display_type)
539        .unwrap_or_else(|failure| {
540            let role_name = match role {
541                ShowBoundRole::Fill => "fill",
542                ShowBoundRole::Suggestion => "suggestion",
543            };
544            panic!(
545                "BUG: show {role_name} value for '{input_key}' failed type_scoped_result_value_from_literal: {}",
546                crate::result_value::rule_result_value_failure_message(failure)
547            )
548        })
549}
550
551/// One data entry in a [`Show`].
552///
553/// A named struct instead of a tuple so JSON-native consumers (TypeScript, Python, ...)
554/// get stable field names. `fill` is a spec literal or literal `with` binding;
555/// `suggestion` is a `-> suggest ...` hint only.
556/// Empty `needed_by_rules` means the slot is offered for reuse (`data x: alias.slot`),
557/// not needed by this spec's remaining rules after normalize.
558#[derive(Debug, Clone, PartialEq)]
559pub struct ShowData {
560    pub lemma_type: LemmaType,
561    /// Import hops to this slot (`[]` = this spec). Clone of [`DataPath::segments`].
562    pub path: Vec<crate::planning::semantics::PathSegment>,
563    pub fill: Option<RuleResultValue>,
564    pub suggestion: Option<RuleResultValue>,
565    /// Show.rules keys that transitively need this data after normalize.
566    /// Empty = reuse catalog only (not an eval intake key for this spec).
567    pub needed_by_rules: Vec<String>,
568}
569
570/// Half-open `[effective_from, effective_to)` for one loaded temporal row.
571#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
572pub struct ShowVersion {
573    pub effective_from: Option<crate::parsing::ast::DateTimeValue>,
574    pub effective_to: Option<crate::parsing::ast::DateTimeValue>,
575}
576
577impl std::fmt::Display for ShowVersion {
578    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
579        match (&self.effective_from, &self.effective_to) {
580            (Some(from), Some(to)) => write!(f, "{from} → {to}"),
581            (Some(from), None) => write!(f, "{from} →"),
582            (None, Some(to)) => write!(f, "→ {to}"),
583            (None, None) => write!(f, "—"),
584        }
585    }
586}
587
588/// Consumer [`Engine::show`] result: declared promptable data catalog (with
589/// [`ShowData::needed_by_rules`] for intake vs reuse), this spec's rule graph
590/// (local rules plus reachable imports), and resolved temporal window.
591/// Source: [`Engine::source`].
592#[derive(Debug, Clone, PartialEq)]
593pub struct Show {
594    /// Interned repository name (`list()` string). `None` = unnamed workspace.
595    pub repository: Option<String>,
596    pub spec: String,
597    pub commentary: Option<String>,
598    pub effective_from: Option<crate::parsing::ast::DateTimeValue>,
599    pub effective_to: Option<crate::parsing::ast::DateTimeValue>,
600    pub versions: Vec<ShowVersion>,
601    pub start_line: usize,
602    pub source_type: Option<crate::parsing::source::SourceType>,
603    pub data: indexmap::IndexMap<String, ShowData>,
604    pub rules: indexmap::IndexMap<String, ShowRule>,
605    /// Spec metadata, in declaration order.
606    pub meta: IndexMap<String, Value>,
607}
608
609impl std::fmt::Display for Show {
610    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
611        match &self.repository {
612            Some(repository) => write!(f, "Spec: {} {}", repository, self.spec)?,
613            None => write!(f, "Spec: {}", self.spec)?,
614        }
615
616        if let Some(commentary) = &self.commentary {
617            write!(f, "\n  {}", commentary)?;
618        }
619
620        if let Some(from) = &self.effective_from {
621            write!(f, "\n  effective_from: {}", from)?;
622        }
623        if let Some(to) = &self.effective_to {
624            write!(f, "\n  effective_to: {}", to)?;
625        }
626
627        if self.versions.len() > 1 {
628            let version_strs: Vec<String> = self
629                .versions
630                .iter()
631                .map(|v| match (&v.effective_from, &v.effective_to) {
632                    (Some(f), Some(t)) => format!("{f} → {t}"),
633                    (Some(f), None) => format!("{f} →"),
634                    (None, Some(t)) => format!("→ {t}"),
635                    (None, None) => "—".to_string(),
636                })
637                .collect();
638            write!(f, "\n  versions: {}", version_strs.join(", "))?;
639        }
640
641        if !self.meta.is_empty() {
642            write!(f, "\n\nMeta:")?;
643            let mut entries: Vec<(&String, &Value)> = self.meta.iter().collect();
644            entries.sort_by_key(|(k, _)| *k);
645            for (key, value) in entries {
646                write!(f, "\n  {}: {}", key, value)?;
647            }
648        }
649
650        if !self.data.is_empty() {
651            write!(f, "\n\nData:")?;
652            for (name, entry) in &self.data {
653                write!(f, "\n  {} ({})", name, entry.lemma_type.specifications)?;
654                for line in type_detail_lines(&entry.lemma_type.specifications) {
655                    write!(f, "\n    {}", line)?;
656                }
657                let help = entry.lemma_type.specifications.help();
658                if !help.is_empty() {
659                    write!(f, "\n    help: {}", help)?;
660                }
661                if let Some(val) = &entry.fill {
662                    write!(f, "\n    fill: {}", val)?;
663                }
664                if let Some(val) = &entry.suggestion {
665                    write!(f, "\n    suggestion: {}", val)?;
666                }
667                if !entry.needed_by_rules.is_empty() {
668                    write!(
669                        f,
670                        "\n    needed_by_rules: {}",
671                        entry.needed_by_rules.join(", ")
672                    )?;
673                }
674            }
675        }
676
677        if !self.rules.is_empty() {
678            write!(f, "\n\nRules:")?;
679            for (name, rule) in &self.rules {
680                write!(f, "\n  {} ({})", name, rule.lemma_type.specifications)?;
681                if !rule.depends_on_rules.is_empty() {
682                    write!(
683                        f,
684                        "\n    depends_on_rules: {}",
685                        rule.depends_on_rules.join(", ")
686                    )?;
687                }
688            }
689        }
690
691        if self.data.is_empty() && self.rules.is_empty() {
692            write!(f, "\n  (no data or rules)")?;
693        }
694
695        Ok(())
696    }
697}
698
699/// Produce a human-readable summary of type constraints, or `None` when there
700/// are no constraints worth showing (e.g. bare `boolean`).
701/// Returns one formatted string per constraint or property of the type specification.
702/// Uses `display_str` for all rational bounds so they render as decimals,
703/// not as raw fractions.
704pub fn type_detail_lines(spec: &TypeSpecification) -> Vec<String> {
705    let mut lines = Vec::new();
706    match spec {
707        TypeSpecification::Measure {
708            minimum,
709            maximum,
710            decimals,
711            units,
712            ..
713        } => {
714            let unit_names: Vec<&str> = units.0.iter().map(|u| u.name.as_str()).collect();
715            if !unit_names.is_empty() {
716                lines.push(format!("units: {}", unit_names.join(", ")));
717            }
718            if let Some(d) = decimals {
719                lines.push(format!("decimals: {}", d));
720            }
721            if let Some((magnitude, unit_name)) = minimum {
722                lines.push(format!(
723                    "minimum: {} {}",
724                    magnitude.display_str(),
725                    unit_name
726                ));
727            }
728            if let Some((magnitude, unit_name)) = maximum {
729                lines.push(format!(
730                    "maximum: {} {}",
731                    magnitude.display_str(),
732                    unit_name
733                ));
734            }
735        }
736        TypeSpecification::Number {
737            minimum,
738            maximum,
739            decimals,
740            ..
741        } => {
742            if let Some(d) = decimals {
743                lines.push(format!("decimals: {}", d));
744            }
745            if let Some(v) = minimum {
746                lines.push(format!("minimum: {}", v.display_str()));
747            }
748            if let Some(v) = maximum {
749                lines.push(format!("maximum: {}", v.display_str()));
750            }
751        }
752        TypeSpecification::Ratio {
753            minimum,
754            maximum,
755            decimals,
756            units,
757            ..
758        } => {
759            let unit_names: Vec<&str> = units.0.iter().map(|u| u.name.as_str()).collect();
760            if !unit_names.is_empty() {
761                lines.push(format!("units: {}", unit_names.join(", ")));
762            }
763            if let Some(d) = decimals {
764                lines.push(format!("decimals: {}", d));
765            }
766            if let Some(v) = minimum {
767                lines.push(format!("minimum: {}", v.display_str()));
768            }
769            if let Some(v) = maximum {
770                lines.push(format!("maximum: {}", v.display_str()));
771            }
772        }
773        TypeSpecification::Text {
774            options, length, ..
775        } => {
776            if let Some(l) = length {
777                lines.push(format!("length: {}", l));
778            }
779            if !options.is_empty() {
780                let quoted: Vec<String> = options.iter().map(|o| format!("\"{}\"", o)).collect();
781                lines.push(format!("options: {}", quoted.join(", ")));
782            }
783        }
784        TypeSpecification::Date {
785            minimum, maximum, ..
786        } => {
787            if let Some(v) = minimum {
788                lines.push(format!("minimum: {}", v));
789            }
790            if let Some(v) = maximum {
791                lines.push(format!("maximum: {}", v));
792            }
793        }
794        TypeSpecification::Time {
795            minimum, maximum, ..
796        } => {
797            if let Some(v) = minimum {
798                lines.push(format!("minimum: {}", v));
799            }
800            if let Some(v) = maximum {
801                lines.push(format!("maximum: {}", v));
802            }
803        }
804        TypeSpecification::MeasureRange {
805            lower,
806            upper,
807            minimum,
808            maximum,
809            units,
810            ..
811        } => {
812            let unit_names: Vec<&str> = units.0.iter().map(|u| u.name.as_str()).collect();
813            if !unit_names.is_empty() {
814                lines.push(format!("units: {}", unit_names.join(", ")));
815            }
816            if let Some((magnitude, unit_name)) = lower {
817                lines.push(format!("lower: {} {}", magnitude.display_str(), unit_name));
818            }
819            if let Some((magnitude, unit_name)) = upper {
820                lines.push(format!("upper: {} {}", magnitude.display_str(), unit_name));
821            }
822            if let Some((magnitude, unit_name)) = minimum {
823                lines.push(format!(
824                    "minimum: {} {}",
825                    magnitude.display_str(),
826                    unit_name
827                ));
828            }
829            if let Some((magnitude, unit_name)) = maximum {
830                lines.push(format!(
831                    "maximum: {} {}",
832                    magnitude.display_str(),
833                    unit_name
834                ));
835            }
836        }
837        TypeSpecification::RatioRange {
838            lower,
839            upper,
840            minimum,
841            maximum,
842            units,
843            ..
844        } => {
845            let unit_names: Vec<&str> = units.0.iter().map(|u| u.name.as_str()).collect();
846            if !unit_names.is_empty() {
847                lines.push(format!("units: {}", unit_names.join(", ")));
848            }
849            if let Some(v) = lower {
850                lines.push(format!("lower: {}", v.display_str()));
851            }
852            if let Some(v) = upper {
853                lines.push(format!("upper: {}", v.display_str()));
854            }
855            if let Some(v) = minimum {
856                lines.push(format!("minimum: {}", v.display_str()));
857            }
858            if let Some(v) = maximum {
859                lines.push(format!("maximum: {}", v.display_str()));
860            }
861        }
862        TypeSpecification::NumberRange {
863            lower,
864            upper,
865            minimum,
866            maximum,
867            ..
868        } => {
869            if let Some(v) = lower {
870                lines.push(format!("lower: {}", v.display_str()));
871            }
872            if let Some(v) = upper {
873                lines.push(format!("upper: {}", v.display_str()));
874            }
875            if let Some(v) = minimum {
876                lines.push(format!("minimum: {}", v.display_str()));
877            }
878            if let Some(v) = maximum {
879                lines.push(format!("maximum: {}", v.display_str()));
880            }
881        }
882        TypeSpecification::DateRange {
883            lower,
884            upper,
885            minimum,
886            maximum,
887            ..
888        } => {
889            if let Some(v) = lower {
890                lines.push(format!("lower: {}", v));
891            }
892            if let Some(v) = upper {
893                lines.push(format!("upper: {}", v));
894            }
895            if let Some((magnitude, unit_name)) = minimum {
896                lines.push(format!(
897                    "minimum: {} {}",
898                    magnitude.display_str(),
899                    unit_name
900                ));
901            }
902            if let Some((magnitude, unit_name)) = maximum {
903                lines.push(format!(
904                    "maximum: {} {}",
905                    magnitude.display_str(),
906                    unit_name
907                ));
908            }
909        }
910        TypeSpecification::TimeRange {
911            lower,
912            upper,
913            minimum,
914            maximum,
915            ..
916        } => {
917            if let Some(v) = lower {
918                lines.push(format!("lower: {}", v));
919            }
920            if let Some(v) = upper {
921                lines.push(format!("upper: {}", v));
922            }
923            if let Some((magnitude, unit_name)) = minimum {
924                lines.push(format!(
925                    "minimum: {} {}",
926                    magnitude.display_str(),
927                    unit_name
928                ));
929            }
930            if let Some((magnitude, unit_name)) = maximum {
931                lines.push(format!(
932                    "maximum: {} {}",
933                    magnitude.display_str(),
934                    unit_name
935                ));
936            }
937        }
938        TypeSpecification::Boolean { .. }
939        | TypeSpecification::Veto { .. }
940        | TypeSpecification::Undetermined
941        | TypeSpecification::Spec { .. } => {}
942    }
943    lines
944}
945
946impl ExecutionPlan {
947    /// Expression-scope unit index (local types plus direct `uses` imports).
948    pub(crate) fn expression_unit_index(&self) -> &crate::planning::unit_index::UnitIndex {
949        &self.resolved_types.unit_index
950    }
951
952    /// Per [`Self::data`] position: local [`Self::rules`] positions that transitively need
953    /// that slot (through rule references), in alphabetical rule-name order.
954    ///
955    /// Built in plan topological order: at a rule-ref cell, OR the target rule's
956    /// already-computed bitset instead of descending Kind (rule references are evaluation
957    /// boundaries). Walks the normalized body so constant-dead unless arms removed
958    /// by normalize stay out of the index.
959    fn build_needed_by_rules(&self) -> Vec<Vec<u32>> {
960        use crate::planning::normalize::{LeafKind, NormalFormKind};
961
962        let data_len = self.data.len();
963        let words = data_len.div_ceil(64);
964        let mut bits_by_rule: Vec<Vec<u64>> = Vec::with_capacity(self.rules.len());
965
966        for (rule_pos, (_path, rule)) in self.rules.iter().enumerate() {
967            let mut bits = vec![0u64; words];
968            let mut visited = HashSet::new();
969            let mut stack = vec![rule.normal_form];
970            while let Some(id) = stack.pop() {
971                if !visited.insert(id) {
972                    continue;
973                }
974                let nf = self.normal_form(id);
975                if let Some(ref_path) = &nf.rule_ref {
976                    let ref_pos = self.rules.get_index_of(ref_path).unwrap_or_else(|| {
977                        panic!(
978                            "BUG: rule_ref target '{ref_path}' missing from plan.rules (rule '{}')",
979                            rule.path
980                        )
981                    });
982                    assert!(
983                        ref_pos < rule_pos,
984                        "BUG: rule_ref target '{ref_path}' must precede '{}' in topo order",
985                        rule.path
986                    );
987                    let dep = &bits_by_rule[ref_pos];
988                    for (word, dep_word) in bits.iter_mut().zip(dep.iter()) {
989                        *word |= *dep_word;
990                    }
991                    continue;
992                }
993                match &nf.kind {
994                    NormalFormKind::Leaf(LeafKind::DataPath(path)) => {
995                        if let Some(target) = self.promptable_data_path(path) {
996                            let data_pos = self.data.get_index_of(target).unwrap_or_else(|| {
997                                panic!(
998                                    "BUG: promptable target '{target}' absent from plan.data (rule '{}')",
999                                    rule.path
1000                                )
1001                            });
1002                            bits[data_pos / 64] |= 1u64 << (data_pos % 64);
1003                        }
1004                    }
1005                    NormalFormKind::OrderedDispatch { .. } => {
1006                        // Match static reachable_data_paths: walk origin Piecewise
1007                        // so shadowed arms stay in the Show needed set.
1008                        let origin = nf.origin.unwrap_or_else(|| {
1009                            panic!(
1010                                "BUG: non-rule_ref OrderedDispatch must carry origin (rule '{}')",
1011                                rule.path
1012                            )
1013                        });
1014                        stack.push(origin);
1015                    }
1016                    _ => stack.extend(nf.kind.children()),
1017                }
1018            }
1019            bits_by_rule.push(bits);
1020        }
1021
1022        let mut consumers: Vec<usize> = self
1023            .rules
1024            .keys()
1025            .enumerate()
1026            .filter(|(_, path)| self.show_rules.contains_key(*path))
1027            .map(|(pos, _)| pos)
1028            .collect();
1029        consumers.sort_by(|&a, &b| {
1030            self.rules[a]
1031                .path
1032                .input_key()
1033                .cmp(&self.rules[b].path.input_key())
1034        });
1035
1036        let mut needed_by_rules = vec![Vec::new(); data_len];
1037        for &rule_pos in &consumers {
1038            let rule_id = u32::try_from(rule_pos).expect("BUG: rule count exceeds u32");
1039            let bits = &bits_by_rule[rule_pos];
1040            for (word_idx, &word) in bits.iter().enumerate() {
1041                let mut remaining = word;
1042                while remaining != 0 {
1043                    let bit = remaining.trailing_zeros() as usize;
1044                    let data_pos = word_idx * 64 + bit;
1045                    needed_by_rules[data_pos].push(rule_id);
1046                    remaining &= remaining - 1;
1047                }
1048            }
1049        }
1050        needed_by_rules
1051    }
1052
1053    /// Promptable [`DataPath`] for a normal-form data leaf, following reference
1054    /// chain ends for references. `None` when the leaf is a rule-target or import
1055    /// reference (not caller-promptable).
1056    ///
1057    /// Reference paths must appear in [`Self::reference_ends`].
1058    pub(crate) fn promptable_data_path<'a>(&'a self, path: &'a DataPath) -> Option<&'a DataPath> {
1059        match self.data.get(path) {
1060            Some(DataDefinition::Value { .. } | DataDefinition::TypeDeclaration { .. }) => {
1061                Some(path)
1062            }
1063            Some(DataDefinition::Reference { .. }) => match self.reference_ends.get(path) {
1064                Some(ReferenceEnd::Promptable(target)) => Some(target),
1065                Some(ReferenceEnd::Rule(_) | ReferenceEnd::Import) => None,
1066                None => {
1067                    panic!("BUG: reference '{path}' missing from reference_ends after planning")
1068                }
1069            },
1070            Some(DataDefinition::Import { .. }) => None,
1071            None => panic!("BUG: normal-form DataPath leaf absent from plan.data: {path}"),
1072        }
1073    }
1074
1075    /// Validate caller-requested rule names and return resolved [`RulePath`]s.
1076    ///
1077    /// `None` means all local rules (this spec's outputs). `Some(&[])` is an error.
1078    /// Unknown names in `Some` slice error. Explicit names may be any Show.rules key.
1079    pub fn validated_response_rule_paths(
1080        &self,
1081        rules: Option<&[String]>,
1082    ) -> Result<std::collections::HashSet<RulePath>, Error> {
1083        let Some(rules) = rules else {
1084            return Ok(self
1085                .rules
1086                .values()
1087                .filter(|r| r.path.segments.is_empty())
1088                .map(|r| r.path.clone())
1089                .collect());
1090        };
1091        if rules.is_empty() {
1092            return Err(Error::request(
1093                "at least one rule required".to_string(),
1094                None::<String>,
1095            ));
1096        }
1097        let mut paths = std::collections::HashSet::new();
1098        for rule_name in rules {
1099            let rule = self.get_rule(rule_name).ok_or_else(|| {
1100                Error::request(
1101                    format!("Rule '{rule_name}' not found in spec '{}'", self.spec_name),
1102                    None::<String>,
1103                )
1104            })?;
1105            paths.insert(rule.path.clone());
1106        }
1107        Ok(paths)
1108    }
1109
1110    /// Look up a Show.rules key (`RulePath::input_key`) in the reachable graph.
1111    pub fn get_rule(&self, name: &str) -> Option<&ExecutableRule> {
1112        let canonical = crate::parsing::ast::ascii_lowercase_logical_name(name.to_string());
1113        let path = self.show_rule_index.get(&canonical)?;
1114        self.rules.get(path)
1115    }
1116
1117    /// Look up a normal-form cell by id.
1118    pub(crate) fn normal_form(&self, id: NormalFormId) -> &NormalForm {
1119        self.normal_forms.get(id.index()).unwrap_or_else(|| {
1120            panic!(
1121                "BUG: NormalFormId {} out of range (table len {})",
1122                id.index(),
1123                self.normal_forms.len()
1124            )
1125        })
1126    }
1127
1128    /// Stamped result type of the cell at `id`.
1129    pub(crate) fn result_type(&self, id: NormalFormId) -> &Arc<LemmaType> {
1130        &self.normal_form(id).result_type
1131    }
1132
1133    /// Position of `path` in [`Self::rules`]. Every rule path a plan cell
1134    /// references was planned, so absence is a bug.
1135    pub(crate) fn rule_index(&self, path: &RulePath) -> RuleIndex {
1136        RuleIndex(
1137            self.rules
1138                .get_index_of(path)
1139                .unwrap_or_else(|| panic!("BUG: rule '{}' missing from execution plan", path.rule)),
1140        )
1141    }
1142
1143    pub(crate) fn rule_at(&self, index: RuleIndex) -> &ExecutableRule {
1144        self.rules
1145            .get_index(index.0)
1146            .map(|(_, rule)| rule)
1147            .unwrap_or_else(|| {
1148                panic!(
1149                    "BUG: rule index {} out of plan.rules range ({} rules)",
1150                    index.0,
1151                    self.rules.len()
1152                )
1153            })
1154    }
1155
1156    /// Data paths a caller can be prompted for, in declaration order.
1157    ///
1158    /// A path is promptable when it carries its own value slot: [`DataDefinition::Value`]
1159    /// (spec prefill, overridable) or [`DataDefinition::TypeDeclaration`] (typed input).
1160    /// [`DataDefinition::Reference`] paths are not promptable — a data target copies from
1161    /// its ultimate target (see [`ExecutionPlan::reference_ends`]) and a rule target is
1162    /// computed — and [`DataDefinition::Import`] paths are owned by the imported spec.
1163    ///
1164    /// Domain of keys that may appear in `RuleResult.missing_data` (as a set).
1165    /// Evaluation decides which unbound members are live and in what order; this
1166    /// iterator does not fix list order.
1167    pub(crate) fn promptable_data_paths(&self) -> impl Iterator<Item = &DataPath> {
1168        self.data
1169            .iter()
1170            .filter_map(|(path, definition)| match definition {
1171                DataDefinition::Value { .. } | DataDefinition::TypeDeclaration { .. } => Some(path),
1172                DataDefinition::Reference { .. } | DataDefinition::Import { .. } => None,
1173            })
1174    }
1175}
1176
1177/// Follow nested [`NormalFormKind::OrderedDispatch`] region bodies using filled
1178/// scrutinee slots until a non-dispatch cell. Stops at rule-reference cells:
1179/// those borrow another rule's Kind and must compare as the origin arm body, not
1180/// the body that rule would itself select. `None` when a nested scrutinee is
1181/// unset or vetoed (the walk has not decided that nest yet).
1182pub(crate) fn resolve_nested_dispatch_body(
1183    plan: &ExecutionPlan,
1184    values: &[Option<crate::computation::OperationResult>],
1185    mut body: NormalFormId,
1186) -> Option<NormalFormId> {
1187    use crate::computation::OperationResult;
1188    use crate::planning::normalize::NormalFormKind;
1189    use crate::planning::ordered_dispatch::region_of_scrutinee;
1190
1191    for _ in 0..=plan.normal_forms.len() {
1192        let cell = plan.normal_form(body);
1193        if cell.rule_ref.is_some() {
1194            return Some(body);
1195        }
1196        let NormalFormKind::OrderedDispatch {
1197            scrutinee,
1198            boundaries,
1199            regions,
1200        } = &cell.kind
1201        else {
1202            return Some(body);
1203        };
1204        let literal = match values.get(scrutinee.index()).and_then(|slot| slot.as_ref()) {
1205            Some(OperationResult::Value(literal)) => literal,
1206            Some(OperationResult::Veto(_)) | None => return None,
1207        };
1208        let region = region_of_scrutinee(boundaries, &literal.value).ok()?;
1209        body = regions[region];
1210    }
1211    panic!("BUG: nested OrderedDispatch chain longer than the normal-form table");
1212}
1213
1214/// DataPath leaves reachable from `root` in evaluator decision-tree preorder.
1215///
1216/// Control liveness is derived from `values` (the evaluation value table), matching
1217/// the evaluator's own decisions:
1218/// - [`NormalFormKind::And`]: right child live only when the left slot is `true`.
1219///   A `false`, vetoed, or unfilled left slot means the evaluator never visits the
1220///   right child.
1221/// - [`NormalFormKind::Piecewise`]: arms decided from filled condition slots, high to
1222///   low. An unfilled or vetoed condition before a winner is found means no decision:
1223///   every arm stays live.
1224/// - [`NormalFormKind::OrderedDispatch`]: only the region for the scrutinee slot. An
1225///   unfilled scrutinee means every origin arm stays live.
1226///
1227/// Every cell the evaluator visits owns its slot, including rule reference cells
1228/// (they hold the referenced rule's value).
1229///
1230/// Order matches the evaluator walk (`tree.rs`): first-seen [`DataPath`] wins
1231/// (rule references may share a leaf under distinct cell ids). Iterative DFS with
1232/// pop-time visited; children pushed in reverse evaluation order.
1233pub(crate) fn reachable_data_paths(
1234    plan: &ExecutionPlan,
1235    root: NormalFormId,
1236    values: &[Option<crate::computation::OperationResult>],
1237) -> IndexSet<DataPath> {
1238    use crate::computation::OperationResult;
1239    use crate::evaluation::branch_semantics::{
1240        condition_outcome, piecewise_decision, BranchOutcome, PiecewiseDecision,
1241    };
1242    use crate::planning::normalize::LeafKind;
1243    use crate::planning::normalize::NormalFormKind;
1244    use crate::planning::ordered_dispatch::region_of_scrutinee;
1245
1246    fn slot_outcome(values: &[Option<OperationResult>], id: NormalFormId) -> Option<BranchOutcome> {
1247        values
1248            .get(id.index())
1249            .and_then(|slot| slot.as_ref())
1250            .map(condition_outcome)
1251    }
1252
1253    /// Piecewise pre-image of a dispatch cell.
1254    ///
1255    /// A cell that borrows a rule's Kind (`rule_ref`) records no `origin` of its
1256    /// own, and the rule it names can itself be a bare reference to a further
1257    /// rule (`rule alias: country`), so the fold can sit any number of
1258    /// references away. Every hop moves to a rule earlier in topological order,
1259    /// so the chain is shorter than the rule table.
1260    fn dispatch_origin(plan: &ExecutionPlan, cell: &NormalForm) -> NormalFormId {
1261        let mut current = cell;
1262        for _ in 0..=plan.rules.len() {
1263            if let Some(origin) = current.origin {
1264                return origin;
1265            }
1266            let ref_path = current.rule_ref.as_ref().unwrap_or_else(|| {
1267                panic!("BUG: OrderedDispatch without origin must carry rule_ref")
1268            });
1269            let body = plan
1270                .rules
1271                .get(ref_path)
1272                .unwrap_or_else(|| {
1273                    panic!("BUG: rule reference '{ref_path}' missing from plan.rules")
1274                })
1275                .normal_form;
1276            current = plan.normal_form(body);
1277        }
1278        panic!("BUG: rule reference chain over a dispatch cell does not terminate");
1279    }
1280
1281    /// Piecewise children the evaluator visits under `decision`, in evaluation
1282    /// order: `cond_n, body_n, …, cond_1, body_1, default`.
1283    fn live_piecewise_children(
1284        arms: &[(NormalFormId, NormalFormId)],
1285        decision: PiecewiseDecision,
1286    ) -> Vec<NormalFormId> {
1287        let mut live = Vec::new();
1288        match decision {
1289            PiecewiseDecision::Taken { arm } => {
1290                for (condition, _) in arms.iter().skip(arm + 1).rev() {
1291                    live.push(*condition);
1292                }
1293                live.push(arms[arm].0);
1294                live.push(arms[arm].1);
1295            }
1296            PiecewiseDecision::Default => {
1297                for (condition, _) in arms.iter().skip(1).rev() {
1298                    live.push(*condition);
1299                }
1300                live.push(arms[0].1);
1301            }
1302            PiecewiseDecision::Undecided { arm } => {
1303                for (condition, _) in arms.iter().skip(arm + 1).rev() {
1304                    live.push(*condition);
1305                }
1306                for (condition, body) in arms.iter().take(arm + 1).skip(1).rev() {
1307                    live.push(*condition);
1308                    live.push(*body);
1309                }
1310                live.push(arms[0].1);
1311            }
1312        }
1313        live
1314    }
1315
1316    let mut out = IndexSet::new();
1317    let mut visited = HashSet::new();
1318    let mut stack = vec![root];
1319
1320    while let Some(id) = stack.pop() {
1321        if !visited.insert(id) {
1322            continue;
1323        }
1324        let nf = plan.normal_form(id);
1325        let live: Vec<NormalFormId> = match &nf.kind {
1326            NormalFormKind::Leaf(LeafKind::DataPath(path)) => {
1327                out.insert(path.clone());
1328                Vec::new()
1329            }
1330            NormalFormKind::And(left, right) => {
1331                // Match evaluate_and: only a settled `true` left keeps the right live.
1332                match slot_outcome(values, *left) {
1333                    Some(BranchOutcome::Taken) => vec![*left, *right],
1334                    Some(BranchOutcome::NotTaken | BranchOutcome::Propagate(_)) | None => {
1335                        vec![*left]
1336                    }
1337                }
1338            }
1339            NormalFormKind::Piecewise(arms) => {
1340                let decision =
1341                    piecewise_decision(arms.len(), |arm| slot_outcome(values, arms[arm].0));
1342                live_piecewise_children(arms, decision)
1343            }
1344            NormalFormKind::OrderedDispatch {
1345                scrutinee,
1346                boundaries,
1347                regions,
1348            } => {
1349                let origin_id = dispatch_origin(plan, nf);
1350                let NormalFormKind::Piecewise(arms) = &plan.normal_form(origin_id).kind else {
1351                    panic!("BUG: OrderedDispatch origin must be Piecewise");
1352                };
1353                visited.insert(origin_id);
1354
1355                let selected = match values.get(scrutinee.index()).and_then(|slot| slot.as_ref()) {
1356                    Some(OperationResult::Value(literal)) => {
1357                        region_of_scrutinee(boundaries, &literal.value)
1358                            .ok()
1359                            .map(|region| regions[region])
1360                    }
1361                    Some(OperationResult::Veto(_)) | None => None,
1362                };
1363                let resolved =
1364                    selected.and_then(|body| resolve_nested_dispatch_body(plan, values, body));
1365                let decision = match resolved {
1366                    Some(resolved) => {
1367                        let winner = arms.iter().rposition(|(_, body)| *body == resolved);
1368                        match winner {
1369                            Some(0) => PiecewiseDecision::Default,
1370                            Some(arm) => PiecewiseDecision::Taken { arm },
1371                            None => panic!(
1372                                "BUG: OrderedDispatch selected region body is not an origin arm body"
1373                            ),
1374                        }
1375                    }
1376                    // Outer key decided but nest open, or outer scrutinee open:
1377                    // keep every origin arm live (safe over-approx).
1378                    None => PiecewiseDecision::Undecided {
1379                        arm: arms.len() - 1,
1380                    },
1381                };
1382                let mut live = vec![*scrutinee];
1383                // Origin Piecewise conditions are not filled under the dispatch
1384                // value walk, so an undecided nest must be visited directly —
1385                // otherwise And short-circuit on empty slots drops the residual
1386                // scrutinee (e.g. weight in `zone is K and weight >= N`).
1387                if let (Some(selected_body), None) = (selected, resolved) {
1388                    live.push(selected_body);
1389                }
1390                live.extend(live_piecewise_children(arms, decision));
1391                live
1392            }
1393            NormalFormKind::SpecMember { base, targets, .. } => {
1394                let chosen = match values.get(base.index()).and_then(|slot| slot.as_ref()) {
1395                    Some(OperationResult::Value(bound)) => match &bound.value {
1396                        crate::planning::semantics::ValueKind::Spec(instance) => targets
1397                            .iter()
1398                            .find(|(candidate, _)| candidate == instance)
1399                            .map(|(_, target)| *target),
1400                        _ => None,
1401                    },
1402                    Some(OperationResult::Veto(_)) | None => None,
1403                };
1404                match chosen {
1405                    Some(target) => vec![*base, target],
1406                    None => vec![*base],
1407                }
1408            }
1409            NormalFormKind::Leaf(LeafKind::Literal(_))
1410            | NormalFormKind::Now
1411            | NormalFormKind::Veto(_)
1412            | NormalFormKind::Sum(_)
1413            | NormalFormKind::Product(_)
1414            | NormalFormKind::Subtract(_, _)
1415            | NormalFormKind::Divide(_, _)
1416            | NormalFormKind::Power(_, _)
1417            | NormalFormKind::Modulo(_, _)
1418            | NormalFormKind::Comparison(_, _, _)
1419            | NormalFormKind::RangeLiteral(_, _)
1420            | NormalFormKind::RangeContainment(_, _)
1421            | NormalFormKind::Negate(_)
1422            | NormalFormKind::Reciprocal(_)
1423            | NormalFormKind::Not(_)
1424            | NormalFormKind::MathOp(_, _)
1425            | NormalFormKind::UnitConversion(_, _)
1426            | NormalFormKind::DateRelative(_, _)
1427            | NormalFormKind::DateCalendar(_, _, _)
1428            | NormalFormKind::PastFutureRange(_, _)
1429            | NormalFormKind::RangeBound(_, _)
1430            | NormalFormKind::ResultIsVeto(_) => nf.kind.children(),
1431        };
1432        stack.extend(live.into_iter().rev());
1433    }
1434    out
1435}
1436
1437pub(crate) fn validate_value_against_type(
1438    expected_type: &LemmaType,
1439    value: &LiteralValue,
1440    unit_index: &crate::planning::unit_index::UnitIndex,
1441) -> Result<(), String> {
1442    use crate::computation::rational::{checked_mul, rational_new, try_pow_i32, RationalInteger};
1443    use crate::planning::semantics::TypeSpecification;
1444
1445    fn exceeds_decimal_places(magnitude: &RationalInteger, max_decimals: u8) -> bool {
1446        let scale = match try_pow_i32(&rational_new(10, 1), i32::from(max_decimals)) {
1447            Ok(value) => value,
1448            Err(_) => return true,
1449        };
1450        let scaled = match checked_mul(magnitude, &scale) {
1451            Ok(value) => value,
1452            Err(_) => return true,
1453        };
1454        match RationalInteger::try_reduce_ref(&scaled) {
1455            Ok(reduced) => !reduced.is_integer(),
1456            Err(_) => true,
1457        }
1458    }
1459
1460    fn format_rational_for_validation_message(
1461        expected_type: &crate::planning::semantics::LemmaType,
1462        magnitude: &RationalInteger,
1463    ) -> String {
1464        expected_type
1465            .try_rational_as_decimal(magnitude)
1466            .map(|decimal| decimal.to_string())
1467            .unwrap_or_else(|_| magnitude.display_str())
1468    }
1469
1470    match (&expected_type.specifications, &value.value) {
1471        (
1472            TypeSpecification::Number {
1473                minimum,
1474                maximum,
1475                decimals,
1476                ..
1477            },
1478            ValueKind::Number(n),
1479        ) => {
1480            use std::cmp::Ordering;
1481            if let Some(d) = decimals {
1482                if exceeds_decimal_places(n, *d) {
1483                    return Err(format!(
1484                        "{} exceeds decimals constraint {d}",
1485                        n.display_str()
1486                    ));
1487                }
1488            }
1489            if let Some(min) = minimum {
1490                if n.try_cmp(min)
1491                    .map_err(|failure| format!("minimum bound compare failed: {failure}"))?
1492                    == Ordering::Less
1493                {
1494                    return Err(format!(
1495                        "{} is below minimum {}",
1496                        format_rational_for_validation_message(expected_type, n),
1497                        format_rational_for_validation_message(expected_type, min)
1498                    ));
1499                }
1500            }
1501            if let Some(max) = maximum {
1502                if n.try_cmp(max)
1503                    .map_err(|failure| format!("maximum bound compare failed: {failure}"))?
1504                    == Ordering::Greater
1505                {
1506                    return Err(format!(
1507                        "{} is above maximum {}",
1508                        format_rational_for_validation_message(expected_type, n),
1509                        format_rational_for_validation_message(expected_type, max)
1510                    ));
1511                }
1512            }
1513            Ok(())
1514        }
1515        (
1516            TypeSpecification::Measure {
1517                minimum,
1518                maximum,
1519                decimals,
1520                units,
1521                ..
1522            },
1523            ValueKind::Measure(magnitude),
1524        ) => {
1525            use crate::computation::rational::checked_div;
1526            use crate::planning::semantics::measure_declared_bound_to_canonical;
1527            use std::cmp::Ordering;
1528            let unit = expected_type
1529                .unit()
1530                .or_else(|| {
1531                    units
1532                        .iter()
1533                        .find(|u| u.is_canonical_factor())
1534                        .or_else(|| units.iter().next())
1535                        .map(|u| u.name.as_str())
1536                })
1537                .ok_or_else(|| {
1538                    format!(
1539                        "measure type '{}' has no declared units for validation",
1540                        expected_type.name()
1541                    )
1542                })?;
1543            let measure_unit = units.get(unit)?;
1544            let factor = &measure_unit.factor;
1545            let in_unit = checked_div(magnitude, factor).map_err(|failure| {
1546                format!("cannot de-canonicalize measure for validation: {failure}")
1547            })?;
1548            if let Some(d) = decimals {
1549                if exceeds_decimal_places(&in_unit, *d) {
1550                    return Err(format!(
1551                        "{} {unit} exceeds decimals constraint {d}",
1552                        in_unit.display_str()
1553                    ));
1554                }
1555            }
1556            if let Some(bound) = minimum {
1557                let canonical_min = measure_declared_bound_to_canonical(
1558                    &bound.0,
1559                    &bound.1,
1560                    units,
1561                    expected_type.name().as_str(),
1562                    "minimum",
1563                )?;
1564                if magnitude
1565                    .try_cmp(&canonical_min)
1566                    .map_err(|failure| format!("minimum bound compare failed: {failure}"))?
1567                    == Ordering::Less
1568                {
1569                    let min_in_unit = checked_div(&canonical_min, factor).map_err(|failure| {
1570                        format!("cannot de-canonicalize minimum for validation: {failure}")
1571                    })?;
1572                    let value_display = format!(
1573                        "{} {}",
1574                        format_rational_for_validation_message(expected_type, &in_unit),
1575                        unit
1576                    );
1577                    let bound_display = format!(
1578                        "{} {}",
1579                        format_rational_for_validation_message(expected_type, &min_in_unit),
1580                        measure_unit.name
1581                    );
1582                    return Err(format!("{value_display} is below minimum {bound_display}"));
1583                }
1584            }
1585            if let Some(bound) = maximum {
1586                let canonical_max = measure_declared_bound_to_canonical(
1587                    &bound.0,
1588                    &bound.1,
1589                    units,
1590                    expected_type.name().as_str(),
1591                    "maximum",
1592                )?;
1593                if magnitude
1594                    .try_cmp(&canonical_max)
1595                    .map_err(|failure| format!("maximum bound compare failed: {failure}"))?
1596                    == Ordering::Greater
1597                {
1598                    let max_in_unit = checked_div(&canonical_max, factor).map_err(|failure| {
1599                        format!("cannot de-canonicalize maximum for validation: {failure}")
1600                    })?;
1601                    let value_display = format!(
1602                        "{} {}",
1603                        format_rational_for_validation_message(expected_type, &in_unit),
1604                        unit
1605                    );
1606                    let bound_display = format!(
1607                        "{} {}",
1608                        format_rational_for_validation_message(expected_type, &max_in_unit),
1609                        measure_unit.name
1610                    );
1611                    return Err(format!("{value_display} is above maximum {bound_display}"));
1612                }
1613            }
1614            Ok(())
1615        }
1616        (
1617            TypeSpecification::Text {
1618                length, options, ..
1619            },
1620            ValueKind::Text(s),
1621        ) => {
1622            let len = s.chars().count();
1623            if let Some(exact) = length {
1624                if len != *exact {
1625                    return Err(format!(
1626                        "'{}' has length {} but required length is {}",
1627                        s, len, exact
1628                    ));
1629                }
1630            }
1631            if !options.is_empty() && !options.iter().any(|opt| opt == s) {
1632                return Err(format!(
1633                    "'{}' is not in allowed options: {}",
1634                    s,
1635                    options.join(", ")
1636                ));
1637            }
1638            Ok(())
1639        }
1640        (
1641            TypeSpecification::Ratio {
1642                minimum,
1643                maximum,
1644                decimals,
1645                units,
1646                ..
1647            },
1648            ValueKind::Ratio(r),
1649        ) => {
1650            use crate::computation::rational::checked_mul;
1651            use std::cmp::Ordering;
1652
1653            let primary_unit = expected_type
1654                .unit()
1655                .or_else(|| expected_type.ratio_primary_unit());
1656
1657            if let Some(d) = decimals {
1658                let magnitude_for_decimals = match primary_unit {
1659                    Some(unit) => {
1660                        let ratio_unit = units.get(unit)?;
1661                        checked_mul(r, &ratio_unit.value).map_err(|failure| failure.to_string())?
1662                    }
1663                    None => r.clone(),
1664                };
1665                if exceeds_decimal_places(&magnitude_for_decimals, *d) {
1666                    return Err(format!(
1667                        "{} exceeds decimals constraint {d}",
1668                        magnitude_for_decimals.display_str()
1669                    ));
1670                }
1671            }
1672            if let Some(type_minimum) = minimum {
1673                if r.try_cmp(type_minimum)
1674                    .map_err(|failure| format!("minimum bound compare failed: {failure}"))?
1675                    == Ordering::Less
1676                {
1677                    let message = match primary_unit {
1678                        Some(unit) => {
1679                            let ratio_unit = units.get(unit)?;
1680                            let value_per_unit = checked_mul(r, &ratio_unit.value)
1681                                .map_err(|failure| failure.to_string())?;
1682                            let bound_per_unit = ratio_unit.minimum.clone().expect(
1683                                "BUG: RatioUnit.minimum missing after type minimum set by sync_ratio_units_from_canonical",
1684                            );
1685                            format!(
1686                                "{} {unit} is below minimum {} {unit}",
1687                                format_rational_for_validation_message(
1688                                    expected_type,
1689                                    &value_per_unit
1690                                ),
1691                                format_rational_for_validation_message(
1692                                    expected_type,
1693                                    &bound_per_unit.clone()
1694                                ),
1695                            )
1696                        }
1697                        None => format!(
1698                            "{} is below minimum {}",
1699                            format_rational_for_validation_message(expected_type, r),
1700                            format_rational_for_validation_message(expected_type, type_minimum),
1701                        ),
1702                    };
1703                    return Err(message);
1704                }
1705            }
1706            if let Some(type_maximum) = maximum {
1707                if r.try_cmp(type_maximum)
1708                    .map_err(|failure| format!("maximum bound compare failed: {failure}"))?
1709                    == Ordering::Greater
1710                {
1711                    let message = match primary_unit {
1712                        Some(unit) => {
1713                            let ratio_unit = units.get(unit)?;
1714                            let value_per_unit = checked_mul(r, &ratio_unit.value)
1715                                .map_err(|failure| failure.to_string())?;
1716                            let bound_per_unit = ratio_unit.maximum.clone().expect(
1717                                "BUG: RatioUnit.maximum missing after type maximum set by sync_ratio_units_from_canonical",
1718                            );
1719                            format!(
1720                                "{} {unit} is above maximum {} {unit}",
1721                                format_rational_for_validation_message(
1722                                    expected_type,
1723                                    &value_per_unit
1724                                ),
1725                                format_rational_for_validation_message(
1726                                    expected_type,
1727                                    &bound_per_unit.clone()
1728                                ),
1729                            )
1730                        }
1731                        None => format!(
1732                            "{} is above maximum {}",
1733                            format_rational_for_validation_message(expected_type, r),
1734                            format_rational_for_validation_message(expected_type, type_maximum),
1735                        ),
1736                    };
1737                    return Err(message);
1738                }
1739            }
1740            Ok(())
1741        }
1742        (
1743            TypeSpecification::Ratio {
1744                minimum,
1745                maximum,
1746                decimals,
1747                units: _,
1748                ..
1749            },
1750            ValueKind::Number(n),
1751        ) => {
1752            use std::cmp::Ordering;
1753            if let Some(d) = decimals {
1754                if exceeds_decimal_places(n, *d) {
1755                    return Err(format!(
1756                        "{} exceeds decimals constraint {d}",
1757                        n.display_str()
1758                    ));
1759                }
1760            }
1761            if let Some(type_minimum) = minimum {
1762                if n.try_cmp(type_minimum)
1763                    .map_err(|failure| format!("minimum bound compare failed: {failure}"))?
1764                    == Ordering::Less
1765                {
1766                    return Err(format!(
1767                        "{} is below minimum {}",
1768                        format_rational_for_validation_message(expected_type, n),
1769                        format_rational_for_validation_message(expected_type, type_minimum)
1770                    ));
1771                }
1772            }
1773            if let Some(type_maximum) = maximum {
1774                if n.try_cmp(type_maximum)
1775                    .map_err(|failure| format!("maximum bound compare failed: {failure}"))?
1776                    == Ordering::Greater
1777                {
1778                    return Err(format!(
1779                        "{} is above maximum {}",
1780                        format_rational_for_validation_message(expected_type, n),
1781                        format_rational_for_validation_message(expected_type, type_maximum)
1782                    ));
1783                }
1784            }
1785            Ok(())
1786        }
1787        (
1788            TypeSpecification::Date {
1789                minimum, maximum, ..
1790            },
1791            ValueKind::Date(dt),
1792        ) => {
1793            use crate::planning::semantics::{compare_semantic_dates, date_time_to_semantic};
1794            use std::cmp::Ordering;
1795            if let Some(min) = minimum {
1796                let min_sem = date_time_to_semantic(min);
1797                if compare_semantic_dates(dt, &min_sem) == Ordering::Less {
1798                    return Err(format!("{} is below minimum {}", dt, min));
1799                }
1800            }
1801            if let Some(max) = maximum {
1802                let max_sem = date_time_to_semantic(max);
1803                if compare_semantic_dates(dt, &max_sem) == Ordering::Greater {
1804                    return Err(format!("{} is above maximum {}", dt, max));
1805                }
1806            }
1807            Ok(())
1808        }
1809        (
1810            TypeSpecification::Time {
1811                minimum, maximum, ..
1812            },
1813            ValueKind::Time(t),
1814        ) => {
1815            use crate::planning::semantics::{compare_semantic_times, time_to_semantic};
1816            use std::cmp::Ordering;
1817            if let Some(min) = minimum {
1818                let min_sem = time_to_semantic(min);
1819                if compare_semantic_times(t, &min_sem) == Ordering::Less {
1820                    return Err(format!("{} is below minimum {}", t, min));
1821                }
1822            }
1823            if let Some(max) = maximum {
1824                let max_sem = time_to_semantic(max);
1825                if compare_semantic_times(t, &max_sem) == Ordering::Greater {
1826                    return Err(format!("{} is above maximum {}", t, max));
1827                }
1828            }
1829            Ok(())
1830        }
1831        (TypeSpecification::Boolean { .. }, ValueKind::Boolean(_)) => Ok(()),
1832        (
1833            range_spec @ (TypeSpecification::NumberRange { .. }
1834            | TypeSpecification::DateRange { .. }
1835            | TypeSpecification::TimeRange { .. }
1836            | TypeSpecification::MeasureRange { .. }
1837            | TypeSpecification::RatioRange { .. }),
1838            ValueKind::Range(left, right),
1839        ) => validate_range_literal(
1840            expected_type,
1841            range_spec,
1842            left.as_ref(),
1843            right.as_ref(),
1844            unit_index,
1845        ),
1846        (TypeSpecification::Veto { .. }, _) | (TypeSpecification::Undetermined, _) => Ok(()),
1847        (spec, value_kind) if !value_kind_matches_spec(value_kind, spec) => unreachable!(
1848            "BUG: validate_value_against_type called with mismatched type/value: \
1849             spec={:?}, value={:?} — typing must be enforced before validation",
1850            spec, value_kind
1851        ),
1852        (spec, value_kind) => unreachable!(
1853            "BUG: validate_value_against_type missed a value_kind_matches_spec pair: \
1854             spec={:?}, value={:?}",
1855            spec, value_kind
1856        ),
1857    }
1858}
1859
1860fn validate_range_literal(
1861    expected_type: &LemmaType,
1862    range_spec: &TypeSpecification,
1863    left: &crate::planning::semantics::TypedLiteral,
1864    right: &crate::planning::semantics::TypedLiteral,
1865    unit_index: &crate::planning::unit_index::UnitIndex,
1866) -> Result<(), String> {
1867    use crate::computation::{comparison_operation, OperationResult};
1868    use crate::planning::semantics::{
1869        compare_semantic_dates, compare_semantic_times, measure_declared_bound_to_canonical,
1870        ValueKind,
1871    };
1872    use std::cmp::Ordering;
1873    use std::sync::Arc;
1874
1875    let mut element_spec = range_spec
1876        .element_from_range()
1877        .expect("BUG: element_from_range missing arm for validated range");
1878    if let TypeSpecification::Measure {
1879        units,
1880        decomposition,
1881        ..
1882    } = &mut element_spec
1883    {
1884        if decomposition.is_none() && !units.0.is_empty() {
1885            *decomposition = Some([(expected_type.name(), 1i32)].into_iter().collect());
1886        }
1887    }
1888    let element_type = Arc::new(LemmaType::primitive(element_spec));
1889    validate_value_against_type(element_type.as_ref(), &left.to_literal(), unit_index)?;
1890    validate_value_against_type(element_type.as_ref(), &right.to_literal(), unit_index)?;
1891
1892    let ordering = match (&left.value, &right.value) {
1893        (ValueKind::Number(l), ValueKind::Number(r)) => l
1894            .try_cmp(r)
1895            .map_err(|failure| format!("range endpoint compare failed: {failure}"))?,
1896        (ValueKind::Date(l), ValueKind::Date(r)) => compare_semantic_dates(l, r),
1897        (ValueKind::Time(l), ValueKind::Time(r)) => compare_semantic_times(l, r),
1898        (ValueKind::Ratio(l), ValueKind::Ratio(r)) => l
1899            .try_cmp(r)
1900            .map_err(|failure| format!("range endpoint compare failed: {failure}"))?,
1901        (ValueKind::Measure(l), ValueKind::Measure(r)) => l
1902            .try_cmp(r)
1903            .map_err(|failure| format!("range endpoint compare failed: {failure}"))?,
1904        (left_kind, right_kind) => unreachable!(
1905            "BUG: range endpoints have mismatched value kinds after typing: {left_kind:?} vs {right_kind:?}"
1906        ),
1907    };
1908    if ordering == Ordering::Greater {
1909        return Err(format!(
1910            "range left endpoint {} is above right endpoint {}",
1911            left.display_value(),
1912            right.display_value()
1913        ));
1914    }
1915
1916    let range_lit = LiteralValue {
1917        value: ValueKind::Range(Box::new(left.clone()), Box::new(right.clone())),
1918    };
1919    let range_type = Arc::new(expected_type.clone());
1920
1921    let compare_width = |bound: &LiteralValue,
1922                         bound_type: &Arc<LemmaType>,
1923                         op: ComparisonComputation,
1924                         fail_msg: String|
1925     -> Result<(), String> {
1926        match comparison_operation(&range_lit.value, &range_type, &op, &bound.value, bound_type) {
1927            OperationResult::Value(result) => match &result.value {
1928                ValueKind::Boolean(true) => Ok(()),
1929                ValueKind::Boolean(false) => Err(fail_msg),
1930                other => unreachable!("BUG: width comparison must return boolean, got {other:?}"),
1931            },
1932            OperationResult::Veto(veto) => Err(veto.to_string()),
1933        }
1934    };
1935
1936    match range_spec {
1937        TypeSpecification::NumberRange {
1938            minimum, maximum, ..
1939        } => {
1940            if let Some(min_w) = minimum {
1941                compare_width(
1942                    &LiteralValue::number(min_w.clone()),
1943                    crate::planning::semantics::primitive_number_arc(),
1944                    ComparisonComputation::GreaterThanOrEqual,
1945                    format!("span is below minimum width {}", min_w.display_str()),
1946                )?;
1947            }
1948            if let Some(max_w) = maximum {
1949                compare_width(
1950                    &LiteralValue::number(max_w.clone()),
1951                    crate::planning::semantics::primitive_number_arc(),
1952                    ComparisonComputation::LessThanOrEqual,
1953                    format!("span is above maximum width {}", max_w.display_str()),
1954                )?;
1955            }
1956        }
1957        TypeSpecification::RatioRange {
1958            minimum, maximum, ..
1959        } => {
1960            if let Some(min_w) = minimum {
1961                compare_width(
1962                    &LiteralValue::ratio(min_w.clone()),
1963                    crate::planning::semantics::primitive_ratio_arc(),
1964                    ComparisonComputation::GreaterThanOrEqual,
1965                    format!("span is below minimum width {}", min_w.display_str()),
1966                )?;
1967            }
1968            if let Some(max_w) = maximum {
1969                compare_width(
1970                    &LiteralValue::ratio(max_w.clone()),
1971                    crate::planning::semantics::primitive_ratio_arc(),
1972                    ComparisonComputation::LessThanOrEqual,
1973                    format!("span is above maximum width {}", max_w.display_str()),
1974                )?;
1975            }
1976        }
1977        TypeSpecification::MeasureRange {
1978            minimum,
1979            maximum,
1980            units,
1981            ..
1982        } => {
1983            if let Some(min_w) = minimum {
1984                let canonical = measure_declared_bound_to_canonical(
1985                    &min_w.0, &min_w.1, units, "range", "minimum",
1986                )?;
1987                let bound = LiteralValue::measure_with_type(canonical, Arc::clone(&element_type));
1988                compare_width(
1989                    &bound,
1990                    &element_type,
1991                    ComparisonComputation::GreaterThanOrEqual,
1992                    format!(
1993                        "span is below minimum width {} {}",
1994                        min_w.0.display_str(),
1995                        min_w.1
1996                    ),
1997                )?;
1998            }
1999            if let Some(max_w) = maximum {
2000                let canonical = measure_declared_bound_to_canonical(
2001                    &max_w.0, &max_w.1, units, "range", "maximum",
2002                )?;
2003                let bound = LiteralValue::measure_with_type(canonical, Arc::clone(&element_type));
2004                compare_width(
2005                    &bound,
2006                    &element_type,
2007                    ComparisonComputation::LessThanOrEqual,
2008                    format!(
2009                        "span is above maximum width {} {}",
2010                        max_w.0.display_str(),
2011                        max_w.1
2012                    ),
2013                )?;
2014            }
2015        }
2016        TypeSpecification::DateRange {
2017            minimum, maximum, ..
2018        }
2019        | TypeSpecification::TimeRange {
2020            minimum, maximum, ..
2021        } => {
2022            let allow_calendar = matches!(range_spec, TypeSpecification::DateRange { .. });
2023            let resolve_bound =
2024                |bound: &(crate::computation::rational::RationalInteger, String),
2025                 command: &str|
2026                 -> Result<(LiteralValue, Arc<LemmaType>), String> {
2027                    let (bare, owner) = unit_index
2028                        .resolve(bound.1.as_str())
2029                        .map_err(|err| format!("{command} width unit '{}': {err}", bound.1))?;
2030                    if allow_calendar {
2031                        if !owner.is_duration_like() && !owner.is_calendar_like() {
2032                            return Err(format!(
2033                                "{command} width unit '{bare}' must be a duration or calendar unit",
2034                            ));
2035                        }
2036                    } else if !owner.is_duration_like() {
2037                        return Err(format!(
2038                            "{command} width unit '{bare}' must be a duration unit",
2039                        ));
2040                    }
2041                    let TypeSpecification::Measure { units, .. } = &owner.specifications else {
2042                        return Err(format!(
2043                            "{command} width unit '{bare}' must resolve to a measure type",
2044                        ));
2045                    };
2046                    let type_name = owner.name();
2047                    let canonical = measure_declared_bound_to_canonical(
2048                        &bound.0,
2049                        &bare,
2050                        units,
2051                        type_name.as_str(),
2052                        command,
2053                    )?;
2054                    Ok((
2055                        LiteralValue::measure_with_type(canonical, Arc::clone(&owner)),
2056                        Arc::clone(&owner),
2057                    ))
2058                };
2059            if let Some(min_w) = minimum {
2060                let (bound, bound_type) = resolve_bound(min_w, "minimum")?;
2061                compare_width(
2062                    &bound,
2063                    &bound_type,
2064                    ComparisonComputation::GreaterThanOrEqual,
2065                    format!(
2066                        "span is below minimum width {} {}",
2067                        min_w.0.display_str(),
2068                        min_w.1
2069                    ),
2070                )?;
2071            }
2072            if let Some(max_w) = maximum {
2073                let (bound, bound_type) = resolve_bound(max_w, "maximum")?;
2074                compare_width(
2075                    &bound,
2076                    &bound_type,
2077                    ComparisonComputation::LessThanOrEqual,
2078                    format!(
2079                        "span is above maximum width {} {}",
2080                        max_w.0.display_str(),
2081                        max_w.1
2082                    ),
2083                )?;
2084            }
2085        }
2086        _ => {}
2087    }
2088
2089    Ok(())
2090}
2091
2092fn validate_literal_data_against_types(plan: &ExecutionPlan) -> Vec<Error> {
2093    let mut errors = Vec::new();
2094
2095    for (data_path, data_definition) in &plan.data {
2096        let (expected_type, lit) = match data_definition {
2097            DataDefinition::Value {
2098                value,
2099                resolved_type,
2100                ..
2101            } => (resolved_type, value),
2102            DataDefinition::TypeDeclaration { .. }
2103            | DataDefinition::Import { .. }
2104            | DataDefinition::Reference { .. } => continue,
2105        };
2106
2107        if let Err(msg) =
2108            validate_value_against_type(expected_type, lit, plan.expression_unit_index())
2109        {
2110            let source = data_definition.source().clone();
2111            errors.push(Error::validation(
2112                format!(
2113                    "Invalid value for data {} (expected {}): {}",
2114                    data_path,
2115                    expected_type.name().as_str(),
2116                    msg
2117                ),
2118                Some(source),
2119                None::<String>,
2120            ));
2121        }
2122    }
2123
2124    errors
2125}
2126
2127fn validate_unit_conversion_targets(plan: &ExecutionPlan) -> Result<(), Error> {
2128    use crate::planning::normalize::NormalFormKind;
2129
2130    let mut errors: Vec<Error> = Vec::new();
2131    let mut visited = HashSet::new();
2132    let mut worklist: Vec<NormalFormId> =
2133        plan.rules.values().map(|rule| rule.normal_form).collect();
2134    while let Some(id) = worklist.pop() {
2135        if !visited.insert(id) {
2136            continue;
2137        }
2138        let nf = plan.normal_form(id);
2139        worklist.extend(nf.kind.children());
2140        if let NormalFormKind::UnitConversion(_, target) = &nf.kind {
2141            if let Some((unit_name, owning_type)) =
2142                crate::computation::units::conversion_target_declares_unit(target)
2143            {
2144                if !crate::computation::units::owning_type_declares_unit_name(
2145                    owning_type.as_ref(),
2146                    unit_name,
2147                ) {
2148                    errors.push(Error::validation(
2149                        format!(
2150                            "Unit conversion target '{unit_name}' is not declared on owning type '{}'",
2151                            owning_type.name()
2152                        ),
2153                        None::<Source>,
2154                        Some(plan.spec_name.clone()),
2155                    ));
2156                }
2157            }
2158        }
2159    }
2160    if let Some(error) = errors.into_iter().next() {
2161        return Err(error);
2162    }
2163    Ok(())
2164}
2165
2166#[cfg(test)]
2167mod tests {
2168    use super::*;
2169    use crate::computation::rational::{rational_new, rational_zero};
2170    use crate::computation::{OperationResult, VetoType};
2171    use crate::evaluation::run_data::RunData;
2172    use crate::literals::DateGranularity;
2173    use crate::literals::TimezoneValue;
2174    use crate::parsing::ast::DateTimeValue;
2175    use crate::planning::semantics::{DataDefinition, DataPath, PathSegment, TypeSpecification};
2176    use crate::Engine;
2177    use crate::{ResourceLimits, RunDataValue};
2178    use serde_json;
2179    use std::collections::HashMap;
2180    use std::str::FromStr;
2181    use std::sync::Arc;
2182
2183    fn default_limits() -> ResourceLimits {
2184        ResourceLimits::default()
2185    }
2186
2187    fn resolve_run_data(plan: &ExecutionPlan, values: HashMap<String, RunDataValue>) -> RunData {
2188        RunData::resolve(plan, values, &default_limits()).expect("resolve")
2189    }
2190
2191    fn veto_reason<'a>(run_data: &'a RunData, path: &DataPath) -> Option<&'a str> {
2192        match run_data.bindings.get(path) {
2193            Some(OperationResult::Veto(veto)) => Some(match veto {
2194                VetoType::Computation { message } => message.as_str(),
2195                other => panic!("expected Computation veto, got {other:?}"),
2196            }),
2197            _ => None,
2198        }
2199    }
2200
2201    fn bound_value<'a>(
2202        run_data: &'a RunData,
2203        path: &DataPath,
2204    ) -> Option<&'a crate::planning::semantics::BoundValueKind> {
2205        run_data.bindings.get(path).and_then(OperationResult::value)
2206    }
2207
2208    fn input_data(pairs: &[(&str, &str)]) -> HashMap<String, RunDataValue> {
2209        pairs
2210            .iter()
2211            .map(|(k, v)| (k.to_string(), RunDataValue::string(*v)))
2212            .collect()
2213    }
2214
2215    #[test]
2216    fn test_with_raw_values() {
2217        let mut engine = Engine::new();
2218        engine
2219            .load([(
2220                crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
2221                    "test.lemma",
2222                ))),
2223                r#"
2224                spec test
2225                data age: number -> suggest 25
2226                "#
2227                .to_string(),
2228            )])
2229            .unwrap();
2230
2231        let plans = engine
2232            .plans
2233            .get_plans(None, "test")
2234            .expect("plans for test");
2235        let plan = plans.values().next().expect("plan");
2236        let data_path = DataPath::new(vec![], "age".to_string());
2237
2238        let values = input_data(&[("age", "30")]);
2239
2240        let run_data = resolve_run_data(plan, values);
2241        let updated_value = bound_value(&run_data, &data_path).expect("bound value");
2242        match &updated_value.value {
2243            crate::planning::semantics::ValueKind::Number(n) => {
2244                assert_eq!(n, &rational_new(30, 1));
2245            }
2246            other => panic!("Expected number literal, got {:?}", other),
2247        }
2248    }
2249
2250    #[test]
2251    fn test_with_raw_values_type_mismatch() {
2252        let mut engine = Engine::new();
2253        engine
2254            .load([(
2255                crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
2256                    "test.lemma",
2257                ))),
2258                r#"
2259                spec test
2260                data age: number
2261                "#
2262                .to_string(),
2263            )])
2264            .unwrap();
2265
2266        let plans = engine
2267            .plans
2268            .get_plans(None, "test")
2269            .expect("plans for test");
2270        let plan = plans.values().next().expect("plan");
2271
2272        let values = input_data(&[("age", "thirty")]);
2273
2274        let run_data = resolve_run_data(plan, values);
2275        let data_path = DataPath::new(vec![], "age".to_string());
2276        match veto_reason(&run_data, &data_path) {
2277            Some(reason) => {
2278                assert!(
2279                    reason.contains("number"),
2280                    "type mismatch must record violation reason, got: {reason}"
2281                );
2282            }
2283            None => panic!("expected veto-bound data for age=thirty"),
2284        }
2285    }
2286
2287    #[test]
2288    fn test_with_raw_values_unknown_data_ignored() {
2289        let mut engine = Engine::new();
2290        engine
2291            .load([(
2292                crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
2293                    "test.lemma",
2294                ))),
2295                r#"
2296                spec test
2297                data known: number
2298                "#
2299                .to_string(),
2300            )])
2301            .unwrap();
2302
2303        let plans = engine
2304            .plans
2305            .get_plans(None, "test")
2306            .expect("plans for test");
2307        let plan = plans.values().next().expect("plan");
2308
2309        let values = input_data(&[("unknown", "30")]);
2310
2311        let run_data = resolve_run_data(plan, values);
2312        assert!(run_data.bindings.is_empty());
2313        assert!(run_data.ignored_unknown.iter().any(|k| k == "unknown"));
2314    }
2315
2316    #[test]
2317    fn test_with_raw_values_nested() {
2318        let mut engine = Engine::new();
2319        engine
2320            .load([(
2321                crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
2322                    "test.lemma",
2323                ))),
2324                r#"
2325                spec private
2326                data base_price: number
2327
2328                spec test
2329                uses rules: private
2330                "#
2331                .to_string(),
2332            )])
2333            .unwrap();
2334
2335        let plans = engine
2336            .plans
2337            .get_plans(None, "test")
2338            .expect("plans for test");
2339        let plan = plans.values().next().expect("plan");
2340
2341        let values = input_data(&[("rules.base_price", "100")]);
2342
2343        let run_data = resolve_run_data(plan, values);
2344        let data_path = DataPath {
2345            segments: vec![PathSegment {
2346                uses: "rules".to_string(),
2347                repository: None,
2348                spec: "private".to_string(),
2349            }],
2350            data: "base_price".to_string(),
2351        };
2352        let updated_value = bound_value(&run_data, &data_path).expect("bound value");
2353        match &updated_value.value {
2354            crate::planning::semantics::ValueKind::Number(n) => {
2355                assert_eq!(n, &rational_new(100, 1));
2356            }
2357            other => panic!("Expected number literal, got {:?}", other),
2358        }
2359    }
2360
2361    #[test]
2362    fn run_data_should_enforce_number_maximum_constraint() {
2363        // Higher-standard requirement: user input must be validated against type constraints.
2364        // If this test fails, Lemma accepts invalid values and gives false reassurance.
2365        let data_path = DataPath::new(vec![], "x".to_string());
2366
2367        let max10 = crate::planning::semantics::LemmaType::primitive(
2368            crate::planning::semantics::TypeSpecification::Number {
2369                minimum: None,
2370                maximum: Some(rational_new(10, 1)),
2371                decimals: None,
2372                help: String::new(),
2373            },
2374        );
2375        let source = Source::new(
2376            crate::parsing::source::SourceType::Volatile,
2377            crate::parsing::ast::Span {
2378                start: 0,
2379                end: 0,
2380                line: 1,
2381                col: 0,
2382            },
2383        );
2384        let mut data = IndexMap::new();
2385        data.insert(
2386            data_path.clone(),
2387            crate::planning::semantics::DataDefinition::Value {
2388                value: crate::planning::semantics::LiteralValue::number_with_type(
2389                    rational_new(0, 1),
2390                    Arc::new(max10.clone()),
2391                ),
2392                resolved_type: Arc::new(max10.clone()),
2393                source: source.clone(),
2394            },
2395        );
2396
2397        let input_key_index = data.keys().map(|p| (p.input_key(), p.clone())).collect();
2398        let data_len = data.len();
2399        let plan = ExecutionPlan {
2400            spec_name: "test".to_string(),
2401            commentary: None,
2402            data,
2403            normal_forms: Vec::new(),
2404            rules: IndexMap::new(),
2405            data_reference_order: Vec::new(),
2406            meta: IndexMap::new(),
2407            resolved_types: ResolvedSpecTypes::default(),
2408            family_units: Arc::new(FamilyUnitCatalog::default()),
2409            signature_index: IndexMap::new(),
2410            effective: EffectiveDate::Origin,
2411            effective_from: None,
2412            effective_to: None,
2413            versions: std::sync::Arc::from([]),
2414            start_line: 1,
2415            source_type: None,
2416            needed_by_rules: vec![Vec::new(); data_len],
2417            data_display: IndexMap::new(),
2418            show_rules: IndexMap::new(),
2419            reference_ends: IndexMap::new(),
2420            input_key_index,
2421            show_rule_index: IndexMap::new(),
2422            data_leaf: IndexMap::new(),
2423        };
2424
2425        let values = input_data(&[("x", "11")]);
2426
2427        let run_data = resolve_run_data(&plan, values);
2428        match veto_reason(&run_data, &data_path) {
2429            Some(reason) => {
2430                assert!(
2431                    reason.contains("maximum") || reason.contains("10"),
2432                    "x=11 must violate maximum 10, got: {reason}"
2433                );
2434            }
2435            None => panic!("expected veto-bound data for x=11"),
2436        }
2437    }
2438
2439    #[test]
2440    fn run_data_should_enforce_text_enum_options() {
2441        // Higher-standard requirement: enum options must be enforced for text types.
2442        let data_path = DataPath::new(vec![], "tier".to_string());
2443
2444        let tier = crate::planning::semantics::LemmaType::primitive(
2445            crate::planning::semantics::TypeSpecification::Text {
2446                length: None,
2447                options: vec!["silver".to_string(), "gold".to_string()],
2448                help: String::new(),
2449            },
2450        );
2451        let source = Source::new(
2452            crate::parsing::source::SourceType::Volatile,
2453            crate::parsing::ast::Span {
2454                start: 0,
2455                end: 0,
2456                line: 1,
2457                col: 0,
2458            },
2459        );
2460        let mut data = IndexMap::new();
2461        data.insert(
2462            data_path.clone(),
2463            crate::planning::semantics::DataDefinition::Value {
2464                value: crate::planning::semantics::LiteralValue::text_with_type(
2465                    "silver".to_string(),
2466                    Arc::new(tier.clone()),
2467                ),
2468                resolved_type: Arc::new(tier.clone()),
2469                source,
2470            },
2471        );
2472
2473        let input_key_index = data.keys().map(|p| (p.input_key(), p.clone())).collect();
2474        let data_len = data.len();
2475        let plan = ExecutionPlan {
2476            spec_name: "test".to_string(),
2477            commentary: None,
2478            data,
2479            normal_forms: Vec::new(),
2480            rules: IndexMap::new(),
2481            data_reference_order: Vec::new(),
2482            meta: IndexMap::new(),
2483            resolved_types: ResolvedSpecTypes::default(),
2484            family_units: Arc::new(FamilyUnitCatalog::default()),
2485            signature_index: IndexMap::new(),
2486            effective: EffectiveDate::Origin,
2487            effective_from: None,
2488            effective_to: None,
2489            versions: std::sync::Arc::from([]),
2490            start_line: 1,
2491            source_type: None,
2492            needed_by_rules: vec![Vec::new(); data_len],
2493            data_display: IndexMap::new(),
2494            show_rules: IndexMap::new(),
2495            reference_ends: IndexMap::new(),
2496            input_key_index,
2497            show_rule_index: IndexMap::new(),
2498            data_leaf: IndexMap::new(),
2499        };
2500
2501        let values = input_data(&[("tier", "platinum")]);
2502
2503        let run_data = resolve_run_data(&plan, values);
2504        match veto_reason(&run_data, &data_path) {
2505            Some(reason) => {
2506                assert!(
2507                    reason.contains("allowed options") || reason.contains("platinum"),
2508                    "invalid enum must record violation, got: {reason}"
2509                );
2510            }
2511            None => panic!("expected veto-bound data for tier=platinum"),
2512        }
2513    }
2514
2515    #[test]
2516    fn run_data_should_enforce_measure_decimals() {
2517        // Higher-standard requirement: decimals should be enforced on measure inputs,
2518        // unless the language explicitly defines rounding semantics.
2519        let data_path = DataPath::new(vec![], "price".to_string());
2520
2521        let money = crate::planning::semantics::LemmaType::primitive(
2522            crate::planning::semantics::TypeSpecification::Measure {
2523                minimum: None,
2524                maximum: None,
2525                decimals: Some(2),
2526                units: crate::planning::semantics::MeasureUnits::from(vec![
2527                    crate::planning::semantics::MeasureUnit::from_decimal_factor(
2528                        "eur".to_string(),
2529                        rust_decimal::Decimal::from_str("1.0").unwrap(),
2530                        Vec::new(),
2531                    )
2532                    .expect("eur unit factor must be exact decimal"),
2533                ]),
2534                traits: Vec::new(),
2535                decomposition: None,
2536                unit: None,
2537                help: String::new(),
2538            },
2539        );
2540        let source = Source::new(
2541            crate::parsing::source::SourceType::Volatile,
2542            crate::parsing::ast::Span {
2543                start: 0,
2544                end: 0,
2545                line: 1,
2546                col: 0,
2547            },
2548        );
2549        let mut data = IndexMap::new();
2550        data.insert(
2551            data_path.clone(),
2552            crate::planning::semantics::DataDefinition::Value {
2553                value: crate::planning::semantics::LiteralValue::measure_with_type(
2554                    rational_zero(),
2555                    Arc::new(money.clone()),
2556                ),
2557                resolved_type: Arc::new(money.clone()),
2558                source,
2559            },
2560        );
2561
2562        let input_key_index = data.keys().map(|p| (p.input_key(), p.clone())).collect();
2563        let data_len = data.len();
2564        let plan = ExecutionPlan {
2565            spec_name: "test".to_string(),
2566            commentary: None,
2567            data,
2568            normal_forms: Vec::new(),
2569            rules: IndexMap::new(),
2570            data_reference_order: Vec::new(),
2571            meta: IndexMap::new(),
2572            resolved_types: ResolvedSpecTypes::default(),
2573            family_units: Arc::new(FamilyUnitCatalog::default()),
2574            signature_index: IndexMap::new(),
2575            effective: EffectiveDate::Origin,
2576            effective_from: None,
2577            effective_to: None,
2578            versions: std::sync::Arc::from([]),
2579            start_line: 1,
2580            source_type: None,
2581            needed_by_rules: vec![Vec::new(); data_len],
2582            data_display: IndexMap::new(),
2583            show_rules: IndexMap::new(),
2584            reference_ends: IndexMap::new(),
2585            input_key_index,
2586            show_rule_index: IndexMap::new(),
2587            data_leaf: IndexMap::new(),
2588        };
2589
2590        let values = input_data(&[("price", "1.234 eur")]);
2591
2592        let run_data = resolve_run_data(&plan, values);
2593        match veto_reason(&run_data, &data_path) {
2594            Some(reason) => {
2595                assert!(
2596                    reason.contains("decimals") || reason.contains("decimal"),
2597                    "1.234 eur must violate decimals=2, got: {reason}"
2598                );
2599            }
2600            None => panic!("expected veto-bound data for price=1.234 eur"),
2601        }
2602    }
2603
2604    fn empty_plan(effective: crate::parsing::ast::EffectiveDate) -> ExecutionPlan {
2605        ExecutionPlan {
2606            spec_name: "s".into(),
2607            commentary: None,
2608            data: IndexMap::new(),
2609            normal_forms: Vec::new(),
2610            rules: IndexMap::new(),
2611            data_reference_order: Vec::new(),
2612            meta: IndexMap::new(),
2613            resolved_types: ResolvedSpecTypes::default(),
2614            family_units: Arc::new(FamilyUnitCatalog::default()),
2615            signature_index: IndexMap::new(),
2616            effective,
2617            effective_from: None,
2618            effective_to: None,
2619            versions: std::sync::Arc::from([]),
2620            start_line: 1,
2621            source_type: None,
2622            needed_by_rules: Vec::new(),
2623            data_display: IndexMap::new(),
2624            show_rules: IndexMap::new(),
2625            reference_ends: IndexMap::new(),
2626            input_key_index: IndexMap::new(),
2627            show_rule_index: IndexMap::new(),
2628            data_leaf: IndexMap::new(),
2629        }
2630    }
2631
2632    fn plans_by_effective(
2633        plans: impl IntoIterator<Item = ExecutionPlan>,
2634    ) -> BTreeMap<EffectiveDate, ExecutionPlan> {
2635        plans
2636            .into_iter()
2637            .map(|plan| (plan.effective.clone(), plan))
2638            .collect()
2639    }
2640
2641    /// Compiled plans for one spec name are ordered by `effective` key.
2642    #[test]
2643    fn plan_set_plans_are_in_ascending_effective_order() {
2644        let june = DateTimeValue {
2645            year: 2025,
2646            month: 6,
2647            day: 1,
2648            hour: 0,
2649            minute: 0,
2650            second: 0,
2651            microsecond: 0,
2652            timezone: None,
2653            granularity: DateGranularity::Full,
2654        };
2655        let dec = DateTimeValue {
2656            year: 2025,
2657            month: 12,
2658            day: 1,
2659            hour: 0,
2660            minute: 0,
2661            second: 0,
2662            microsecond: 0,
2663            timezone: None,
2664            granularity: DateGranularity::Full,
2665        };
2666
2667        let plans = plans_by_effective([
2668            empty_plan(EffectiveDate::Origin),
2669            empty_plan(EffectiveDate::DateTimeValue(june)),
2670            empty_plan(EffectiveDate::DateTimeValue(dec)),
2671        ]);
2672
2673        let effectives: Vec<_> = plans.keys().cloned().collect();
2674        for window in effectives.windows(2) {
2675            assert!(
2676                window[0] < window[1],
2677                "plans must be strictly ascending: {:?} >= {:?}",
2678                window[0],
2679                window[1]
2680            );
2681        }
2682    }
2683
2684    #[test]
2685    fn plan_at_exact_boundary_selects_later_slice() {
2686        use crate::parsing::ast::{DateTimeValue, EffectiveDate};
2687
2688        let june = DateTimeValue {
2689            year: 2025,
2690            month: 6,
2691            day: 1,
2692            hour: 0,
2693            minute: 0,
2694            second: 0,
2695            microsecond: 0,
2696            timezone: None,
2697
2698            granularity: DateGranularity::Full,
2699        };
2700        let dec = DateTimeValue {
2701            year: 2025,
2702            month: 12,
2703            day: 1,
2704            hour: 0,
2705            minute: 0,
2706            second: 0,
2707            microsecond: 0,
2708            timezone: None,
2709
2710            granularity: DateGranularity::Full,
2711        };
2712
2713        let june_key = EffectiveDate::DateTimeValue(june.clone());
2714        let dec_key = EffectiveDate::DateTimeValue(dec.clone());
2715        let plans = plans_by_effective([
2716            empty_plan(EffectiveDate::Origin),
2717            empty_plan(june_key.clone()),
2718            empty_plan(dec_key.clone()),
2719        ]);
2720
2721        let june_plan = plan_at(&plans, &june_key).expect("boundary instant");
2722        assert!(std::ptr::eq(
2723            june_plan,
2724            plans.get(&june_key).expect("june slice")
2725        ));
2726
2727        let dec_plan = plan_at(&plans, &dec_key).expect("dec boundary");
2728        assert!(std::ptr::eq(
2729            dec_plan,
2730            plans.get(&dec_key).expect("dec slice")
2731        ));
2732    }
2733
2734    #[test]
2735    fn plan_at_day_before_boundary_stays_in_earlier_slice() {
2736        use crate::parsing::ast::{DateTimeValue, EffectiveDate};
2737
2738        let june = DateTimeValue {
2739            year: 2025,
2740            month: 6,
2741            day: 1,
2742            hour: 0,
2743            minute: 0,
2744            second: 0,
2745            microsecond: 0,
2746            timezone: None,
2747
2748            granularity: DateGranularity::Full,
2749        };
2750        let may_end = DateTimeValue {
2751            year: 2025,
2752            month: 5,
2753            day: 31,
2754            hour: 23,
2755            minute: 59,
2756            second: 59,
2757            microsecond: 0,
2758            timezone: None,
2759
2760            granularity: DateGranularity::DateTime,
2761        };
2762
2763        let origin = EffectiveDate::Origin;
2764        let plans = plans_by_effective([
2765            empty_plan(origin.clone()),
2766            empty_plan(EffectiveDate::DateTimeValue(june)),
2767        ]);
2768
2769        let may_instant = EffectiveDate::DateTimeValue(may_end);
2770        let may_plan = plan_at(&plans, &may_instant).expect("may 31");
2771        assert!(std::ptr::eq(
2772            may_plan,
2773            plans.get(&origin).expect("origin slice")
2774        ));
2775    }
2776
2777    #[test]
2778    fn plan_at_single_plan_matches_any_instant_after_start() {
2779        use crate::parsing::ast::{DateTimeValue, EffectiveDate};
2780
2781        let t = DateTimeValue {
2782            year: 2025,
2783            month: 3,
2784            day: 1,
2785            hour: 0,
2786            minute: 0,
2787            second: 0,
2788            microsecond: 0,
2789            timezone: None,
2790
2791            granularity: DateGranularity::Full,
2792        };
2793        let start = EffectiveDate::DateTimeValue(DateTimeValue {
2794            year: 2025,
2795            month: 1,
2796            day: 1,
2797            hour: 0,
2798            minute: 0,
2799            second: 0,
2800            microsecond: 0,
2801            timezone: None,
2802
2803            granularity: DateGranularity::Full,
2804        });
2805        let plans = plans_by_effective([empty_plan(start.clone())]);
2806        let instant = EffectiveDate::DateTimeValue(t);
2807        let selected = plan_at(&plans, &instant).expect("inside single slice");
2808        assert!(std::ptr::eq(
2809            selected,
2810            plans.get(&start).expect("single slice")
2811        ));
2812    }
2813
2814    /// The show JSON shape is the IO contract for every non-Rust consumer
2815    /// (WASM playground, Hex, HTTP, TypeScript). Nail the exact envelope.
2816    #[test]
2817    fn show_json_shape_contract() {
2818        let mut engine = Engine::new();
2819        engine
2820            .load([(
2821                crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
2822                    "test.lemma",
2823                ))),
2824                r#"
2825                spec pricing
2826                data bridge_height: measure
2827                  -> unit meter: 1
2828                  -> suggest 100 meter
2829                data quantity: number -> minimum 0
2830                rule cost: bridge_height * quantity
2831                "#
2832                .to_string(),
2833            )])
2834            .unwrap();
2835        let now = DateTimeValue::now();
2836        let schema = engine.show(None, "pricing", Some(&now)).unwrap();
2837
2838        let value: serde_json::Value =
2839            serde_json::to_value(crate::api::Show::from(&schema)).unwrap();
2840
2841        let bh = &value["data"]["bridge_height"];
2842        assert!(
2843            bh.is_object(),
2844            "data entry must be a named object, not tuple"
2845        );
2846        assert!(
2847            bh.get("type").is_some(),
2848            "data entry must expose `type` field"
2849        );
2850        assert!(
2851            bh.get("suggestion").is_some(),
2852            "bridge_height exposes `-> suggest` as schema suggestion"
2853        );
2854        assert!(
2855            bh.get("fill").is_none(),
2856            "bridge_height is not filled from spec"
2857        );
2858
2859        let ty = &bh["type"];
2860        assert_eq!(
2861            ty["kind"], "measure",
2862            "kind tag sits on the type object itself"
2863        );
2864        assert!(
2865            ty["units"].is_array(),
2866            "measure-only fields flatten up to top level"
2867        );
2868        assert!(
2869            ty.get("options").is_none(),
2870            "text-only fields must not leak"
2871        );
2872
2873        let quantity = &value["data"]["quantity"];
2874        assert_eq!(quantity["type"]["kind"], "number");
2875        assert!(
2876            quantity.get("suggestion").is_none(),
2877            "quantity has no suggestion"
2878        );
2879        assert!(
2880            quantity.get("fill").is_none(),
2881            "quantity has no fill literal"
2882        );
2883
2884        let cost = &value["rules"]["cost"];
2885        assert_eq!(
2886            cost["type"]["kind"], "measure",
2887            "rule types nest under type"
2888        );
2889        assert!(
2890            cost["type"]["units"].is_array()
2891                && !cost["type"]["units"].as_array().unwrap().is_empty(),
2892            "measure rule result types expose declared units"
2893        );
2894        assert!(
2895            cost["type"]["units"][0].get("factor").is_some(),
2896            "measure rule units use factor field"
2897        );
2898        assert!(cost.get("branches").is_some(), "ShowRule exposes branches");
2899        assert!(
2900            cost.get("depends_on_rules").is_some(),
2901            "ShowRule exposes depends_on_rules"
2902        );
2903    }
2904
2905    #[test]
2906    fn show_rule_result_units_contract() {
2907        let mut engine = Engine::new();
2908        engine
2909            .load([(
2910                crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
2911                    "units_contract.lemma",
2912                ))),
2913                r#"
2914                spec units_contract
2915                data money: measure
2916                  -> unit eur: 1
2917                  -> unit usd: 0.91
2918                data rate: ratio
2919                  -> unit basis_points: 10000
2920                  -> unit percent: 100
2921                  -> suggest 500 basis_points
2922                rule total: money
2923                rule rate_out: rate
2924                "#
2925                .to_string(),
2926            )])
2927            .unwrap();
2928        let now = DateTimeValue::now();
2929        let schema = engine.show(None, "units_contract", Some(&now)).unwrap();
2930        let value: serde_json::Value =
2931            serde_json::to_value(crate::api::Show::from(&schema)).unwrap();
2932
2933        let money_units = &value["data"]["money"]["type"]["units"];
2934        assert!(money_units.is_array() && !money_units.as_array().unwrap().is_empty());
2935        assert!(money_units[0].get("name").is_some());
2936        assert!(money_units[0].get("factor").is_some());
2937        assert!(money_units[0]["factor"].get("numer").is_some());
2938        assert!(money_units[0]["factor"].get("denom").is_some());
2939
2940        let rate_units = &value["data"]["rate"]["type"]["units"];
2941        assert!(rate_units.is_array() && !rate_units.as_array().unwrap().is_empty());
2942        assert!(rate_units[0].get("name").is_some());
2943        assert!(rate_units[0].get("value").is_some());
2944        assert!(rate_units[0]["value"].get("numer").is_some());
2945        assert!(rate_units[0]["value"].get("denom").is_some());
2946
2947        let total_rule_units = &value["rules"]["total"]["type"]["units"];
2948        let money_unit_names: Vec<_> = money_units
2949            .as_array()
2950            .unwrap()
2951            .iter()
2952            .map(|u| u["name"].as_str().unwrap())
2953            .collect();
2954        let total_rule_unit_names: Vec<_> = total_rule_units
2955            .as_array()
2956            .unwrap()
2957            .iter()
2958            .map(|u| u["name"].as_str().unwrap())
2959            .collect();
2960        assert_eq!(total_rule_unit_names, money_unit_names);
2961
2962        let rate_out_rule_units = &value["rules"]["rate_out"]["type"]["units"];
2963        let rate_unit_names: Vec<_> = rate_units
2964            .as_array()
2965            .unwrap()
2966            .iter()
2967            .map(|u| u["name"].as_str().unwrap())
2968            .collect();
2969        let rate_out_rule_unit_names: Vec<_> = rate_out_rule_units
2970            .as_array()
2971            .unwrap()
2972            .iter()
2973            .map(|u| u["name"].as_str().unwrap())
2974            .collect();
2975        assert_eq!(rate_out_rule_unit_names, rate_unit_names);
2976    }
2977
2978    #[test]
2979    fn show_json_round_trip_preserves_shape() {
2980        let mut engine = Engine::new();
2981        engine
2982            .load([(
2983                crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("s.lemma"))),
2984                r#"
2985                spec s
2986                data age: number -> minimum 0 -> suggest 18
2987                data grade: text -> options "A" "B" "C"
2988                rule adult: age >= 18
2989                "#
2990                .to_string(),
2991            )])
2992            .unwrap();
2993        let now = DateTimeValue::now();
2994        let schema = engine.show(None, "s", Some(&now)).unwrap();
2995
2996        let api_show = crate::api::Show::from(&schema);
2997        let json = serde_json::to_string(&api_show).unwrap();
2998        let round_tripped: crate::api::Show = serde_json::from_str(&json).unwrap();
2999        assert_eq!(api_show, round_tripped);
3000    }
3001
3002    const COST_PRICE_SPEC: &str = r#"
3003spec cost_price
3004uses lemma units
3005
3006data money: measure
3007  -> unit eur: 1.00
3008  -> unit inr: 0.0092
3009  -> decimals 2
3010
3011data labor_cost: measure
3012  -> unit eur_per_hour: eur/hour
3013  -> unit inr_per_hour: inr/hour
3014  -> suggest 25 eur_per_hour
3015
3016data product_cost: measure
3017  -> unit eur_per_kg: eur/kilogram
3018  -> unit inr_per_kg: inr/kilogram
3019  -> suggest 4 eur_per_kg
3020
3021data throughput: measure
3022  -> unit kg_per_hour: kilogram/hour
3023  -> suggest 12 kg_per_hour
3024
3025rule cost_price: product_cost + labor_cost / throughput
3026"#;
3027
3028    fn cost_price_inputs() -> HashMap<String, RunDataValue> {
3029        let mut data = HashMap::new();
3030        data.insert("product_cost".into(), RunDataValue::string("4 eur_per_kg"));
3031        data.insert("labor_cost".into(), RunDataValue::string("25 eur_per_hour"));
3032        data.insert("throughput".into(), RunDataValue::string("12 kg_per_hour"));
3033        data
3034    }
3035
3036    const FILM_ACCESS: &str = r#"
3037spec premium_membership
3038uses lemma units
3039data start: date
3040data length: units.calendar
3041rule valid: now in start...start + length
3042
3043spec film_access
3044uses premium_membership
3045data type: text
3046  -> option "rental"
3047  -> option "purchase"
3048data views_consumed: number
3049data premium_member: boolean
3050rule max_views: 3
3051  unless premium_membership.valid then 10
3052  unless premium_member then 5
3053rule can_view: no
3054  unless type is "rental" and views_consumed < max_views then yes
3055  unless type is "purchase" then yes
3056"#;
3057
3058    fn film_access_effective() -> DateTimeValue {
3059        DateTimeValue {
3060            year: 2027,
3061            month: 2,
3062            day: 14,
3063            hour: 12,
3064            minute: 0,
3065            second: 0,
3066            microsecond: 0,
3067            timezone: Some(TimezoneValue {
3068                offset_hours: 0,
3069                offset_minutes: 0,
3070            }),
3071            granularity: DateGranularity::DateTime,
3072        }
3073    }
3074
3075    #[test]
3076    fn run_data_accepts_per_unit_measure_equivalent_to_canonical_magnitude() {
3077        let mut engine = Engine::new();
3078        engine
3079            .load([(
3080                crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
3081                    "cost_price.lemma",
3082                ))),
3083                COST_PRICE_SPEC.to_string(),
3084            )])
3085            .expect("load");
3086        let plans = engine
3087            .plans
3088            .get_plans(None, "cost_price")
3089            .expect("plans for cost_price");
3090        let plan = plans.values().next().expect("plan");
3091        let mut data = HashMap::new();
3092        data.insert("product_cost".into(), RunDataValue::string("4 eur_per_kg"));
3093        data.insert(
3094            "labor_cost".into(),
3095            RunDataValue::string("0.0069444444444444444444444444 eur_per_hour"),
3096        );
3097        data.insert(
3098            "throughput".into(),
3099            RunDataValue::string("0.0033333333333333333333333333 kg_per_hour"),
3100        );
3101        let run_data = resolve_run_data(plan, data);
3102        assert!(
3103            !run_data
3104                .bindings
3105                .values()
3106                .any(|b| matches!(b, OperationResult::Veto(_))),
3107            "parsed decimal run data values must not be veto-bound after input boundary: {:?}",
3108            run_data.bindings
3109        );
3110    }
3111
3112    #[test]
3113    fn run_data_accepts_per_unit_measure() {
3114        let mut engine = Engine::new();
3115        engine
3116            .load([(
3117                crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
3118                    "cost_price.lemma",
3119                ))),
3120                COST_PRICE_SPEC.to_string(),
3121            )])
3122            .expect("load");
3123        let plans = engine
3124            .plans
3125            .get_plans(None, "cost_price")
3126            .expect("plans for cost_price");
3127        let plan = plans.values().next().expect("plan");
3128        let run_data = resolve_run_data(plan, cost_price_inputs());
3129        assert!(!run_data
3130            .bindings
3131            .values()
3132            .any(|b| matches!(b, OperationResult::Veto(_))));
3133    }
3134
3135    #[test]
3136    fn run_data_rejects_oversize_input() {
3137        let mut engine = Engine::new();
3138        engine
3139            .load([(
3140                crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
3141                    "cost_price.lemma",
3142                ))),
3143                COST_PRICE_SPEC.to_string(),
3144            )])
3145            .expect("load");
3146        let plans = engine
3147            .plans
3148            .get_plans(None, "cost_price")
3149            .expect("plans for cost_price");
3150        let plan = plans.values().next().expect("plan");
3151        let mut data = cost_price_inputs();
3152        data.insert(
3153            "labor_cost".into(),
3154            RunDataValue::string(
3155                "1000000000000000000000000000000000000000000000000000000000000 eur_per_hour",
3156            ),
3157        );
3158        let run_data = resolve_run_data(plan, data);
3159        assert!(matches!(
3160            run_data.bindings.get(&DataPath::local("labor_cost".into())),
3161            Some(OperationResult::Veto(_))
3162        ));
3163    }
3164    #[test]
3165    fn typedecl_default_stays_typedecl_on_immutable_plan() {
3166        let mut engine = Engine::new();
3167        engine
3168            .load([(
3169                crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("s.lemma"))),
3170                r#"
3171        spec s
3172        data n: number -> suggest 42
3173        rule r: n
3174    "#
3175                .to_string(),
3176            )])
3177            .expect("load");
3178
3179        let plans = engine.plans.get_plans(None, "s").expect("plans for s");
3180        let plan = plans.values().next().expect("plan");
3181        let path = DataPath::local("n".into());
3182        match plan.data.get(&path).expect("n") {
3183            DataDefinition::TypeDeclaration {
3184                declared_suggestion: Some(_),
3185                ..
3186            } => {}
3187            other => panic!("expected TypeDeclaration with default, got {other:?}"),
3188        }
3189    }
3190
3191    fn response_missing_data_union(response: &crate::Response) -> Vec<String> {
3192        let mut seen = std::collections::HashSet::new();
3193        let mut names = Vec::new();
3194        for result in response.results.values() {
3195            for key in result.missing_data() {
3196                if seen.insert(key.clone()) {
3197                    names.push(key.clone());
3198                }
3199            }
3200        }
3201        names
3202    }
3203
3204    #[test]
3205    fn run_prunes_inactive_nut_branches_for_total_price() {
3206        let code = r#"
3207spec bag
3208uses lemma units
3209
3210data weight: measure
3211  -> unit kg: 1
3212
3213data money: measure
3214  -> unit eur: 1
3215
3216data price_per_weight: measure
3217  -> unit eur_per_kg: eur/kg
3218
3219data item_cost: price_per_weight
3220data roasting: price_per_weight
3221data chocolatizing: price_per_weight
3222
3223rule total_price: weight * (item_cost + roasting + chocolatizing)
3224
3225spec calc
3226uses bag
3227  -> with item_cost: item_cost
3228  -> with roasting: roasting
3229
3230data type_of_nut: text -> options "peanut" "cashew"
3231
3232rule price_peanut: 1.5 eur_per_kg
3233rule price_peanut_roasting: 0.45 eur_per_kg
3234
3235rule price_cashew: 2.0 eur_per_kg
3236rule price_cashew_roasting: 0.55 eur_per_kg
3237
3238rule item_cost: veto "No item cost"
3239  unless type_of_nut is "peanut" then price_peanut
3240  unless type_of_nut is "cashew" then price_cashew
3241
3242rule roasting: veto "No roasting"
3243  unless type_of_nut is "peanut" then price_peanut_roasting
3244  unless type_of_nut is "cashew" then price_cashew_roasting
3245
3246rule total_price: bag.total_price
3247"#;
3248
3249        let mut engine = Engine::new();
3250        engine
3251            .load([(
3252                crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
3253                    "calc.lemma",
3254                ))),
3255                code.to_string(),
3256            )])
3257            .unwrap();
3258
3259        let now = DateTimeValue::now();
3260        let mut inputs = HashMap::new();
3261        inputs.insert("type_of_nut".to_string(), "peanut".to_string());
3262        let response = engine
3263            .run(
3264                None,
3265                "calc",
3266                Some(&now),
3267                inputs,
3268                Some(&["total_price".to_string()]),
3269                false,
3270            )
3271            .expect("run must succeed");
3272
3273        let names = response_missing_data_union(&response);
3274        assert!(
3275            !names.contains(&"type_of_nut".to_string()),
3276            "supplied type_of_nut is bound and must not appear in missing_data: {names:?}"
3277        );
3278        assert!(names.contains(&"bag.weight".to_string()));
3279        assert!(names.contains(&"bag.chocolatizing".to_string()));
3280        assert!(!names.contains(&"bag.item_cost".to_string()));
3281        assert!(!names.contains(&"bag.roasting".to_string()));
3282    }
3283
3284    #[test]
3285    fn run_includes_membership_dates_when_premium_member_false() {
3286        let mut engine = Engine::new();
3287        engine
3288            .load([(crate::SourceType::Volatile, FILM_ACCESS.to_string())])
3289            .expect("film_access spec must load");
3290        let now = film_access_effective();
3291        let mut inputs = HashMap::new();
3292        inputs.insert("type".to_string(), "rental".to_string());
3293        inputs.insert("views_consumed".to_string(), "6".to_string());
3294        inputs.insert("premium_member".to_string(), "false".to_string());
3295        let response = engine
3296            .run(
3297                None,
3298                "film_access",
3299                Some(&now),
3300                inputs,
3301                Some(&["can_view".to_string()]),
3302                false,
3303            )
3304            .expect("run must succeed");
3305
3306        let names = response_missing_data_union(&response);
3307        assert!(names.contains(&"premium_membership.start".to_string()));
3308        assert!(names.contains(&"premium_membership.length".to_string()));
3309    }
3310
3311    #[test]
3312    fn run_includes_membership_dates_when_premium_member_unknown() {
3313        let mut engine = Engine::new();
3314        engine
3315            .load([(crate::SourceType::Volatile, FILM_ACCESS.to_string())])
3316            .expect("film_access spec must load");
3317        let now = film_access_effective();
3318        let mut inputs = HashMap::new();
3319        inputs.insert("type".to_string(), "rental".to_string());
3320        inputs.insert("views_consumed".to_string(), "6".to_string());
3321        let response = engine
3322            .run(
3323                None,
3324                "film_access",
3325                Some(&now),
3326                inputs,
3327                Some(&["can_view".to_string()]),
3328                false,
3329            )
3330            .expect("run must succeed");
3331
3332        let names = response_missing_data_union(&response);
3333        assert!(names.contains(&"premium_member".to_string()));
3334        assert!(names.contains(&"premium_membership.start".to_string()));
3335        assert!(names.contains(&"premium_membership.length".to_string()));
3336    }
3337
3338    const UNITS_SPEC: &str = r#"
3339spec units
3340uses lemma units
3341data money: measure
3342  -> unit eur: 1
3343  -> decimals 2
3344"#;
3345
3346    const WAREHOUSING_SPEC: &str = r#"
3347spec warehousing
3348uses units
3349uses si: lemma units
3350
3351data units_per_pallet: number
3352  -> minimum 1
3353  -> suggest 1
3354
3355data storage_duration: si.duration
3356  -> minimum 0 week
3357  -> suggest 10 day
3358
3359data interbranch_transport_per_pallet: units.money
3360  -> minimum 0 eur
3361  -> suggest 0 eur
3362
3363data inbound_handling_per_pallet: units.money
3364  -> minimum 0 eur
3365  -> suggest 0 eur
3366
3367data storage_per_pallet_per_week: units.money
3368  -> minimum 0 eur
3369  -> suggest 10 eur
3370
3371data labeling_per_pallet: units.money
3372  -> minimum 0 eur
3373  -> suggest 0 eur
3374
3375data outbound_handling_per_pallet: units.money
3376  -> minimum 0 eur
3377  -> suggest 0 eur
3378
3379rule storage_cost_per_pallet:
3380  storage_per_pallet_per_week
3381  * ceil (storage_duration as week as number)
3382
3383rule total_logistics_per_pallet:
3384  interbranch_transport_per_pallet
3385  + inbound_handling_per_pallet
3386  + storage_cost_per_pallet
3387  + labeling_per_pallet
3388  + outbound_handling_per_pallet
3389
3390rule total_logistics_per_ce:
3391  total_logistics_per_pallet / units_per_pallet
3392"#;
3393
3394    const QUOTATION_SPEC: &str = r#"
3395spec quotation
3396uses wh: warehousing
3397rule total: wh.total_logistics_per_ce
3398"#;
3399
3400    fn load_cross_spec_fixtures(engine: &mut Engine) {
3401        engine
3402            .load([(crate::SourceType::Volatile, UNITS_SPEC.to_string())])
3403            .expect("units spec must load");
3404        engine
3405            .load([(crate::SourceType::Volatile, WAREHOUSING_SPEC.to_string())])
3406            .expect("warehousing spec must load");
3407    }
3408
3409    #[test]
3410    fn quotation_plans_without_consumer_stdlib_units() {
3411        let mut engine = Engine::new();
3412        load_cross_spec_fixtures(&mut engine);
3413        engine
3414            .load([(crate::SourceType::Volatile, QUOTATION_SPEC.to_string())])
3415            .expect("quotation must plan without uses lemma units");
3416        let plans = engine
3417            .plans
3418            .get_plans(None, "quotation")
3419            .expect("plans for quotation");
3420        let plan = plans.values().next().expect("plan");
3421
3422        let expression_units = &plan.resolved_types.unit_index;
3423        assert!(
3424            expression_units.unique_owner("week").is_none(),
3425            "consumer expression scope must not contain week: {:?}",
3426            expression_units.keys().collect::<Vec<_>>()
3427        );
3428        assert!(
3429            expression_units.unique_owner("minute").is_none(),
3430            "consumer expression scope must not contain minute: {:?}",
3431            expression_units.keys().collect::<Vec<_>>()
3432        );
3433        let mut keys: Vec<_> = expression_units.keys().cloned().collect();
3434        keys.sort();
3435        assert_eq!(
3436            keys,
3437            ["percent", "permille"],
3438            "consumer expression scope must only have builtin ratio units, not dependency units"
3439        );
3440    }
3441
3442    fn warehousing_default_inputs(prefix: &str) -> HashMap<String, String> {
3443        let key = |name: &str| {
3444            if prefix.is_empty() {
3445                name.to_string()
3446            } else {
3447                format!("{prefix}.{name}")
3448            }
3449        };
3450        HashMap::from([
3451            (key("units_per_pallet"), "1".to_string()),
3452            (key("storage_duration"), "10 day".to_string()),
3453            (key("interbranch_transport_per_pallet"), "0 eur".to_string()),
3454            (key("inbound_handling_per_pallet"), "0 eur".to_string()),
3455            (key("storage_per_pallet_per_week"), "10 eur".to_string()),
3456            (key("labeling_per_pallet"), "0 eur".to_string()),
3457            (key("outbound_handling_per_pallet"), "0 eur".to_string()),
3458        ])
3459    }
3460
3461    #[test]
3462    fn quotation_evaluates_cross_spec_duration_conversion() {
3463        let mut engine = Engine::new();
3464        load_cross_spec_fixtures(&mut engine);
3465        engine
3466            .load([(crate::SourceType::Volatile, QUOTATION_SPEC.to_string())])
3467            .expect("quotation must load");
3468        let plans = engine
3469            .plans
3470            .get_plans(None, "quotation")
3471            .expect("plans for quotation");
3472        let plan = plans.values().next().expect("plan");
3473        assert!(
3474            plan.resolved_types
3475                .unit_index
3476                .unique_owner("week")
3477                .is_none(),
3478            "consumer unit_index must not contain week"
3479        );
3480        let now = DateTimeValue::now();
3481        let response = engine
3482            .run(
3483                None,
3484                "quotation",
3485                Some(&now),
3486                warehousing_default_inputs("wh"),
3487                None,
3488                false,
3489            )
3490            .expect("quotation must evaluate");
3491        let display = response
3492            .results
3493            .get("total")
3494            .expect("rule total must be present")
3495            .result()
3496            .expect("total must have display")
3497            .to_string();
3498        assert_eq!(
3499            display, "20.00 eur",
3500            "10 eur/week * ceil(10 day as week) / 1 CE must be 20.00 eur, got: {display}"
3501        );
3502    }
3503
3504    #[test]
3505    fn ratio_range_default_endpoints_must_be_ratio_not_measure() {
3506        let code = r#"
3507spec policy
3508data allowed_band: ratio range -> suggest 10%...50%
3509rule band: allowed_band
3510"#;
3511        let mut engine = Engine::new();
3512        engine
3513            .load([(
3514                crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
3515                    "ratio_range_endpoint_typing.lemma",
3516                ))),
3517                code.to_string(),
3518            )])
3519            .unwrap();
3520
3521        let plans = engine
3522            .plans
3523            .get_plans(None, "policy")
3524            .expect("plans for policy");
3525        let plan = plans.values().next().expect("plan");
3526        let path = DataPath::local("allowed_band".into());
3527        let def = plan.data.get(&path).expect("allowed_band in plan.data");
3528        let suggestion = def
3529            .bound_suggestion()
3530            .expect("declared default must exist")
3531            .to_literal();
3532
3533        let (left, right) = match &suggestion.value {
3534            crate::planning::semantics::ValueKind::Range(l, r) => (l.as_ref(), r.as_ref()),
3535            other => panic!("expected Range, got {other:?}"),
3536        };
3537        for (label, endpoint) in [("left", left), ("right", right)] {
3538            assert!(
3539                matches!(
3540                    &endpoint.value,
3541                    crate::planning::semantics::ValueKind::Ratio(_)
3542                ),
3543                "{label} endpoint must be Ratio for a percent literal in a ratio range default, got {:?}",
3544                endpoint.value
3545            );
3546            assert!(
3547                matches!(
3548                    &endpoint.value,
3549                    crate::planning::semantics::ValueKind::Ratio(_)
3550                ),
3551                "{label} endpoint ValueKind must be Ratio (got {:?})",
3552                endpoint.value
3553            );
3554        }
3555    }
3556
3557    #[test]
3558    fn ratio_range_typedef_with_second_ratio_field_loads() {
3559        let code = r#"
3560spec policy
3561data margin_pct: ratio -> suggest 15%
3562data allowed_band: ratio range
3563rule margin: margin_pct
3564rule band_slot: allowed_band
3565"#;
3566        let mut engine = Engine::new();
3567        engine
3568            .load([(
3569                crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
3570                    "ratio_range_load.lemma",
3571                ))),
3572                code.to_string(),
3573            )])
3574            .unwrap();
3575
3576        let plans = engine
3577            .plans
3578            .get_plans(None, "policy")
3579            .expect("plans for policy");
3580        let plan = plans.values().next().expect("plan");
3581        let path = DataPath::local("allowed_band".into());
3582        let def = plan.data.get(&path).expect("allowed_band in plan.data");
3583        let lemma_type = def
3584            .schema_type()
3585            .expect("allowed_band must be a typed data slot");
3586        match &lemma_type.specifications {
3587            TypeSpecification::RatioRange { units, .. } => {
3588                let names: Vec<&str> = units.iter().map(|u| u.name.as_str()).collect();
3589                assert!(
3590                    names.contains(&"percent"),
3591                    "ratio range must inherit builtin percent, got {names:?}"
3592                );
3593            }
3594            other => panic!("allowed_band must be RatioRange, got {other:?}"),
3595        }
3596    }
3597}