Skip to main content

active_core/eval/
mod.rs

1//! Evaluator — topological evaluation of the IR
2//!
3//! - Cycle detection
4//! - Topological order
5//! - Full string interpolation
6//! - Unit normalization (time → ms, bytes → raw bytes)
7//! - Secret redaction / taint
8//! - Basic arithmetic and logic
9
10use petgraph::algo::{is_cyclic_directed, toposort};
11use std::collections::HashMap;
12use std::time::Instant;
13use thiserror::Error;
14
15use crate::diag::Span;
16use crate::diag::{Diagnostic, Severity};
17use crate::ir::*;
18use crate::parser::{validate_patch_path, PathSeg};
19use crate::types::Unit;
20
21/// Host-provided environment lookup (F-016): consulted on map miss before
22/// defaults apply. Mirrors the map-hit charging exactly (bytes as delta),
23/// so lazy host values cost what eager values cost. `None` preserves all
24/// legacy behavior (map, then default, then null).
25///
26/// The `arc_with_non_send_sync` lint is allowed here with justification:
27/// the evaluator is single-threaded by construction and the FFI contract
28/// confines modules to the calling thread, so this Arc is never shared
29/// across threads; it exists so handles can cheaply hand the lookup down.
30#[allow(clippy::arc_with_non_send_sync)]
31pub type EnvLookup = std::sync::Arc<dyn Fn(&str) -> Option<String>>;
32
33#[derive(Debug, Error)]
34pub enum EvalError {
35    #[error("cycle detected in dependency graph")]
36    CycleDetected,
37    #[error("missing root")]
38    MissingRoot,
39    #[error("node {0:?} not found")]
40    MissingNode(NodeId),
41    #[error("type error: {message}")]
42    TypeError {
43        message: String,
44        span: Option<Span>,
45        file: Option<String>,
46    },
47    /// A patch application failure with its own stable code (E019):
48    /// rename conflicts and invalid paths discovered at apply time.
49    /// Missing remove/rename targets warn (W004) instead of failing.
50    #[error("patch error: {message}")]
51    PatchError {
52        message: String,
53        span: Option<Span>,
54        file: Option<String>,
55    },
56    #[error("secret used in unsafe context")]
57    SecretLeak { file: Option<String> },
58    /// Schema validation failures (C-031, spec/validation.md) and
59    /// constraint failures (C-034, spec/annotations.md): every
60    /// violation diagnostic, collected in deterministic order. Strict
61    /// mode returns these instead of a value tree; resilient mode
62    /// keeps them as ordinary diagnostics alongside the tree.
63    #[error("validation failed")]
64    ValidationFailed { diagnostics: Vec<Diagnostic> },
65    /// Execution budget violations (C-037, spec/budgets.md): always
66    /// fatal, in strict and resilient modes alike — budgets protect
67    /// the process, not the document.
68    #[error("evaluation budget exceeded: {kind} (limit {limit})")]
69    BudgetExceeded {
70        kind: BudgetKind,
71        limit: u64,
72        span: Option<Span>,
73        file: Option<String>,
74    },
75}
76
77/// Which budget tripped (C-037). Display strings name the budget;
78/// the error message adds the configured limit.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum BudgetKind {
81    Nodes,
82    WallClock,
83    Depth,
84    ArrayLength,
85    Keys,
86    /// Materialized value size (reference expansion included).
87    ValueSize,
88}
89
90impl std::fmt::Display for BudgetKind {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        match self {
93            BudgetKind::Nodes => write!(f, "node budget"),
94            BudgetKind::WallClock => write!(f, "time budget"),
95            BudgetKind::Depth => write!(f, "depth budget"),
96            BudgetKind::ArrayLength => write!(f, "array budget"),
97            BudgetKind::Keys => write!(f, "key budget"),
98            BudgetKind::ValueSize => write!(f, "value size budget"),
99        }
100    }
101}
102
103/// Runtime resource limits enforced by the evaluator accountant
104/// (spec/budgets.md §2, C-037). Every field `0` means unlimited —
105/// the embedder accepts the risk explicitly.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub struct Budget {
108    /// Maximum IR nodes visited (arena-length pre-check).
109    pub max_nodes: u64,
110    /// Maximum value nesting depth (reference-aware).
111    pub max_depth: u64,
112    /// Longest single array.
113    pub max_array_len: u64,
114    /// Total value-table keys across the arena (schema tables
115    /// excluded: they are metadata, validated separately).
116    pub max_keys: u64,
117    /// Wall-clock milliseconds for the whole evaluation.
118    pub max_wall_ms: u64,
119    /// Maximum materialized value size in data units (scalars and
120    /// string bytes), counting reference expansion: each `Ref` clone
121    /// costs its target's bound. Kills exponential blowup
122    /// (billion-laughs class) before anything materializes.
123    pub max_value_size: u64,
124    /// Maximum cumulative materialized size across the whole
125    /// evaluation (same units). Per-node bounds cap each clone;
126    /// this caps their sum — many aliases of one large value trip
127    /// here even when every node is individually fine.
128    pub max_total_value_size: u64,
129}
130
131impl Default for Budget {
132    /// Shipped profile: tight enough for CI and serverless loops,
133    /// with headroom over the C-055 10k-node performance target.
134    fn default() -> Self {
135        Self {
136            max_nodes: 100_000,
137            max_depth: 256,
138            max_array_len: 100_000,
139            max_keys: 100_000,
140            max_wall_ms: 100,
141            max_value_size: 10_000_000,
142            max_total_value_size: 100_000_000,
143        }
144    }
145}
146
147impl Budget {
148    /// Absolute evaluation deadline, or None when the wall-clock
149    /// budget is disabled (`max_wall_ms == 0`). Saturating: absurd
150    /// configured durations degrade to no deadline instead of
151    /// panicking on `Instant` overflow.
152    ///
153    /// Platform delta (G-003): `Instant` aborts on
154    /// wasm32-unknown-unknown, so wall-clock degrades to disabled there
155    /// (this function returns None). Count, depth, array, key, and
156    /// value-size budgets still bound execution, and the `regex` crate
157    /// guarantees linear-time matching, so no wall-clock backstop is
158    /// needed for termination on that target; hosts enforce their own
159    /// timeouts (Worker terminate pattern). See `wall_supported`.
160    pub fn wall_deadline(&self) -> Option<Instant> {
161        if self.max_wall_ms == 0 || !Self::wall_supported() {
162            None
163        } else {
164            Instant::now().checked_add(std::time::Duration::from_millis(self.max_wall_ms))
165        }
166    }
167
168    /// False on targets without a monotonic clock (wasm32-unknown-unknown)
169    /// and under Miri (its mock clock advances with interpreted work, so
170    /// any wall budget would trip spuriously while count, depth, array,
171    /// key, and value-size budgets still bind execution there):
172    /// `wall_deadline` degrades to None in both cases. Hosts must check
173    /// this when they promise wall-clock enforcement to their own callers.
174    pub fn wall_supported() -> bool {
175        #[cfg(any(target_arch = "wasm32", miri))]
176        {
177            false
178        }
179        #[cfg(not(any(target_arch = "wasm32", miri)))]
180        {
181            true
182        }
183    }
184}
185
186impl EvalError {
187    /// Stable diagnostic code for this error class (see docs/diagnostics.md).
188    pub fn code(&self) -> &'static str {
189        match self {
190            EvalError::CycleDetected => "S001",
191            EvalError::MissingRoot => "S002",
192            EvalError::MissingNode(_) => "S003",
193            EvalError::TypeError { .. } => "T001",
194            EvalError::PatchError { .. } => "E019",
195            EvalError::SecretLeak { .. } => "S004",
196            EvalError::BudgetExceeded { kind, .. } => match kind {
197                BudgetKind::Nodes => "X001",
198                BudgetKind::WallClock => "X002",
199                BudgetKind::Depth => "X003",
200                BudgetKind::ArrayLength => "X004",
201                BudgetKind::Keys => "X005",
202                BudgetKind::ValueSize => "X006",
203            },
204            EvalError::ValidationFailed { diagnostics } => {
205                match diagnostics.first().map(|d| d.code.as_str()) {
206                    Some("T003") => "T003",
207                    Some("T004") => "T004",
208                    _ => "T002",
209                }
210            }
211        }
212    }
213
214    /// Source span when the error can be attributed to a node position.
215    pub fn span(&self) -> Option<&Span> {
216        match self {
217            EvalError::TypeError { span, .. } | EvalError::PatchError { span, .. } => span.as_ref(),
218            EvalError::BudgetExceeded { span, .. } => span.as_ref(),
219            EvalError::ValidationFailed { diagnostics } => {
220                diagnostics.first().and_then(|d| d.span.as_ref())
221            }
222            _ => None,
223        }
224    }
225
226    /// Source file when the error can be attributed to a node position
227    /// (multi-file documents via includes, C-025).
228    pub fn file(&self) -> Option<&str> {
229        match self {
230            EvalError::TypeError { file, .. } | EvalError::PatchError { file, .. } => {
231                file.as_deref()
232            }
233            EvalError::SecretLeak { file } => file.as_deref(),
234            EvalError::BudgetExceeded { file, .. } => file.as_deref(),
235            EvalError::ValidationFailed { diagnostics } => {
236                diagnostics.first().and_then(|d| d.file.as_deref())
237            }
238            _ => None,
239        }
240    }
241
242    /// Attach a source position to a type error (used by the evaluation
243    /// loop, which knows the failing node's id).
244    fn type_error(message: impl Into<String>, module: &Module, node: NodeId) -> Self {
245        EvalError::TypeError {
246            message: message.into(),
247            span: module.node_spans.get(&node).cloned(),
248            file: module.node_files.get(&node).cloned(),
249        }
250    }
251
252    /// Attach a source position to a budget error (the offending node
253    /// when attribution is possible; global conditions carry none).
254    /// The configured limit travels with the error for the message.
255    pub(crate) fn budget_exceeded(
256        kind: BudgetKind,
257        limit: u64,
258        module: &Module,
259        node: Option<NodeId>,
260    ) -> Self {
261        EvalError::BudgetExceeded {
262            kind,
263            limit,
264            span: node.and_then(|n| module.node_spans.get(&n).cloned()),
265            file: node.and_then(|n| module.node_files.get(&n).cloned()),
266        }
267    }
268    /// Attach a source position to a patch error (rename conflicts and
269    /// invalid paths discovered at apply time).
270    fn patch_error(message: impl Into<String>, module: &Module, node: NodeId) -> Self {
271        EvalError::PatchError {
272            message: message.into(),
273            span: module.node_spans.get(&node).cloned(),
274            file: module.node_files.get(&node).cloned(),
275        }
276    }
277
278    /// Attach a source file to a secret-leak error.
279    fn secret_leak(module: &Module, node: NodeId) -> Self {
280        EvalError::SecretLeak {
281            file: module.node_files.get(&node).cloned(),
282        }
283    }
284}
285
286pub struct Engine {
287    pub resilient: bool,
288    /// CLI `--profile` override: when set, selects this profile instead
289    /// of the document's `active_profile` key (spec §3.5).
290    pub profile_override: Option<String>,
291    /// Runtime resource limits (spec/budgets.md, C-037). Violations
292    /// are always fatal, in both modes.
293    pub budget: Budget,
294    /// The closed environment snapshot: the ONLY external input
295    /// evaluation may read (spec/reproducibility.md §1, C-050).
296    /// `new()` snapshots the process environment (CLI-compatible);
297    /// `with_env` injects a fixed map (tests, agents, replays).
298    pub env: std::collections::HashMap<String, String>,
299    /// Lazy host lookup consulted on snapshot miss, before defaults
300    /// (F-016). `None` preserves legacy behavior exactly.
301    pub env_fallback: Option<EnvLookup>,
302}
303
304impl Default for Engine {
305    fn default() -> Self {
306        Self::new()
307    }
308}
309
310/// Iterative result-depth check for patch-composed trees
311/// (spec/budgets.md §2, C-037). Explicit stack (never recursion):
312/// patch paths are input-proportioned, but the composed depth is not
313/// statically bounded. Runs only when patches ran (profiles merge
314/// key-wise and cannot deepen).
315fn check_result_depth(result: &Value, max_depth: u64) -> Result<(), EvalError> {
316    let mut stack: Vec<(&Value, u64)> = vec![(result, 0)];
317    while let Some((value, depth)) = stack.pop() {
318        if depth > max_depth {
319            return Err(EvalError::BudgetExceeded {
320                kind: BudgetKind::Depth,
321                limit: max_depth,
322                span: None,
323                file: None,
324            });
325        }
326        match value {
327            Value::Table(map) => {
328                for v in map.values() {
329                    stack.push((v, depth + 1));
330                }
331            }
332            Value::Array(elements) => {
333                for v in elements {
334                    stack.push((v, depth + 1));
335                }
336            }
337            _ => {}
338        }
339    }
340    Ok(())
341}
342
343/// Cumulative materialization account threaded through post-walk
344/// transforms (patch sets, profile merges, default fills). The DP
345/// pass seeds it with the arena total; each transform charges what
346/// it clones. Trips X006 past the total limit.
347pub(crate) struct Accountant {
348    spent: u64,
349    limit: u64,
350}
351
352impl Accountant {
353    pub(crate) fn charge(
354        &mut self,
355        module: &Module,
356        node: Option<NodeId>,
357        amount: u64,
358    ) -> Result<(), EvalError> {
359        self.spent = self.spent.saturating_add(amount);
360        // `>= limit + 1` (not `> limit`): identical ordinarily, and
361        // still correct at `u64::MAX`, where the saturating cap below
362        // would otherwise go blind.
363        if self.limit != 0 && self.spent >= self.limit.saturating_add(1) {
364            return Err(EvalError::budget_exceeded(
365                BudgetKind::ValueSize,
366                self.limit,
367                module,
368                node,
369            ));
370        }
371        Ok(())
372    }
373}
374
375/// Budget context threaded through validation (spec/budgets.md §2):
376/// the shared evaluation deadline, its configured limit for messages,
377/// and the cumulative materialization account. Bundled so validation
378/// entry points stay under the argument-count lint.
379pub(crate) struct BudgetCx<'a> {
380    pub deadline: Option<Instant>,
381    pub wall_limit: u64,
382    pub account: &'a mut Accountant,
383}
384
385/// Iterative value-size measure in DP units (scalars 1, strings and
386/// secrets plus their bytes, composites 1 plus children). Matches
387/// `check_dp_bounds` semantics for transform-site accounting.
388pub(crate) fn measure_value(value: &Value) -> u64 {
389    let mut total = 0u64;
390    let mut stack = vec![value];
391    while let Some(v) = stack.pop() {
392        total = total.saturating_add(1);
393        match v {
394            Value::String(s) | Value::Secret(s) => {
395                total = total.saturating_add(s.len() as u64);
396            }
397            Value::Array(items) => stack.extend(items.iter()),
398            Value::Table(map) => stack.extend(map.values()),
399            _ => {}
400        }
401    }
402    total
403}
404
405/// Static budget pre-checks (spec/budgets.md §2, C-037): arena-wide
406/// sums, span-attributed where a single node offends. Over-counting
407/// unreachable nodes is fail-safe. Runs before the walk: monsters
408/// fail fast. Depth and materialized size need reference-aware
409/// longest-path bounds — those run as `check_dp_bounds` over the
410/// topological order instead (a plain roots walk would both miss
411/// reference expansion and report nondeterministic spans).
412fn check_static_budgets(module: &Module, budget: &Budget) -> Result<(), EvalError> {
413    let over = |limit: u64, used: u64| limit != 0 && used > limit;
414    if over(budget.max_nodes, module.arena.len() as u64) {
415        return Err(EvalError::BudgetExceeded {
416            kind: BudgetKind::Nodes,
417            limit: budget.max_nodes,
418            span: None,
419            file: None,
420        });
421    }
422    if budget.max_array_len != 0 || budget.max_keys != 0 {
423        let mut keys = 0u64;
424        for (i, node) in module.arena.iter().enumerate() {
425            let id = NodeId(i as u32);
426            match node {
427                // Value tables only: schema tables are metadata with
428                // their own (parser-side) limits.
429                Node::Table { fields } => {
430                    keys += fields.len() as u64;
431                    if over(budget.max_keys, keys) {
432                        return Err(EvalError::BudgetExceeded {
433                            kind: BudgetKind::Keys,
434                            limit: budget.max_keys,
435                            span: None,
436                            file: None,
437                        });
438                    }
439                }
440                Node::Array { elements } if over(budget.max_array_len, elements.len() as u64) => {
441                    return Err(EvalError::budget_exceeded(
442                        BudgetKind::ArrayLength,
443                        budget.max_array_len,
444                        module,
445                        Some(id),
446                    ));
447                }
448                _ => {}
449            }
450        }
451    }
452    Ok(())
453}
454
455/// Reference-aware materialization bounds (spec/budgets.md §2,
456/// C-037): one linear dynamic-programming pass over the topological
457/// order (dependencies first, so every bound is final when read).
458/// `size` bounds materialized data units (scalars plus string bytes;
459/// each `Ref` clone costs its target's bound — this is what kills
460/// exponential reference expansion before anything materializes).
461/// `depth` bounds value nesting through references. Both saturate at
462/// `limit + 1` so the numbers stay tiny; tripping either aborts with
463/// the offending node's span. Memoized per node, so shared subtrees
464/// cost O(1) — the pass itself is linear. Returns the cumulative
465/// bound total plus the per-node bounds table (drives delta
466/// accounting for dynamic values during the walk).
467fn check_dp_bounds(
468    module: &Module,
469    order: &[petgraph::graph::NodeIndex],
470    budget: &Budget,
471) -> Result<(u64, Vec<u64>), EvalError> {
472    if budget.max_value_size == 0 && budget.max_depth == 0 && budget.max_total_value_size == 0 {
473        return Ok((0, vec![1u64; module.arena.len()]));
474    }
475    // Separate saturation caps: comparing against `limit + 1` keeps
476    // numbers tiny, and `>= cap` (instead of `> limit`) stays correct
477    // even at `u64::MAX`, where `limit + 1` saturates. A disabled
478    // limit (0) caps at `u64::MAX` so accounting stays exact while
479    // its trip check is skipped.
480    let size_cap = if budget.max_value_size == 0 {
481        u64::MAX
482    } else {
483        budget.max_value_size.saturating_add(1)
484    };
485    let depth_cap = if budget.max_depth == 0 {
486        u64::MAX
487    } else {
488        budget.max_depth.saturating_add(1)
489    };
490    let mut size = vec![1u64; module.arena.len()];
491    let mut depth = vec![0u64; module.arena.len()];
492    let sat_size = |a: u64, b: u64| a.saturating_add(b).min(size_cap);
493    // Cumulative materialization across the whole pass: per-node
494    // bounds cap each clone, this caps their sum (many aliases of one
495    // large value trip here). Over-approximates (unreachable nodes
496    // count) in the fail-safe direction.
497    let mut total = 0u64;
498    for node_idx in order {
499        let id = module.graph[*node_idx];
500        let i = id.0 as usize;
501        if i >= size.len() {
502            continue;
503        }
504        // Child bounds: table/array fields and elements, reference
505        // targets (the clone!), interpolation parts, and composite
506        // operands. Expression-only children are parser-capped, but
507        // counting them is harmless over-approximation in the safe
508        // direction.
509        let mut child_size = 0u64;
510        let mut child_depth = 0u64;
511        match module.get(id) {
512            Some(Node::Table { fields }) => {
513                for child in fields.values() {
514                    let c = child.0 as usize;
515                    if c < size.len() {
516                        child_size = sat_size(child_size, size[c]);
517                        child_depth = child_depth.max(depth[c]);
518                    }
519                }
520                size[i] = sat_size(1, child_size);
521                depth[i] = child_depth.saturating_add(1).min(depth_cap);
522            }
523            Some(Node::Array { elements }) => {
524                for child in elements {
525                    let c = child.0 as usize;
526                    if c < size.len() {
527                        child_size = sat_size(child_size, size[c]);
528                        child_depth = child_depth.max(depth[c]);
529                    }
530                }
531                size[i] = sat_size(1, child_size);
532                depth[i] = child_depth.saturating_add(1).min(depth_cap);
533            }
534            Some(Node::Ref(target)) => {
535                let c = target.0 as usize;
536                if c < size.len() {
537                    size[i] = size[c];
538                    depth[i] = depth[c];
539                }
540            }
541            Some(Node::Literal(Value::String(s))) => {
542                size[i] = sat_size(1, s.len() as u64);
543            }
544            Some(Node::Interpolate { parts }) => {
545                for part in parts {
546                    match part {
547                        InterpPart::Expr(e) => {
548                            let c = e.0 as usize;
549                            if c < size.len() {
550                                child_size = sat_size(child_size, size[c]);
551                                child_depth = child_depth.max(depth[c]);
552                            }
553                        }
554                        InterpPart::Literal(s) => {
555                            child_size = sat_size(child_size, s.len() as u64);
556                        }
557                    }
558                }
559                size[i] = sat_size(1, child_size);
560            }
561            Some(Node::Binary { lhs, rhs, .. }) => {
562                for child in [*lhs, *rhs] {
563                    let c = child.0 as usize;
564                    if c < size.len() {
565                        child_size = sat_size(child_size, size[c]);
566                    }
567                }
568                size[i] = sat_size(1, child_size);
569            }
570            Some(Node::Unary { expr, .. }) => {
571                let c = expr.0 as usize;
572                if c < size.len() {
573                    size[i] = sat_size(1, size[c]);
574                }
575            }
576            // Transparent wrapper: the tag is tool treatment only, so
577            // the bound is the inner value's bound exactly.
578            Some(Node::Semantic { inner, .. }) => {
579                let c = inner.0 as usize;
580                if c < size.len() {
581                    size[i] = size[c];
582                    depth[i] = depth[c];
583                }
584            }
585            _ => {}
586        }
587        // `>= cap` (not `> limit`): identical for ordinary limits,
588        // and still correct at `u64::MAX`, where `limit + 1`
589        // saturates and only a truly astronomical bound trips.
590        if budget.max_value_size != 0 && size[i] >= size_cap {
591            return Err(EvalError::budget_exceeded(
592                BudgetKind::ValueSize,
593                budget.max_value_size,
594                module,
595                Some(id),
596            ));
597        }
598        if budget.max_depth != 0 && depth[i] >= depth_cap {
599            return Err(EvalError::budget_exceeded(
600                BudgetKind::Depth,
601                budget.max_depth,
602                module,
603                Some(id),
604            ));
605        }
606        total = total.saturating_add(size[i]);
607        if budget.max_total_value_size != 0
608            && total >= budget.max_total_value_size.saturating_add(1)
609        {
610            return Err(EvalError::budget_exceeded(
611                BudgetKind::ValueSize,
612                budget.max_total_value_size,
613                module,
614                Some(id),
615            ));
616        }
617    }
618    Ok((total, size))
619}
620
621/// Memo store for incremental evaluation (C-055, spec/performance.md
622/// §2): fingerprint → evaluated value plus the diagnostics produced
623/// while evaluating it. Reused across `evaluate_incremental` calls.
624/// Telemetry (`last_*`) refreshes every call. The store is bounded
625/// (see the cap in `evaluate_incremental`): clearing it only loses
626/// hits, never correctness.
627///
628/// Deliberately NOT `derive(Debug)`: the store holds evaluated
629/// values (including plaintext intermediates), and a derived dump
630/// would print document content into any log that formats the
631/// memo. The manual impl below reports shape only — entry count
632/// and telemetry. Secret-kind values additionally carry `Value`'s
633/// own redacting `Debug` wherever else they are formatted.
634pub struct IncrementalMemo {
635    map: HashMap<u64, (Value, Vec<Diagnostic>)>,
636    config: Option<(bool, Option<String>, Budget)>,
637    salt: u64,
638    pub last_eval_count: u64,
639    pub last_memo_hits: u64,
640}
641
642impl std::fmt::Debug for IncrementalMemo {
643    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
644        f.debug_struct("IncrementalMemo")
645            .field("entries", &self.map.len())
646            .field("last_eval_count", &self.last_eval_count)
647            .field("last_memo_hits", &self.last_memo_hits)
648            .finish()
649    }
650}
651
652impl Default for IncrementalMemo {
653    fn default() -> Self {
654        Self {
655            map: HashMap::new(),
656            config: None,
657            salt: memo_salt(),
658            last_eval_count: 0,
659            last_memo_hits: 0,
660        }
661    }
662}
663
664/// One salt per memo (C-055 review): keeps fingerprint keys local to
665/// the memo that minted them. `getrandom` failure falls back to a
666/// fixed constant — uniqueness lost, correctness kept (a constant
667/// salt is exactly the old unsalted behavior).
668fn memo_salt() -> u64 {
669    let mut buf = [0u8; 8];
670    if getrandom::getrandom(&mut buf).is_ok() {
671        u64::from_le_bytes(buf)
672    } else {
673        0x9E37_79B9_7F4A_7C15
674    }
675}
676
677/// FNV-1a 64-bit mixer for fingerprints (C-055 §2). Chosen over
678/// SipHash (`DefaultHasher`) deliberately: per-node hasher
679/// construction dominates a 10k walk, and FNV-1a folds bytes ~10×
680/// cheaper with no allocation and no per-hasher setup. Implements
681/// `std::hash::Hasher` so every structural piece hashes through one
682/// path (`Hash` impls, discriminant bytes, float bit patterns) with
683/// zero formatting. The 64-bit width is load-bearing — a collision
684/// would serve a stale value — so fingerprints must never narrow.
685/// `usize` lengths hash platform-wide; harmless because memos never
686/// cross processes (fingerprints are in-memory keys, never
687/// persisted), but they must never be persisted without review.
688struct FpHasher(u64);
689
690impl FpHasher {
691    fn new(salt: u64) -> Self {
692        // Per-memo salt: keys stay unique to the memo that minted
693        // them, so a logged or leaked key is not a stable
694        // cross-process oracle for short secret-derived values, and
695        // any collision search starts over per memo. Output
696        // determinism is unaffected — the salt only names keys.
697        Self(14_695_981_039_346_656_037 ^ salt)
698    }
699}
700
701impl std::hash::Hasher for FpHasher {
702    fn write(&mut self, bytes: &[u8]) {
703        for &byte in bytes {
704            self.0 ^= u64::from(byte);
705            self.0 = self.0.wrapping_mul(1_099_511_628_211);
706        }
707    }
708
709    fn finish(&self) -> u64 {
710        self.0
711    }
712}
713
714/// Structural hash of a literal value (no formatting: discriminants,
715/// integer/float bit patterns, and raw string bytes feed the mixer
716/// directly). Floats hash by bit pattern — `-0.0` vs `0.0` or
717/// differing NaN payloads fingerprint apart and simply miss the
718/// memo, which is the safe direction. Table order is the binding
719/// order, which evaluation preserves deterministically.
720fn fp_value(hasher: &mut FpHasher, value: &Value) {
721    use std::hash::Hash;
722    match value {
723        Value::Null => 0u8.hash(hasher),
724        Value::Bool(flag) => {
725            1u8.hash(hasher);
726            flag.hash(hasher);
727        }
728        Value::Int(num) => {
729            2u8.hash(hasher);
730            num.hash(hasher);
731        }
732        Value::Float(num) => {
733            3u8.hash(hasher);
734            num.to_bits().hash(hasher);
735        }
736        Value::String(text) => {
737            4u8.hash(hasher);
738            text.hash(hasher);
739        }
740        Value::DurationMs(millis) => {
741            5u8.hash(hasher);
742            millis.hash(hasher);
743        }
744        Value::Quantity(amount, unit) => {
745            6u8.hash(hasher);
746            amount.to_bits().hash(hasher);
747            unit.hash(hasher);
748        }
749        Value::Array(items) => {
750            7u8.hash(hasher);
751            items.len().hash(hasher);
752            for item in items {
753                fp_value(hasher, item);
754            }
755        }
756        Value::Table(fields) => {
757            8u8.hash(hasher);
758            fields.len().hash(hasher);
759            for (key, item) in fields {
760                key.hash(hasher);
761                fp_value(hasher, item);
762            }
763        }
764        // Same-process memo key only: secrecy rests on the redacting
765        // `Debug` impl and export refusal, never on hiding this hash.
766        Value::Secret(text) => {
767            9u8.hash(hasher);
768            text.hash(hasher);
769        }
770    }
771}
772
773/// Structural fingerprint of one node for incremental memoization:
774/// expression shape, literal data, environment values read, and the
775/// already-computed fingerprints of structural children. `cache` is
776/// arena-indexed (`NodeId.0` is a dense arena index): the caller
777/// walks in topological order, so every dependency's fingerprint is
778/// already stored — no recursion, no stack risk on deep chains, no
779/// hash-map lookup per edge. Returns None for shapes with no stable
780/// form (schema/patch/profile nodes evaluate to Null or outside the
781/// walk anyway); unhashable nodes simply re-evaluate every call.
782/// Same fingerprint ⇒ same value: every input the evaluator can
783/// observe (literals, env snapshot, dependency values) feeds the
784/// hash.
785fn fingerprint_one(
786    engine: &Engine,
787    module: &Module,
788    id: NodeId,
789    cache: &[Option<u64>],
790    salt: u64,
791) -> Option<u64> {
792    use std::hash::{Hash, Hasher};
793    let child = |dep: &NodeId| cache.get(dep.0 as usize).copied().flatten();
794    let mut hasher = FpHasher::new(salt);
795    match module.get(id)? {
796        Node::Literal(v) => {
797            0u8.hash(&mut hasher);
798            fp_value(&mut hasher, v);
799        }
800        Node::Env { name, default } => {
801            1u8.hash(&mut hasher);
802            name.hash(&mut hasher);
803            engine.env.get(name).hash(&mut hasher);
804            match default {
805                Some(d) => child(d)?.hash(&mut hasher),
806                None => 0u8.hash(&mut hasher),
807            }
808        }
809        Node::Secret { locator } => {
810            2u8.hash(&mut hasher);
811            child(locator)?.hash(&mut hasher);
812        }
813        Node::Ref(target) => {
814            3u8.hash(&mut hasher);
815            child(target)?.hash(&mut hasher);
816        }
817        Node::Ident(name) => {
818            4u8.hash(&mut hasher);
819            name.hash(&mut hasher);
820        }
821        Node::Interpolate { parts } => {
822            5u8.hash(&mut hasher);
823            parts.len().hash(&mut hasher);
824            for part in parts {
825                match part {
826                    crate::ir::InterpPart::Literal(s) => {
827                        0u8.hash(&mut hasher);
828                        s.hash(&mut hasher);
829                    }
830                    crate::ir::InterpPart::Expr(e) => {
831                        1u8.hash(&mut hasher);
832                        child(e)?.hash(&mut hasher);
833                    }
834                }
835            }
836        }
837        Node::Binary { op, lhs, rhs } => {
838            6u8.hash(&mut hasher);
839            op.hash(&mut hasher);
840            child(lhs)?.hash(&mut hasher);
841            child(rhs)?.hash(&mut hasher);
842        }
843        Node::Unary { op, expr } => {
844            7u8.hash(&mut hasher);
845            op.hash(&mut hasher);
846            child(expr)?.hash(&mut hasher);
847        }
848        Node::Array { elements } => {
849            8u8.hash(&mut hasher);
850            elements.len().hash(&mut hasher);
851            for element in elements {
852                child(element)?.hash(&mut hasher);
853            }
854        }
855        Node::Table { fields } => {
856            9u8.hash(&mut hasher);
857            fields.len().hash(&mut hasher);
858            for (key, child_id) in fields {
859                key.hash(&mut hasher);
860                child(child_id)?.hash(&mut hasher);
861            }
862        }
863        Node::Quantity { value, unit } => {
864            10u8.hash(&mut hasher);
865            unit.hash(&mut hasher);
866            child(value)?.hash(&mut hasher);
867        }
868        Node::Semantic { tag, inner } => {
869            11u8.hash(&mut hasher);
870            tag.as_str().hash(&mut hasher);
871            child(inner)?.hash(&mut hasher);
872        }
873        _ => return None,
874    }
875    Some(hasher.finish())
876}
877
878impl Engine {
879    pub fn new() -> Self {
880        let mut engine = Self::hermetic();
881        // Undecodable entries cannot enter the snapshot (keys
882        // and values must be Unicode to be referenceable);
883        // skipping them is deterministic per machine, unlike a
884        // `vars()` panic on exotic ambients.
885        engine.env = std::env::vars_os()
886            .filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?)))
887            .collect();
888        engine
889    }
890
891    /// Hermetic engine: identical to `new()` except the environment starts
892    /// empty instead of snapshotting the process ambient. Embeddings (FFI,
893    /// WASM) use this so host ambient can never leak across the boundary;
894    /// host values arrive only through explicit maps or lookup callbacks.
895    /// Required on platforms without a process environment
896    /// (wasm32-unknown-unknown aborts on ambient access): construct here,
897    /// never snapshot-then-discard.
898    pub fn hermetic() -> Self {
899        Self {
900            resilient: true,
901            profile_override: None,
902            budget: Budget::default(),
903            env: std::collections::HashMap::new(),
904            env_fallback: None,
905        }
906    }
907
908    /// Fixed environment snapshot instead of the process ambient.
909    pub fn with_env(mut self, env: std::collections::HashMap<String, String>) -> Self {
910        self.env = env;
911        self
912    }
913
914    /// Lazy host lookup consulted on snapshot miss (F-016).
915    pub fn with_env_fallback(mut self, lookup: EnvLookup) -> Self {
916        self.env_fallback = Some(lookup);
917        self
918    }
919
920    pub fn evaluate(&self, module: &Module) -> Result<(Value, Vec<Diagnostic>), EvalError> {
921        self.evaluate_inner(module, None)
922    }
923
924    /// Incremental evaluation over a reused memo store (C-055,
925    /// spec/performance.md §2): nodes whose structural fingerprint
926    /// (expression shape, literal data, env values read, and
927    /// dependency fingerprints) matches a previous call serve the
928    /// memoized value and diagnostics; only the affected sub-DAG
929    /// re-evaluates. Configuration changes (resilient flag, profile
930    /// override, budgets) clear the store first. Post-walk
931    /// transforms (profiles, patches, validation) re-run every
932    /// call — they compose the final tree and stay cheap relative
933    /// to the walk they follow.
934    pub fn evaluate_incremental(
935        &self,
936        module: &Module,
937        memo: &mut IncrementalMemo,
938    ) -> Result<(Value, Vec<Diagnostic>), EvalError> {
939        // `Budget` compares by value (all fields are `u64`): any
940        // configuration change clears the store before the walk.
941        let token = (self.resilient, self.profile_override.clone(), self.budget);
942        if memo.config.as_ref() != Some(&token) {
943            memo.map.clear();
944            memo.config = Some(token);
945        }
946        memo.last_eval_count = 0;
947        memo.last_memo_hits = 0;
948        let outcome = self.evaluate_inner(module, Some(memo));
949        // Bound the store: every document version inserts novel
950        // fingerprints, so without eviction a long-lived memo (the
951        // language server reuses one per document) grows with edit
952        // history. Clearing only loses hits — correctness is
953        // unaffected — and the bound is a pure function of counts,
954        // so behavior stays deterministic: same edit history, same
955        // clears, same outputs.
956        let cap = module.arena.len().saturating_mul(4).max(1024);
957        if memo.map.len() > cap {
958            memo.map.clear();
959        }
960        outcome
961    }
962
963    fn evaluate_inner(
964        &self,
965        module: &Module,
966        mut memo: Option<&mut IncrementalMemo>,
967    ) -> Result<(Value, Vec<Diagnostic>), EvalError> {
968        // The deadline covers everything from here on (spec/budgets.md
969        // §2): static scans, cycle check, sort, walk, transforms, and
970        // validation share one clock.
971        let deadline = self.budget.wall_deadline();
972        // Budgets first: fail fast on monsters before any real work.
973        check_static_budgets(module, &self.budget)?;
974        if is_cyclic_directed(&module.graph) {
975            return Err(EvalError::CycleDetected);
976        }
977
978        let order = toposort(&module.graph, None).map_err(|_| EvalError::CycleDetected)?;
979        // Reference-aware materialization bounds (size and depth): the
980        // billion-laughs killer. Runs on the topological order so every
981        // bound is final when read; saturating arithmetic keeps it
982        // linear even for exponential shapes. Returns the cumulative
983        // bound, seeding the transform accountant below.
984        let (dp_total, dp_bounds) = check_dp_bounds(module, &order, &self.budget)?;
985        let mut account = Accountant {
986            spent: dp_total,
987            limit: self.budget.max_total_value_size,
988        };
989        // Actual materialized sizes for dynamic values (env- and
990        // secret-backed strings): the DP counts 1 per node, so each
991        // clone charges the delta between actual and bound. Static
992        // targets need no entry — DP soundness guarantees actuals stay
993        // within bounds, hence zero delta without measuring.
994        let mut actuals: HashMap<NodeId, u64> = HashMap::new();
995        let mut values: HashMap<NodeId, Value> = HashMap::new();
996        let mut diagnostics = module.diagnostics.clone();
997        diagnostics.extend(module.healing.iter().cloned());
998        // Wall-clock backstop (spec/budgets.md §2): sampled every 1024
999        // nodes so the overhead is unmeasurable. Counts already passed
1000        // their static and DP checks; only time needs watching mid-walk.
1001        // `deadline` was computed at entry and shared with validation.
1002        let mut visited = 0u64;
1003
1004        // Fingerprints for this call (arena-indexed cache, sized
1005        // once): computed inline during the walk below, which runs
1006        // in topological order — every dependency's fingerprint is
1007        // already stored when its dependents hash. No recursion, no
1008        // stack risk on deep chains, no second pass over the graph.
1009        // Allocated only for incremental calls; plain `evaluate`
1010        // pays nothing.
1011        let mut fp_cache: Vec<Option<u64>> = if memo.is_some() {
1012            vec![None; module.arena.len()]
1013        } else {
1014            Vec::new()
1015        };
1016        for node_idx in order {
1017            visited += 1;
1018            if visited % 1024 == 0 {
1019                if let Some(end) = deadline {
1020                    if Instant::now() >= end {
1021                        let node_id = module.graph[node_idx];
1022                        return Err(EvalError::budget_exceeded(
1023                            BudgetKind::WallClock,
1024                            self.budget.max_wall_ms,
1025                            module,
1026                            Some(node_id),
1027                        ));
1028                    }
1029                }
1030            }
1031            let node_id = module.graph[node_idx];
1032            let node = module.get(node_id).ok_or(EvalError::MissingNode(node_id))?;
1033            // Incremental fast path: the fingerprint is computed
1034            // inline (dependencies already walked, so their prints
1035            // are cached) and an identical print serves the memoized
1036            // value and its diagnostics verbatim — except positions.
1037            // Served diagnostics are re-anchored to the CURRENT
1038            // module's span/file for this node: the memo may hold
1039            // diagnostics from an earlier parse where the node sat
1040            // on different lines (the W001 stale-span bug). This is
1041            // exact, not a guess: every diagnostic the walk emits
1042            // is node-local, and post-walk transforms (profiles,
1043            // patches, validation) re-run every call rather than
1044            // serving memoized diagnostics.
1045            let mut memo_fp = None;
1046            if let Some(memo) = memo.as_mut() {
1047                let salt = memo.salt;
1048                if let Some(fp) = fingerprint_one(self, module, node_id, &fp_cache, salt) {
1049                    if let Ok(index) = usize::try_from(node_id.0) {
1050                        if let Some(slot) = fp_cache.get_mut(index) {
1051                            *slot = Some(fp);
1052                        }
1053                    }
1054                    memo_fp = Some(fp);
1055                    if let Some((value, diags)) = memo.map.get(&fp) {
1056                        values.insert(node_id, value.clone());
1057                        let span = module.node_spans.get(&node_id).cloned();
1058                        let file = module.node_files.get(&node_id).cloned();
1059                        diagnostics.extend(diags.iter().map(|d| {
1060                            let mut fresh = d.clone();
1061                            // Unconditional: a missing current
1062                            // position means "unknown", and serving
1063                            // a stale position as fact is worse
1064                            // than serving none.
1065                            fresh.span = span.clone();
1066                            fresh.file = file.clone();
1067                            fresh
1068                        }));
1069                        memo.last_memo_hits += 1;
1070                        continue;
1071                    }
1072                }
1073            }
1074
1075            let diag_mark = diagnostics.len();
1076            let value = match node {
1077                Node::Literal(v) => v.clone(),
1078
1079                Node::Env { name, default } => match self.env.get(name) {
1080                    Some(s) => {
1081                        // Operator-controlled dynamic content (like
1082                        // secrets below): charge the bytes as delta and
1083                        // record the actual for reference deltas.
1084                        actuals.insert(node_id, 1u64.saturating_add(s.len() as u64));
1085                        account.charge(module, Some(node_id), s.len() as u64)?;
1086                        Value::String(s.clone())
1087                    }
1088                    None => {
1089                        // Lazy host lookup (F-016): same charging as a
1090                        // snapshot hit, so lazy values cost eager values.
1091                        // A consulted miss falls through to defaults, so a
1092                        // callback that knows nothing changes nothing.
1093                        if let Some(lookup) = self.env_fallback.clone() {
1094                            if let Some(s) = lookup(name) {
1095                                actuals.insert(node_id, 1u64.saturating_add(s.len() as u64));
1096                                account.charge(module, Some(node_id), s.len() as u64)?;
1097                                Value::String(s)
1098                            } else if let Some(def) = default {
1099                                values.get(def).cloned().unwrap_or(Value::Null)
1100                            } else {
1101                                Value::Null
1102                            }
1103                        } else if let Some(def) = default {
1104                            values.get(def).cloned().unwrap_or(Value::Null)
1105                        } else {
1106                            Value::Null
1107                        }
1108                    }
1109                },
1110
1111                Node::Secret { locator } => {
1112                    let loc = values.get(locator).cloned().unwrap_or(Value::Null);
1113                    let secret = match loc {
1114                        Value::String(s) => Value::Secret(s),
1115                        _ => Value::Secret(String::new()),
1116                    };
1117                    // Dynamic content: the DP counted 1, so charge the
1118                    // string bytes as delta and record the actual for
1119                    // reference deltas below.
1120                    if let Value::Secret(s) = &secret {
1121                        let actual = 1u64.saturating_add(s.len() as u64);
1122                        actuals.insert(node_id, actual);
1123                        account.charge(module, Some(node_id), s.len() as u64)?;
1124                    }
1125                    secret
1126                }
1127
1128                Node::Ref(id) => {
1129                    let v = values.get(id).cloned().unwrap_or(Value::Null);
1130                    // Dynamic targets (env/secret actuals recorded at
1131                    // production) charge actual-minus-bound per clone;
1132                    // static targets stay within DP bounds (delta zero,
1133                    // no measuring needed).
1134                    if let Some(actual) = actuals.get(id) {
1135                        let bound = dp_bounds.get(id.0 as usize).copied().unwrap_or(1);
1136                        account.charge(module, Some(node_id), actual.saturating_sub(bound))?;
1137                    }
1138                    v
1139                }
1140
1141                // Semantic tags are tool treatment only: transparent to the value.
1142                Node::Semantic { inner, .. } => values.get(inner).cloned().unwrap_or(Value::Null),
1143
1144                // Schema nodes carry types, not values: they evaluate to
1145                // Null and never export (extracted from value fields at
1146                // parse). Their constraint arguments evaluate normally;
1147                // C-031 reads those values during validation.
1148                Node::SchemaField { .. } | Node::SchemaTable { .. } | Node::SchemaArray { .. } => {
1149                    Value::Null
1150                }
1151                Node::Ident(name) => {
1152                    diagnostics.push(Diagnostic {
1153                        severity: Severity::Warning,
1154                        code: "W001".into(),
1155                        message: format!("unresolved identifier '{}'", name),
1156                        path: Some(name.clone()),
1157                        span: module.node_spans.get(&node_id).cloned(),
1158                        repair: None,
1159                        confidence: 1.0,
1160                        file: module.node_files.get(&node_id).cloned(),
1161                    });
1162                    Value::Null
1163                }
1164
1165                Node::Interpolate { parts } => {
1166                    let mut out = String::new();
1167                    for part in parts {
1168                        match part {
1169                            InterpPart::Literal(s) => out.push_str(s),
1170                            InterpPart::Expr(id) => {
1171                                if let Some(v) = values.get(id) {
1172                                    match v {
1173                                        Value::String(s) => out.push_str(s),
1174                                        Value::Int(i) => out.push_str(&i.to_string()),
1175                                        Value::Float(f) => out.push_str(&f.to_string()),
1176                                        Value::Bool(b) => out.push_str(&b.to_string()),
1177                                        Value::DurationMs(ms) => out.push_str(&format!("{}ms", ms)),
1178                                        Value::Quantity(n, unit) => {
1179                                            out.push_str(&unit.format_amount(*n))
1180                                        }
1181                                        Value::Secret(_) => {
1182                                            return Err(EvalError::secret_leak(module, node_id));
1183                                        }
1184                                        Value::Null => out.push_str("null"),
1185                                        // Structured values have no string
1186                                        // form: interpolating them is a type
1187                                        // error, never a placeholder.
1188                                        Value::Array(_) | Value::Table(_) => {
1189                                            return Err(EvalError::type_error(
1190                                                format!(
1191                                                    "cannot interpolate a {} into a string",
1192                                                    match v {
1193                                                        Value::Array(_) => "array",
1194                                                        _ => "table",
1195                                                    }
1196                                                ),
1197                                                module,
1198                                                node_id,
1199                                            ));
1200                                        }
1201                                    }
1202                                } else {
1203                                    out.push_str("null");
1204                                }
1205                            }
1206                        }
1207                    }
1208                    Value::String(out)
1209                }
1210
1211                Node::Binary { op, lhs, rhs } => {
1212                    let l = values.get(lhs).cloned().unwrap_or(Value::Null);
1213                    let r = values.get(rhs).cloned().unwrap_or(Value::Null);
1214                    eval_binary(*op, &l, &r)
1215                        .map_err(|m| EvalError::type_error(m, module, node_id))?
1216                }
1217
1218                Node::Unary { op, expr } => {
1219                    let v = values.get(expr).cloned().unwrap_or(Value::Null);
1220                    eval_unary(*op, &v).map_err(|m| EvalError::type_error(m, module, node_id))?
1221                }
1222
1223                Node::Array { elements } => {
1224                    let els: Vec<Value> = elements
1225                        .iter()
1226                        .map(|e| values.get(e).cloned().unwrap_or(Value::Null))
1227                        .collect();
1228                    Value::Array(els)
1229                }
1230
1231                Node::Table { fields } => {
1232                    let mut map = indexmap::IndexMap::new();
1233                    for (k, id) in fields {
1234                        map.insert(k.clone(), values.get(id).cloned().unwrap_or(Value::Null));
1235                    }
1236                    Value::Table(map)
1237                }
1238
1239                Node::Quantity { value, unit } => {
1240                    let v = values.get(value).cloned().unwrap_or(Value::Null);
1241                    normalize_quantity(&v, unit)
1242                        .map_err(|m| EvalError::type_error(m, module, node_id))?
1243                }
1244
1245                _ => Value::Null,
1246            };
1247            // Memoize the fresh result under its fingerprint for
1248            // later calls (misses only — hits continued above).
1249            if let Some(memo) = memo.as_mut() {
1250                memo.last_eval_count += 1;
1251                if let Some(fp) = memo_fp {
1252                    let fresh = diagnostics[diag_mark..].to_vec();
1253                    memo.map.insert(fp, (value.clone(), fresh));
1254                }
1255            }
1256
1257            values.insert(node_id, value);
1258        }
1259
1260        let root = module.root.ok_or(EvalError::MissingRoot)?;
1261        let result = values.get(&root).cloned().unwrap_or(Value::Null);
1262        let result = self.apply_profile(module, &values, result, &mut diagnostics, &mut account)?;
1263        let mut result = result;
1264        self.apply_patches(module, &values, &mut result, &mut diagnostics, &mut account)?;
1265        // Patch edits compose arena values at paths and can deepen the
1266        // tree past every statically-checked root (profile merges only
1267        // replace key-wise and cannot deepen). Re-check result depth
1268        // iteratively when patches ran; the tree is alias-free (every
1269        // Ref clone materializes), so a plain walk stays linear.
1270        if !module.patches.is_empty() && self.budget.max_depth != 0 {
1271            check_result_depth(&result, self.budget.max_depth)?;
1272        }
1273        // Schema validation runs last: post-evaluation, post-profile,
1274        // post-patch, pre-export (spec/validation.md §1). Resilient
1275        // mode may insert schema defaults (C-032), so the tree is
1276        // mutable here. Constraint predicates run after schema
1277        // validation (spec/annotations.md §3). Both share the
1278        // evaluation deadline computed before the walk
1279        // (spec/budgets.md §2).
1280        self.validate_schema(
1281            module,
1282            &values,
1283            &mut result,
1284            &mut diagnostics,
1285            BudgetCx {
1286                deadline,
1287                wall_limit: self.budget.max_wall_ms,
1288                account: &mut account,
1289            },
1290        )?;
1291        self.validate_annotations(
1292            module,
1293            &values,
1294            &result,
1295            &mut diagnostics,
1296            deadline,
1297            self.budget.max_wall_ms,
1298        )?;
1299        // Closing wall-clock sample: small documents take zero walk
1300        // samples, so the deadline is checked once more at the end.
1301        if let Some(end) = deadline {
1302            if Instant::now() >= end {
1303                return Err(EvalError::budget_exceeded(
1304                    BudgetKind::WallClock,
1305                    self.budget.max_wall_ms,
1306                    module,
1307                    None,
1308                ));
1309            }
1310        }
1311        Ok((result, diagnostics))
1312    }
1313
1314    /// Reject a patch whose targets overlap an `@ai_immutable` path
1315    /// (spec/annotations.md §4, C-034). Every target is checked; the first
1316    /// overlap fails the whole patch application with E019 at the
1317    /// directive's span. Profiles and default fills are not patch edits
1318    /// and are unaffected.
1319    fn check_immutable(
1320        module: &Module,
1321        patch_id: NodeId,
1322        targets: &[&str],
1323    ) -> Result<(), EvalError> {
1324        for target in targets {
1325            if let Some(immutable) = Self::immutable_overlap(module, target) {
1326                return Err(EvalError::patch_error(
1327                    format!("patch target `{target}` overlaps ai_immutable `{immutable}`"),
1328                    module,
1329                    patch_id,
1330                ));
1331            }
1332        }
1333        Ok(())
1334    }
1335
1336    /// First `@ai_immutable: true` annotation path overlapping `target` —
1337    /// in either direction, compared segment-wise so ancestor and
1338    /// descendant edits are both caught — or None. Annotation paths sort
1339    /// first so multi-overlap reports are deterministic. Unparseable
1340    /// paths on either side cannot overlap (patch paths were validated at
1341    /// parse; annotation paths are scope joins).
1342    fn immutable_overlap(module: &Module, target: &str) -> Option<String> {
1343        let target_segs = validate_patch_path(target).ok()?;
1344        let mut paths: Vec<&String> = module
1345            .annotations
1346            .values()
1347            .flatten()
1348            .filter(|a| matches!(&a.kind, crate::ir::AnnotationKind::AiImmutable(true)))
1349            .map(|a| &a.path)
1350            .collect();
1351        paths.sort();
1352        for apath in paths {
1353            let Ok(asegs) = validate_patch_path(apath) else {
1354                continue;
1355            };
1356            let shared = target_segs.len().min(asegs.len());
1357            if shared > 0 && target_segs[..shared] == asegs[..shared] {
1358                return Some(apath.clone());
1359            }
1360        }
1361        None
1362    }
1363    /// Apply structural patch directives in source order (spec §2.9):
1364    /// after evaluation (and profile merge), before schema validation.
1365    /// Values were computed with the base tree intact, so a patch may
1366    /// reference the very key it overwrites. Missing remove/rename
1367    /// targets warn (W004); an existing rename target is E019.
1368    fn apply_patches(
1369        &self,
1370        module: &Module,
1371        values: &HashMap<NodeId, Value>,
1372        result: &mut Value,
1373        diagnostics: &mut Vec<Diagnostic>,
1374        account: &mut Accountant,
1375    ) -> Result<(), EvalError> {
1376        use crate::ir::PatchOp;
1377
1378        for &patch_id in &module.patches {
1379            let (op, span, file) = match module.get(patch_id) {
1380                Some(Node::PatchDirective { op }) => (
1381                    op.clone(),
1382                    module.node_spans.get(&patch_id).cloned(),
1383                    module.node_files.get(&patch_id).cloned(),
1384                ),
1385                _ => continue,
1386            };
1387            let mut warn = |code: &str, message: String| {
1388                diagnostics.push(Diagnostic {
1389                    severity: Severity::Warning,
1390                    code: code.into(),
1391                    message,
1392                    path: None,
1393                    span: span.clone(),
1394                    repair: None,
1395                    confidence: 1.0,
1396                    file: file.clone(),
1397                });
1398            };
1399            match op {
1400                PatchOp::Set { path, value } => {
1401                    Self::check_immutable(module, patch_id, &[&path])?;
1402                    let segs = validate_patch_path(&path).map_err(|why| {
1403                        EvalError::patch_error(
1404                            format!("invalid patch path `{path}`: {why}"),
1405                            module,
1406                            patch_id,
1407                        )
1408                    })?;
1409                    let v = values.get(&value).cloned().unwrap_or(Value::Null);
1410                    // Cloned values cost against the cumulative budget:
1411                    // many patches sharing one large value must trip
1412                    // like many aliases do. Path intermediates cost one
1413                    // unit per segment (input-proportioned tables).
1414                    account.charge(
1415                        module,
1416                        Some(patch_id),
1417                        measure_value(&v).saturating_add(segs.len() as u64),
1418                    )?;
1419                    // Failed sets roll back: navigation may have
1420                    // materialized intermediate tables before hitting a
1421                    // blocked hop, and a failed patch must leave no trace.
1422                    let snapshot = result.clone();
1423                    if !patch_set(result, &segs, v) {
1424                        *result = snapshot;
1425                        warn(
1426                            "W004",
1427                            format!("patch target `{path}` is blocked or out of bounds"),
1428                        );
1429                    }
1430                }
1431                PatchOp::Remove { path } => {
1432                    Self::check_immutable(module, patch_id, &[&path])?;
1433                    let segs = validate_patch_path(&path).map_err(|why| {
1434                        EvalError::patch_error(
1435                            format!("invalid patch path `{path}`: {why}"),
1436                            module,
1437                            patch_id,
1438                        )
1439                    })?;
1440                    if !patch_remove(result, &segs) {
1441                        warn("W004", format!("remove target `{path}` does not exist"));
1442                    }
1443                }
1444                PatchOp::Rename { from, to } => {
1445                    Self::check_immutable(module, patch_id, &[&from, &to])?;
1446                    let segs = validate_patch_path(&from).map_err(|why| {
1447                        EvalError::patch_error(
1448                            format!("invalid patch path `{from}`: {why}"),
1449                            module,
1450                            patch_id,
1451                        )
1452                    })?;
1453                    match patch_rename(result, &segs, &to) {
1454                        RenameOutcome::Ok => {}
1455                        RenameOutcome::Missing => {
1456                            warn("W004", format!("rename source `{from}` does not exist"));
1457                        }
1458                        RenameOutcome::Exists => {
1459                            return Err(EvalError::patch_error(
1460                                format!("rename target `{to}` already exists"),
1461                                module,
1462                                patch_id,
1463                            ));
1464                        }
1465                    }
1466                }
1467            }
1468        }
1469        Ok(())
1470    }
1471
1472    /// Select and merge the active profile (spec §3.5). The `--profile`
1473    /// override wins; otherwise the root value's `active_profile` field
1474    /// decides. Missing key → base only. Unknown name or non-string →
1475    /// W002. Type changes on override → W003. Every profile evaluates
1476    /// whether selected or not (strictness catches rot in inactive
1477    /// environments); selection reads the BASE key only.
1478    fn apply_profile(
1479        &self,
1480        module: &Module,
1481        values: &HashMap<NodeId, Value>,
1482        result: Value,
1483        diagnostics: &mut Vec<Diagnostic>,
1484        account: &mut Accountant,
1485    ) -> Result<Value, EvalError> {
1486        if module.profiles.is_empty() {
1487            // No profiles defined: the effective selector (override first,
1488            // then the key) still validates, then base stands.
1489            let selected: Option<String> = match &self.profile_override {
1490                Some(name) => Some(name.clone()),
1491                None => match &result {
1492                    Value::Table(map) => match map.get("active_profile") {
1493                        Some(Value::String(s)) => Some(s.clone()),
1494                        Some(_) => {
1495                            diagnostics.push(Diagnostic {
1496                                severity: Severity::Warning,
1497                                code: "W002".into(),
1498                                message: "active_profile must be a string".into(),
1499                                path: Some("active_profile".into()),
1500                                span: if self.profile_override.is_some() {
1501                                    None
1502                                } else {
1503                                    active_key_span(module, &result)
1504                                },
1505                                repair: None,
1506                                confidence: 1.0,
1507                                file: if self.profile_override.is_some() {
1508                                    None
1509                                } else {
1510                                    active_key_file(module, &result)
1511                                },
1512                            });
1513                            return Ok(result);
1514                        }
1515                        None => None,
1516                    },
1517                    _ => None,
1518                },
1519            };
1520            if let Some(name) = selected {
1521                // With no profiles defined, any effective selector is unknown.
1522                diagnostics.push(Diagnostic {
1523                    severity: Severity::Warning,
1524                    code: "W002".into(),
1525                    message: format!("unknown profile '{name}'"),
1526                    path: Some("active_profile".into()),
1527                    span: if self.profile_override.is_some() {
1528                        None
1529                    } else {
1530                        active_key_span(module, &result)
1531                    },
1532                    repair: None,
1533                    confidence: 1.0,
1534                    file: if self.profile_override.is_some() {
1535                        None
1536                    } else {
1537                        active_key_file(module, &result)
1538                    },
1539                });
1540            }
1541            return Ok(result);
1542        }
1543        let base_table = match &result {
1544            Value::Table(map) => map.clone(),
1545            _ => return Ok(result),
1546        };
1547        let selected: Option<String> = match &self.profile_override {
1548            Some(name) => Some(name.clone()),
1549            None => match base_table.get("active_profile") {
1550                None => return Ok(result),
1551                Some(Value::String(s)) => Some(s.clone()),
1552                Some(other) => {
1553                    diagnostics.push(Diagnostic {
1554                        severity: Severity::Warning,
1555                        code: "W002".into(),
1556                        message: format!(
1557                            "active_profile must be a string, got {}",
1558                            value_kind_name(other)
1559                        ),
1560                        path: Some("active_profile".into()),
1561                        span: if self.profile_override.is_some() {
1562                            None
1563                        } else {
1564                            active_key_span(module, &result)
1565                        },
1566                        repair: None,
1567                        confidence: 1.0,
1568                        file: if self.profile_override.is_some() {
1569                            None
1570                        } else {
1571                            active_key_file(module, &result)
1572                        },
1573                    });
1574                    return Ok(result);
1575                }
1576            },
1577        };
1578        let name = match selected {
1579            Some(n) => n,
1580            None => return Ok(result),
1581        };
1582        let Some(&profile_id) = module.profiles.get(&name) else {
1583            diagnostics.push(Diagnostic {
1584                severity: Severity::Warning,
1585                code: "W002".into(),
1586                message: format!("unknown profile '{name}'"),
1587                path: Some("active_profile".into()),
1588                span: if self.profile_override.is_some() {
1589                    None
1590                } else {
1591                    active_key_span(module, &result)
1592                },
1593                repair: None,
1594                confidence: 1.0,
1595                file: if self.profile_override.is_some() {
1596                    None
1597                } else {
1598                    active_key_file(module, &result)
1599                },
1600            });
1601            return Ok(result);
1602        };
1603        let profile_value = values.get(&profile_id).cloned().unwrap_or(Value::Null);
1604        // Merging copies the profile subtree into the result: charge
1605        // its measured size (over-approximates replacement as
1606        // addition — fail-safe direction).
1607        account.charge(module, Some(profile_id), measure_value(&profile_value))?;
1608        let mut merged = base_table;
1609        merge_profile_value(
1610            &mut merged,
1611            profile_value,
1612            &name,
1613            String::new(),
1614            module.node_spans.get(&profile_id).cloned(),
1615            module.node_files.get(&profile_id).cloned(),
1616            diagnostics,
1617        );
1618        Ok(Value::Table(merged))
1619    }
1620}
1621
1622/// Span of the root `active_profile` key's node, when it exists.
1623fn active_key_span(module: &Module, result: &Value) -> Option<crate::diag::Span> {
1624    let Value::Table(_) = result else {
1625        return None;
1626    };
1627    let root = module.root?;
1628    let Node::Table { fields } = module.get(root)? else {
1629        return None;
1630    };
1631    let id = fields.get("active_profile")?;
1632    module.node_spans.get(id).cloned()
1633}
1634
1635/// File of the root `active_profile` key's node, when recorded.
1636fn active_key_file(module: &Module, result: &Value) -> Option<String> {
1637    let Value::Table(_) = result else {
1638        return None;
1639    };
1640    let root = module.root?;
1641    let Node::Table { fields } = module.get(root)? else {
1642        return None;
1643    };
1644    let id = fields.get("active_profile")?;
1645    module.node_files.get(id).cloned()
1646}
1647
1648/// Short kind name for diagnostics (never includes values, so secrets
1649/// cannot leak through it). Numbers share a kind; quantities carry
1650/// their unit so different dimensions compare unequal.
1651pub(crate) fn value_kind_name(v: &Value) -> String {
1652    match v {
1653        Value::Null => "null".into(),
1654        Value::Bool(_) => "boolean".into(),
1655        Value::Int(_) | Value::Float(_) => "number".into(),
1656        Value::String(_) => "string".into(),
1657        Value::DurationMs(_) => "duration".into(),
1658        Value::Quantity(_, unit) => format!("quantity:{}", unit.suffix()),
1659        Value::Array(_) => "array".into(),
1660        Value::Table(_) => "table".into(),
1661        Value::Secret(_) => "secret".into(),
1662    }
1663}
1664
1665/// Merge a profile table into the base table in place (spec §3.5):
1666/// tables recurse, scalars last-wins (profile wins). Additions are
1667/// silent; a profile value that overrides a base value of a different
1668/// kind wins with a W003 warning naming the dotted path. `span`/`file`
1669/// locate the profile block for the warning.
1670fn merge_profile_value(
1671    base: &mut indexmap::IndexMap<String, Value>,
1672    overlay: Value,
1673    profile: &str,
1674    path: String,
1675    span: Option<crate::diag::Span>,
1676    file: Option<String>,
1677    diagnostics: &mut Vec<Diagnostic>,
1678) {
1679    let Value::Table(fields) = overlay else {
1680        return;
1681    };
1682    for (k, v) in fields {
1683        let child = if path.is_empty() {
1684            k.clone()
1685        } else {
1686            format!("{path}.{k}")
1687        };
1688        match base.get(&k) {
1689            None => {
1690                base.insert(k, v);
1691            }
1692            Some(existing) => match (existing, &v) {
1693                (Value::Table(_), Value::Table(_)) => {
1694                    if let Some(Value::Table(base_sub)) = base.get_mut(&k) {
1695                        merge_profile_value(
1696                            base_sub,
1697                            v,
1698                            profile,
1699                            child,
1700                            span.clone(),
1701                            file.clone(),
1702                            diagnostics,
1703                        );
1704                    }
1705                }
1706                _ => {
1707                    if value_kind_name(existing) != value_kind_name(&v) {
1708                        diagnostics.push(Diagnostic {
1709                            severity: Severity::Warning,
1710                            code: "W003".into(),
1711                            message: format!(
1712                                "profile '{profile}' overrides '{child}' with a different type"
1713                            ),
1714                            path: Some(child),
1715                            span: span.clone(),
1716                            repair: None,
1717                            confidence: 1.0,
1718                            file: file.clone(),
1719                        });
1720                    }
1721                    base.insert(k, v);
1722                }
1723            },
1724        }
1725    }
1726}
1727
1728/// Outcome of a rename application.
1729#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1730enum RenameOutcome {
1731    Ok,
1732    Missing,
1733    Exists,
1734}
1735
1736/// Navigate to the parent of a path's final segment. With `create`,
1737/// missing intermediate keys materialize as tables (or arrays when the
1738/// following segment indexes); without it, navigation is read-only and
1739/// anything missing returns None. A scalar blocking the way, or an
1740/// array index that does not exist, always returns None (no sparse
1741/// extension, no clobbering). Read-only navigation keeps failed
1742/// remove/rename operations side-effect free (spec §2.9).
1743fn patch_parent<'a>(root: &'a mut Value, segs: &[PathSeg], create: bool) -> Option<&'a mut Value> {
1744    let mut cur = root;
1745    let last = segs.len().checked_sub(1)?;
1746    let mut i = 0;
1747    while i < last {
1748        match &segs[i] {
1749            PathSeg::Key(k) => {
1750                let need_array = matches!(segs.get(i + 1), Some(PathSeg::Index(_)));
1751                cur = match cur {
1752                    Value::Table(map) => {
1753                        if !create && !map.contains_key(k) {
1754                            return None;
1755                        }
1756                        map.entry(k.clone()).or_insert_with(|| {
1757                            if need_array {
1758                                Value::Array(Vec::new())
1759                            } else {
1760                                Value::Table(indexmap::IndexMap::new())
1761                            }
1762                        })
1763                    }
1764                    _ => return None,
1765                };
1766            }
1767            PathSeg::Index(idx) => {
1768                cur = match cur {
1769                    Value::Array(items) => items.get_mut(*idx)?,
1770                    _ => return None,
1771                };
1772            }
1773        }
1774        i += 1;
1775    }
1776    Some(cur)
1777}
1778
1779/// Set the value at `path`, creating missing intermediate tables.
1780/// Returns false when the path is blocked (scalar in the way) or an
1781/// array index does not exist.
1782fn patch_set(root: &mut Value, segs: &[PathSeg], value: Value) -> bool {
1783    let Some(last) = segs.last() else {
1784        return false;
1785    };
1786    let Some(parent) = patch_parent(root, segs, true) else {
1787        return false;
1788    };
1789    match (parent, last) {
1790        (Value::Table(map), PathSeg::Key(k)) => {
1791            map.insert(k.clone(), value);
1792            true
1793        }
1794        (Value::Array(items), PathSeg::Index(i)) => {
1795            if let Some(slot) = items.get_mut(*i) {
1796                *slot = value;
1797                true
1798            } else {
1799                false
1800            }
1801        }
1802        _ => false,
1803    }
1804}
1805
1806/// Delete the value at `path`. Array removals shift later elements.
1807/// Returns false when the target does not exist.
1808fn patch_remove(root: &mut Value, segs: &[PathSeg]) -> bool {
1809    let Some(last) = segs.last() else {
1810        return false;
1811    };
1812    let Some(parent) = patch_parent(root, segs, false) else {
1813        return false;
1814    };
1815    match (parent, last) {
1816        (Value::Table(map), PathSeg::Key(k)) => map.shift_remove(k).is_some(),
1817        (Value::Array(items), PathSeg::Index(i)) if *i < items.len() => {
1818            items.remove(*i);
1819            true
1820        }
1821        _ => false,
1822    }
1823}
1824
1825/// Rename a key within its parent table. Returns Missing when the source
1826/// path does not resolve (checked before the target, so a missing
1827/// source warns W004 even when the target exists), Exists when the new
1828/// name is taken.
1829fn patch_rename(root: &mut Value, segs: &[PathSeg], new_name: &str) -> RenameOutcome {
1830    let Some(last) = segs.last() else {
1831        return RenameOutcome::Missing;
1832    };
1833    // Renaming an array element is meaningless: only plain keys rename.
1834    if !matches!(last, PathSeg::Key(_)) {
1835        return RenameOutcome::Missing;
1836    }
1837    let Some(parent) = patch_parent(root, segs, false) else {
1838        return RenameOutcome::Missing;
1839    };
1840    let Value::Table(map) = parent else {
1841        return RenameOutcome::Missing;
1842    };
1843    let PathSeg::Key(old) = last else {
1844        return RenameOutcome::Missing;
1845    };
1846    if !map.contains_key(old) {
1847        return RenameOutcome::Missing;
1848    }
1849    if map.contains_key(new_name) {
1850        return RenameOutcome::Exists;
1851    }
1852    match map.shift_remove(old) {
1853        Some(v) => {
1854            map.insert(new_name.to_string(), v);
1855            RenameOutcome::Ok
1856        }
1857        None => RenameOutcome::Missing,
1858    }
1859}
1860
1861fn normalize_quantity(v: &Value, unit: &Unit) -> Result<Value, String> {
1862    let num = match v {
1863        Value::Int(n) => *n as f64,
1864        Value::Float(f) => *f,
1865        _ => {
1866            return Err(format!("quantity requires numeric value, got {v:?}"));
1867        }
1868    };
1869
1870    if let Some(ms) = unit.normalize_time_to_ms(num) {
1871        return Ok(Value::DurationMs(ms));
1872    }
1873    if let Some(bytes) = unit.normalize_to_bytes(num) {
1874        // Represent bytes as Int for simplicity in the alpha.
1875        // Saturate: u64 byte counts past i64::MAX must not wrap
1876        // negative through `as` (found by audit: `20000000TiB`).
1877        return Ok(Value::Int(i64::try_from(bytes).unwrap_or(i64::MAX)));
1878    }
1879    // Rates and custom units retain their dimension (e.g. 100/req/s).
1880    // Percent canonicalizes to the dimensionless ratio (100% = 1.0);
1881    // values are NOT clamped: negative and over-100 percents are meaningful
1882    // (spec Ch. 4 §4.1).
1883    if *unit == Unit::Percent {
1884        return Ok(Value::Quantity(num / 100.0, Unit::Percent));
1885    }
1886    // Frequency canonicalizes to f64 hertz (fractional Hz are real).
1887    // Negative frequencies clamp at zero, mirroring time canonicalization
1888    // (spec Ch. 4 §4.1).
1889    let hz_factor = match unit {
1890        Unit::Hertz => Some(1.0),
1891        Unit::Kilohertz => Some(1_000.0),
1892        Unit::Megahertz => Some(1_000_000.0),
1893        Unit::Gigahertz => Some(1_000_000_000.0),
1894        Unit::Terahertz => Some(1_000_000_000_000.0),
1895        _ => None,
1896    };
1897    if let Some(factor) = hz_factor {
1898        return Ok(Value::Quantity((num * factor).max(0.0), Unit::Hertz));
1899    }
1900    // Bits canonicalize to f64 bits; data rates to f64 bits/sec with MB/s
1901    // converting through megabytes (1 MB/s = 8,000,000 bps), so cross-spelled
1902    // rates compare equal. Literal magnitudes clamp at zero.
1903    let bit_factor = match unit {
1904        Unit::Bits => Some((1.0, false)),
1905        Unit::Kilobits => Some((1_000.0, false)),
1906        Unit::Megabits => Some((1_000_000.0, false)),
1907        Unit::Gigabits => Some((1_000_000_000.0, false)),
1908        Unit::BitsPerSecond => Some((1.0, true)),
1909        Unit::KilobitsPerSecond => Some((1_000.0, true)),
1910        Unit::MegabitsPerSecond => Some((1_000_000.0, true)),
1911        Unit::GigabitsPerSecond => Some((1_000_000_000.0, true)),
1912        Unit::MegabytesPerSecond => Some((8_000_000.0, true)),
1913        _ => None,
1914    };
1915    if let Some((factor, is_rate)) = bit_factor {
1916        let canonical = if is_rate {
1917            Unit::BitsPerSecond
1918        } else {
1919            Unit::Bits
1920        };
1921        return Ok(Value::Quantity((num * factor).max(0.0), canonical));
1922    }
1923    // AI-age units: token counts, inference throughput, compute. Longest-match
1924    // prefix composition is inherent (the lexer consumes the maximal run and
1925    // the whitelist carries each spelling). Literal magnitudes clamp at zero.
1926    let ai_factor = match unit {
1927        Unit::Tokens => Some((1.0, Unit::Tokens)),
1928        Unit::KiloTokens => Some((1_000.0, Unit::Tokens)),
1929        Unit::MegaTokens => Some((1_000_000.0, Unit::Tokens)),
1930        Unit::TokensPerSecond => Some((1.0, Unit::TokensPerSecond)),
1931        Unit::Flops => Some((1.0, Unit::Flops)),
1932        Unit::TeraFlops => Some((1_000_000_000_000.0, Unit::Flops)),
1933        Unit::PetaFlops => Some((1_000_000_000_000_000.0, Unit::Flops)),
1934        _ => None,
1935    };
1936    if let Some((factor, canonical)) = ai_factor {
1937        return Ok(Value::Quantity((num * factor).max(0.0), canonical));
1938    }
1939    // Domain SI units: pressure (Pa; bar = 1e5, psi = 6894.757293168),
1940    // electrical (V/A/W), energy (J; kWh = 3.6e6), force (N). Each
1941    // canonicalizes to its coherent SI value; literal magnitudes clamp.
1942    let si_factor = match unit {
1943        Unit::Pascal => Some((1.0, Unit::Pascal)),
1944        Unit::Bar => Some((100_000.0, Unit::Pascal)),
1945        Unit::Psi => Some((6_894.757_293_168, Unit::Pascal)),
1946        Unit::Volts => Some((1.0, Unit::Volts)),
1947        Unit::Amperes => Some((1.0, Unit::Amperes)),
1948        Unit::Watts => Some((1.0, Unit::Watts)),
1949        Unit::Joules => Some((1.0, Unit::Joules)),
1950        Unit::KilowattHours => Some((3_600_000.0, Unit::Joules)),
1951        Unit::Newtons => Some((1.0, Unit::Newtons)),
1952        _ => None,
1953    };
1954    if let Some((factor, canonical)) = si_factor {
1955        return Ok(Value::Quantity((num * factor).max(0.0), canonical));
1956    }
1957    // Angles (deg→rad), rotation (retained rpm), length (m), mass (kg).
1958    let mech_factor = match unit {
1959        Unit::Degrees => Some((std::f64::consts::PI / 180.0, Unit::Radians)),
1960        Unit::Radians => Some((1.0, Unit::Radians)),
1961        Unit::Rpm => Some((1.0, Unit::Rpm)),
1962        Unit::Meters => Some((1.0, Unit::Meters)),
1963        Unit::Kilometers => Some((1_000.0, Unit::Meters)),
1964        Unit::Kilograms => Some((1.0, Unit::Kilograms)),
1965        Unit::Grams => Some((0.001, Unit::Kilograms)),
1966        _ => None,
1967    };
1968    if let Some((factor, canonical)) = mech_factor {
1969        return Ok(Value::Quantity((num * factor).max(0.0), canonical));
1970    }
1971    // Temperature canonicalizes to kelvin through affine transforms;
1972    // below-absolute-zero literals clamp at 0 K. Decibels are retained
1973    // (log scale, no conversion). Both are ordering-only (spec Ch. 4 §4.1).
1974    match unit {
1975        Unit::Kelvin => return Ok(Value::Quantity(num.max(0.0), Unit::Kelvin)),
1976        Unit::Celsius => return Ok(Value::Quantity((num + 273.15).max(0.0), Unit::Kelvin)),
1977        Unit::Fahrenheit => {
1978            return Ok(Value::Quantity(
1979                ((num - 32.0) * 5.0 / 9.0 + 273.15).max(0.0),
1980                Unit::Kelvin,
1981            ))
1982        }
1983        Unit::Decibel => return Ok(Value::Quantity(num, Unit::Decibel)),
1984        _ => {}
1985    }
1986    Ok(Value::Quantity(num, unit.clone()))
1987}
1988
1989pub(crate) fn eval_binary(op: BinOp, lhs: &Value, rhs: &Value) -> Result<Value, String> {
1990    use BinOp::*;
1991    match (op, lhs, rhs) {
1992        // Integer arithmetic saturates: user input must never panic
1993        // (debug) or wrap (release). Consistent with the DurationMs
1994        // arms below and the budget code's saturating style.
1995        // `MIN / -1` is the only overflow division: checked, saturates.
1996        (Add, Value::Int(a), Value::Int(b)) => Ok(Value::Int(a.saturating_add(*b))),
1997        (Sub, Value::Int(a), Value::Int(b)) => Ok(Value::Int(a.saturating_sub(*b))),
1998        (Mul, Value::Int(a), Value::Int(b)) => Ok(Value::Int(a.saturating_mul(*b))),
1999        (Div, Value::Int(a), Value::Int(b)) if *b != 0 => {
2000            Ok(Value::Int(a.checked_div(*b).unwrap_or(i64::MAX)))
2001        }
2002
2003        (Add, Value::Float(a), Value::Float(b)) => Ok(Value::Float(a + b)),
2004        (Sub, Value::Float(a), Value::Float(b)) => Ok(Value::Float(a - b)),
2005        (Mul, Value::Float(a), Value::Float(b)) => Ok(Value::Float(a * b)),
2006        (Div, Value::Float(a), Value::Float(b)) if *b != 0.0 => Ok(Value::Float(a / b)),
2007
2008        // Mixed int/float
2009        (Add, Value::Int(a), Value::Float(b)) => Ok(Value::Float(*a as f64 + b)),
2010        (Add, Value::Float(a), Value::Int(b)) => Ok(Value::Float(a + *b as f64)),
2011        (Mul, Value::Int(a), Value::Float(b)) => Ok(Value::Float(*a as f64 * b)),
2012        (Mul, Value::Float(a), Value::Int(b)) => Ok(Value::Float(a * *b as f64)),
2013
2014        (Eq, a, b) => Ok(Value::Bool(value_eq(a, b))),
2015        (Ne, a, b) => Ok(Value::Bool(!value_eq(a, b))),
2016
2017        (Lt, Value::Int(a), Value::Int(b)) => Ok(Value::Bool(a < b)),
2018        (Le, Value::Int(a), Value::Int(b)) => Ok(Value::Bool(a <= b)),
2019        (Gt, Value::Int(a), Value::Int(b)) => Ok(Value::Bool(a > b)),
2020        (Ge, Value::Int(a), Value::Int(b)) => Ok(Value::Bool(a >= b)),
2021
2022        // Duration arithmetic (time is the only dimension with a dedicated
2023        // Value; bytes normalize to Int). Scalar mul/div, same-dimension
2024        // add/sub and ordering. Mismatched dimensions (e.g. DurationMs + Int)
2025        // fall through to the type error below.
2026        (Add, Value::DurationMs(a), Value::DurationMs(b)) => {
2027            Ok(Value::DurationMs(a.saturating_add(*b)))
2028        }
2029        (Sub, Value::DurationMs(a), Value::DurationMs(b)) => {
2030            Ok(Value::DurationMs(a.saturating_sub(*b)))
2031        }
2032        (Mul, Value::DurationMs(a), Value::Int(b)) => {
2033            Ok(Value::DurationMs(scale_dur(*a, *b as f64)))
2034        }
2035        (Mul, Value::Int(a), Value::DurationMs(b)) => {
2036            Ok(Value::DurationMs(scale_dur(*b, *a as f64)))
2037        }
2038        (Mul, Value::DurationMs(a), Value::Float(b)) => Ok(Value::DurationMs(scale_dur(*a, *b))),
2039        (Mul, Value::Float(a), Value::DurationMs(b)) => Ok(Value::DurationMs(scale_dur(*b, *a))),
2040        (Div, Value::DurationMs(a), Value::Int(b)) if *b != 0 => {
2041            Ok(Value::DurationMs(scale_dur(*a, 1.0 / *b as f64)))
2042        }
2043        (Div, Value::DurationMs(a), Value::Float(b)) if *b != 0.0 => {
2044            Ok(Value::DurationMs(scale_dur(*a, 1.0 / *b)))
2045        }
2046        (Div, Value::DurationMs(a), Value::DurationMs(b)) if *b != 0 => {
2047            Ok(Value::Int((*a / *b) as i64))
2048        }
2049        (Lt, Value::DurationMs(a), Value::DurationMs(b)) => Ok(Value::Bool(a < b)),
2050        (Le, Value::DurationMs(a), Value::DurationMs(b)) => Ok(Value::Bool(a <= b)),
2051        (Gt, Value::DurationMs(a), Value::DurationMs(b)) => Ok(Value::Bool(a > b)),
2052        (Ge, Value::DurationMs(a), Value::DurationMs(b)) => Ok(Value::Bool(a >= b)),
2053
2054        // Same-suffix quantity arithmetic (percent, rates, custom):
2055        // same-dimension add/sub/compare, scalar mul/div, same-dimension
2056        // division yields a bare ratio. Cross-suffix operations fall through
2057        // to the dimensional TypeError below (spec Ch. 4 §4.1/§4.3).
2058        // Absolute temperatures and decibels are excluded from arithmetic
2059        // (affine/log values do not add); their comparisons below still
2060        // apply (Unit::is_non_arithmetic).
2061        (Add, Value::Quantity(a, ua), Value::Quantity(b, ub))
2062            if ua == ub && !ua.is_non_arithmetic() =>
2063        {
2064            Ok(Value::Quantity(a + b, ua.clone()))
2065        }
2066        (Sub, Value::Quantity(a, ua), Value::Quantity(b, ub))
2067            if ua == ub && !ua.is_non_arithmetic() =>
2068        {
2069            Ok(Value::Quantity(a - b, ua.clone()))
2070        }
2071        (Mul, Value::Quantity(a, u), Value::Int(n)) if !u.is_non_arithmetic() => {
2072            Ok(Value::Quantity(a * *n as f64, u.clone()))
2073        }
2074        (Mul, Value::Quantity(a, u), Value::Float(f)) if !u.is_non_arithmetic() => {
2075            Ok(Value::Quantity(a * f, u.clone()))
2076        }
2077        (Div, Value::Quantity(a, u), Value::Int(n)) if *n != 0 && !u.is_non_arithmetic() => {
2078            Ok(Value::Quantity(a / *n as f64, u.clone()))
2079        }
2080        (Div, Value::Quantity(a, u), Value::Float(f)) if *f != 0.0 && !u.is_non_arithmetic() => {
2081            Ok(Value::Quantity(a / f, u.clone()))
2082        }
2083        (Div, Value::Quantity(a, ua), Value::Quantity(b, ub))
2084            if ua == ub && *b != 0.0 && !ua.is_non_arithmetic() =>
2085        {
2086            Ok(Value::Float(a / b))
2087        }
2088        (Lt, Value::Quantity(a, ua), Value::Quantity(b, ub)) if ua == ub => Ok(Value::Bool(a < b)),
2089        (Le, Value::Quantity(a, ua), Value::Quantity(b, ub)) if ua == ub => Ok(Value::Bool(a <= b)),
2090        (Gt, Value::Quantity(a, ua), Value::Quantity(b, ub)) if ua == ub => Ok(Value::Bool(a > b)),
2091        (Ge, Value::Quantity(a, ua), Value::Quantity(b, ub)) if ua == ub => Ok(Value::Bool(a >= b)),
2092
2093        (And, Value::Bool(a), Value::Bool(b)) => Ok(Value::Bool(*a && *b)),
2094        (Or, Value::Bool(a), Value::Bool(b)) => Ok(Value::Bool(*a || *b)),
2095
2096        _ => Err(format!(
2097            "unsupported binary op {op:?} on {lhs:?} and {rhs:?}"
2098        )),
2099    }
2100}
2101
2102/// Scale a duration in milliseconds by a factor, clamped at zero.
2103fn scale_dur(ms: u64, factor: f64) -> u64 {
2104    (ms as f64 * factor).round().max(0.0) as u64
2105}
2106
2107pub(crate) fn eval_unary(op: UnOp, v: &Value) -> Result<Value, String> {
2108    match (op, v) {
2109        (UnOp::Neg, Value::Int(n)) => Ok(Value::Int(n.saturating_neg())),
2110        (UnOp::Neg, Value::Float(f)) => Ok(Value::Float(-f)),
2111        // Quantities negate by amount, keeping the unit: discounts, deltas
2112        // and signed rates are meaningful, and nothing here clamps
2113        // (spec Ch. 4 §4.1). Durations intentionally do NOT get this arm.
2114        (UnOp::Neg, Value::Quantity(a, u)) => Ok(Value::Quantity(-a, u.clone())),
2115        (UnOp::Not, Value::Bool(b)) => Ok(Value::Bool(!b)),
2116        _ => Err(format!("unsupported unary op {op:?} on {v:?}")),
2117    }
2118}
2119
2120fn value_eq(a: &Value, b: &Value) -> bool {
2121    match (a, b) {
2122        (Value::Null, Value::Null) => true,
2123        (Value::Bool(x), Value::Bool(y)) => x == y,
2124        (Value::Int(x), Value::Int(y)) => x == y,
2125        (Value::Float(x), Value::Float(y)) => (x - y).abs() < f64::EPSILON,
2126        (Value::String(x), Value::String(y)) => x == y,
2127        (Value::DurationMs(x), Value::DurationMs(y)) => x == y,
2128        (Value::Quantity(x, ux), Value::Quantity(y, uy)) => x == y && ux == uy,
2129        _ => false,
2130    }
2131}