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