sui_eval/normalize_env.rs
1//! The tree-walker's *consume* side of the `sui-normalize` attrset-binding
2//! plan.
3//!
4//! Mirrors [`crate::resolve_env`]'s three parts — a one-way env-flag latch, a
5//! thread-local table keyed by `(source_id, text_offset)`, and
6//! populate/lookup/clear hooks wired into `eval_with_file` — because the
7//! keying hazard is identical: a plan recorded for a binder at offset `o` in
8//! one parse tree must never be read for a different (imported) tree that
9//! happens to have a binder at the same offset.
10//!
11//! # ★ The failure discipline here is the INVERSE of `resolve_env`'s
12//!
13//! `sui-resolve` fails SAFE to `Dynamic` because its fallback is
14//! *equivalent* — `lookup_fast` probes the same map with the same symbol, so
15//! falling back costs only speed.
16//!
17//! **This table's fallback path is the divergence itself.** Falling back means
18//! "walk `set.entries()` yourself", which is precisely the code that produces
19//! the silent wrong answers `sui-normalize` exists to remove. So a miss must
20//! never be treated as "nothing to do" in a group that NEEDED a plan.
21//!
22//! The shape that makes that safe: `sui-normalize` records a group **only**
23//! when it has a duplicate static key or a dotted path. A miss therefore means
24//! "this group has neither", which is exactly when the existing path is
25//! already correct. The absence is a *positive* statement, not a fallback —
26//! and that is what bounds this change's blast radius to the groups that are
27//! wrong today.
28//!
29//! # Default ON since 2026-08-18
30//!
31//! `SUI_NORMALIZE=0` opts OUT, restoring the pre-plan construction path. The
32//! latch survives the flip on purpose — a divergence suspected to come from
33//! this pass is then one command away from being confirmed or cleared, which
34//! is worth more than the tidiness of deleting it.
35
36use std::cell::RefCell;
37use std::sync::OnceLock;
38
39use std::rc::Rc;
40
41use sui_normalize::{GroupPlan, NormalizeError};
42
43/// One-time read of `SUI_NORMALIZE`. Default ON; `SUI_NORMALIZE=0` opts out.
44static ENABLED: OnceLock<bool> = OnceLock::new();
45
46/// Whether plan-driven attrset construction is enabled. **Default: yes.**
47///
48/// Flipped from opt-in to opt-out on 2026-08-18, on this evidence:
49///
50/// * every wrong-answer shape in the class matches nix, including the
51/// acceptance case `{ a = rec { b = c+1; d = 2; }; a.c = d+3; }.a.b` -> 6,
52/// which needs mutual recursion ACROSS the merge boundary;
53/// * a fleet scan found ZERO false rejects — every rejection is a file
54/// `nix-instantiate --parse` also refuses. **Corrected 2026-08-18: the
55/// count was FOUR, not "the one".** Re-scanned at 4570 files:
56/// `blackmatter-services/…/jitsi`, `…/keycloak`,
57/// `kindling-profiles/…/macos-developer`, and
58/// `blackmatter/…/enhanced/composed.nix`, each verified individually
59/// against `nix-instantiate --parse`, which refuses all four and names the
60/// same attribute path sui does. The zero-false-rejects claim is unchanged
61/// and is the load-bearing half; the "one" was a coverage figure that
62/// rotted UPWARD — understated, so it read as modest and nothing ever
63/// flagged it. Re-run the scanner rather than trusting this number:
64/// `cargo run --release -p sui-normalize --example scan -- <dir>`;
65/// * `sui perf-seal` moved DOWN or held on all three attr-merge rows
66/// (`dotted full-set leaf deep-merge` 6 -> 5), which is what confirms the
67/// splice happens at PARSE time rather than adding eval work;
68/// * the suites are green both ways.
69///
70/// The latch is KEPT, deliberately, in the `SUI_SCOPE_NARROW` spirit:
71/// `SUI_NORMALIZE=0` restores the pre-plan construction path, so a divergence
72/// suspected to come from this pass can be bisected in one command instead of
73/// a revert. That is also why the old entry loops are not deleted yet.
74///
75/// ★ SINCE THE REJECTION TIER (2026-08-18) THE LATCH ALSO DISABLES REFUSAL,
76/// and that makes the engines disagree ON PURPOSE. `plan_for_node` returns
77/// `Ok(None)` when disabled, so with `SUI_NORMALIZE=0` the walker goes back to
78/// silently ACCEPTING `{ a = 1; a = 2; }` while the bytecode VM — which has no
79/// such latch and always plans — still refuses it. That is a bisect tool
80/// behaving as intended, not a bug; but it means an engine comparison run with
81/// `SUI_NORMALIZE=0` is not measuring what it looks like it is measuring.
82/// Clear the variable before comparing engines.
83#[must_use]
84pub fn enabled() -> bool {
85 *ENABLED.get_or_init(|| std::env::var("SUI_NORMALIZE").ok().as_deref() != Some("0"))
86}
87
88thread_local! {
89 /// `(source_id << 32) | text_offset` -> the binder's plan. Same keying as
90 /// `resolve_env::RESOLVE_TABLE` and `value::intern_cached`.
91 /// `Rc` because a lookup happens on EVERY evaluation of a planned attrset.
92 /// Storing the plan by value made `plan_for` a deep copy of the whole plan
93 /// subtree per evaluation; refcounted, it is a pointer bump.
94 static PLAN_TABLE: RefCell<rustc_hash::FxHashMap<u64, Memo>> =
95 RefCell::new(rustc_hash::FxHashMap::default());
96}
97
98#[inline]
99fn key(source_id: u32, text_offset: u32) -> u64 {
100 (u64::from(source_id) << 32) | u64::from(text_offset)
101}
102
103/// The plan for one binder node, computed ON DEMAND and memoized.
104///
105/// ★ Replaces a parse-door walk that planned every binder in every parsed
106/// file. Laziness means most of those are never evaluated, so that work was
107/// mostly discarded — measured as a ~4% wall-clock tax on a real nixpkgs eval.
108/// Planning at first evaluation is identical in result (a group's plan depends
109/// only on its own entries) and pays only for groups that are reached.
110///
111/// The memo is keyed exactly as before, so a plan computed for a node at
112/// offset `o` in one parse tree is never read for a different (imported) tree
113/// with a binder at the same offset.
114///
115/// A `None` return is a POSITIVE statement — the group has no duplicate static
116/// key and no dotted path, so the caller's existing path is already correct.
117/// See the module docs on why that is not a fallback.
118///
119/// # Errors
120///
121/// [`NormalizeError`] for a group nix itself rejects — a duplicate attribute
122/// (`{ a = 1; a = 2; }`) or a duplicate formal.
123///
124/// ★ This used to swallow that error with `.ok().flatten()`, which silently
125/// turned a rejection into "no plan" and sent the group down the entry loop.
126/// The result was accepting what nix refuses, and — worse — accepting it
127/// DIFFERENTLY from the bytecode VM: measured 2026-08-18, `{ a = 1; a = 2; }`
128/// is `{ a = 2; }` on the walker and `{ a = 1; }` on the VM, both at exit 0
129/// where nix exits 1. Neither answer is right, so no choice of winner
130/// reconciles the engines; only refusing does.
131pub fn plan_for_node<N>(
132 node: &N,
133 recursive: bool,
134 source_id: u32,
135 offset: u32,
136) -> Result<Option<Rc<GroupPlan>>, NormalizeError>
137where
138 N: rnix::ast::HasEntry,
139{
140 if !enabled() {
141 return Ok(None);
142 }
143 let k = key(source_id, offset);
144 if let Some(hit) = PLAN_TABLE.with(|t| t.borrow().get(&k).cloned()) {
145 return hit.0;
146 }
147 let computed = sui_normalize::plan_for_group(node, recursive).map(|p| p.map(Rc::new));
148 PLAN_TABLE.with(|t| t.borrow_mut().insert(k, Memo(computed.clone())));
149 computed
150}
151
152/// A memo entry.
153///
154/// `Memo(Ok(None))` records "this group needs no plan", which must be
155/// remembered too — otherwise every evaluation of an ordinary attrset re-runs
156/// `needs_plan`, which is the cost the memo exists to remove. A rejection is
157/// memoized for the same reason: a group nix refuses is refused on every
158/// evaluation, and re-deriving that is pure waste.
159#[derive(Clone)]
160struct Memo(Result<Option<Rc<GroupPlan>>, NormalizeError>);
161
162
163/// Drop every recorded plan. Wired into the same lifecycle point as
164/// `resolve_env::clear`.
165pub fn clear() {
166 PLAN_TABLE.with(|t| t.borrow_mut().clear());
167}