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