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