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