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