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