From 2915d3e8ae6aca6fabcaa2e60678aec909a3a9a3 Mon Sep 17 00:00:00 2001 From: codenlighten Date: Thu, 17 Sep 2026 19:46:47 -0400 Subject: [PATCH] Leave windows on deep stacks to the table: the search never wins there At --effort high the A* search runs on every window. Its cost grows with the stack the window starts from, but its wins do not. Counting what it found on real scripts: field-300.hex 1,391 searches, 15 wins (16 B). Every win started from a stack of at most 15 items; the 446 searches on stacks of 20+ found nothing and took 40 s of the 43 s spent searching. a 934 B mainnet contract 280 searches, 3 wins (3 B), all from stacks of at most 6; the 90 searches on stacks of 20+ took 113 s of 176 s. So a window whose stack is deeper than searchMaxDepth (16) is now left to the table, as a window wider than the table already is. Same output, less time: field-300.hex 2,607 B 45 s -> 8.9 s 934 B contract 694 B 155 s -> 51 s (3 more contracts: 1.0-3.5x) The search also builds each state's key once, when the state is created, instead of rebuilding it at every pop, and tests the goal by comparing stacks rather than strings. Output is byte for byte what it was, checked by SHA-256 on the Miller loop, the final exponentiation, field-300.hex, sha256.block, g1.inSubgroup, fp12.mul, fp12.inv and ec.add. --- src/superopt.js | 24 ++++++++++++++---------- src/windows.js | 8 ++++++-- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/superopt.js b/src/superopt.js index 0948e12..a3f9bee 100644 --- a/src/superopt.js +++ b/src/superopt.js @@ -73,7 +73,6 @@ function search (p) { for (const x of p.target) need[x]++ for (const x of p.targetAlt) need[x]++ const useAlt = p.startAlt.length > 0 || p.targetAlt.length > 0 || p.allowAlt - const targetKey = p.target.join(',') + '|' + p.targetAlt.join(',') const constList = [...p.consts.entries()].map(([sym, buf]) => [sym, pushOp(buf)]) const have = new Int32Array(nsym) @@ -92,25 +91,32 @@ function search (p) { return Math.max(Math.ceil(missing / 3), Math.ceil(surplus / 2)) } + const same = (a, b) => { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false + return true + } const isGoal = st => (!p.touch || st.t) && (!p.touchAlt || st.ta) && - st.main.length === p.target.length && st.alt.length === p.targetAlt.length && - (st.main.join(',') + '|' + st.alt.join(',')) === targetKey + same(st.main, p.target) && same(st.alt, p.targetAlt) - const keyOf = st => st.main.join(',') + '|' + st.alt.join(',') + '|' + (st.t ? 1 : 0) + (st.ta ? 1 : 0) + // Each state carries its own key: it is built once, when the state is made, + // rather than again at every pop and every goal test. + const keyOf = (main, alt, t, ta) => main.join(',') + '|' + alt.join(',') + '|' + (t ? 1 : 0) + (ta ? 1 : 0) const root = { main: p.start.slice(), alt: p.startAlt.slice(), t: p.start.length === 0, ta: p.startAlt.length === 0, g: 0, parent: null, op: null } + root.key = keyOf(root.main, root.alt, root.t, root.ta) const best = new Map() const q = new BucketQueue() const h0 = h(root.main, root.alt) if (h0 >= p.bound) return null q.push(h0, root) - best.set(keyOf(root), 0) + best.set(root.key, 0) let expanded = 0 while (q.size) { const st = q.pop() - if (best.get(keyOf(st)) < st.g) continue + if (best.get(st.key) < st.g) continue if (isGoal(st)) { const ops = [] for (let n = st; n.parent; n = n.parent) ops.push(...n.op.slice().reverse()) @@ -124,13 +130,11 @@ function search (p) { if (g >= p.bound || main.length > p.maxLen || alt.length > p.maxAlt) return const hh = h(main, alt) if (g + hh >= p.bound) return - // A move that produces a non-goal state still costs at least one more byte. - const child = { main, alt, t: touched, ta: touchedAlt, g, parent: st, op: ops } - const k = keyOf(child) + const k = keyOf(main, alt, touched, touchedAlt) const prev = best.get(k) if (prev !== undefined && prev <= g) return best.set(k, g) - q.push(g + hh, child) + q.push(g + hh, { main, alt, t: touched, ta: touchedAlt, g, parent: st, op: ops, key: k }) } for (const [code, k, fn] of FIXED) { diff --git a/src/windows.js b/src/windows.js index bf76544..1abd454 100644 --- a/src/windows.js +++ b/src/windows.js @@ -104,9 +104,13 @@ function windowsRegion (ops, g, ga, cache, opts = {}) { problem.maxExpand = maxExpand // Pure stack shuffles with constants are left to the table unless effort // allows open-ended search; folding and alt-stack windows always search. - problem.tableOnly = !opts.searchAll && ( + // The A* search's cost grows with the stack it starts from, while its + // wins do not: on real scripts every replacement it finds starts from a + // stack of at most ~16 items, and the deeper searches only run out their + // budget. Past that depth the window is left to the table. + problem.tableOnly = problem.start.length > (opts.searchMaxDepth ?? 16) || (!opts.searchAll && ( (!win.some(o => !isPush(o) && !STACK_OPS.has(o.code)) && !problem.allowAlt && !!problem.consts.size) || - problem.start.length > tableN) + problem.start.length > tableN)) // The window's bytes plus the facts that constrain its replacement // determine the answer, so they make a compact, reusable key. const key = encode(win).toString('hex') + '|' + (problem.touch ? 1 : 0) + (problem.touchAlt ? 1 : 0) +