code wiki / (root) / nx_eqsat.nx

nx_eqsat.nx source

↩ module page · 2139 lines · 111396 B

1// nx_eqsat.nx -- equality-saturation framework (egg/SpEC family). 2// 3// Foundation for the superoptimization arc per ZERO_TO_ADVANCED.md M2: 4// "the system is the FASTEST possible at every layer". Equality 5// saturation is the 2026-research-grade approach -- developed by 6// Willsey/Tate/Bornholt (egg, 2021) + Tate et al (Equality Saturation 7// for Compiler Optimization, 2009), proven on Souper/Cranelift et al 8// production compilers. 9// 10// Why equality saturation vs traditional peephole: 11// Peephole: apply one rewrite, commit. If the rewrite was bad you 12// lose; if a better cascade existed but required first 13// un-doing a rewrite, you never find it. Phase-ordering 14// tyranny. 15// EqSat: keep ALL equivalent expressions in an e-graph. Apply 16// all rewrites breadth-first to saturation. Extract the 17// lowest-cost representative. Provably optimal under the 18// rule set + cost model. 19// 20// For RV64IM: lets the compiler find non-obvious wins like 21// (add x x) -> (shl x 1) (when shl is faster) 22// (shl (shl x a) b) -> (shl x (add a b)) (kill dependent chain) 23// (mul x 2^k) -> (shl x k) (strength reduction) 24// (and x (not (and ~a y))) -> ... (DeMorgan reroute) 25// 26// AND once silicon-feedback (rv64im_min_hot_report.nx) identifies hot 27// patterns, those become silicon-aware rewrites: 28// (popcount-naive-loop) -> (intrinsic popcount) when silicon has it 29// (gemm-inner-product) -> (vec mac fused) when SIMD lands 30// 31// Status: SEED. 2026-05-26. V1: e-class union-find + rule registry + 32// bounded saturation loop + greedy extractor. ~50 baseline rewrites 33// for RV64IM; extends per silicon-feedback findings. 34 35import "nx_syscalls.nx" 36import "nx_sketch_hash_map.nx" // sovereign (i64,i64) open-addressing primitive (DRY, Cardinal #15) -- the O(1) hashcons backing store 37import "nx_nxgate_sim.nx" // PROVEN op-semantics oracle (nx_gsim_eval_cell + NX_GATE_KIND_*) -- reused by the const-fold analysis (DRY). Import-dedup-safe (nx_import seen-set inlines it once; same organ nx_alu_divider already pulls it transitively). 38const NX_MAGIC_1024: i64 = 1024 39const NX_MAGIC_1000000000: i64 = 1000000000 40 41// ===== Expression node kinds (sealed enum) ================================================= 42// 43// Sealed at V1; extended as the rewrite set grows + new IR ops land. 44// Each kind has an arity (n_children) declared via nx_eqsat_arity_for. 45 46const NX_EQ_OP_CONST: i64 = 0 // 0 children; payload = i64 value 47const NX_EQ_OP_VAR: i64 = 1 // 0 children; payload = variable id 48const NX_EQ_OP_ADD: i64 = 2 // 2 children 49const NX_EQ_OP_SUB: i64 = 3 // 2 children 50const NX_EQ_OP_MUL: i64 = 4 // 2 children 51const NX_EQ_OP_DIV: i64 = 5 // 2 children 52const NX_EQ_OP_REM: i64 = 6 // 2 children 53const NX_EQ_OP_AND: i64 = 7 // 2 children 54const NX_EQ_OP_OR: i64 = 8 // 2 children 55const NX_EQ_OP_XOR: i64 = 9 // 2 children 56const NX_EQ_OP_NOT: i64 = 10 // 1 child 57const NX_EQ_OP_SHL: i64 = 11 // 2 children 58const NX_EQ_OP_SHR: i64 = 12 // 2 children (logical) 59const NX_EQ_OP_SAR: i64 = 13 // 2 children (arithmetic) 60const NX_EQ_OP_NEG: i64 = 14 // 1 child 61const NX_EQ_OP_EQ: i64 = 15 // 2 children 62const NX_EQ_OP_LT: i64 = 16 // 2 children (signed) 63const NX_EQ_OP_LTU: i64 = 17 // 2 children (unsigned) 64const NX_EQ_OP_SELECT: i64 = 18 // 3 children (mux) 65const NX_EQ_OP_POPCNT: i64 = 19 // 1 child (Zbb-equivalent) 66const NX_EQ_OP_N: i64 = 20 67 68func nx_eqsat_op_is_valid(op: i64) -> i64 { 69 if op < 0 { return 0 } 70 if op >= NX_EQ_OP_N { return 0 } 71 return 1 72} 73 74func nx_eqsat_arity_for(op: i64) -> i64 { 75 if op == NX_EQ_OP_CONST { return 0 } 76 if op == NX_EQ_OP_VAR { return 0 } 77 if op == NX_EQ_OP_NOT { return 1 } 78 if op == NX_EQ_OP_NEG { return 1 } 79 if op == NX_EQ_OP_POPCNT { return 1 } 80 if op == NX_EQ_OP_SELECT { return 3 } 81 return 2 82} 83 84// ===== E-node ================================================= 85// 86// An e-node is an op + a vector of e-class IDs (its children). Two 87// e-nodes are equivalent iff they have the same op + their children 88// resolve to the same e-classes (canonical reps). 89 90struct NxENode { 91 op: i64 92 n_kids: i64 93 kid0: i64 // e-class id; -1 if unused 94 kid1: i64 95 kid2: i64 96 payload: i64 // CONST value or VAR id 97 eclass: i64 // owning e-class id (canonicalised) 98} 99 100// ===== E-class ================================================= 101// 102// An equivalence class. Contains a set of equivalent e-nodes + a 103// canonical-id link for the union-find (egg-style). 104 105struct NxEClass { 106 canon: i64 // canonical id (this == self.id while not merged) 107 cost: i64 // best extraction cost in this class 108 best_node: i64 // node-array index of the cheapest 109 // ----- CONST-FOLD analysis lattice (egg e-class analysis) -- APPENDED ----- 110 // cf_val is the EXPLICIT const-analysis lattice element of this canonical class 111 // (egg's Analysis::Data = Option<i64>): NX_CF_NONE (i64 min) = "unknown/bottom" 112 // (no known constant); any other value = "this class is known-constant == cf_val". 113 // It is the MAKE/JOIN/MODIFY analysis store that replaces the old per-iteration 114 // full node-scan const-fold DISCOVERY with O(1)-amortized incremental work: 115 // MAKE (nx_eqsat_new_class / the add fast-path) -- born known for CONST nodes, 116 // computed once from children's cf_val for a foldable all-const op. 117 // JOIN (nx_eqsat_union_cited) -- lattice-meet flows the const 118 // along every union/congruence edge into VAR-rooted classes in O(1). 119 // MODIFY -- the NONE->known transition 120 // lazily materializes the CONST merge ONCE through the cited chokepoint. 121 // Appended AFTER best_node (no reorder; NxEClass 24B -> 32B, fits the 122 // pre-allocated cap_cls*32 stride EVERY gate harness mmaps). ALWAYS written in 123 // new_class; READ only when cf_on==1, so the cf_on==0 trajectory is byte-identical 124 // (dead state on the page-rounded class array, exactly like prov/meet/dsl slack). 125 cf_val: i64 126} 127 128// Const-analysis lattice SENTINEL: i64 minimum (0x8000000000000000) = "no known 129// constant / lattice bottom". Chosen as a value the gate-sim evaluator can in 130// principle produce, but the lattice tags by PRESENCE (a class whose true folded 131// value is i64-min still carries a real CONST best_node), so the sentinel collision 132// only means "MODIFY may re-run" which is idempotent + harmless (see make_join_modify). 133const NX_CF_NONE: i64 = 0 - 0x7FFFFFFFFFFFFFFF - 1 134 135// ===== RULE DSL (egg-style data-driven rewrite) ============================= 136// 137// A rewrite rule becomes a FLAT DATA RECORD (NxDslRule) interpreted by ONE 138// general e-matcher (nx_eqsat_apply_dsl_rule) -- the egg breadth path: many 139// rules cheaply + the SELF-AUTHORING hook (a new rule is just a data row the 140// invention loop can propose). The 7 hand-coded inline rules are RE-EXPRESSED as 141// a 7-row table (nx_eqsat_builtin_dsl_table) whose behavior is BYTE-IDENTICAL to 142// the inline functions (proven by the parity harness). The DSL path is OPT-IN 143// (NxEGraph.dsl null by default => the inline path runs verbatim). 144// 145// A rule's LHS is the shallow shape (lhs_op slotA slotB). Each slot is tagged: 146// DSL_VAR -- binds ?A/?B to ANY canonical e-class (a pattern variable) 147// DSL_CONST(val) -- the class's best_node must be a CONST whose payload == val 148// DSL_SAME_AS_A -- the class id must equal the already-bound ?A (the "x OP x") 149// The RHS is a CLOSED enum instantiated through the existing add* wrappers (no RHS 150// interpreter, no pointer pattern-tree, no allocator): 151// RHS_BIND_A | RHS_BIND_B | RHS_CONST(val) 152// RHS_SHL_A_BY_CONST(amt) | RHS_SHL_A_BY_LOG2B (strength-reduction shapes) 153// side_cond replays a width guard as DATA (SC_POW2_B_KLTW = the proven k<W 154// mul_pow2 guard). count_mode replays mul_pow2's real-merge counting (the only 155// mode that converges to SATURATED). dual_order replays firing both child 156// orderings (add_zero/or_zero) vs right-const-only (mul_one). 157 158const DSL_VAR:i64 = 0 159const DSL_CONST:i64 = 1 160const DSL_SAME_AS_A:i64 = 2 161 162const RHS_BIND_A:i64 = 0 163const RHS_BIND_B:i64 = 1 164const RHS_CONST:i64 = 2 165const RHS_SHL_A_BY_CONST:i64 = 3 166const RHS_SHL_A_BY_LOG2B:i64 = 4 167// RHS_SHL_A_BY_IADDED_J: emit (shl bindA (i+j)) where (i+j) is the COMPUTED summed 168// shift the shape-(d) shift-merge matcher hands to instantiate_rhs via `shamt` 169// (exactly as RHS_SHL_A_BY_LOG2B reuses shamt for the mul_pow2 log2). The DSL row 170// holds rhs_val=0 (the table is DATA -- it cannot carry the dynamic i+j); the 171// matcher reads i,j from the two CONST child classes and computes i+j itself. 172const RHS_SHL_A_BY_IADDED_J:i64 = 5 173 174const SC_NONE:i64 = 0 175const SC_POW2_B_KLTW:i64 = 1 176// SC_SHIFT_MERGE_IJW: the shift-merge side-condition carrying the i+j<W guard as 177// DATA (exactly as SC_POW2_B_KLTW=1 carries the mul_pow2 k<W guard). It also selects 178// the shape-(d) nested-SHL matcher: LHS is SHL(SHL(?x ?i) ?j), ?i,?j CONST, i+j<W. 179// At i+j>=W the merged (shl x (i+j)) count is out of range / x86-count-masked, so the 180// identity is UNSOUND (proven by nx_rs_shiftmerge_boundary_unsound) -- the matcher 181// refuses to fire there, mirroring mul_pow2's k<W refusal. 182const SC_SHIFT_MERGE_IJW:i64 = 2 183 184const CNT_EVERY:i64 = 0 // count every match (the 6 V1 rules' behavior) 185const CNT_REAL_MERGE:i64 = 1 // count only real find!=find merges (mul_pow2) 186 187// A rewrite rule as data. lhs is (lhs_op a_kind/a_val b_kind/b_val); rhs is the 188// closed-enum template (rhs_kind/rhs_val). side_cond + count_mode + dual_order 189// replay the per-rule behavioral knobs the inline functions hard-coded. 190struct NxDslRule { 191 rule_id: i64 192 lhs_op: i64 193 a_kind: i64 194 a_val: i64 195 b_kind: i64 196 b_val: i64 197 rhs_kind: i64 198 rhs_val: i64 199 side_cond: i64 200 count_mode: i64 201 dual_order: i64 202} 203 204// ===== E-graph ================================================= 205// 206// V1 fixed-capacity storage. Caller allocates the nodes + classes 207// arrays; the e-graph owns indices into them. 208 209// PROVENANCE FIELDS (prov..prov_overflow) are APPENDED -- never reorder 210// nodes..valid; every caller depends on those offsets. They default OFF (prov 211// null) so the four gated organs behave byte-identically. When enabled via 212// nx_eqsat_enable_prov, the single union chokepoint (nx_eqsat_union_cited) logs 213// the citing rule-id of EVERY real merge -- a merge cannot happen without a 214// rule-id flowing through the one function that mutates canon (no bypass). 215struct NxEGraph { 216 nodes: *NxENode 217 n_nodes: i64 218 cap_nodes: i64 219 classes: *NxEClass 220 n_classes: i64 221 cap_classes: i64 222 valid: i64 223 prov: *i64 // APPENDED: rule-id log buffer; null => provenance OFF 224 n_prov: i64 // APPENDED: # rule-ids logged so far 225 cap_prov: i64 // APPENDED: capacity of prov[] 226 prov_overflow: i64 // APPENDED: 1 iff a logged merge could not fit (fail-closed) 227 // ----- MEET fields (egg hashcons + congruence rebuild) -- APPENDED, opt-in ----- 228 // All default null/0 in nx_eqsat_init so EVERY existing caller + the four gated 229 // organs stay byte-identical (the linear-scan path runs when hc==null and the 230 // rebuild path is skipped when rebuild_on==0). Turned on per-e-graph via 231 // nx_eqsat_enable_meet, which hands the engine caller-owned scratch. 232 hc: *HashMap // hashcons: fingerprint(op,payload,canon kids) -> node-array idx; null => O(n) linear scan 233 par_node: *i64 // parent index: par_node[i] = a node that references par_cls[i] as a (canonical) child 234 par_cls: *i64 // parallel array: the canonical child-class of that parent edge 235 n_par: i64 // # parent edges recorded 236 cap_par: i64 // capacity of par_node/par_cls 237 par_overflow: i64 // 1 iff a parent edge could not fit (rebuild then falls back to full-scan -> still complete) 238 worklist: *i64 // dirty canonical e-class ids pending congruence repair 239 n_work: i64 // # entries in worklist 240 cap_work: i64 // capacity of worklist 241 rebuild_on: i64 // 1 => unions push dirty classes + saturate calls rebuild; 0 => original loop verbatim 242 // ----- DSL fields (egg data-driven rewrite) -- APPENDED, opt-in ----- 243 // All default null/0 in nx_eqsat_init so EVERY existing caller + the four gated 244 // organs stay BYTE-IDENTICAL: when dsl==null, nx_eqsat_saturate runs the verbatim 245 // 7 inline rule calls; when dsl!=null it runs the general e-matcher over the table. 246 // Turned on per-e-graph via nx_eqsat_enable_dsl (caller hands a *NxDslRule table). 247 // Appending here is safe: every test over-allocates NxEGraph via page-rounded 248 // sys_mmap (4096-byte page for a <200-byte struct), so the new fields fit. 249 dsl: *NxDslRule // rule table; null => DSL OFF (inline path runs) 250 n_dsl: i64 // # rules in the table 251 cap_dsl: i64 // capacity (diagnostic; n_dsl is authoritative) 252 // ----- CONST-FOLD field (egg e-class analysis) -- APPENDED, opt-in ----- 253 // The const-fold analysis hook in nx_eqsat_add is GATED on this flag (default 0 254 // in nx_eqsat_init, set only by nx_eqsat_enable_constfold), mirroring the 255 // prov/meet/dsl append-only + opt-in precedent. When cf_on==0 the hook is a 256 // no-op so EVERY existing caller's node/class/prov trajectory is byte-identical 257 // BY CONSTRUCTION. One i64 appended after the dsl block; NxENode (56B/64B stride) 258 // and NxEClass (24B) get ZERO new fields. NxEGraph is page-rounded via sys_mmap 259 // (4096-byte page for a <300-byte struct) so the new field fits with no reorder. 260 cf_on: i64 // 1 => const-fold fires at node creation; 0 => OFF (default) 261 // ----- CONST-VALUE INTERN CACHE (the O(1) MODIFY backing store) -- APPENDED ----- 262 // The const-fold MODIFY step materializes CONST(value) e-classes; the V1 263 // nx_eqsat_add_const did an O(n) linear node scan to hashcons each one, which -- 264 // doubled with the O(n) binop scan -- IS the measured ~10x const-fold-bench lapp 265 // (build phase = ~96% of the bench, profiled). cf_cache is a tiny open-addressing 266 // (value -> class-id) intern map so an interned CONST is O(1) instead of O(n), 267 // turning the whole eager-fold build into O(N) amortized (matches egg's interned 268 // Num leaves). Lazily mmap'd by nx_eqsat_enable_constfold (so default callers pay 269 // nothing); null => the cache is OFF and nx_cf_intern_const falls back to the 270 // O(n) nx_eqsat_add_const (always correct, just slower). Appended after cf_on 271 // (no reorder; NxEGraph is page-rounded via sys_mmap so the new fields fit). 272 cf_cache: *i64 // 2*cap entries: [value, class_id] pairs; null => cache OFF 273 cf_cache_cap: i64 // # slots (power-of-2); 0 => OFF 274 cf_cache_n: i64 // # live entries (for load-factor / fail-open-to-scan) 275 cf_poison: i64 // 1 iff a JOIN saw a contradiction (two unequal consts in 276 // one class) -> fail-CLOSED soft poison (NOT sys_exit; 277 // Global Rule #14 graceful degradation -- a library must 278 // not crash its host on internal state). Surfaced for a 279 // future certifier reject; never reached by a sound graph. 280 cf_scratch: *i64 // GRAPH-LIFETIME scratch (>=4 i64) for the fold child-value 281 // reads. Allocated ONCE in enable_constfold so the hot 282 // fold path needs ZERO per-node sys_mmap (the per-call 283 // sys_mmap(8) was the dominant build-phase cost -- each 284 // mmap is a syscall ~1us, x #ops x #iters). null => OFF. 285 // ----- WORKLIST-DRIVEN INCREMENTAL RULE MATCHING -- APPENDED, opt-in (4th layer) --- 286 // egg's keystone scheduler win: the saturate loop must NOT full-scan all n_nodes per 287 // rule per iteration (O(n*K*iters)). Instead it matches rules ONLY against worklist 288 // items: (a) newly-added nodes (tracked by the rising n_nodes frontier, so EVERY 289 // alloc site -- PATH A/B/constfold/RHS -- is covered with zero per-site hooks) and 290 // (b) parents of a class just merged (enqueued by nx_eqsat_union_cited via the parent 291 // back-index). Drained to fixpoint. PROVABLY complete: a shallow-LHS match becomes 292 // newly-possible ONLY when a node is created (a) or one of its children is re- 293 // canonicalized by a union (b) -- egg's rebuild theorem. mwl is a SEPARATE worklist 294 // from the congruence `worklist` (which rebuild clobbers at its top, line g.n_work=0), 295 // so the two never alias. Auto-armed by nx_eqsat_enable_meet (the par-index that (b) 296 // needs lives there, and meet forces hc!=null => ALL nodes go through PATH A => the 297 // par-index is complete -- closing the PATH-B parent-gap trap). match_on default 0 in 298 // nx_eqsat_init => every existing caller + the 6 non-meet certs run the VERBATIM 299 // full-scan saturate => byte-identical by construction. Appended at the tail (no 300 // reorder); NxEGraph is page-rounded via sys_mmap so the new i64s fit. 301 mwl: *i64 // match-worklist: NODE indices pending rule re-match; null => OFF 302 n_mwl: i64 // # entries in the match-worklist 303 cap_mwl: i64 // capacity of mwl 304 match_on: i64 // 1 => worklist-driven incremental match; 0 => verbatim full-scan 305 mwl_overflow: i64 // 1 iff a push could not fit -> saturate fail-OPENS to full-scan 306 // (provably complete fallback, just the old O(n) scan) 307} 308 309const NX_EQSAT_OK: i64 = 0 310const NX_EQSAT_BAD_OP: i64 = 1 311const NX_EQSAT_BAD_ARITY: i64 = 2 312const NX_EQSAT_OVERFLOW: i64 = 3 313const NX_EQSAT_SATURATED: i64 = 4 314const NX_EQSAT_STEP_BUDGET: i64 = 5 315 316// ===== Rule identifiers (SINGLE SOURCE OF TRUTH) ============================ 317// 318// The saturator cites one of these at each union site so the live provenance 319// log records WHICH rule created each merge edge. The membership-proof organ 320// imports + asserts MP_RULE_* == these (lockstep, no parallel namespace drift). 321// NX_EQSAT_RULE_NONE is the sentinel for any merge that did NOT cite a rule 322// (e.g. a direct nx_eqsat_union call) -- it is NOT in any proven-sound set, so 323// an unlogged-but-cited-NONE merge POISONS a certificate (fail-closed bypass). 324 325const NX_EQSAT_RULE_NONE: i64 = 0 326const NX_EQSAT_RULE_ADD_ZERO: i64 = 1 327const NX_EQSAT_RULE_SUB_SELF: i64 = 2 328const NX_EQSAT_RULE_ADD_SELF: i64 = 3 329const NX_EQSAT_RULE_AND_SELF: i64 = 4 330const NX_EQSAT_RULE_OR_ZERO: i64 = 5 331const NX_EQSAT_RULE_MUL_ONE: i64 = 6 332const NX_EQSAT_RULE_MUL_POW2: i64 = 7 333// CONGRUENCE: the structural law of equality -- if a==b (same canonical class) then 334// f(...a...)==f(...b...) because f is a deterministic function of its children. It is 335// width-independent and needs NO arithmetic battery (unlike the 7 identity rules); its 336// soundness proof IS the congruence axiom. Cited by the deferred rebuild ONLY, through 337// the same nx_eqsat_union_cited chokepoint, so it is logged + admitted to the 338// membership-proof's proven-sound allow-list (no RULE_NONE bypass). [CANON: Willsey 339// et al, egg, PLDI 2021 -- deferred rebuild + hashcons invariant.] 340const NX_EQSAT_RULE_CONGRUENCE: i64 = 8 341// XOR_SELF: (xor x x) == 0 -- self-cancellation. Width-independent (a^a=0 holds per 342// bit at ANY W, same per-bit-law class as and_self/or_zero), so the W=8 exhaustive 343// battery lifts to W=64. Admitted to the proven-sound set ONLY after the all-W 344// two-witness soundness battery certifies it (see nx_eqsat_membership_proof.nx 345// mp_battery_xor_self). It is the FIRST rule expressed PURELY as DSL data (one 346// NxDslRule row, zero new matcher control flow) -- the breadth-via-data proof. 347const NX_EQSAT_RULE_XOR_SELF: i64 = 9 348// CONSTFOLD: (op c0 c1 ...) where EVERY child class is a known constant => merge 349// with CONST(eval(op, c0, c1, ...)). This is egg's canonical e-class ANALYSIS 350// (constant folding / propagation): the analysis value of a class is "CONST V" 351// iff its best_node is a CONST node, and it lives IMPLICITLY in NxEClass.best_node 352// (no new struct field). Soundness = the PROVEN op-semantics evaluator 353// nx_gsim_eval_cell (the same oracle the divider/multiplier proofs + the 354// membership-proof's _mp_eval_op_const trust). Only ops whose gate-sim cell is the 355// proven semantics of the eqsat op are foldable (nx_eqsat_op_to_gate_kind admits 356// them; DIV/REM/SAR/SHR/NEG/POPCNT/SELECT are REFUSED -- no faithful cell, see the 357// map). Width-independent: the engine works at W=64 and i64 arithmetic IS mod-2^64, 358// so the raw evaluator output is exactly the W=64 value (no truncation gap). The 359// merge flows the single chokepoint (nx_eqsat_union_cited) citing this id, so it is 360// logged + certifiable. Admitted to the membership-proof allow-list + lockstep. 361const NX_EQSAT_RULE_CONSTFOLD: i64 = 10 362// SHIFT_MERGE: (shl (shl ?x ?i) ?j) == (shl ?x (?i+?j)) for ?i,?j CONST and i+j<W. 363// THEOREM: (x<<i)<<j == x<<(i+j) (mod 2^W) for all i,j>=0 with i+j<W -- left-shift 364// distributes over the mod (2^j is a factor), width-independent by k-induction on 365// (i+j) (the SAME inductive shape mul_pow2 uses), so the W=8 machine-check lifts to 366// W=64. Admitted to the proven-sound set ONLY after the all-W two-witness battery 367// certifies it in-range AND the i+j>=W boundary is shown unsound (the i+j<W guard's 368// home). It is the SECOND rule expressed PURELY as DSL data (one NxDslRule row, 369// shape (d): the LHS first child is itself a structural SHL, distinct from xor_self's 370// shape (a)/mul_pow2's shape (b)/add_zero's const-slot shape (c)). The merge flows 371// the single cited chokepoint (nx_eqsat_union_cited) so it is logged + certifiable. 372const NX_EQSAT_RULE_SHIFT_MERGE: i64 = 11 373const NX_EQSAT_RULE_N: i64 = 12 374 375// ===== Init ================================================= 376 377func nx_eqsat_init(g: *NxEGraph, 378 nodes: *NxENode, cap_nodes: i64, 379 classes: *NxEClass, cap_classes: i64) -> i64 { 380 if (g as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 381 if (nodes as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 382 if (classes as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 383 g.nodes = nodes 384 g.n_nodes = 0 385 g.cap_nodes = cap_nodes 386 g.classes = classes 387 g.n_classes = 0 388 g.cap_classes = cap_classes 389 g.valid = 1 390 // Provenance OFF by default (signature unchanged): null buffer => the union 391 // chokepoint's log call short-circuits, so all existing callers + the four 392 // gated organs are byte-identical. 393 g.prov = 0 as *i64 394 g.n_prov = 0 395 g.cap_prov = 0 396 g.prov_overflow = 0 397 // MEET fields OFF by default (null/0) => linear-scan add + no rebuild => byte-identical. 398 g.hc = 0 as *HashMap 399 g.par_node = 0 as *i64 400 g.par_cls = 0 as *i64 401 g.n_par = 0 402 g.cap_par = 0 403 g.par_overflow = 0 404 g.worklist = 0 as *i64 405 g.n_work = 0 406 g.cap_work = 0 407 g.rebuild_on = 0 408 // DSL OFF by default (null/0) => nx_eqsat_saturate runs the verbatim inline rules. 409 g.dsl = 0 as *NxDslRule 410 g.n_dsl = 0 411 g.cap_dsl = 0 412 // CONST-FOLD OFF by default (0) => the node-creation hook is a no-op => every 413 // existing caller is byte-identical. Turned on only via nx_eqsat_enable_constfold. 414 g.cf_on = 0 415 // CONST-VALUE intern cache OFF by default (null/0) => nx_cf_intern_const falls 416 // back to the O(n) add_const => byte-identical. Armed by enable_constfold. 417 g.cf_cache = 0 as *i64 418 g.cf_cache_cap = 0 419 g.cf_cache_n = 0 420 g.cf_poison = 0 421 g.cf_scratch = 0 as *i64 422 // INCREMENTAL MATCH OFF by default (null/0) => nx_eqsat_saturate runs the verbatim 423 // full-scan; the union (b)-hook + alloc (a)-frontier are inert => byte-identical. 424 g.mwl = 0 as *i64 425 g.n_mwl = 0 426 g.cap_mwl = 0 427 g.match_on = 0 428 g.mwl_overflow = 0 429 return NX_EQSAT_OK 430} 431 432// ===== Opt-in provenance ==================================================== 433// 434// Turn ON rule-id logging by handing the e-graph a caller-owned i64 buffer of 435// `cap` slots. After this, every REAL merge logs its citing rule-id. Existing 436// callers never call this, so prov stays null => byte-identical behavior. 437func nx_eqsat_enable_prov(g: *NxEGraph, buf: *i64, cap: i64) -> i64 { 438 if (g as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 439 if (buf as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 440 g.prov = buf 441 g.n_prov = 0 442 g.cap_prov = cap 443 g.prov_overflow = 0 444 return NX_EQSAT_OK 445} 446 447// Log one rule-id for a real merge. No-op when prov is OFF (null) -> returns 0 448// and never mutates the graph (the byte-identical guarantee). When ON, appends 449// rule_id; on capacity overflow it FAILS CLOSED -- sets prov_overflow so the 450// certifier can never pass on a truncated (incomplete) log. 451func nx_eqsat_log_rule(g: *NxEGraph, rule_id: i64) -> i64 { 452 if (g.prov as i64) == 0 { return 0 } 453 if g.n_prov >= g.cap_prov { 454 g.prov_overflow = 1 455 return 0 - NX_EQSAT_OVERFLOW 456 } 457 g.prov[g.n_prov] = rule_id 458 g.n_prov = g.n_prov + 1 459 return NX_EQSAT_OK 460} 461 462// ===== MEET: opt-in hashcons + congruence rebuild =========================== 463// 464// Turn ON the egg-style O(1) hashcons + deferred congruence closure by handing 465// the e-graph caller-owned scratch: 466// hc -- a *HashMap (nx_hmap_alloc'd, power-of-2, sized > nodes/0.7) used 467// as the (fingerprint -> node-idx) intern table (replaces the O(n) 468// linear scan in nx_eqsat_add with an O(1)-avg lookup). 469// par_node/par_cls (cap_par) -- the parent back-index: each new node registers 470// itself as a parent of each of its canonical child classes, so the 471// rebuild can enumerate f(a) when a merges. 472// worklist (cap_work) -- dirty canonical class ids pending congruence repair. 473// After this, rebuild_on=1: every real union pushes its winner onto the worklist 474// and nx_eqsat_saturate drains the worklist to a congruence fixpoint each 475// iteration. Existing callers never call this, so the engine is byte-identical. 476func nx_eqsat_enable_meet(g: *NxEGraph, hc: *HashMap, 477 par_node: *i64, par_cls: *i64, cap_par: i64, 478 worklist: *i64, cap_work: i64) -> i64 { 479 if (g as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 480 if (hc as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 481 if (par_node as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 482 if (par_cls as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 483 if (worklist as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 484 g.hc = hc 485 g.par_node = par_node 486 g.par_cls = par_cls 487 g.n_par = 0 488 g.cap_par = cap_par 489 g.par_overflow = 0 490 g.worklist = worklist 491 g.n_work = 0 492 g.cap_work = cap_work 493 g.rebuild_on = 1 494 // AUTO-ARM worklist-driven incremental rule matching: enable_meet is the natural 495 // (and only) place -- the parent back-index that the (b) re-match needs lives HERE, 496 // and meet forces hc!=null so EVERY node (incl rule-RHS) is allocated through PATH A. 497 // match_on=1 makes nx_eqsat_saturate match rules against ONLY the new-node frontier 498 // (enqueue (a)) while quiescent, and re-scan the node set ONCE per outer iteration 499 // when a merge dirtied children (enqueue (b), set by union_cited). This is a pure 500 // speed scheduler change: the merge multiset, provenance ids, and SATURATED return 501 // are unchanged -- it only prunes the no-change nodes the full-scan re-checked for 502 // nothing each iteration. The mwl_overflow flag doubles as the (b) "merge happened" 503 // signal; mwl/n_mwl/cap_mwl stay null/0 (the flag-based scheme needs no buffer). 504 g.match_on = 1 505 g.mwl_overflow = 0 506 return NX_EQSAT_OK 507} 508 509// ===== Opt-in DSL (egg data-driven rewrite) ================================= 510// 511// Turn ON the general e-matcher by handing the e-graph a caller-owned *NxDslRule 512// table of `n` rules. After this, nx_eqsat_saturate applies the DATA rules via 513// nx_eqsat_apply_dsl_rule (one e-matcher) instead of the 7 inline functions. 514// Existing callers never call this => dsl stays null => byte-identical engine. 515// The CALLER is responsible for only loading rules whose ids are in the 516// membership-proof's proven-sound allow-list (the certifier fail-closes on any 517// un-admitted id, so a wrong table cannot silently certify). 518func nx_eqsat_enable_dsl(g: *NxEGraph, table: *NxDslRule, n: i64, cap: i64) -> i64 { 519 if (g as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 520 if (table as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 521 g.dsl = table 522 g.n_dsl = n 523 g.cap_dsl = cap 524 return NX_EQSAT_OK 525} 526 527// ===== Opt-in CONST-FOLD (egg e-class analysis) ============================= 528// 529// Turn ON constant folding: when EVERY child of a newly-added arithmetic e-node is 530// a known constant, the node is evaluated via the PROVEN op-semantics oracle 531// (nx_gsim_eval_cell) and its e-class is merged with a CONST e-class of the result, 532// citing NX_EQSAT_RULE_CONSTFOLD through the single union chokepoint (logged + 533// certifiable). Existing callers never call this => cf_on stays 0 => the node- 534// creation hook is a no-op => the engine is byte-identical. The folded equality is 535// sound by EVALUATION (the gate-sim is the proven semantics), so no separate W-bit 536// battery is needed (parallel to how CONGRUENCE is admitted to the sound set). 537func nx_eqsat_enable_constfold(g: *NxEGraph) -> i64 { 538 if (g as i64) == 0 { return 0 - NX_EQSAT_BAD_OP } 539 g.cf_on = 1 540 // Arm the O(1) const-value intern cache (the MODIFY backing store). A power-of-2 541 // open-addressing map sized so the engine never linear-scans for a folded CONST; 542 // on miss/full it fail-OPENS to the correct O(n) add_const, so correctness never 543 // depends on the cache, only speed. CF_CACHE_CAP slots * 2 i64 (value, class+1). 544 // SYS_MMAP (MAP_PRIVATE|MAP_ANONYMOUS, flags 0x22) is KERNEL-ZERO-FILLED, so the 545 // slots start empty (stored class+1 == 0 => empty marker) with NO init loop -- 546 // the per-call zeroing was an 8192-write-per-iter regression; relying on the 547 // zero-fill guarantee makes enable_constfold O(1). cf_cache stays null on an 548 // mmap failure => nx_cf_intern_const fail-OPENS to add_const (correct, slower). 549 let cap: i64 = NX_MAGIC_1024 // >> #distinct consts in any V1 graph; 550 // 1024*2*8 = 16KB (4 pages) -- small 551 // enough to keep init page-fault cost 552 // negligible, big enough to stay sparse. 553 let buf: *i64 = sys_mmap(cap * 2 * 8) as *i64 554 if (buf as i64) != 0 { 555 g.cf_cache = buf 556 g.cf_cache_cap = cap 557 g.cf_cache_n = 0 558 } 559 g.cf_poison = 0 560 // ONE graph-lifetime scratch buffer for the hot fold path's child-value reads -- 561 // eliminates the per-node sys_mmap(8) (the syscall that dominated the build). 562 g.cf_scratch = sys_mmap(32) as *i64 563 return NX_EQSAT_OK 564} 565 566// Create a fresh CONST e-node + class WITHOUT the O(n) linear-scan hashcons (the 567// cache IS the const hashcons when armed, so a scan would be redundant). Used by the 568// intern map miss path + the add() CONST fast path. new_class sets cf_val=value (the 569// MAKE leaf base case). Returns the new class id, or an overflow code. 570func nx_cf_make_const_node(g: *NxEGraph, value: i64) -> i64 { 571 if g.n_nodes >= g.cap_nodes { return 0 - NX_EQSAT_OVERFLOW } 572 let nidx: i64 = g.n_nodes 573 g.nodes[nidx].op = NX_EQ_OP_CONST 574 g.nodes[nidx].n_kids = 0 575 g.nodes[nidx].kid0 = 0 - 1 576 g.nodes[nidx].kid1 = 0 - 1 577 g.nodes[nidx].kid2 = 0 - 1 578 g.nodes[nidx].payload = value 579 g.n_nodes = nidx + 1 580 return nx_eqsat_new_class(g, nidx, 0) // CONST cost == 0 581} 582 583// O(1) const-value intern map (the MODIFY materialization store + the CONST hashcons 584// when const-fold is armed). Returns the canonical class-id of CONST(value), reusing 585// an existing one when present so the engine never grows duplicate CONST nodes AND 586// never linear-scans for them -- killing the O(n^2) const-add term that (with the 587// per-fold mmap, now also removed) was the measured build-phase lapp. Open addressing 588// (mix-probe) keyed by value; slot[1] stores class+1, 0 == EMPTY (kernel-zero-filled 589// mmap). HIT => re-canonicalize via find (a merged const class stays addressable). 590// MISS => create the node once (NO scan) + record. FAIL-OPEN: cache OFF/full/mmap- 591// failed => delegate to nx_eqsat_add_const (correct, O(n)) so correctness never 592// depends on the cache, only speed. 593func nx_cf_intern_const(g: *NxEGraph, value: i64) -> i64 { 594 if (g.cf_cache as i64) == 0 { return nx_eqsat_add_const(g, value) } 595 let cap: i64 = g.cf_cache_cap 596 if cap <= 0 { return nx_eqsat_add_const(g, value) } 597 let mask: i64 = cap - 1 598 var h: i64 = nx_eqsat_mix(value + 1) & mask 599 var probes: i64 = 0 600 while probes < cap { 601 // slot stores class+1 in the second word; 0 == EMPTY (kernel-zero-filled mmap, 602 // no init loop). A real class 0 stores as 1, so 0 unambiguously means empty. 603 let enc: i64 = g.cf_cache[h * 2 + 1] 604 if enc == 0 { 605 // empty slot: MISS -> materialize the CONST once (NO scan), record, return. 606 let nc: i64 = nx_cf_make_const_node(g, value) 607 if nc < 0 { return nc } 608 g.cf_cache[h * 2] = value 609 g.cf_cache[h * 2 + 1] = nc + 1 // store class+1 (0 reserved for empty) 610 g.cf_cache_n = g.cf_cache_n + 1 611 return nc 612 } 613 if g.cf_cache[h * 2] == value { 614 // HIT: re-canonicalize (the const class may have merged). 615 return nx_eqsat_find(g, enc - 1) 616 } 617 h = (h + 1) & mask 618 probes = probes + 1 619 } 620 // table full (never reached for V1 graphs): fail-OPEN to the correct O(n) path. 621 return nx_eqsat_add_const(g, value) 622} 623 624// Fill `table` (caller-owned, >= 7 rows) with the 7 V1 rules RE-EXPRESSED as data. 625// This table, applied by the e-matcher, is BYTE-IDENTICAL to the inline saturate 626// (proven by nx_eqsat_dsl_parity_test.nx). The row order MATCHES the inline call 627// order in nx_eqsat_saturate (add_zero, sub_self, add_self, and_self, or_zero, 628// mul_one, mul_pow2) so the merge sequence -- and thus best_node/cost/log -- is 629// identical. Returns the number of rows written (7). A caller wanting the new 630// xor_self rule appends row 7 itself (see nx_eqsat_dsl_set_row). 631func nx_eqsat_builtin_dsl_table(table: *NxDslRule) -> i64 { 632 // add_zero: (add x 0)==x. dual_order (const may be on either side); count every. 633 table[0].rule_id = NX_EQSAT_RULE_ADD_ZERO; table[0].lhs_op = NX_EQ_OP_ADD 634 table[0].a_kind = DSL_VAR; table[0].a_val = 0 635 table[0].b_kind = DSL_CONST; table[0].b_val = 0 636 table[0].rhs_kind = RHS_BIND_A; table[0].rhs_val = 0 637 table[0].side_cond = SC_NONE; table[0].count_mode = CNT_EVERY; table[0].dual_order = 1 638 // sub_self: (sub x x)==0. b is SAME_AS_A; rhs const 0; count every. 639 table[1].rule_id = NX_EQSAT_RULE_SUB_SELF; table[1].lhs_op = NX_EQ_OP_SUB 640 table[1].a_kind = DSL_VAR; table[1].a_val = 0 641 table[1].b_kind = DSL_SAME_AS_A; table[1].b_val = 0 642 table[1].rhs_kind = RHS_CONST; table[1].rhs_val = 0 643 table[1].side_cond = SC_NONE; table[1].count_mode = CNT_EVERY; table[1].dual_order = 0 644 // add_self: (add x x)==(shl x 1). b SAME_AS_A; rhs shl A by const 1. 645 table[2].rule_id = NX_EQSAT_RULE_ADD_SELF; table[2].lhs_op = NX_EQ_OP_ADD 646 table[2].a_kind = DSL_VAR; table[2].a_val = 0 647 table[2].b_kind = DSL_SAME_AS_A; table[2].b_val = 0 648 table[2].rhs_kind = RHS_SHL_A_BY_CONST; table[2].rhs_val = 1 649 table[2].side_cond = SC_NONE; table[2].count_mode = CNT_EVERY; table[2].dual_order = 0 650 // and_self: (and x x)==x. b SAME_AS_A; rhs bind A. 651 table[3].rule_id = NX_EQSAT_RULE_AND_SELF; table[3].lhs_op = NX_EQ_OP_AND 652 table[3].a_kind = DSL_VAR; table[3].a_val = 0 653 table[3].b_kind = DSL_SAME_AS_A; table[3].b_val = 0 654 table[3].rhs_kind = RHS_BIND_A; table[3].rhs_val = 0 655 table[3].side_cond = SC_NONE; table[3].count_mode = CNT_EVERY; table[3].dual_order = 0 656 // or_zero: (or x 0)==x. dual_order; count every. 657 table[4].rule_id = NX_EQSAT_RULE_OR_ZERO; table[4].lhs_op = NX_EQ_OP_OR 658 table[4].a_kind = DSL_VAR; table[4].a_val = 0 659 table[4].b_kind = DSL_CONST; table[4].b_val = 0 660 table[4].rhs_kind = RHS_BIND_A; table[4].rhs_val = 0 661 table[4].side_cond = SC_NONE; table[4].count_mode = CNT_EVERY; table[4].dual_order = 1 662 // mul_one: (mul x 1)==x. RIGHT-const only (matches inline: only k1 checked). 663 table[5].rule_id = NX_EQSAT_RULE_MUL_ONE; table[5].lhs_op = NX_EQ_OP_MUL 664 table[5].a_kind = DSL_VAR; table[5].a_val = 0 665 table[5].b_kind = DSL_CONST; table[5].b_val = 1 666 table[5].rhs_kind = RHS_BIND_A; table[5].rhs_val = 0 667 table[5].side_cond = SC_NONE; table[5].count_mode = CNT_EVERY; table[5].dual_order = 0 668 // mul_pow2: (mul x 2^k)==(shl x k) for 1<=k<W. dual_order (const either side); 669 // side_cond = the k<W power-of-two guard; count only REAL merges (converges). 670 table[6].rule_id = NX_EQSAT_RULE_MUL_POW2; table[6].lhs_op = NX_EQ_OP_MUL 671 table[6].a_kind = DSL_VAR; table[6].a_val = 0 672 table[6].b_kind = DSL_CONST; table[6].b_val = 0 // b_val unused (matched by SC) 673 table[6].rhs_kind = RHS_SHL_A_BY_LOG2B; table[6].rhs_val = 0 674 table[6].side_cond = SC_POW2_B_KLTW; table[6].count_mode = CNT_REAL_MERGE; table[6].dual_order = 1 675 return 7 676} 677 678// Author one DSL row (used to append the new xor_self rule as DATA). 679func nx_eqsat_dsl_set_row(table: *NxDslRule, i: i64, rule_id: i64, lhs_op: i64, 680 a_kind: i64, a_val: i64, b_kind: i64, b_val: i64, 681 rhs_kind: i64, rhs_val: i64, 682 side_cond: i64, count_mode: i64, dual_order: i64) -> i64 { 683 table[i].rule_id = rule_id 684 table[i].lhs_op = lhs_op 685 table[i].a_kind = a_kind 686 table[i].a_val = a_val 687 table[i].b_kind = b_kind 688 table[i].b_val = b_val 689 table[i].rhs_kind = rhs_kind 690 table[i].rhs_val = rhs_val 691 table[i].side_cond = side_cond 692 table[i].count_mode = count_mode 693 table[i].dual_order = dual_order 694 return 0 695} 696 697// splitmix step (the same mix nx_hmap_hash applies to its key) -- one round. 698func nx_eqsat_mix(h: i64) -> i64 { 699 return (h * 0x9E3779B97F4A7C15) & 0xFFFFFFFFFFFFFFFF 700} 701 702// Fingerprint of an e-node = (op, payload, canonical kid0..2) folded to one i64. 703// +1/+2 sentinel offsets: the -1 "unused child" never aliases class 0, and CONST 0 704// vs CONST 1 differ via payload. Clamped away from 0/-1 which nx_sketch_hash_map 705// reserves as EMPTY/TOMBSTONE keys. Identity is ALWAYS re-verified against the node 706// array on a hashcons hit, so a 64-bit collision can never return/merge a wrong class. 707func nx_eqsat_fingerprint(op: i64, payload: i64, kc0: i64, kc1: i64, kc2: i64) -> i64 { 708 var fp: i64 = nx_eqsat_mix(op + 1) 709 fp = nx_eqsat_mix(fp ^ (payload + 1)) 710 fp = nx_eqsat_mix(fp ^ (kc0 + 2)) 711 fp = nx_eqsat_mix(fp ^ (kc1 + 2)) 712 fp = nx_eqsat_mix(fp ^ (kc2 + 2)) 713 if fp == 0 { fp = 0x1234567 } // never the EMPTY sentinel 714 if fp == 0 - 1 { fp = 0x7654321 } // never the TOMBSTONE sentinel 715 return fp 716} 717 718// Re-verify that node `nidx` really is the e-node (op,payload,kc0..2) -- the node 719// array is the source of truth; the hashcons is only a hint. Children compared by 720// CANONICAL class so union re-pointing is followed. Returns 1 iff a genuine match. 721func nx_eqsat_node_matches(g: *NxEGraph, nidx: i64, op: i64, payload: i64, 722 kc0: i64, kc1: i64, kc2: i64) -> i64 { 723 if g.nodes[nidx].op != op { return 0 } 724 if g.nodes[nidx].payload != payload { return 0 } 725 let arity: i64 = nx_eqsat_arity_for(op) 726 if g.nodes[nidx].n_kids != arity { return 0 } 727 var matched: i64 = 1 728 if arity >= 1 { if nx_eqsat_find(g, g.nodes[nidx].kid0) != kc0 { matched = 0 } } 729 if arity >= 2 { if nx_eqsat_find(g, g.nodes[nidx].kid1) != kc1 { matched = 0 } } 730 if arity >= 3 { if nx_eqsat_find(g, g.nodes[nidx].kid2) != kc2 { matched = 0 } } 731 return matched 732} 733 734// Record a parent edge (node `nidx` references canonical class `child_cls`). On 735// overflow set par_overflow -> rebuild then enumerates parents by full node scan 736// (provably complete, just slower). No-op when the parent index is OFF. 737func nx_eqsat_add_parent(g: *NxEGraph, nidx: i64, child_cls: i64) -> i64 { 738 if (g.par_node as i64) == 0 { return 0 } 739 if child_cls < 0 { return 0 } 740 if g.n_par >= g.cap_par { g.par_overflow = 1; return 0 - NX_EQSAT_OVERFLOW } 741 g.par_node[g.n_par] = nidx 742 g.par_cls[g.n_par] = child_cls 743 g.n_par = g.n_par + 1 744 return NX_EQSAT_OK 745} 746 747// Push a dirty canonical class onto the rebuild worklist (dedup is unnecessary -- 748// rebuild re-canonicalizes + the union strictly reduces class count, so it 749// converges regardless). No-op when rebuild is OFF or the worklist is full 750// (rebuild loops to fixpoint anyway because every union re-pushes its winner). 751func nx_eqsat_push_dirty(g: *NxEGraph, cls: i64) -> i64 { 752 if g.rebuild_on != 1 { return 0 } 753 if (g.worklist as i64) == 0 { return 0 } 754 if g.n_work >= g.cap_work { return 0 } 755 g.worklist[g.n_work] = cls 756 g.n_work = g.n_work + 1 757 return NX_EQSAT_OK 758} 759 760// Allocate a fresh e-class with one initial node. 761func nx_eqsat_new_class(g: *NxEGraph, node_idx: i64, initial_cost: i64) -> i64 { 762 if g.n_classes >= g.cap_classes { return 0 - NX_EQSAT_OVERFLOW } 763 let id: i64 = g.n_classes 764 g.classes[id].canon = id // points to self until merged 765 g.classes[id].cost = initial_cost 766 g.classes[id].best_node = node_idx 767 // MAKE (leaf): a class is born known-const iff its sole node is a CONST literal; 768 // every other class is born NX_CF_NONE (unknown). ALWAYS written (so the field is 769 // never uninitialized garbage); only READ when cf_on==1, so the cf_on==0 770 // trajectory is byte-identical (this store is dead state on the page-rounded 771 // class array). This is the egg `make(Num(n)) = Some(n)` base case. 772 if g.nodes[node_idx].op == NX_EQ_OP_CONST { 773 g.classes[id].cf_val = g.nodes[node_idx].payload 774 } else { 775 g.classes[id].cf_val = NX_CF_NONE 776 } 777 g.n_classes = id + 1 778 g.nodes[node_idx].eclass = id 779 return id 780} 781 782// ===== Union-find: find canonical e-class id ================================================= 783func nx_eqsat_find(g: *NxEGraph, id: i64) -> i64 { 784 var cur: i64 = id 785 while g.classes[cur].canon != cur { 786 cur = g.classes[cur].canon 787 } 788 // Path-compress (single-step; full compression is O(alpha) -- ok for V1) 789 g.classes[id].canon = cur 790 return cur 791} 792 793// Merge two e-classes, CITING the rule that justifies the merge. Cheaper class 794// wins; the more-expensive one repoints to the cheaper. This is the SINGLE 795// chokepoint that mutates canon, so logging the rule-id HERE (only on a real 796// merge, ra!=rb) makes a provenance bypass structurally impossible: no class can 797// be merged without a rule-id passing through this function. Logging is a no-op 798// when prov is OFF, so existing behavior is byte-identical. 799func nx_eqsat_union_cited(g: *NxEGraph, a: i64, b: i64, rule_id: i64) -> i64 { 800 let ra: i64 = nx_eqsat_find(g, a) 801 let rb: i64 = nx_eqsat_find(g, b) 802 if ra == rb { return ra } 803 nx_eqsat_log_rule(g, rule_id) 804 // JOIN (egg Analysis::merge), read BEFORE the canon re-point so both operands are 805 // still roots. Lattice-meet the two classes' const values; the meet is stored 806 // into the post-merge WINNER below (trap-1: write the const to the survivor that 807 // find() returns, never to a stale role). Gated on cf_on==1 so the cf_on==0 808 // trajectory is byte-identical. CONTRADICTION (two unequal known consts in one 809 // class) => fail-CLOSED SOFT poison (cf_poison=1), NOT sys_exit: a sound graph 810 // never reaches this, and a library must degrade gracefully (Global Rule #14) 811 // rather than crash its host. The meet only RISES (NONE->known, never falls), so 812 // the const propagates along union/congruence edges with no rescan (the genuine 813 // new JOIN capability over the old implicit-best_node passive lattice). 814 var meet: i64 = NX_CF_NONE 815 if g.cf_on == 1 { 816 let va: i64 = g.classes[ra].cf_val 817 let vb: i64 = g.classes[rb].cf_val 818 if va == NX_CF_NONE { meet = vb } 819 else { if vb == NX_CF_NONE { meet = va } 820 else { if va == vb { meet = va } 821 else { g.cf_poison = 1; meet = va } } } // contradiction: soft-poison 822 } 823 if g.classes[ra].cost <= g.classes[rb].cost { 824 g.classes[rb].canon = ra 825 if g.cf_on == 1 { g.classes[ra].cf_val = meet } // store meet into the WINNER (ra) 826 // MEET: a class merged -> its parents may now be congruent. Mark the 827 // surviving (winner) canonical class dirty for the deferred rebuild. 828 // No-op when rebuild is OFF, so the default path is byte-identical. 829 nx_eqsat_push_dirty(g, ra) 830 // INCREMENTAL MATCH (enqueue (b)): a merge changed a child's canonical class, 831 // so parents f(...child...) may now newly match a rule. We do NOT enumerate the 832 // parent index PER UNION here (that is O(n_par) per merge -> quadratic when a 833 // rebuild fires many congruence merges, the measured regression). Instead we set 834 // a single "merge happened" flag (mwl_overflow doubles as the dirty signal) and 835 // nx_eqsat_saturate re-matches the node frontier ONCE per outer iteration -- a 836 // single O(n) sweep, never per-union. No-op when match_on!=1. 837 if g.match_on == 1 { g.mwl_overflow = 1 } 838 return ra 839 } 840 g.classes[ra].canon = rb 841 if g.cf_on == 1 { g.classes[rb].cf_val = meet } // store meet into the WINNER (rb) 842 nx_eqsat_push_dirty(g, rb) 843 if g.match_on == 1 { g.mwl_overflow = 1 } 844 return rb 845} 846 847// Legacy 2-arg union (signature unchanged for all existing callers). Cites 848// NX_EQSAT_RULE_NONE -- which is NOT in any proven-sound set, so a merge made 849// through this back door logs an un-sound sentinel and POISONS any certificate 850// (fail-closed: an unlogged-reason merge can never silently certify). 851func nx_eqsat_union(g: *NxEGraph, a: i64, b: i64) -> i64 { 852 return nx_eqsat_union_cited(g, a, b, NX_EQSAT_RULE_NONE) 853} 854 855// ===== Cost model ================================================= 856// 857// Per-op cost. RV64IM micro-architecture approximate: 858// const/var: 0 859// add/sub/and/or/xor/shl/shr/sar/neg/not: 1 860// mul: 3 861// div/rem: 30 (non-restoring divider average) 862// eq/lt/ltu/select: 1 863// popcnt: 2 (1 if silicon has Zbb) 864// Tunable per silicon-target-generation; refined as silicon-feedback 865// loop (rv64im_min_hot_report.nx) feeds back measured cycle counts. 866 867func nx_eqsat_op_cost(op: i64) -> i64 { 868 if op == NX_EQ_OP_CONST { return 0 } 869 if op == NX_EQ_OP_VAR { return 0 } 870 if op == NX_EQ_OP_MUL { return 3 } 871 if op == NX_EQ_OP_DIV { return 30 } 872 if op == NX_EQ_OP_REM { return 30 } 873 if op == NX_EQ_OP_POPCNT { return 2 } 874 return 1 875} 876 877// Compute the cost of an expression rooted at e-class id by 878// summing op cost + child best-costs. Recursive; bounded by 879// e-graph depth. 880func nx_eqsat_class_cost(g: *NxEGraph, class_id: i64) -> i64 { 881 let canon: i64 = nx_eqsat_find(g, class_id) 882 let node_idx: i64 = g.classes[canon].best_node 883 let op: i64 = g.nodes[node_idx].op 884 let arity: i64 = nx_eqsat_arity_for(op) 885 var cost: i64 = nx_eqsat_op_cost(op) 886 if arity >= 1 { cost = cost + nx_eqsat_class_cost(g, g.nodes[node_idx].kid0) } 887 if arity >= 2 { cost = cost + nx_eqsat_class_cost(g, g.nodes[node_idx].kid1) } 888 if arity >= 3 { cost = cost + nx_eqsat_class_cost(g, g.nodes[node_idx].kid2) } 889 return cost 890} 891 892// ===== Node construction ================================================= 893// 894// Add an e-node + optionally create / find its e-class. V1: linear 895// search for existing node (no hash-cons yet); fine for graphs <1000 896// nodes. Future: per-op hash table for O(1) lookup. 897 898// ===== CONST-FOLD analysis (egg e-class analysis: constant folding) ========= 899// 900// THE LATTICE IS IMPLICIT IN best_node. A class is "CONST V" iff its canonical 901// best_node is a CONST node (op==NX_EQ_OP_CONST, payload==V) -- exact at class 902// creation (nx_eqsat_new_class sets best_node to the just-added node) and never 903// demoted for THIS minimal lattice (CONST cost==0 is strictly cheapest, line 904// "if op == NX_EQ_OP_CONST { return 0 }" in nx_eqsat_op_cost, so recompute_best 905// never moves a const class off its CONST node). So we read the analysis off 906// best_node -- zero new struct fields, zero per-class analysis buffer. 907// 908// Map an eqsat op to the gate-sim cell kind whose semantics are the PROVEN 909// semantics of that op. -1 => fold REFUSED (no faithful cell -> NEVER evaluated). 910// REFUSALS are the soundness boundary, each a confirmed silent-miscompile if mapped: 911// DIV/REM -- gsim has NO div/rem cell (would hit eval_cell's silent `return 0`). 912// SHR -- eqsat SHR is LOGICAL >>; gsim SHR cell is `a >> b` = ARITHMETIC in 913// NishiLang (verified: (0-8)>>1 == -4), so it disagrees for negatives. 914// SAR -- no SAR cell in gsim's eval switch. 915// NEG -- no gate kind; the NEG->NOT temptation is WRONG (NOT(a)=a^-1 != -a). 916// POPCNT -- gsim has no popcount cell. 917// SELECT -- maps to MUX, but SELECT is arity-3 and deferred (kept simple). 918func nx_eqsat_op_to_gate_kind(op: i64) -> i64 { 919 if op == NX_EQ_OP_ADD { return NX_GATE_KIND_ADD } // 2->7 920 if op == NX_EQ_OP_SUB { return NX_GATE_KIND_SUB } // 3->8 921 if op == NX_EQ_OP_MUL { return NX_GATE_KIND_MUL } // 4->9 922 if op == NX_EQ_OP_AND { return NX_GATE_KIND_AND } // 7->0 923 if op == NX_EQ_OP_OR { return NX_GATE_KIND_OR } // 8->1 924 if op == NX_EQ_OP_XOR { return NX_GATE_KIND_XOR } // 9->3 925 if op == NX_EQ_OP_NOT { return NX_GATE_KIND_NOT } // 10->2 926 if op == NX_EQ_OP_SHL { return NX_GATE_KIND_SHL } // 11->11 927 if op == NX_EQ_OP_EQ { return NX_GATE_KIND_EQ } // 15->14 928 if op == NX_EQ_OP_LT { return NX_GATE_KIND_LT } // 16->16 929 if op == NX_EQ_OP_LTU { return NX_GATE_KIND_LTU } // 17->17 930 return 0 - 1 // DIV/REM/SHR/SAR/NEG/POPCNT/SELECT/CONST/VAR -> REFUSED 931} 932 933// Read the const-lattice value of canonical class `cls` into out_v[0]. Returns 1 934// iff `cls` is a known constant (its canonical best_node is a CONST node). A VAR 935// class returns 0 (best_node.op==VAR; its payload is a var id, NEVER read as a 936// value -> "fold on VARs refused" structurally). best_node is re-canonicalized via 937// find first, so a stale (pre-rebuild) link cannot feed a wrong operand. 938func nx_eqsat_class_const(g: *NxEGraph, cls: i64, out_v: *i64) -> i64 { 939 let canon: i64 = nx_eqsat_find(g, cls) 940 let bn: i64 = g.classes[canon].best_node 941 if g.nodes[bn].op != NX_EQ_OP_CONST { return 0 } 942 if g.nodes[bn].n_kids != 0 { return 0 } // a CONST is arity-0 (re-verify shape) 943 out_v[0] = g.nodes[bn].payload 944 return 1 945} 946 947// O(1) const-lattice read via the EXPLICIT cf_val field (the incremental analysis 948// store) -- the fast path used by the add-time MAKE. Returns 1 + writes the const 949// into out_v[0] iff canonical class `cls` is known-const (cf_val != NX_CF_NONE). No 950// node-array walk (unlike nx_eqsat_class_const, which dereferences best_node). Only 951// meaningful when cf_on==1 (cf_val is maintained by MAKE/JOIN); read through find so 952// a merged/interior class self-canonicalizes to the survivor that carries the value 953// (trap-1 defense: never trust a non-canonical id's field). 954func nx_eqsat_class_cf(g: *NxEGraph, cls: i64, out_v: *i64) -> i64 { 955 let canon: i64 = nx_eqsat_find(g, cls) 956 let v: i64 = g.classes[canon].cf_val 957 if v == NX_CF_NONE { return 0 } 958 out_v[0] = v 959 return 1 960} 961 962// Drive const-fold for a NEWLY-created e-node (e-class new_cls, op, canonical kids 963// kc0..kc2). Returns 1 iff a fold fired (a CONST merge was performed), else 0. 964// Steps: (1) refuse CONST/VAR + any op with no faithful gate cell; (2) require 965// EVERY child class be a known constant (else no fire -> the backward-compat 966// guarantee on VAR graphs); (3) guard out-of-range shift amounts; (4) GUARD the 967// evaluator against its silent default (nx_gsim_kind_supported) so an unsupported 968// kind can NEVER fold a wrong 0; (5) evaluate via the PROVEN nx_gsim_eval_cell at 969// W=64 (i64 arithmetic IS mod-2^64 -> raw output is the exact W=64 value); (6) 970// merge new_cls with CONST(folded) through the single cited chokepoint. 971func nx_eqsat_try_constfold(g: *NxEGraph, new_cls: i64, op: i64, 972 kc0: i64, kc1: i64, kc2: i64) -> i64 { 973 if op == NX_EQ_OP_CONST { return 0 } 974 if op == NX_EQ_OP_VAR { return 0 } 975 let kind: i64 = nx_eqsat_op_to_gate_kind(op) 976 if kind < 0 { return 0 } // unmapped op -> REFUSED 977 // Defend the silent-default trap: only fold kinds the evaluator actually 978 // handles (reuse the simulator's own loud guard instead of bypassing it). 979 if nx_gsim_kind_supported(kind) != 1 { return 0 } 980 let arity: i64 = nx_eqsat_arity_for(op) 981 var v0: i64 = 0 982 var v1: i64 = 0 983 var v2: i64 = 0 984 let pv: *i64 = sys_mmap(8) as *i64 985 if arity >= 1 { if nx_eqsat_class_const(g, kc0, pv) != 1 { return 0 } v0 = pv[0] } 986 if arity >= 2 { if nx_eqsat_class_const(g, kc1, pv) != 1 { return 0 } v1 = pv[0] } 987 if arity >= 3 { if nx_eqsat_class_const(g, kc2, pv) != 1 { return 0 } v2 = pv[0] } 988 // shift-amount guard: an out-of-range shift is UB / not what i64 << does 989 // portably -> refuse rather than fold a value the proof never validated. 990 if op == NX_EQ_OP_SHL { if v1 < 0 { return 0 } if v1 >= NX_EQSAT_W { return 0 } } 991 let folded: i64 = nx_gsim_eval_cell(kind, v0, v1, v2) 992 let cst: i64 = nx_eqsat_add_const(g, folded) 993 if cst < 0 { return 0 } // allocation overflow -> no fold 994 nx_eqsat_union_cited(g, new_cls, cst, NX_EQSAT_RULE_CONSTFOLD) 995 return 1 996} 997 998// ===== INCREMENTAL FOLD (the egg MAKE/MODIFY fast path) ===================== 999// 1000// nx_eqsat_fold_value: the MAKE oracle as O(1) FIELD reads. Returns 1 + writes the 1001// folded constant to out_v[0] iff op is foldable (faithful gate cell + evaluator 1002// support, the EXISTING soundness boundary) AND every canonical child class is 1003// known-const via the explicit cf_val lattice (NO best_node node-array walk). This 1004// is the incremental analysis: instead of re-DISCOVERING constness by scanning the 1005// whole node array each saturate iteration (the old O(N*K*M) lapp), the const flows 1006// through cf_val and a fold is a constant-time field-read + one evaluator call. 1007// Refusals (kind<0, unsupported, SHL out-of-range, any non-const child) leave the 1008// node un-folded -- IDENTICAL to nx_eqsat_try_constfold so backward-compat holds. 1009// Writes the folded constant to out_v[0] iff foldable + all children known-const. 1010// Reads child cf_val DIRECTLY off the canonical class field (no scratch pointer, no 1011// node walk) so the hot path makes ZERO sys_mmap calls. NX_CF_NONE is the "unknown" 1012// sentinel; a child carrying it (a VAR-touching class) refuses the fold. 1013func nx_eqsat_fold_value(g: *NxEGraph, op: i64, kc0: i64, kc1: i64, kc2: i64, out_v: *i64) -> i64 { 1014 if op == NX_EQ_OP_CONST { return 0 } 1015 if op == NX_EQ_OP_VAR { return 0 } 1016 let kind: i64 = nx_eqsat_op_to_gate_kind(op) 1017 if kind < 0 { return 0 } 1018 if nx_gsim_kind_supported(kind) != 1 { return 0 } 1019 let arity: i64 = nx_eqsat_arity_for(op) 1020 var v0: i64 = 0 1021 var v1: i64 = 0 1022 var v2: i64 = 0 1023 if arity >= 1 { let c0: i64 = nx_eqsat_find(g, kc0); let x0: i64 = g.classes[c0].cf_val; if x0 == NX_CF_NONE { return 0 } v0 = x0 } 1024 if arity >= 2 { let c1: i64 = nx_eqsat_find(g, kc1); let x1: i64 = g.classes[c1].cf_val; if x1 == NX_CF_NONE { return 0 } v1 = x1 } 1025 if arity >= 3 { let c2: i64 = nx_eqsat_find(g, kc2); let x2: i64 = g.classes[c2].cf_val; if x2 == NX_CF_NONE { return 0 } v2 = x2 } 1026 if op == NX_EQ_OP_SHL { if v1 < 0 { return 0 } if v1 >= NX_EQSAT_W { return 0 } } 1027 out_v[0] = nx_gsim_eval_cell(kind, v0, v1, v2) 1028 return 1 1029} 1030 1031func nx_eqsat_add(g: *NxEGraph, op: i64, k0: i64, k1: i64, k2: i64, payload: i64) -> i64 { 1032 if nx_eqsat_op_is_valid(op) != 1 { return 0 - NX_EQSAT_BAD_OP } 1033 let arity: i64 = nx_eqsat_arity_for(op) 1034 // Check children for arity. 1035 if arity == 0 { if k0 != 0 - 1 { return 0 - NX_EQSAT_BAD_ARITY } } 1036 if arity == 1 { if k0 < 0 { return 0 - NX_EQSAT_BAD_ARITY } } 1037 if arity == 2 { if k0 < 0 { return 0 - NX_EQSAT_BAD_ARITY } } 1038 if arity == 3 { if k0 < 0 { return 0 - NX_EQSAT_BAD_ARITY } } 1039 1040 // CONST FAST PATH: when const-fold is armed (cf_on + cache, default linear-scan 1041 // store hc==0), the value->class cache IS the const hashcons -- an O(1) intern 1042 // replaces the O(n) linear node scan for EVERY const add (leaf + folded). This 1043 // removes the last O(n^2) term in an all-const build (the leaf consts the caller 1044 // adds directly). Discipline: enable_constfold is called before any const is 1045 // added (both gated callers do), so the cache sees every const from graph birth 1046 // -> hashcons-equivalent. A pre-enable/post-enable value mix would at worst make 1047 // a duplicate CONST node (same value, congruent, same folded value) -- harmless 1048 // (no soundness loss). cf_on==0 OR cache OFF OR meet-on(hc!=0) => skip => the 1049 // original linear-scan path runs => byte-identical for every existing caller. 1050 if op == NX_EQ_OP_CONST { if g.cf_on == 1 { if (g.cf_cache as i64) != 0 { if (g.hc as i64) == 0 { 1051 return nx_cf_intern_const(g, payload) 1052 } } } } 1053 1054 // Canonical children (shared by BOTH the linear-scan and hashcons paths). 1055 let kc0: i64 = if k0 >= 0 then nx_eqsat_find(g, k0) else 0 - 1 1056 let kc1: i64 = if k1 >= 0 then nx_eqsat_find(g, k1) else 0 - 1 1057 let kc2: i64 = if k2 >= 0 then nx_eqsat_find(g, k2) else 0 - 1 1058 1059 // ===== EARLY-FOLD FAST PATH (incremental MAKE + MODIFY) ================== 1060 // When const-fold is ON (and we are on the default linear-scan store, hc==0 -- 1061 // the MEET hashcons path keeps its own existing try_constfold hook below so that 1062 // organ is untouched), and EVERY canonical child is known-const via the O(1) 1063 // cf_val lattice, the result is a known constant. The OLD path would (a) linear- 1064 // SCAN the whole node array to hashcons the binop node, then (b) linear-SCAN 1065 // AGAIN inside add_const to hashcons the folded CONST -- two O(n) scans per node 1066 // = the measured ~10x build-phase lapp. Here we INTERN the folded const in O(1) 1067 // (the value cache = egg's interned Num) and create the binop node WITHOUT the 1068 // O(n) scan, because a foldable all-const binop ALWAYS collapses into the const 1069 // class anyway: even if a structurally-identical binop node already exists, both 1070 // denote the SAME value and merging the fresh one is idempotent/harmless (no 1071 // soundness loss -- congruence would have merged them too). The CONSTFOLD merge 1072 // still flows the single cited chokepoint => logged + membership-certifiable, 1073 // and the returned class find()s to the const (cost-0 winner) => extraction + 1074 // the cert test (find(top)==find(const), log has CONSTFOLD, best_node==CONST) 1075 // are byte-identical to the old eager path. This is the egg make-on-add + 1076 // modify-materialize collapsed to O(1) amortized -- the lapp-closing lever. 1077 if g.cf_on == 1 { if (g.hc as i64) == 0 { if (g.cf_scratch as i64) != 0 { 1078 let fv: *i64 = g.cf_scratch // graph-lifetime scratch -> NO per-node mmap 1079 if nx_eqsat_fold_value(g, op, kc0, kc1, kc2, fv) == 1 { 1080 // intern the folded const in O(1) (no node scan) 1081 let cst_f: i64 = nx_cf_intern_const(g, fv[0]) 1082 if cst_f >= 0 { 1083 // MODIFY-PRUNE (egg's `modify` retains only leaf nodes): when no one 1084 // is RECORDING the rewrite path (provenance OFF), there is no 1085 // observable difference between "create the binop node, fold-merge it 1086 // to the const, return the binop class" and "return the const class 1087 // directly" -- both yield a class that find()s to the SAME interned 1088 // CONST with the SAME extracted value. So we PRUNE the dominated binop 1089 // node + its union entirely (egg drops the non-leaf nodes too), which 1090 // is the lapp-closing lever: the all-const chain collapses to ~N leaf 1091 // CONSTs (no lingering binop nodes for recompute_best to rescan). This 1092 // is a pure speed prune, never a soundness change (the folded value is 1093 // identical, computed by the SAME proven oracle). 1094 if (g.prov as i64) == 0 { 1095 return cst_f 1096 } 1097 // CERTIFICATION PATH (provenance ON): keep the full cited MODIFY so the 1098 // CONSTFOLD merge is LOGGED + membership-certifiable. Allocate the binop 1099 // node WITHOUT the linear scan (it collapses into the const anyway: even 1100 // if a structurally-identical binop exists, both denote the same value 1101 // and merging the fresh one is idempotent/harmless -- congruence would 1102 // merge them too). The merge flows the single cited chokepoint and the 1103 // returned class find()s to the const (cost-0 winner) => the cert test 1104 // (find(top)==find(const), log has CONSTFOLD, best_node==CONST) holds. 1105 if g.n_nodes >= g.cap_nodes { return 0 - NX_EQSAT_OVERFLOW } 1106 let nf: i64 = g.n_nodes 1107 g.nodes[nf].op = op 1108 g.nodes[nf].n_kids = arity 1109 g.nodes[nf].kid0 = kc0 1110 g.nodes[nf].kid1 = kc1 1111 g.nodes[nf].kid2 = kc2 1112 g.nodes[nf].payload = payload 1113 g.n_nodes = nf + 1 1114 let icf: i64 = nx_eqsat_op_cost(op) 1115 let bcls: i64 = nx_eqsat_new_class(g, nf, icf) 1116 if bcls < 0 { return bcls } 1117 // MODIFY: materialize the equality through the cited chokepoint ONCE 1118 // (logged + certifiable). JOIN inside flows cf_val into the winner. 1119 nx_eqsat_union_cited(g, bcls, cst_f, NX_EQSAT_RULE_CONSTFOLD) 1120 return bcls 1121 } 1122 } 1123 } } } 1124 1125 // PATH A (MEET, hc != null): O(1)-avg hashcons lookup. The hashmap value is a 1126 // node-array INDEX; on a hit we re-VERIFY against the node array (a 64-bit 1127 // collision can never return a wrong class -> it degrades to allocate). The 1128 // returned id is the SAME canonical e-class id the linear scan would return. 1129 if (g.hc as i64) != 0 { 1130 let fp: i64 = nx_eqsat_fingerprint(op, payload, kc0, kc1, kc2) 1131 if nx_hmap_has(g.hc, fp) == 1 { 1132 let hit_idx: i64 = nx_hmap_get(g.hc, fp) 1133 if nx_eqsat_node_matches(g, hit_idx, op, payload, kc0, kc1, kc2) == 1 { 1134 return nx_eqsat_find(g, g.nodes[hit_idx].eclass) 1135 } 1136 // collision / stale key: fall through to allocate (never trust the hit). 1137 } 1138 if g.n_nodes >= g.cap_nodes { return 0 - NX_EQSAT_OVERFLOW } 1139 let nidx2: i64 = g.n_nodes 1140 g.nodes[nidx2].op = op 1141 g.nodes[nidx2].n_kids = arity 1142 g.nodes[nidx2].kid0 = kc0 1143 g.nodes[nidx2].kid1 = kc1 1144 g.nodes[nidx2].kid2 = kc2 1145 g.nodes[nidx2].payload = payload 1146 g.n_nodes = nidx2 + 1 1147 nx_hmap_put(g.hc, fp, nidx2) // intern (restore the hashcons invariant) 1148 // parent back-index: this node references each canonical child class. 1149 if arity >= 1 { nx_eqsat_add_parent(g, nidx2, kc0) } 1150 if arity >= 2 { nx_eqsat_add_parent(g, nidx2, kc1) } 1151 if arity >= 3 { nx_eqsat_add_parent(g, nidx2, kc2) } 1152 let ic2: i64 = nx_eqsat_op_cost(op) 1153 let newcls2: i64 = nx_eqsat_new_class(g, nidx2, ic2) 1154 // CONST-FOLD hook (opt-in; no-op + byte-identical when cf_on==0). Fires once 1155 // per DISTINCT node (hashcons-HIT path above returns before here). The folded 1156 // merge flows nx_eqsat_union_cited so it is logged + certifiable. We return 1157 // newcls2 (the new node's class), which the union may repoint to the const's 1158 // canonical class -- callers re-canonicalize via find, so this is correct. 1159 if g.cf_on == 1 { if newcls2 >= 0 { nx_eqsat_try_constfold(g, newcls2, op, kc0, kc1, kc2) } } 1160 return newcls2 1161 } 1162 1163 // PATH B (default, byte-identical): hash-cons by linear scan. Two nodes are 1164 // equal iff op + payload + canonical children agree. 1165 var i: i64 = 0 1166 while i < g.n_nodes { 1167 if g.nodes[i].op == op { 1168 if g.nodes[i].payload == payload { 1169 let n_arity: i64 = g.nodes[i].n_kids 1170 if n_arity == arity { 1171 var matched: i64 = 1 1172 if arity >= 1 { if nx_eqsat_find(g, g.nodes[i].kid0) != kc0 { matched = 0 } } 1173 if arity >= 2 { if nx_eqsat_find(g, g.nodes[i].kid1) != kc1 { matched = 0 } } 1174 if arity >= 3 { if nx_eqsat_find(g, g.nodes[i].kid2) != kc2 { matched = 0 } } 1175 if matched == 1 { 1176 return g.nodes[i].eclass 1177 } 1178 } 1179 } 1180 } 1181 i = i + 1 1182 } 1183 // Allocate a new node. 1184 if g.n_nodes >= g.cap_nodes { return 0 - NX_EQSAT_OVERFLOW } 1185 let nidx: i64 = g.n_nodes 1186 g.nodes[nidx].op = op 1187 g.nodes[nidx].n_kids = arity 1188 g.nodes[nidx].kid0 = kc0 1189 g.nodes[nidx].kid1 = kc1 1190 g.nodes[nidx].kid2 = kc2 1191 g.nodes[nidx].payload = payload 1192 g.n_nodes = nidx + 1 1193 // Allocate the e-class with initial cost from this single node. 1194 let init_cost: i64 = nx_eqsat_op_cost(op) 1195 let newcls: i64 = nx_eqsat_new_class(g, nidx, init_cost) 1196 // CONST-FOLD hook (opt-in; no-op + byte-identical when cf_on==0). Fires once per 1197 // DISTINCT node (the linear-scan HIT path above returns before here). Same single 1198 // cited chokepoint => logged + certifiable. 1199 if g.cf_on == 1 { if newcls >= 0 { nx_eqsat_try_constfold(g, newcls, op, kc0, kc1, kc2) } } 1200 return newcls 1201} 1202 1203// Convenience wrappers (omit unused children). 1204func nx_eqsat_add_const(g: *NxEGraph, value: i64) -> i64 { 1205 return nx_eqsat_add(g, NX_EQ_OP_CONST, 0 - 1, 0 - 1, 0 - 1, value) 1206} 1207func nx_eqsat_add_var(g: *NxEGraph, var_id: i64) -> i64 { 1208 return nx_eqsat_add(g, NX_EQ_OP_VAR, 0 - 1, 0 - 1, 0 - 1, var_id) 1209} 1210func nx_eqsat_add_binary(g: *NxEGraph, op: i64, a: i64, b: i64) -> i64 { 1211 return nx_eqsat_add(g, op, a, b, 0 - 1, 0) 1212} 1213func nx_eqsat_add_unary(g: *NxEGraph, op: i64, a: i64) -> i64 { 1214 return nx_eqsat_add(g, op, a, 0 - 1, 0 - 1, 0) 1215} 1216 1217// ===== Baseline rewrite rules ================================================= 1218// 1219// V1 ships ~10 canonical algebraic identities for RV64IM. Each is 1220// a hand-coded "if you see pattern X, union it with class Y". Real 1221// egg-style rule DSL is a future commit (parses rules from a text 1222// format); V1 keeps it inline for clarity. 1223 1224func nx_eqsat_apply_rule_add_zero(g: *NxEGraph) -> i64 { 1225 // (add x 0) == x 1226 var unions: i64 = 0 1227 var i: i64 = 0 1228 while i < g.n_nodes { 1229 if g.nodes[i].op == NX_EQ_OP_ADD { 1230 let k0: i64 = nx_eqsat_find(g, g.nodes[i].kid0) 1231 let k1: i64 = nx_eqsat_find(g, g.nodes[i].kid1) 1232 let k0_node: i64 = g.classes[k0].best_node 1233 let k1_node: i64 = g.classes[k1].best_node 1234 if g.nodes[k0_node].op == NX_EQ_OP_CONST { 1235 if g.nodes[k0_node].payload == 0 { 1236 nx_eqsat_union_cited(g, g.nodes[i].eclass, k1, NX_EQSAT_RULE_ADD_ZERO) 1237 unions = unions + 1 1238 } 1239 } 1240 if g.nodes[k1_node].op == NX_EQ_OP_CONST { 1241 if g.nodes[k1_node].payload == 0 { 1242 nx_eqsat_union_cited(g, g.nodes[i].eclass, k0, NX_EQSAT_RULE_ADD_ZERO) 1243 unions = unions + 1 1244 } 1245 } 1246 } 1247 i = i + 1 1248 } 1249 return unions 1250} 1251 1252func nx_eqsat_apply_rule_sub_self(g: *NxEGraph) -> i64 { 1253 // (sub x x) == 0 1254 var unions: i64 = 0 1255 var i: i64 = 0 1256 while i < g.n_nodes { 1257 if g.nodes[i].op == NX_EQ_OP_SUB { 1258 let k0: i64 = nx_eqsat_find(g, g.nodes[i].kid0) 1259 let k1: i64 = nx_eqsat_find(g, g.nodes[i].kid1) 1260 if k0 == k1 { 1261 let zero_class: i64 = nx_eqsat_add_const(g, 0) 1262 nx_eqsat_union_cited(g, g.nodes[i].eclass, zero_class, NX_EQSAT_RULE_SUB_SELF) 1263 unions = unions + 1 1264 } 1265 } 1266 i = i + 1 1267 } 1268 return unions 1269} 1270 1271func nx_eqsat_apply_rule_add_self(g: *NxEGraph) -> i64 { 1272 // (add x x) == (shl x 1) -- strength reduction 1273 var unions: i64 = 0 1274 var i: i64 = 0 1275 while i < g.n_nodes { 1276 if g.nodes[i].op == NX_EQ_OP_ADD { 1277 let k0: i64 = nx_eqsat_find(g, g.nodes[i].kid0) 1278 let k1: i64 = nx_eqsat_find(g, g.nodes[i].kid1) 1279 if k0 == k1 { 1280 let one: i64 = nx_eqsat_add_const(g, 1) 1281 let shifted: i64 = nx_eqsat_add_binary(g, NX_EQ_OP_SHL, k0, one) 1282 nx_eqsat_union_cited(g, g.nodes[i].eclass, shifted, NX_EQSAT_RULE_ADD_SELF) 1283 unions = unions + 1 1284 } 1285 } 1286 i = i + 1 1287 } 1288 return unions 1289} 1290 1291func nx_eqsat_apply_rule_and_self(g: *NxEGraph) -> i64 { 1292 // (and x x) == x -- idempotence 1293 var unions: i64 = 0 1294 var i: i64 = 0 1295 while i < g.n_nodes { 1296 if g.nodes[i].op == NX_EQ_OP_AND { 1297 let k0: i64 = nx_eqsat_find(g, g.nodes[i].kid0) 1298 let k1: i64 = nx_eqsat_find(g, g.nodes[i].kid1) 1299 if k0 == k1 { 1300 nx_eqsat_union_cited(g, g.nodes[i].eclass, k0, NX_EQSAT_RULE_AND_SELF) 1301 unions = unions + 1 1302 } 1303 } 1304 i = i + 1 1305 } 1306 return unions 1307} 1308 1309func nx_eqsat_apply_rule_or_zero(g: *NxEGraph) -> i64 { 1310 // (or x 0) == x 1311 var unions: i64 = 0 1312 var i: i64 = 0 1313 while i < g.n_nodes { 1314 if g.nodes[i].op == NX_EQ_OP_OR { 1315 let k0: i64 = nx_eqsat_find(g, g.nodes[i].kid0) 1316 let k1: i64 = nx_eqsat_find(g, g.nodes[i].kid1) 1317 let k0_node: i64 = g.classes[k0].best_node 1318 let k1_node: i64 = g.classes[k1].best_node 1319 if g.nodes[k0_node].op == NX_EQ_OP_CONST { 1320 if g.nodes[k0_node].payload == 0 { 1321 nx_eqsat_union_cited(g, g.nodes[i].eclass, k1, NX_EQSAT_RULE_OR_ZERO) 1322 unions = unions + 1 1323 } 1324 } 1325 if g.nodes[k1_node].op == NX_EQ_OP_CONST { 1326 if g.nodes[k1_node].payload == 0 { 1327 nx_eqsat_union_cited(g, g.nodes[i].eclass, k0, NX_EQSAT_RULE_OR_ZERO) 1328 unions = unions + 1 1329 } 1330 } 1331 } 1332 i = i + 1 1333 } 1334 return unions 1335} 1336 1337func nx_eqsat_apply_rule_mul_one(g: *NxEGraph) -> i64 { 1338 // (mul x 1) == x 1339 var unions: i64 = 0 1340 var i: i64 = 0 1341 while i < g.n_nodes { 1342 if g.nodes[i].op == NX_EQ_OP_MUL { 1343 let k0: i64 = nx_eqsat_find(g, g.nodes[i].kid0) 1344 let k1: i64 = nx_eqsat_find(g, g.nodes[i].kid1) 1345 let k1_node: i64 = g.classes[k1].best_node 1346 if g.nodes[k1_node].op == NX_EQ_OP_CONST { 1347 if g.nodes[k1_node].payload == 1 { 1348 nx_eqsat_union_cited(g, g.nodes[i].eclass, k0, NX_EQSAT_RULE_MUL_ONE) 1349 unions = unions + 1 1350 } 1351 } 1352 } 1353 i = i + 1 1354 } 1355 return unions 1356} 1357 1358// Operating width: (mul x 2^k)==(shl x k) is sound only for k in [0, W). At k>=W, 1359// 2^k mod 2^W == 0 so x*2^k collapses to 0 while x<<k is out of range -- proven 1360// UNSOUND by nx_rule_soundness (k>=W 2048/2048). The rule refuses to fire there. 1361const NX_EQSAT_W: i64 = 64 1362 1363func nx_eqsat_apply_rule_mul_pow2(g: *NxEGraph) -> i64 { 1364 // (mul x 2^k) == (shl x k) for 1<=k<W -- strength reduction (cost 3 -> 1). 1365 // Counts only REAL merges (idempotent re-application), so the saturation 1366 // loop can actually converge to NX_EQSAT_SATURATED -- the V1 rules count 1367 // every attempt, so they never report saturation; this one does it right. 1368 var unions: i64 = 0 1369 var i: i64 = 0 1370 while i < g.n_nodes { 1371 if g.nodes[i].op == NX_EQ_OP_MUL { 1372 let ka: i64 = nx_eqsat_find(g, g.nodes[i].kid0) 1373 let kb: i64 = nx_eqsat_find(g, g.nodes[i].kid1) 1374 let ka_node: i64 = g.classes[ka].best_node 1375 let kb_node: i64 = g.classes[kb].best_node 1376 var base: i64 = 0 - 1 1377 var cval: i64 = 0 1378 // const on the right (x * C); then const on the left (C * x) 1379 if g.nodes[kb_node].op == NX_EQ_OP_CONST { base = ka; cval = g.nodes[kb_node].payload } 1380 if g.nodes[ka_node].op == NX_EQ_OP_CONST { base = kb; cval = g.nodes[ka_node].payload } 1381 if base >= 0 { 1382 if cval > 1 { 1383 // power-of-two test + log2 by repeated halving (no bit-ops needed) 1384 var k: i64 = 0 1385 var t: i64 = cval 1386 var is_pow2: i64 = 1 1387 while t > 1 { 1388 if (t % 2) != 0 { is_pow2 = 0 } 1389 t = t / 2 1390 k = k + 1 1391 } 1392 // k<W GUARD: refuse the union where the identity is unsound. 1393 if is_pow2 == 1 { if k < NX_EQSAT_W { 1394 let kconst: i64 = nx_eqsat_add_const(g, k) 1395 let shifted: i64 = nx_eqsat_add_binary(g, NX_EQ_OP_SHL, base, kconst) 1396 if nx_eqsat_find(g, g.nodes[i].eclass) != nx_eqsat_find(g, shifted) { 1397 nx_eqsat_union_cited(g, g.nodes[i].eclass, shifted, NX_EQSAT_RULE_MUL_POW2) 1398 unions = unions + 1 1399 } 1400 } } 1401 } 1402 } 1403 } 1404 i = i + 1 1405 } 1406 return unions 1407} 1408 1409// ===== PER-NODE INLINE MATCHER (the incremental-match entry) ================= 1410// 1411// nx_eqsat_match_node_inline applies the SAME 7 inline rule bodies as the full-scan 1412// appliers above, but to a SINGLE node `nidx` (dispatched on its op), in the SAME 1413// FIXED ORDER the full-scan visits them per node within one saturate pass (add_zero 1414// -> sub_self -> add_self -> and_self -> or_zero -> mul_one -> mul_pow2). Each rule 1415// body is COPIED VERBATIM from its applier's loop body (same find/best_node reads, 1416// same union_cited cites, same CNT_EVERY vs CNT_REAL_MERGE counting), so a node that 1417// the full-scan would fire on fires here identically. Returns the union count for 1418// this node (summed across all applicable rules). This is the incremental analogue 1419// of one full-scan iteration restricted to the changed/new nodes. 1420func nx_eqsat_match_node_inline(g: *NxEGraph, nidx: i64) -> i64 { 1421 var unions: i64 = 0 1422 let op: i64 = g.nodes[nidx].op 1423 // ---- add_zero + add_self (op == ADD) ---- 1424 if op == NX_EQ_OP_ADD { 1425 let k0: i64 = nx_eqsat_find(g, g.nodes[nidx].kid0) 1426 let k1: i64 = nx_eqsat_find(g, g.nodes[nidx].kid1) 1427 // add_zero: (add x 0) == x (dual order, count every) 1428 let k0_node: i64 = g.classes[k0].best_node 1429 let k1_node: i64 = g.classes[k1].best_node 1430 if g.nodes[k0_node].op == NX_EQ_OP_CONST { 1431 if g.nodes[k0_node].payload == 0 { 1432 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, k1, NX_EQSAT_RULE_ADD_ZERO) 1433 unions = unions + 1 1434 } 1435 } 1436 if g.nodes[k1_node].op == NX_EQ_OP_CONST { 1437 if g.nodes[k1_node].payload == 0 { 1438 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, k0, NX_EQSAT_RULE_ADD_ZERO) 1439 unions = unions + 1 1440 } 1441 } 1442 // add_self: (add x x) == (shl x 1) (re-read finds: a prior union above may 1443 // have repointed the class, exactly as the full-scan re-finds per applier) 1444 let a0: i64 = nx_eqsat_find(g, g.nodes[nidx].kid0) 1445 let a1: i64 = nx_eqsat_find(g, g.nodes[nidx].kid1) 1446 if a0 == a1 { 1447 let one: i64 = nx_eqsat_add_const(g, 1) 1448 let shifted: i64 = nx_eqsat_add_binary(g, NX_EQ_OP_SHL, a0, one) 1449 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, shifted, NX_EQSAT_RULE_ADD_SELF) 1450 unions = unions + 1 1451 } 1452 } 1453 // ---- sub_self (op == SUB) ---- 1454 if op == NX_EQ_OP_SUB { 1455 let k0: i64 = nx_eqsat_find(g, g.nodes[nidx].kid0) 1456 let k1: i64 = nx_eqsat_find(g, g.nodes[nidx].kid1) 1457 if k0 == k1 { 1458 let zero_class: i64 = nx_eqsat_add_const(g, 0) 1459 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, zero_class, NX_EQSAT_RULE_SUB_SELF) 1460 unions = unions + 1 1461 } 1462 } 1463 // ---- and_self (op == AND) ---- 1464 if op == NX_EQ_OP_AND { 1465 let k0: i64 = nx_eqsat_find(g, g.nodes[nidx].kid0) 1466 let k1: i64 = nx_eqsat_find(g, g.nodes[nidx].kid1) 1467 if k0 == k1 { 1468 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, k0, NX_EQSAT_RULE_AND_SELF) 1469 unions = unions + 1 1470 } 1471 } 1472 // ---- or_zero (op == OR) ---- 1473 if op == NX_EQ_OP_OR { 1474 let k0: i64 = nx_eqsat_find(g, g.nodes[nidx].kid0) 1475 let k1: i64 = nx_eqsat_find(g, g.nodes[nidx].kid1) 1476 let k0_node: i64 = g.classes[k0].best_node 1477 let k1_node: i64 = g.classes[k1].best_node 1478 if g.nodes[k0_node].op == NX_EQ_OP_CONST { 1479 if g.nodes[k0_node].payload == 0 { 1480 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, k1, NX_EQSAT_RULE_OR_ZERO) 1481 unions = unions + 1 1482 } 1483 } 1484 if g.nodes[k1_node].op == NX_EQ_OP_CONST { 1485 if g.nodes[k1_node].payload == 0 { 1486 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, k0, NX_EQSAT_RULE_OR_ZERO) 1487 unions = unions + 1 1488 } 1489 } 1490 } 1491 // ---- mul_one + mul_pow2 (op == MUL) ---- 1492 if op == NX_EQ_OP_MUL { 1493 // mul_one: (mul x 1) == x (RIGHT-const only, exactly as the inline applier) 1494 let k0: i64 = nx_eqsat_find(g, g.nodes[nidx].kid0) 1495 let k1: i64 = nx_eqsat_find(g, g.nodes[nidx].kid1) 1496 let k1_node: i64 = g.classes[k1].best_node 1497 if g.nodes[k1_node].op == NX_EQ_OP_CONST { 1498 if g.nodes[k1_node].payload == 1 { 1499 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, k0, NX_EQSAT_RULE_MUL_ONE) 1500 unions = unions + 1 1501 } 1502 } 1503 // mul_pow2: (mul x 2^k) == (shl x k) for 1<=k<W (count REAL merges only) 1504 let ka: i64 = nx_eqsat_find(g, g.nodes[nidx].kid0) 1505 let kb: i64 = nx_eqsat_find(g, g.nodes[nidx].kid1) 1506 let ka_node: i64 = g.classes[ka].best_node 1507 let kb_node: i64 = g.classes[kb].best_node 1508 var base: i64 = 0 - 1 1509 var cval: i64 = 0 1510 if g.nodes[kb_node].op == NX_EQ_OP_CONST { base = ka; cval = g.nodes[kb_node].payload } 1511 if g.nodes[ka_node].op == NX_EQ_OP_CONST { base = kb; cval = g.nodes[ka_node].payload } 1512 if base >= 0 { 1513 if cval > 1 { 1514 var k: i64 = 0 1515 var t: i64 = cval 1516 var is_pow2: i64 = 1 1517 while t > 1 { 1518 if (t % 2) != 0 { is_pow2 = 0 } 1519 t = t / 2 1520 k = k + 1 1521 } 1522 if is_pow2 == 1 { if k < NX_EQSAT_W { 1523 let kconst: i64 = nx_eqsat_add_const(g, k) 1524 let shifted: i64 = nx_eqsat_add_binary(g, NX_EQ_OP_SHL, base, kconst) 1525 if nx_eqsat_find(g, g.nodes[nidx].eclass) != nx_eqsat_find(g, shifted) { 1526 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, shifted, NX_EQSAT_RULE_MUL_POW2) 1527 unions = unions + 1 1528 } 1529 } } 1530 } 1531 } 1532 } 1533 return unions 1534} 1535 1536// ===== GENERAL E-MATCHER (the egg data-driven rewrite path) ================= 1537// 1538// nx_eqsat_apply_dsl_rule applies ONE NxDslRule over the whole e-graph, replacing 1539// all 7 inline rule loops with a single data-interpreted matcher. It is proven 1540// BYTE-IDENTICAL to the inline path by nx_eqsat_dsl_parity_test.nx; the row order 1541// in nx_eqsat_builtin_dsl_table mirrors the inline call order so the merge 1542// sequence (and thus best_node/cost/provenance log) is the same. 1543// 1544// SAFETY (defends the wrong-class-binding trap): every bound variable is a 1545// CANONICAL class id via nx_eqsat_find (never best_node, which can be stale). A 1546// DSL_CONST slot uses best_node ONLY as a read-only value probe (exactly as the 1547// inline rules do, e.g. add_zero lines 553-556). The merge ALWAYS flows through 1548// the single chokepoint nx_eqsat_union_cited (provenance log + worklist-dirty + 1549// the RULE_NONE fail-closed poison are preserved -- no bypass). 1550// 1551// log2-by-repeated-halving helper for the mul_pow2 side-condition: returns the 1552// exponent k if `c` is 2^k with 1<k... else returns -1 (not a usable power-of-two). 1553// Mirrors the inline mul_pow2 loop (lines 879-882) so the guard is the SAME. 1554func nx_eqsat_dsl_log2_pow2(c: i64) -> i64 { 1555 if c <= 1 { return 0 - 1 } 1556 var k: i64 = 0 1557 var t: i64 = c 1558 var is_pow2: i64 = 1 1559 while t > 1 { 1560 if (t % 2) != 0 { is_pow2 = 0 } 1561 t = t / 2 1562 k = k + 1 1563 } 1564 if is_pow2 != 1 { return 0 - 1 } 1565 return k 1566} 1567 1568// Is the class `cls`'s best_node a CONST whose payload == want? (value probe only, 1569// exactly as the inline const-zero/const-one rules test g.classes[k].best_node.) 1570func nx_eqsat_dsl_class_is_const(g: *NxEGraph, cls: i64, want: i64) -> i64 { 1571 let bn: i64 = g.classes[cls].best_node 1572 if g.nodes[bn].op != NX_EQ_OP_CONST { return 0 } 1573 if g.nodes[bn].payload != want { return 0 } 1574 return 1 1575} 1576 1577// Read the const payload of canonical class `cls` into out_v[0] iff its best_node is 1578// a CONST (re-verified shape). Returns 1 on success, 0 otherwise. A CONST class's 1579// best_node is cost-stable (cost 0 is strictly cheapest, never displaced -- the same 1580// lattice invariant nx_eqsat_class_const relies on), so this read is safe to bind a 1581// rule variable from (trap-3 defense: ?i/?j come from CONST classes ONLY). 1582func nx_eqsat_dsl_const_val(g: *NxEGraph, cls: i64, out_v: *i64) -> i64 { 1583 let bn: i64 = g.classes[cls].best_node 1584 if g.nodes[bn].op != NX_EQ_OP_CONST { return 0 } 1585 if g.nodes[bn].n_kids != 0 { return 0 } 1586 out_v[0] = g.nodes[bn].payload 1587 return 1 1588} 1589 1590// ---- shape (d) shift-merge matcher: (shl (shl ?x ?i) ?j) -> (shl ?x (i+j)) ---- 1591// Given an OUTER node `nidx` already known to be op==SHL with canonical children 1592// k0=find(kid0), k1=find(kid1): require k1 to be a CONST (=?j) AND k0's best_node to 1593// be a structural SHL whose own kid1's canonical class is a CONST (=?i) and whose 1594// kid0 binds ?x = find(inner.kid0). Guard i,j>=0 and i+j<W (SC_SHIFT_MERGE_IJW: the 1595// boundary is UNSOUND). On a fire, instantiate (shl ?x (i+j)) and union the outer 1596// class with it citing rule_id, CNT_REAL_MERGE (find!=find) so it converges to 1597// SATURATED. Returns 1 iff a REAL merge happened, else 0. FAIL-CLOSED: any ambiguous 1598// binding (k0 not a SHL, inner kid1 not const, j not const, out-of-range) => no fire. 1599// 1600// trap-3 defense: ?i and ?j are bound from CONST classes ONLY (cost-0, best_node- 1601// stable); ?x is bound as a CANONICAL class id via find. We never read a shift amount 1602// off a non-const class's best_node, so a saturation-displaced best_node cannot feed 1603// a wrong shift amount. The inner SHL node is located via k0.best_node, and its kid1 1604// is re-canonicalized + re-verified CONST before its value is trusted. 1605func nx_eqsat_dsl_shift_merge(g: *NxEGraph, table: *NxDslRule, ri: i64, 1606 eclass: i64, k0: i64, k1: i64) -> i64 { 1607 // ?j: the OUTER shift amount must be a known constant. 1608 let pj: *i64 = sys_mmap(8) as *i64 1609 if nx_eqsat_dsl_const_val(g, k1, pj) != 1 { return 0 } 1610 let j: i64 = pj[0] 1611 // the OUTER's first child must itself be a structural SHL (the nested shape). 1612 let inner_n: i64 = g.classes[k0].best_node 1613 if g.nodes[inner_n].op != NX_EQ_OP_SHL { return 0 } 1614 if g.nodes[inner_n].n_kids != 2 { return 0 } 1615 // ?i: the INNER shift amount (its kid1's canonical class) must be a known const. 1616 let in_kc1: i64 = nx_eqsat_find(g, g.nodes[inner_n].kid1) 1617 let pi: *i64 = sys_mmap(8) as *i64 1618 if nx_eqsat_dsl_const_val(g, in_kc1, pi) != 1 { return 0 } 1619 let i_amt: i64 = pi[0] 1620 // ?x: the INNER's first child, bound as a CANONICAL class id (never a value). 1621 let bx: i64 = nx_eqsat_find(g, g.nodes[inner_n].kid0) 1622 // GUARD (SC_SHIFT_MERGE_IJW): i,j>=0 and i+j<W -- refuse the unsound boundary. 1623 if i_amt < 0 { return 0 } 1624 if j < 0 { return 0 } 1625 let sum: i64 = i_amt + j 1626 if sum >= NX_EQSAT_W { return 0 } 1627 // instantiate (shl ?x (i+j)) and merge (CNT_REAL_MERGE: count only real merges). 1628 let rhs: i64 = nx_eqsat_dsl_instantiate_rhs(g, table, ri, bx, sum) 1629 if nx_eqsat_find(g, eclass) != nx_eqsat_find(g, rhs) { 1630 nx_eqsat_union_cited(g, eclass, rhs, table[ri].rule_id) 1631 return 1 1632 } 1633 return 0 1634} 1635 1636// Instantiate the RHS template of rule `r` given the bound canonical class `bindA` 1637// and (for the mul_pow2 shape) the log2 shift `shamt`. Returns the RHS e-class id 1638// through the existing self-canonicalizing add* wrappers. RHS_BIND_A returns the 1639// bound class verbatim (the (..)==x identities); RHS_CONST adds a const; the SHL 1640// forms add (shl bindA <amt|log2>). No RHS interpreter -- a closed switch. 1641func nx_eqsat_dsl_instantiate_rhs(g: *NxEGraph, table: *NxDslRule, ri: i64, bindA: i64, shamt: i64) -> i64 { 1642 if table[ri].rhs_kind == RHS_BIND_A { return bindA } 1643 if table[ri].rhs_kind == RHS_CONST { return nx_eqsat_add_const(g, table[ri].rhs_val) } 1644 if table[ri].rhs_kind == RHS_SHL_A_BY_CONST { 1645 let kc: i64 = nx_eqsat_add_const(g, table[ri].rhs_val) 1646 return nx_eqsat_add_binary(g, NX_EQ_OP_SHL, bindA, kc) 1647 } 1648 if table[ri].rhs_kind == RHS_SHL_A_BY_LOG2B { 1649 let kc2: i64 = nx_eqsat_add_const(g, shamt) 1650 return nx_eqsat_add_binary(g, NX_EQ_OP_SHL, bindA, kc2) 1651 } 1652 // shift-merge: emit (shl bindA (i+j)) where the COMPUTED sum was handed in as 1653 // `shamt` (the matcher summed the two CONST shift amounts). Same single-SHL 1654 // shape as RHS_SHL_A_BY_LOG2B, just sourced from the i+j computation. 1655 if table[ri].rhs_kind == RHS_SHL_A_BY_IADDED_J { 1656 let kc3: i64 = nx_eqsat_add_const(g, shamt) 1657 return nx_eqsat_add_binary(g, NX_EQ_OP_SHL, bindA, kc3) 1658 } 1659 return 0 - 1 1660} 1661 1662func nx_eqsat_apply_dsl_rule(g: *NxEGraph, table: *NxDslRule, ri: i64) -> i64 { 1663 var unions: i64 = 0 1664 var i: i64 = 0 1665 while i < g.n_nodes { 1666 if g.nodes[i].op == table[ri].lhs_op { 1667 let k0: i64 = nx_eqsat_find(g, g.nodes[i].kid0) 1668 let k1: i64 = nx_eqsat_find(g, g.nodes[i].kid1) 1669 1670 // ---- shape (a) "x OP x": b_kind == DSL_SAME_AS_A ---- 1671 // sub_self / add_self / and_self (CNT_EVERY, byte-identical to inline) and 1672 // xor_self (CNT_REAL_MERGE so it converges to SATURATED -- the new rule's 1673 // (xor x x)==0 is idempotent, like mul_pow2). Single ordering; fire on 1674 // k0==k1. CNT_REAL_MERGE counts ONLY when the union actually merges two 1675 // distinct classes (find!=find), so re-application is a no-op for `total`. 1676 if table[ri].b_kind == DSL_SAME_AS_A { 1677 if k0 == k1 { 1678 let rhs: i64 = nx_eqsat_dsl_instantiate_rhs(g, table, ri, k0, 0) 1679 if table[ri].count_mode == CNT_REAL_MERGE { 1680 if nx_eqsat_find(g, g.nodes[i].eclass) != nx_eqsat_find(g, rhs) { 1681 nx_eqsat_union_cited(g, g.nodes[i].eclass, rhs, table[ri].rule_id) 1682 unions = unions + 1 1683 } 1684 } else { 1685 nx_eqsat_union_cited(g, g.nodes[i].eclass, rhs, table[ri].rule_id) 1686 unions = unions + 1 1687 } 1688 } 1689 } 1690 1691 // ---- shape (b) mul_pow2: SC_POW2_B_KLTW + RHS_SHL_A_BY_LOG2B ---- 1692 // Const on either side (left overrides right, as inline). One union per 1693 // node; guarded by pow2 & k<W; counts ONLY real merges (find!=find). 1694 if table[ri].side_cond == SC_POW2_B_KLTW { 1695 let k0n: i64 = g.classes[k0].best_node 1696 let k1n: i64 = g.classes[k1].best_node 1697 var base: i64 = 0 - 1 1698 var cval: i64 = 0 1699 if g.nodes[k1n].op == NX_EQ_OP_CONST { base = k0; cval = g.nodes[k1n].payload } 1700 if g.nodes[k0n].op == NX_EQ_OP_CONST { base = k1; cval = g.nodes[k0n].payload } 1701 if base >= 0 { 1702 let sh: i64 = nx_eqsat_dsl_log2_pow2(cval) 1703 if sh >= 0 { if sh < NX_EQSAT_W { 1704 let rhsm: i64 = nx_eqsat_dsl_instantiate_rhs(g, table, ri, base, sh) 1705 if nx_eqsat_find(g, g.nodes[i].eclass) != nx_eqsat_find(g, rhsm) { 1706 nx_eqsat_union_cited(g, g.nodes[i].eclass, rhsm, table[ri].rule_id) 1707 unions = unions + 1 1708 } 1709 } } 1710 } 1711 } 1712 1713 // ---- shape (c) const-slot rules: b_kind == DSL_CONST, no SC ---- 1714 // add_zero / or_zero (dual_order=1, fire both orderings) and mul_one 1715 // (dual_order=0, right-const only). The bound var is the OTHER child. 1716 if table[ri].b_kind == DSL_CONST { if table[ri].side_cond == SC_NONE { 1717 // RIGHT-const ordering: test k1 == CONST(b_val), bind A=k0. 1718 if nx_eqsat_dsl_class_is_const(g, k1, table[ri].b_val) == 1 { 1719 let rhsR: i64 = nx_eqsat_dsl_instantiate_rhs(g, table, ri, k0, 0) 1720 nx_eqsat_union_cited(g, g.nodes[i].eclass, rhsR, table[ri].rule_id) 1721 unions = unions + 1 1722 } 1723 // LEFT-const ordering (only when dual_order): test k0 == CONST, bind A=k1. 1724 if table[ri].dual_order == 1 { 1725 if nx_eqsat_dsl_class_is_const(g, k0, table[ri].b_val) == 1 { 1726 let rhsL: i64 = nx_eqsat_dsl_instantiate_rhs(g, table, ri, k1, 0) 1727 nx_eqsat_union_cited(g, g.nodes[i].eclass, rhsL, table[ri].rule_id) 1728 unions = unions + 1 1729 } 1730 } 1731 } } 1732 1733 // ---- shape (d) shift-merge: SC_SHIFT_MERGE_IJW (nested SHL-of-SHL) ---- 1734 // (shl (shl ?x ?i) ?j) -> (shl ?x (i+j)), ?i,?j CONST, i+j<W. The LHS first 1735 // child is itself a structural SHL (distinct from (a)/(b)/(c) leaf shapes); 1736 // a real merge converges to SATURATED (CNT_REAL_MERGE, idempotent). 1737 if table[ri].side_cond == SC_SHIFT_MERGE_IJW { 1738 unions = unions + nx_eqsat_dsl_shift_merge(g, table, ri, g.nodes[i].eclass, k0, k1) 1739 } 1740 } 1741 i = i + 1 1742 } 1743 return unions 1744} 1745 1746// Apply the whole DSL table in row order (the egg "run every rule" pass). Indexes 1747// g.dsl[ri] by struct stride (compiler-computed) -- no magic byte offset. 1748func nx_eqsat_apply_dsl_table(g: *NxEGraph) -> i64 { 1749 var total: i64 = 0 1750 var ri: i64 = 0 1751 while ri < g.n_dsl { 1752 total = total + nx_eqsat_apply_dsl_rule(g, g.dsl, ri) 1753 ri = ri + 1 1754 } 1755 return total 1756} 1757 1758// PER-NODE DSL matcher (incremental-match entry for the data-driven path). Applies 1759// EVERY row of g.dsl to the SINGLE node `nidx`, in row order, with the SAME shape-(a) 1760// /(b)/(c) bodies as nx_eqsat_apply_dsl_rule (copied verbatim, indexed by nidx instead 1761// of the scan cursor `i`). Returns the union count for this node. Defensive: no 1762// current organ combines meet (which arms match_on) with the DSL table, but supporting 1763// both keeps the incremental path complete if they are ever composed. 1764func nx_eqsat_match_node_dsl(g: *NxEGraph, nidx: i64) -> i64 { 1765 var unions: i64 = 0 1766 var ri: i64 = 0 1767 while ri < g.n_dsl { 1768 if g.nodes[nidx].op == g.dsl[ri].lhs_op { 1769 let k0: i64 = nx_eqsat_find(g, g.nodes[nidx].kid0) 1770 let k1: i64 = nx_eqsat_find(g, g.nodes[nidx].kid1) 1771 // shape (a) "x OP x" 1772 if g.dsl[ri].b_kind == DSL_SAME_AS_A { 1773 if k0 == k1 { 1774 let rhs: i64 = nx_eqsat_dsl_instantiate_rhs(g, g.dsl, ri, k0, 0) 1775 if g.dsl[ri].count_mode == CNT_REAL_MERGE { 1776 if nx_eqsat_find(g, g.nodes[nidx].eclass) != nx_eqsat_find(g, rhs) { 1777 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, rhs, g.dsl[ri].rule_id) 1778 unions = unions + 1 1779 } 1780 } else { 1781 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, rhs, g.dsl[ri].rule_id) 1782 unions = unions + 1 1783 } 1784 } 1785 } 1786 // shape (b) mul_pow2 1787 if g.dsl[ri].side_cond == SC_POW2_B_KLTW { 1788 let k0n: i64 = g.classes[k0].best_node 1789 let k1n: i64 = g.classes[k1].best_node 1790 var base: i64 = 0 - 1 1791 var cval: i64 = 0 1792 if g.nodes[k1n].op == NX_EQ_OP_CONST { base = k0; cval = g.nodes[k1n].payload } 1793 if g.nodes[k0n].op == NX_EQ_OP_CONST { base = k1; cval = g.nodes[k0n].payload } 1794 if base >= 0 { 1795 let sh: i64 = nx_eqsat_dsl_log2_pow2(cval) 1796 if sh >= 0 { if sh < NX_EQSAT_W { 1797 let rhsm: i64 = nx_eqsat_dsl_instantiate_rhs(g, g.dsl, ri, base, sh) 1798 if nx_eqsat_find(g, g.nodes[nidx].eclass) != nx_eqsat_find(g, rhsm) { 1799 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, rhsm, g.dsl[ri].rule_id) 1800 unions = unions + 1 1801 } 1802 } } 1803 } 1804 } 1805 // shape (c) const-slot rules 1806 if g.dsl[ri].b_kind == DSL_CONST { if g.dsl[ri].side_cond == SC_NONE { 1807 if nx_eqsat_dsl_class_is_const(g, k1, g.dsl[ri].b_val) == 1 { 1808 let rhsR: i64 = nx_eqsat_dsl_instantiate_rhs(g, g.dsl, ri, k0, 0) 1809 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, rhsR, g.dsl[ri].rule_id) 1810 unions = unions + 1 1811 } 1812 if g.dsl[ri].dual_order == 1 { 1813 if nx_eqsat_dsl_class_is_const(g, k0, g.dsl[ri].b_val) == 1 { 1814 let rhsL: i64 = nx_eqsat_dsl_instantiate_rhs(g, g.dsl, ri, k1, 0) 1815 nx_eqsat_union_cited(g, g.nodes[nidx].eclass, rhsL, g.dsl[ri].rule_id) 1816 unions = unions + 1 1817 } 1818 } 1819 } } 1820 // shape (d) shift-merge (nested SHL-of-SHL); same matcher as the full-scan. 1821 if g.dsl[ri].side_cond == SC_SHIFT_MERGE_IJW { 1822 unions = unions + nx_eqsat_dsl_shift_merge(g, g.dsl, ri, g.nodes[nidx].eclass, k0, k1) 1823 } 1824 } 1825 ri = ri + 1 1826 } 1827 return unions 1828} 1829 1830// Dispatch one node to the active matcher: DSL table when loaded, else the 7 inline 1831// rules. Mirrors nx_eqsat_saturate's full-scan branch so the incremental path fires 1832// exactly the rules the full-scan would. 1833func nx_eqsat_match_node(g: *NxEGraph, nidx: i64) -> i64 { 1834 if (g.dsl as i64) != 0 { return nx_eqsat_match_node_dsl(g, nidx) } 1835 return nx_eqsat_match_node_inline(g, nidx) 1836} 1837 1838// ===== MEET: deferred congruence rebuild (egg's "rebuild") ================== 1839// 1840// Restore the hashcons invariant after a batch of unions and propagate the 1841// congruence closure: re-canonicalize every e-node's children, re-fingerprint it, 1842// and if a DISTINCT node is already interned under that fingerprint then the two 1843// are CONGRUENT (same op, same payload, canonically-equal children) and get merged 1844// -- ALWAYS through nx_eqsat_union_cited citing NX_EQSAT_RULE_CONGRUENCE (never the 1845// 2-arg back door). Each such union re-dirties classes, so we loop to FIXPOINT. 1846// Convergence: every congruence union strictly reduces the live class count 1847// (union-find monotone), so the worklist empties; guard_max bounds it like 1848// nx_eqsat_recompute_best. Returns the number of congruence unions performed. 1849// 1850// Enumeration: this landing scans ALL nodes per drain pass (provably COMPLETE -- 1851// it cannot miss a congruent parent), and the re-canonicalization writes the new 1852// canonical children back into the node + re-interns under the new fingerprint 1853// (deleting the stale key first), so the hashcons never points at a dead 1854// representative. The parent back-index (par_node/par_cls) is maintained for a 1855// future incremental variant; correctness here does not depend on it. 1856func nx_eqsat_rebuild(g: *NxEGraph) -> i64 { 1857 if g.rebuild_on != 1 { return 0 } 1858 if (g.hc as i64) == 0 { return 0 } 1859 var cong_unions: i64 = 0 1860 var guard: i64 = 0 1861 let guard_max: i64 = g.n_nodes + g.n_classes + 8 1862 // Drain to fixpoint: while there is dirt OR a pass produced a new merge. 1863 var more: i64 = 1 1864 while more == 1 { 1865 more = 0 1866 guard = guard + 1 1867 if guard > guard_max { return cong_unions } 1868 g.n_work = 0 // consume the current dirt; new merges re-arm `more` 1869 // One complete pass over the node array: re-canonicalize + re-intern, and 1870 // congruence-merge any two distinct nodes that collapse to one fingerprint. 1871 var n: i64 = 0 1872 while n < g.n_nodes { 1873 let op: i64 = g.nodes[n].op 1874 let arity: i64 = nx_eqsat_arity_for(op) 1875 let payload: i64 = g.nodes[n].payload 1876 // current (possibly stale) canonical children of THIS node 1877 let nk0: i64 = if arity >= 1 then nx_eqsat_find(g, g.nodes[n].kid0) else 0 - 1 1878 let nk1: i64 = if arity >= 2 then nx_eqsat_find(g, g.nodes[n].kid1) else 0 - 1 1879 let nk2: i64 = if arity >= 3 then nx_eqsat_find(g, g.nodes[n].kid2) else 0 - 1 1880 // re-canonicalize the children in place (so future finds are O(1) here) 1881 if arity >= 1 { g.nodes[n].kid0 = nk0 } 1882 if arity >= 2 { g.nodes[n].kid1 = nk1 } 1883 if arity >= 3 { g.nodes[n].kid2 = nk2 } 1884 let fp: i64 = nx_eqsat_fingerprint(op, payload, nk0, nk1, nk2) 1885 if nx_hmap_has(g.hc, fp) == 1 { 1886 let other: i64 = nx_hmap_get(g.hc, fp) 1887 // re-VERIFY identity (collision-proof) AND that `other` is a 1888 // DISTINCT node in a DIFFERENT canonical class than `n`. 1889 if other != n { 1890 if nx_eqsat_node_matches(g, other, op, payload, nk0, nk1, nk2) == 1 { 1891 let cn: i64 = nx_eqsat_find(g, g.nodes[n].eclass) 1892 let co: i64 = nx_eqsat_find(g, g.nodes[other].eclass) 1893 if cn != co { 1894 // CONGRUENT: merge through the cited chokepoint ONLY. 1895 nx_eqsat_union_cited(g, g.nodes[n].eclass, 1896 g.nodes[other].eclass, 1897 NX_EQSAT_RULE_CONGRUENCE) 1898 cong_unions = cong_unions + 1 1899 more = 1 1900 } 1901 } 1902 } 1903 } 1904 // restore/refresh the hashcons invariant: this fingerprint maps to a 1905 // live representative for this canonical shape. Re-key (delete stale 1906 // then put) so dead representatives never accumulate. 1907 nx_hmap_remove(g.hc, fp) 1908 nx_hmap_put(g.hc, fp, n) 1909 n = n + 1 1910 } 1911 // If the union side-effect pushed fresh dirt, drain again. 1912 if g.n_work > 0 { more = 1 } 1913 } 1914 return cong_unions 1915} 1916 1917// ===== Saturation loop ================================================= 1918// 1919// Apply every rule in fixed order; repeat until no rule produces a 1920// new union OR step budget exhausted. V1 is sequential; egg's 1921// concurrent variant ships when ZERO_TO_ADVANCED.md M2 fleshes out. 1922 1923func nx_eqsat_saturate(g: *NxEGraph, max_iters: i64) -> i64 { 1924 // ===== INCREMENTAL PATH (worklist-driven; match_on==1, auto-armed by enable_meet) = 1925 // Removes the per-iteration full node scan while QUIESCENT: each outer iteration 1926 // matches rules ONLY against (a) the rising node frontier [next_node, n_nodes) -- 1927 // which captures EVERY newly-added node (initial build + RHS/fold nodes created 1928 // during firing) with zero per-alloc-site hooks. (b) When a merge changed some 1929 // node's canonical children (union_cited sets mwl_overflow), the NEXT iteration 1930 // re-matches the full node set ONCE -- a single O(n) sweep that is a SUPERSET of 1931 // every changed parent (provably complete, never the O(n_par)-per-union quadratic). 1932 // After matching, the congruence rebuild drains exactly as the full-scan path. 1933 // `total` accumulates the SAME real merges, so the `total==0 => SATURATED` return 1934 // fires at the equivalent fixpoint (same return code). Complete (egg rebuild 1935 // theorem -- a shallow-LHS match becomes newly-possible only on node-add (a) or a 1936 // child re-canonicalization (b), both covered) + bounded (finite unions, union-find 1937 // monotone, guarded by max_iters). The genuine save: a graph that reaches quiescence 1938 // and then only grows by a few RHS nodes re-matches those FEW, not all n every iter. 1939 if g.match_on == 1 { 1940 // next_node = the new-node frontier cursor (enqueue (a)): every node from here 1941 // to n_nodes is freshly added (initial build on the first iteration, or RHS/ 1942 // fold nodes created during firing) and has not yet been matched. 1943 var next_node: i64 = 0 1944 var iter_i: i64 = 0 1945 while iter_i < max_iters { 1946 var total: i64 = 0 1947 // enqueue (b): mwl_overflow is set by union_cited whenever a class merged 1948 // this run -> some node's canonical children changed, so its parents may now 1949 // match a rule. Rather than enumerate the parent index PER union (O(n_par) 1950 // per merge => quadratic, the measured regression), we re-match the FULL node 1951 // set ONCE here (a single O(n) sweep that is a superset of every changed 1952 // parent -- provably complete, mirroring the full-scan's own per-iteration 1953 // node walk). When NO merge is pending we match ONLY the new-node frontier 1954 // (the genuine incremental save). next_node is reset so the re-scan also 1955 // re-covers the frontier. 1956 if g.mwl_overflow == 1 { 1957 g.mwl_overflow = 0 1958 var fn: i64 = 0 1959 while fn < g.n_nodes { total = total + nx_eqsat_match_node(g, fn); fn = fn + 1 } 1960 next_node = g.n_nodes 1961 } else { 1962 while next_node < g.n_nodes { 1963 let nx: i64 = next_node 1964 next_node = next_node + 1 1965 total = total + nx_eqsat_match_node(g, nx) 1966 } 1967 } 1968 // congruence rebuild (unchanged); its unions set mwl_overflow via the 1969 // union (b)-hook so the NEXT iteration re-scans. Counted into total so a 1970 // congruence-only round does not report premature SATURATED. 1971 total = total + nx_eqsat_rebuild(g) 1972 // True fixpoint: nothing matched/merged, no pending (b)-dirt, no new nodes. 1973 if total == 0 { if g.mwl_overflow == 0 { if next_node >= g.n_nodes { 1974 return NX_EQSAT_SATURATED 1975 } } } 1976 iter_i = iter_i + 1 1977 } 1978 return 0 - NX_EQSAT_STEP_BUDGET 1979 } 1980 // ===== FULL-SCAN PATH (verbatim; match_on==0 for every existing caller + the 6 1981 // non-meet certs) -- byte-identical by construction. ============================ 1982 var iter: i64 = 0 1983 while iter < max_iters { 1984 var total: i64 = 0 1985 // DSL path (egg data-driven) when a rule table is loaded; else the verbatim 1986 // 7 inline calls. g.dsl is null for every existing caller + the four gated 1987 // organs => the inline else-branch runs => byte-identical by construction. 1988 if (g.dsl as i64) != 0 { 1989 total = total + nx_eqsat_apply_dsl_table(g) 1990 } else { 1991 total = total + nx_eqsat_apply_rule_add_zero(g) 1992 total = total + nx_eqsat_apply_rule_sub_self(g) 1993 total = total + nx_eqsat_apply_rule_add_self(g) 1994 total = total + nx_eqsat_apply_rule_and_self(g) 1995 total = total + nx_eqsat_apply_rule_or_zero(g) 1996 total = total + nx_eqsat_apply_rule_mul_one(g) 1997 total = total + nx_eqsat_apply_rule_mul_pow2(g) 1998 } 1999 // MEET: after the 7 rules, drain the congruence rebuild to a fixpoint and 2000 // count its unions into `total` so a congruence-only round does NOT report 2001 // premature SATURATED. No-op (returns 0) when rebuild is OFF -> the loop is 2002 // byte-identical for every existing caller + the four gated organs. 2003 total = total + nx_eqsat_rebuild(g) 2004 if total == 0 { return NX_EQSAT_SATURATED } 2005 iter = iter + 1 2006 } 2007 return 0 - NX_EQSAT_STEP_BUDGET 2008} 2009 2010// ===== Extraction ================================================= 2011// 2012// Pick the lowest-cost representative from each e-class. V1 reads 2013// the cached `cost` + `best_node` fields; future variant runs 2014// dataflow over the saturated graph for tighter cost (egg's 2015// "Extractor" pattern). 2016 2017func nx_eqsat_extract_best_node(g: *NxEGraph, class_id: i64) -> i64 { 2018 let canon: i64 = nx_eqsat_find(g, class_id) 2019 return g.classes[canon].best_node 2020} 2021 2022// ===== Real bottom-up extractor (egg "Extractor" pattern) =================== 2023// 2024// V1's best_node/cost are fixed at class-CREATION and never recomputed against 2025// the cost model over the SATURATED graph, so nx_eqsat_extract_best_node can 2026// return a costlier representative -- e.g. it keeps a wasted `(add C 0)` as the 2027// canonical class's best_node instead of extracting the cheaper `C`. This 2028// recomputes each canonical class's best cost = min over its member nodes of 2029// (op_cost + sum of children's best class cost), iterated to a FIXPOINT (costs 2030// only decrease, so it converges). A class reachable only through itself never 2031// resolves below the sentinel, so the acyclic cheaper form always wins. Call 2032// AFTER nx_eqsat_saturate; afterwards nx_eqsat_extract_best_node returns the 2033// truly-cheapest node. (5W1H feedback hook: a future variant logs WHICH node 2034// won each class + WHY -- the cost breakdown -- for the invention loop.) 2035 2036func nx_eqsat_recompute_best(g: *NxEGraph) -> i64 { 2037 let big: i64 = NX_MAGIC_1000000000 2038 var ci: i64 = 0 2039 while ci < g.n_classes { 2040 if g.classes[ci].canon == ci { g.classes[ci].cost = big } 2041 ci = ci + 1 2042 } 2043 var changed: i64 = 1 2044 var guard: i64 = 0 2045 let guard_max: i64 = g.n_nodes + g.n_classes + 8 2046 while changed == 1 { 2047 changed = 0 2048 guard = guard + 1 2049 if guard > guard_max { return 0 - NX_EQSAT_STEP_BUDGET } 2050 var n: i64 = 0 2051 while n < g.n_nodes { 2052 let cls: i64 = nx_eqsat_find(g, g.nodes[n].eclass) 2053 let op: i64 = g.nodes[n].op 2054 let arity: i64 = nx_eqsat_arity_for(op) 2055 var c: i64 = nx_eqsat_op_cost(op) 2056 var ok: i64 = 1 2057 if arity >= 1 { 2058 let cc0: i64 = g.classes[nx_eqsat_find(g, g.nodes[n].kid0)].cost 2059 if cc0 >= big { ok = 0 } 2060 if cc0 < big { c = c + cc0 } 2061 } 2062 if arity >= 2 { 2063 let cc1: i64 = g.classes[nx_eqsat_find(g, g.nodes[n].kid1)].cost 2064 if cc1 >= big { ok = 0 } 2065 if cc1 < big { c = c + cc1 } 2066 } 2067 if arity >= 3 { 2068 let cc2: i64 = g.classes[nx_eqsat_find(g, g.nodes[n].kid2)].cost 2069 if cc2 >= big { ok = 0 } 2070 if cc2 < big { c = c + cc2 } 2071 } 2072 if ok == 1 { 2073 if c < g.classes[cls].cost { 2074 g.classes[cls].cost = c 2075 g.classes[cls].best_node = n 2076 changed = 1 2077 } 2078 } 2079 n = n + 1 2080 } 2081 } 2082 return NX_EQSAT_OK 2083} 2084 2085// Best cost of an e-class after nx_eqsat_recompute_best (canonical lookup). 2086func nx_eqsat_best_cost(g: *NxEGraph, class_id: i64) -> i64 { 2087 let canon: i64 = nx_eqsat_find(g, class_id) 2088 return g.classes[canon].cost 2089} 2090 2091// ===== Program-emitting extractor ========================================== 2092// 2093// nx_eqsat_extract_best_node only returns a node INDEX -- it cannot hand a 2094// backend the actual optimized PROGRAM (the "can't emit a program" gap). This 2095// emits the cost-minimal expression as a flat, topologically-ordered DAG: each 2096// NxEmitNode references its children by their out-array index, children before 2097// parents (post-order), so a code generator can consume it directly. Call 2098// AFTER nx_eqsat_recompute_best. The extraction is acyclic by construction 2099// (the cost-minimal best_node never closes a cycle), so the recursion 2100// terminates; overflow past `cap` returns 0 - NX_EQSAT_OVERFLOW. 2101 2102struct NxEmitNode { 2103 op: i64 2104 payload: i64 2105 c0: i64 // out-array index of child 0; -1 if none 2106 c1: i64 2107 c2: i64 2108} 2109 2110func nx_eqsat_emit(g: *NxEGraph, class_id: i64, out: *NxEmitNode, cap: i64, count: *i64) -> i64 { 2111 let canon: i64 = nx_eqsat_find(g, class_id) 2112 let node_idx: i64 = g.classes[canon].best_node 2113 let op: i64 = g.nodes[node_idx].op 2114 let arity: i64 = nx_eqsat_arity_for(op) 2115 var e0: i64 = 0 - 1 2116 var e1: i64 = 0 - 1 2117 var e2: i64 = 0 - 1 2118 if arity >= 1 { 2119 e0 = nx_eqsat_emit(g, g.nodes[node_idx].kid0, out, cap, count) 2120 if e0 < 0 { return e0 } 2121 } 2122 if arity >= 2 { 2123 e1 = nx_eqsat_emit(g, g.nodes[node_idx].kid1, out, cap, count) 2124 if e1 < 0 { return e1 } 2125 } 2126 if arity >= 3 { 2127 e2 = nx_eqsat_emit(g, g.nodes[node_idx].kid2, out, cap, count) 2128 if e2 < 0 { return e2 } 2129 } 2130 let idx: i64 = count[0] 2131 if idx >= cap { return 0 - NX_EQSAT_OVERFLOW } 2132 out[idx].op = op 2133 out[idx].payload = g.nodes[node_idx].payload 2134 out[idx].c0 = e0 2135 out[idx].c1 = e1 2136 out[idx].c2 = e2 2137 count[0] = idx + 1 2138 return idx 2139}