Skip to content

Script-size optimizer, all 7 tiers: p256-wallet 958,792 → 147,113 B (−84.7%), corpus −65.1% - #160

Open
icellan wants to merge 16 commits into
mainfrom
feat/script-size-optimizer
Open

Script-size optimizer, all 7 tiers: p256-wallet 958,792 → 147,113 B (−84.7%), corpus −65.1%#160
icellan wants to merge 16 commits into
mainfrom
feat/script-size-optimizer

Conversation

@icellan

@icellan icellan commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Not for merge as-is. This is the script-size exploration from the brief, now complete across all seven compiler tiers. Every optimization is opt-in and default output is byte-identical everywhere. §"Landing this" at the bottom says what would still have to happen.

Headline

conformance/tests/p256-wallet — the fixture the brief calls its "959,592 B reference implementation" — 958,792 → 147,113 bytes (−84.7 %).

stage p256-wallet p384-wallet corpus
shipping 958,792 1,963,300 13,526,563
+ EC constant pool 304,463 (−68.2 %) 463,435 (−76.4 %) 6,285,154 (−53.5 %)
+ reduction sinking 179,890 (−81.2 %) 272,678 (−86.1 %)
+ fixed-base comb (NIST) 147,113 (−84.7 %) 223,204 (−88.6 %) 4,906,225 (−63.7 %)
+ fixed-base comb (secp256k1) 4,726,567 (−65.1 %)

8.8 MB off the corpus. 43 of 72 fixtures changed, none grown.

p256-wallet's constant pushes went 697,019 → 2,116 bytes. What remains is 70.0 % stack traffic and 25.4 % arithmetic.

Three assumptions the measurements overturned

  1. The baseline is not where the brief says. 72.7 % of p256-wallet was constant pushes, and 680,850 B of it was 20,025 separate 33-byte pushes of one number — the field prime, re-pushed at every modular reduction. Pooling it into a stack slot is the single largest win in the whole stack, and it is not an algebraic optimization.
  2. Eager dead-slot retirement is not worth building. Only 657 of 387,749 OP_PICK/OP_ROLL sites sit deeper than 16, where the depth push crosses into 2 bytes. Depths 0–2 are all one byte.
  3. A generic scheduler cannot reach P-256. The crypto emitters bypass 05-stack-lower.ts entirely — they emit Stack IR directly through a hand-written tracker. That is 13.4 MB of the 13.5 MB corpus. This is the architectural finding of the exercise and it is why the work split into two independent prototypes.

All seven tiers, byte-identical

Flags in every tier: --ec-constant-pool --ec-reduction-sinking --ec-fixed-base-comb

For one ecMulGen contract with all three on, all seven compilers emit the same 50,157 bytes (down from 424,567):

tier parity gate assertions CLI
TypeScript conformance/ec-flag-parity/parity.test.ts fixture re-derived in-process reference
Go codegen/ec_flag_parity_test.go 120 subtests identical
Rust tests/ec_flag_parity_tests.rs 24 emitters × 4 variants identical
Python tests/test_ec_flag_parity.py 48 identical
Ruby test/codegen/test_ec_flag_parity.rb 24 × 4 identical
Java codegen/EcFlagParityTest 3 tests over 24 × 4 identical
Zig passes/helpers/ec_flag_parity_test.zig 8 tests (secp256k1 + NIST) identical

Why a new gate was needed

The flags default off, so the ordinary conformance suite — which compiles with defaults — cannot see them at all. Seven tiers could each ship a different --ec-constant-pool and the suite would stay green.

That is not hypothetical. The flags change which reduction form is emitted and which addition formula each ladder round uses. A tier that ports the constant pool but not the sign lattice's Reduced precondition produces a script that is smaller, passes every test it has, and is wrong on ecAdd((0,1), (2^256−1,1)).

conformance/ec-flag-parity/expected.json pins the exact script the TS reference emits for all 24 EC emitters under all 4 flag combinations, as a byte count plus a SHA-256. It is derived, never hand-edited, and re-derived in-process by its own test so it cannot go stale.

Six defects it caught

None findable by a tier-local test:

tier defect symptom
Go cDecomposePoint never recorded that BIN2NUM of an unsigned coordinate is NonNegative NIST tier emitted a larger script under --ec-reduction-sinking (94,137 vs 90,610)
Go six NIST entry points never released the pooled prime 2 bytes each
Go the three +n pushes in cEmitMul routed through the pool strictly smaller, but the reference pushes literals — the tiers would have diverged
Rust backend options built with ..Default::default() --ec-constant-pool reached the frontend and vanished; the compile succeeded and emitted the unoptimized script
Python the u2·Q ladder was called without options verifier at 628,923 bytes where the reference emits 319,693 — the flag applied to exactly half of it
Python / Ruby / Java verifier never released its two pooled constants 4 bytes

The Go NonNegative fact and the Rust ..Default::default() are the interesting two: both produce a compiler that works, passes its suite, and silently does not do what the flag says.

Soundness

The sign lattice is a sign lattice, not a modular-domain lattice. Multiply and add need only dividend ≥ 0, which unsigned decoding gives for free (~70 % of reductions). Subtraction's cheap form needs subtrahend < p — and OP_BIN2NUM of 32 unsigned bytes does not imply that. The blanket version passed 256 EC oracle assertions and was still wrong: ecAdd((0,1),(2^256−1,1)) differs by exactly 2^256 − p = 2^32 + 977. Reduced is tracked separately for that one reason.

The comb re-derives the interval argument rather than inheriting it. The binary ladder uses the cheap incomplete add everywhere but its last step, justified by an interval argument over c_i mod n — and that comment insists the argument be redone by anything changing the offset or the iteration count. A comb changes both. comb.ts#combSafeRounds is that argument as executable interval arithmetic: it proves 81 of 86 rounds at w=3 and falls back to the complete add-or-double form on the rest (~1.2 kB). combParams searches for the scalar offset rather than reusing the ladder's +3n — right for P-256 and secp256k1 at w=3, wrong for P-384, which needs +5n.

secp256k1's curve record is written out, not templated. makeCurve hardcodes the NIST a = −3; secp256k1 is y² = x³ + 7. A wrong a there does not produce an obviously broken table — it produces a table of points on a different curve, which that curve's on-curve check accepts. The published 2G vector is pinned for exactly that.

ecMul / p256Mul / p384Mul are deliberately not combed. Their bases arrive at run time and the interval argument does not cover an attacker-chosen point. Asserted in the fixture test.

Straus/Shamir: measured, then rejected

Not skipped — measured. Complete addition costs +299 B/round, taking the P-256 ladder 90,610 → 167,410 (+84.8 %). Joint-and-complete lands at ≈700 B per bit-position against 690 for two independent ladders. Straus only wins with the incomplete formula, whose interval argument collapses once the accumulator is c_i·G + d_i·Q with attacker-supplied Q: choose Q = k·G and solve c_i + d_i·k ≡ 1 (mod n). The comb was taken instead because its single compile-time generator keeps the argument intact.

A miscompile the byte counts did not catch

The liveness scheduler once accepted a witness the shipping compiler rejects. Byte counts, all 72 goldens, and 4,113 unit tests were green. conformance/witnesses/ caught it through runDifferentialExecution — 2 of 86 cases. Cause: restoring alt-stack spills immediately before an if leaves a layout lowerIf's arm reconciliation was not written for. Fix: refuse to spill in any scope with control flow ahead.

My first bisect falsely acquitted the spilling: with commutative reordering disabled, the method-level cost guard simply preferred the baseline schedule, so no spilling happened at all. Only after confirming the variant still changed bytes did the second bisect mean anything.

Tier-specific findings

Rust pushed the EC constants as pre-encoded script-number byte blobs because they exceed i128. PushValue::Int carries a BigInt, so the blob was never necessary — and it cost real things: a Bytes push is invisible to the peephole's constant folding (which is why k + 3n had to be hand-folded there and only there) and invisible to the sign lattice. Switching to Int left the shipped bytes unchanged and brought the raw op-count goldens into agreement with Go, where they had been 4 ops short.

Zig carried the same duplication in nist_ec_emitters.zig (NistTracker, predating the lattice) and it was deleted the same way — the NIST emitters now share ec_emitters.ECTracker. Its one curve-specific field became a parameter; nothing about a tracker is per-curve.

Rust also carried a hand-copy of ECTracker in p256_p384.rs, commented "duplicated since it's private there". Fine for 200 lines of stack bookkeeping; not fine once it carries a sign lattice whose transfer functions decide which reduction shape gets emitted — two copies is two chances to prove Reduced where only NonNegative holds. Deleted; the tiers share one tracker.

Zig is the one tier that cannot assert the raw hash, for two reasons — both differences in spelling that its own peephole normalises away before the script ships.

k + 3n. Its peephole reassociates only i64 push_int chains, and a 256/384-bit constant is a push_data blob in its IR, so the offset stays pre-folded there while the reference emits three +n steps its own peephole collapses. The two curve families differ in when this bites: the secp256k1 reference routes those pushes through the constant pool, so Zig matches raw-for-raw as soon as --ec-constant-pool is on and diverges only under off; the NIST reference (cEmitMul) pushes raw literals under every flag combination, so the divergence there is constant — −70 B per P-256 ladder (3×(1+33)+3 against (1+33)+1) and −102 B per P-384 ladder. Copying either family's conditional into the other is a live way to get this wrong.

MINIMALDATA on one-byte blobs. The reference writes push [0x02] as OP_2; Zig's encoder always length-prefixes. Two such pushes per site — the 0x02/0x03 prefix pair in decompressPubKey, and the parity select in pNNNEncodeCompressed. This one is pre-existing and flag-independent: it is there with every flag off, on an emitter no flag reaches. Nothing gated it before this PR.

Zig therefore gates on the raw byte count with both priced exactly in allowedDelta — which returns zero for every emitter they do not name, and derives the ladder term from ladderCount(name, variant) × THREE_N rather than a table of constants, so a tier that combed u2·Q (an attacker-supplied base, outside the interval argument) changes the count and fails rather than absorbing the difference. Plus end-to-end hex identity through the CLI; the fixture carries a postPeephole measurement for the distinction.

Zig's cost model also differs by construction: its roll/pick carry the depth themselves, so they cost sizeOfScriptNumber(depth) + 1 where every other tier charges 1 and counts a separate push.

Ruby needed an explicit extended-Euclid mod_inverse: Integer#pow rejects a negative exponent, so there is no x.pow(-1, m) shortcut.

Verification

  • Default output unchanged everywhere. 72/72 goldens byte-for-byte, script-size-check 72/72 ok, and every tier pins that flags-off reproduces the shipping hash for all 24 emitters.
  • Per-tier suites: Go all green · Rust all green · Python 1,208 passed / 1 skipped · Ruby 67 test files · Java 683 tests · Zig 761 tests.
  • The cost model is a checked mirror of the emitter, not a second opinion: estimateScriptBytes(ops) === emitMethod(ops).scriptHex.length / 2, swept over the whole corpus in every tier.
  • Semantic equivalence under the flags: ec-comb.test.ts (83), ec-constant-pool-equivalence, ec-reduction-sinking, liveness-scheduler-equivalence including the combined all variant, plus the real-crypto witness corpus.

One thing to know about the history

gofmt -w codegen/ reformats the whole directory. Running it while porting swept 43 unrelated files (babybear, bn254, koalabear, sp1_fri, …) into the Go commit as pure whitespace churn. fix(go): revert the gofmt sweep… restores them; the net diff for those files is zero, but the round-trip is visible in the history.

Landing this

Nothing here should merge as-is:

  • The checked-in EC goldens were stamped under flags-off and are unchanged — correct today, but nothing regenerates them for a flags-on world, and script-size-baseline.json would trip its 50 % shrink guard by design.
  • No golden-provenance entries exist for a flags-on stamping, because nothing has been stamped.
  • The liveness scheduler is reported separately: it moves 34 fixtures but −0.0 % of corpus bytes. Its value is the structural guarantee (lower both ways, keep the cheaper post-peephole, so it can never grow a method), not the number.

Full write-up including the answers to all eight of the brief's questions: docs/experiments/script-size-optimizer-results.md. Supersedes #156, #158, #159.

…ntation

Nothing in the compiler could compare two candidate lowerings by the metric that
actually matters — serialized locking-script bytes. `OP_DUP` and a 33-byte
constant push are one instruction each and 34x apart in cost, so an instruction
count cannot rank them.

- `metrics/cost-model.ts`: `sizeOfStackOp` / `estimateScriptBytes`, routing every
  push through the same `push-encoding.ts` encoders the emit pass uses. Asserted
  byte-exact against `emitMethod` for every method of every fixture, before and
  after peephole — the model is a checked mirror of `06-emit.ts`, not a second
  opinion about encoding. An unknown opcode throws rather than costing zero.

- `metrics/script-metrics.ts`: buckets a serialized script by what each byte is
  spent on. One rule worth stating: a push immediately consumed by OP_PICK /
  OP_ROLL is charged to stack access, not to constants, so `bringToTop`'s depth
  operands do not get blamed on the wrong optimizer. Worth 21,926 bytes of
  reclassification on p256-wallet alone.

- `golden-invariance.test.ts`: every fixture that ships a `.runar.ts` reproduces
  its checked-in `expected-script.hex`. This is the TS-tier-only version of what
  the conformance runner checks across seven tiers, so a backend experiment can
  be shown byte-neutral in seconds without building six native toolchains.

Read-only: no pass consults any of this and no emitted byte moves.
…uler

Both are opt-in and inert by default, so the 72 goldens,
conformance/script-size-baseline.json and cross-tier hex parity are untouched.
All 72 fixtures still reproduce their expected-script.hex byte-for-byte, and the
Go and Rust cross-compiler golden tests still pass.

--ec-constant-pool

  `fieldMod` pushes the curve's field prime inline at every modular reduction:
  20,025 pushes of a 34-byte literal in p256-wallet, 680,850 of its 958,792
  bytes. ECTracker gains a pooled slot per constant, and `pushConst` compares the
  emitted cost of picking that slot against re-pushing the literal and takes the
  cheaper — so no individual call site can grow. Parameterized by CurveParams /
  GroupParams, so secp256k1, P-256 and P-384 share one code path.

    p256-wallet    958,792 ->   304,463  (-68.2%)
    p384-wallet  1,963,300 ->   463,435  (-76.4%)
    corpus      13,526,563 -> 6,285,154  (-53.5%), 9 fixtures, none grown

  Proved equivalent on the real @bsv/sdk engine against OpenSSL signatures on
  both curves, plus every SEC1 rejection case (r=0, s=0, r=n, s=n, all-zero
  signature, wrong message, wrong parity, truncated and oversized inputs).

--stack-scheduler=liveness

  Parks a result on the alt stack when the next binding does not consume it, so
  the operands an ANF chain reads repeatedly stay at depth 0/1; the whole spill
  group is restored in production order, which is the order an accumulation
  reads it. Plus commutative operand ordering.

  Ordering is scored by running the candidate op sequences through the real
  peephole rather than a byte formula: two consumed operands at depths 1 and 0
  emit OP_SWAP OP_SWAP, which `swap-swap` deletes outright — free — while the
  cheaper-looking reversed order emits one real OP_SWAP and costs a byte. That
  correction took `arithmetic` from 24 bytes to 18.

    arithmetic     28 -> 18 B  (-35.7%), the hand-derived optimum
    bounded-loop   42 -> 37 B  (-11.9%)
    ~30 mid-size fixtures -0.1%; 34 changed, none grown

  Selection is per method: both schedules are lowered and the cheaper one,
  measured after peephole, is kept — so "the scheduler never grows a method" is
  structural rather than a property of the greedy heuristic.

  Spilling is refused in any scope that still has control flow ahead of it.
  Restoring immediately before an `if` leaves the parent stack in a shape
  lowerIf's arm reconciliation, declared-result trim and Layer B/C depth
  invariants were not written for, and it MISCOMPILED if-without-else-multi-temp
  into accepting a witness the shipping compiler rejects. Byte counts, the other
  goldens and 4,099 compiler unit tests all passed while that was true; the
  conformance/witnesses corpus replayed through runDifferentialExecution caught
  it, 2 of 86 cases.

Also:
- `conformance/runner/script-metrics.ts` — "where did the bytes go?", the
  companion to script-size-check.ts's "did anything grow?". Compares named
  compiler variants and never silently drops a fixture from a size report.
- `runDifferentialExecution` accepts both flags, so any experiment can be run
  through the same source-vs-script oracle.
- `--stack-scheduler` rejects an unknown mode instead of falling back to the
  default: a benchmark that quietly measured the shipping compiler while
  reporting an experiment is worse than a crash.
Three reports under docs/experiments/, all reproducible with
`pnpm --filter runar-conformance run script-metrics`.

script-size-optimization-baseline.md
  58% of every byte the compiler has ever emitted is a constant push, and 56% of
  the whole 13.5 MB corpus is nine numbers — each curve's field prime — pushed
  over and over. p256-wallet is 72.7% constant pushes, 71.0% of the fixture in a
  single 33-byte literal repeated 20,025 times. Also records that brief Phase 3
  (fix-point peephole) and Phase 15 (OP_PUSH_TX binding) already ship.

stack-scheduler-design.md
  The current lowering algorithm with line references, the measured
  inefficiencies, the byte-cost function, the correctness invariants, and the
  benchmark plan — written before the prototype, then updated with what actually
  happened, including the miscompile the witness corpus caught and why a passing
  bisect can be vacuous.

script-size-optimizer-results.md
  What is generic, what is not, and where the remaining bytes are. After
  pooling, p256-wallet's 304,463 bytes are 70.6% stack traffic and 27.2%
  arithmetic — all of it the modular-reduction sequence itself, ~20,000 times.
  Reaching ~30 kB needs the algebra, not more scheduling: modular-domain
  analysis first, then Straus/Shamir, then a fixed-base comb.

Two plan assumptions died on measurement and are recorded as dead rather than
quietly dropped:

  - Eager dead-slot retirement. 657 of 387,749 pick/roll sites in the corpus are
    deeper than 16; typical depths are 2-5, and depths 0-2 are single-byte
    opcodes. Every drop would cost bytes to save nothing.
  - That a generic scheduler could reach P-256 at all. The crypto emitters build
    their own stack layout through ECTracker and never pass through
    05-stack-lower.ts — 13.4 MB of the 13.5 MB corpus is out of its reach.
…rojecting it

§3.6 carried an estimate for the next step. It is now a measurement: `fieldMod`
/ `cFieldMod` were patched behind a throwaway switch to emit the short form, the
corpus was re-measured, and the patch was discarded.

    p256-wallet    958,792 -> 304,463 (pool) -> 179,796 (+ sinking, -81.2%)
    p384-wallet  1,963,300 -> 463,435        -> 272,584            (-86.1%)
    ec-primitives 1,332,782 -> 433,880       -> 258,160            (-80.6%)

The sound variant captures 89% of the theoretical floor, so the analysis does
not need to be clever about subtraction. The two optimizations are also not
independent: without pooling the cheap `fieldSub` form pushes the prime twice
and p256-wallet gets LARGER (958,792 -> 999,371). Sinking only pays once the
prime is a 2-byte pick.

The more useful result is what the analysis actually has to prove (new §3.8).
The short-reduction variant passes 256 EC oracle assertions — OpenSSL signatures
on both curves, ec-on-curve-canonicity, ec-degenerate-add, ec-mul-scalars,
p256-p384-scalars, p256-p384-ecdsa-verify. It would have shipped looking green.
It is still unsound:

  - multiply / add / mulconst need only `dividend >= 0`, which unsigned
    coordinate decoding already gives — ~70% of reductions, trivial analysis;
  - subtract needs `subtrahend < p`, which decoding 32 unsigned bytes does NOT
    imply.

    ecAdd((0, 1), (2^256 - 1, 1))
      shipping : ...fffffffdfffff85f
      sinking  : ...0001000003d0        0x1000003d0 = 2^32 + 977 = 2^256 - p

Reachable only through the unguarded bare builtins; verifyECDSA_* and onCurve
run a canonicity guard first. So Phase 4/5 is a sign lattice plus a `< p` bit
that only subtrahends carry — materially smaller than a full modular-domain
lattice, and the difference between passing 256 oracle assertions and being
correct.

Also reorders §6. At 179,796 bytes the split is 69.6% stack-shuffle / 26.7%
arithmetic and OP_PICK (x36,683) is the largest opcode: once a reduction costs
3 bytes, ECTracker's own operand shuffling is the bottleneck, so the typed
field-element IR moves ahead of Straus/comb.

No code changes; the experiment branch was deleted.
…ion-sinking

`fieldMod` costs 10 bytes and runs ~20,000 times in a P-256 verify. Six of them
are a sign fix-up that exists only because OP_MOD takes the sign of the
dividend. Where the dividend is provably non-negative they are dead.

    p256-wallet    958,792 -> 304,463 (pool) -> 179,890  (-81.2%)
    p384-wallet  1,963,300 -> 463,435        -> 272,678  (-86.1%)
    ec-primitives 1,332,782 -> 433,880       -> 258,303  (-80.6%)
    ec-unit         479,716 -> 157,129       ->  93,678  (-80.5%)

That is within 94 bytes of the measured ceiling for this transformation
(179,796 on p256-wallet, docs/experiments §3.7), so the lattice recovers
essentially all of the available win. Opt-in and inert by default: all 72
goldens still reproduce byte-for-byte and script-size-check is 72/72 ok.

WHY A LATTICE AND NOT A REWRITE

The two paths need different facts, and conflating them is not hypothetical —
the ceiling measurement did exactly that, passed 256 EC oracle assertions, and
was still wrong:

  - multiply / add / mulconst need `dividend >= 0`. Unsigned coordinate
    decoding already gives it, so ~70% of reductions qualify immediately.
  - subtract's cheap `a - b + p` form needs the strictly stronger
    `subtrahend < p`, which OP_BIN2NUM of 32 unsigned bytes does NOT imply: a
    coordinate may exceed p by up to 2^32 + 977.

        ecAdd((0, 1), (2^256 - 1, 1))
          correct  : ...fffffffdfffff85f
          blanket  : ...0001000003d0     0x1000003d0 = 2^32 + 977 = 2^256 - p

IMPLEMENTATION

`Dom` is a three-point lattice (Unknown < NonNegative < Reduced) carried as
`ECTracker.dm`, a SLOT-parallel array to `nm` rather than a name-keyed map:
names are reused (`_fmul_prod` is written by every multiply) and the same name
can be resident twice, so a map would go stale in exactly the cases that matter.
Every `nm` mutation mirrors into `dm` with the same splice, external mutation
now goes through pushTracked/popTracked/removeSlotAt, and `domainOf` throws if
the arrays ever differ in length — a silent desync would hand a transfer
function a fact about the wrong slot, which is the one failure mode that yields
a smaller script that quietly computes something else.

Transfer functions: add/mul are non-negative iff both operands are; a square is
non-negative unconditionally; mulconst keeps the operand's sign for positive c;
every reduction result is Reduced; a decoded coordinate is NonNegative but never
Reduced. Anything a rawBlock or an OP_IF produces stays Unknown, so an
un-analysed value can only fall back to the shipping reduction. Group-order
reductions are deliberately left at NonNegative rather than Reduced, so a value
reduced mod n can never be mistaken for one reduced mod p.

The cheap subtraction references the prime twice, so whether to use it is a cost
comparison (`cheapSubPays`) against the pooled push cost, not a flag: without
`--ec-constant-pool` it would make p256-wallet larger.

TESTING

`ec-reduction-sinking.test.ts` is a differential sweep, not a signature check —
that question was already answered wrongly once. It runs every emitter over the
coordinate values on the boundary (0, 1, 2, p-1, p, p+1, 2^256-1, G.x) including
the full 8x8 cross product for ecAdd and p256Add, and requires the sunk script's
RESULT to match the shipping one on every combination, including ones no valid
curve point could produce. The counterexample above is pinned by name. An
absolute OpenSSL oracle then re-checks accept plus seven rejection cases.
One doubling and one conditional add per COLUMN instead of per bit, wherever
the base point is a compile-time constant: p256MulGen, p384MulGen, and the
u1*G half of ECDSA verification. u2*Q keeps the ladder — Q arrives in the
witness.

    p256-wallet    958,792 -> 179,890 (sink) -> 147,113  (-84.7%)
    p384-wallet  1,963,300 -> 272,678        -> 223,204  (-88.6%)

    emitP256MulGen        90,676 -> 54,117   (-40.3%)
    emitP384MulGen       136,599 -> 81,418   (-40.4%)
    emitVerifyECDSA_P256 195,120 -> 158,560  (-18.7%)

Opt-in and inert by default: all 72 goldens still reproduce byte-for-byte and
script-size-check is 72/72 ok.

WHY NOT STRAUS

The obvious move is a joint ladder for u1*G + u2*Q. Measured, it does not pay.
The ladder's speed comes from the CHEAP incomplete mixed add, justified in
buildJacobianAddOrDoubleInline by an interval argument over c_i mod n — an
argument that holds because the accumulator is c_i*P and the addend is P, one
generator. A joint ladder makes the accumulator c_i*G + d_i*Q with Q supplied
by the caller; an attacker choosing Q = k*G solves c_i + d_i*k == 1 (mod n) for
k, so the exception becomes reachable at an arbitrary step. Completing the
addition costs a measured +299 B/round (a whole ladder goes 90,610 -> 167,410,
+84.8%), against 690 B/round for two independent ladders — so joint + complete
lands at ~700 B/round. A loss. The comb keeps a single generator, so the
argument survives.

SOUNDNESS

comb.ts re-derives the interval argument for the comb rather than assuming it,
as executable arithmetic:

  - combParams searches for the scalar offset m with m*n >= 2^(w*d-1) and
    (m+1)*n - 1 < 2^(w*d), so the first digit is never zero and the accumulator
    never starts at infinity. For P-256 at w=3 that returns the same +3n the
    ladder hardcodes; for P-384 at w=3 it returns +5n. Reusing +3n there would
    have left the leading digit free to vanish.
  - combSafeRounds proves, per round, that the pre-add accumulator cannot be 0,
    +T[j] or -T[j] modulo n for any table entry, over the whole scalar domain.
    Rounds it cannot prove get the complete add-or-double form. For P-256 at
    w=3 it proves 81 of 86, so the fallback costs ~1.2 kB. `true` is never
    assumed.

The window width is not hardcoded: cEmitCombBest renders w = 2, 3 and 4 in full
and keeps whichever estimateScriptBytes scores smallest.

TESTING

ec-comb.test.ts is a differential against the binary ladder on the real @bsv/sdk
engine over the scalars the argument turns on — 0, 1, small values, n-1, n, n+1,
2n, negatives, and the powers of two either side of each block boundary
(2^85, 2^86, 2^171, 2^172, 2^255) — plus a cross-check against the INDEPENDENT
generic-point ladder so a shared bug in the MulGen wrapper cannot hide. Then the
verifier under an OpenSSL oracle: genuine signature accepted, seven near-misses
rejected, comb and ladder agreeing on every one.

comb-table.test.ts pins the compile-time arithmetic against published vectors
(2G, n*G = infinity), checks every table entry is on the curve for both curves
and w in {2,3,4}, and checks the safety analysis is monotone under a widened
domain — a checker that proved every round would be broken, not clever.

NOT IN SCOPE

secp256k1. comb.ts is curve-generic, but the emitter uses the NIST codegen's
a = -3 doubling; ec-codegen.ts needs its own wiring, which is why ec-primitives,
ec-demo, schnorr-zkp, ec-unit and convergence-proof are unchanged here.
… stack

Measured with every flag on:

    p256-wallet    958,792 ->   147,113  (-84.7%)
    p384-wallet  1,963,300 ->   223,204  (-88.6%)
    corpus      13,526,563 -> 4,906,225  (-63.7%), 43/72 changed, none grown

Adds the combined headline, the Straus/Shamir negative result with its
per-round arithmetic, and the two facts the comb's soundness work turned up
(the ladder's +3n offset is not portable to P-384 at w=3; the safety checker
has to be allowed to fail, and does, on 5 of 86 rounds).

Reorders the recommendations: pool, then sinking, then comb — sinking depends
on the pool, and the comb carries the heaviest proof obligation. secp256k1 comb
wiring moves to the top of "do next" since the analysis is already written and
4.5 MB of fixtures are still on the ladder.

Also runs the witness corpus under an `all` variant, so the flag combination a
user would actually turn on is proved together rather than only separately.
`ecMulGen` was the last compile-time-known base still running the 257-round
binary ladder. It now uses the same Lim-Lee comb the NIST curves got, cutting
it from 84,203 to 52,237 bytes (-38.0%) and the conformance corpus from
4,906,225 to 4,726,567 (-3.7%, and -65.1% against the shipping baseline).

`comb.ts` was already curve-generic, but `makeCurve` hardcodes the NIST
a = -3. secp256k1 is y^2 = x^3 + 7, so `SECP256K1_COMB_CURVE` is written out
rather than built from that template — a wrong `a` there does not produce an
obviously broken table, it produces a table of points on a DIFFERENT curve
that the other curve's on-curve check accepts. The published 2G vector is
pinned for exactly that reason.

The emitter is a twin of `cEmitCombMulGen`, not a share: secp256k1's
`jacobianDouble` computes D = 3X^2 where the NIST version computes
3(X-Z^2)(X+Z^2). Only the compile-time table and the interval checker are
common, and those read `a` from the curve record.

`combSafeRounds` proves 81 of 86 rounds safe for the cheap incomplete add at
w=3; the rest fall back to the complete add-or-double form. `combParams`
independently re-derives the scalar offset (m=3, d=86 for secp256k1) instead
of reusing the ladder's hardcoded +3n.

`emitEcMul` is deliberately NOT combed: its base arrives at run time, and the
interval argument does not cover an attacker-chosen point.

Also adds `conformance/ec-flag-parity/`, the cross-tier target for the ports
that follow. The size flags default off, so the ordinary conformance suite —
which compiles with defaults — cannot see them; seven tiers could each ship a
different `--ec-constant-pool` and stay green. The fixture pins the exact
script the TS reference emits for all 24 EC emitters under all 4 flag
combinations, and is re-derived in-process by its own test so it cannot go
stale.

Tests: 83 in ec-comb.test.ts (secp256k1 differential against the ladder over
the interval argument's boundary scalars, plus absolute published k*G
vectors), 14 in comb-table.test.ts, 4 in parity.test.ts. Default output
unchanged.
Replays the whole TypeScript optimizer stack into `compilers/go`: the exact
byte-cost model, the compile-time comb table and its interval checker, the
sign lattice, the constant pool, reduction sinking, and the fixed-base comb
for all three curves.

Byte-exact against the TS reference for all 24 EC emitters under all 4 flag
combinations, plus end-to-end through both CLIs (`ecMulGen` contract:
424,567 -> 50,157 bytes, identical hex from `runar-compiler-go
--ec-fixed-base-comb` and the TS `--ec-fixed-base-comb`).

New: `codegen/cost_model.go`, `codegen/comb.go`, and the flags
`--ec-constant-pool`, `--ec-reduction-sinking`, `--ec-fixed-base-comb` on
`runar-compiler-go`, threaded through `CompileOptions` ->
`LowerToStackOptions` -> `loweringContext`.

Three defects the parity fixture caught, none of which any Go-side test could
have found on its own:

1. `cDecomposePoint` did not record that BIN2NUM of an unsigned coordinate is
   `NonNegative`, so the NIST tier proved fewer domains and emitted a LARGER
   script under `--ec-reduction-sinking` than the reference (P256Mul 94,137 vs
   90,610). Silently correct, silently worse.
2. Six NIST entry points never released the pooled prime, leaving a 2-byte
   divergence per emitter.
3. The three `+n` pushes in `cEmitMul` were routed through the pool. That is
   strictly smaller (-96 B per P-256 ladder, -144 B per P-384) but the TS
   reference pushes them as literals, so the tiers would have diverged. Matched
   the reference and recorded the missed opportunity in a comment.

Every direct `t.nm` mutation in `ec.go` and `p256_p384.go` now goes through
`pushTracked`/`popTracked`/`removeSlotAt`, and `domainOf` panics if the name
and lattice slices ever desynchronise — a silent desync would hand a transfer
function a fact about the WRONG slot, which is the one failure mode that
produces a smaller script that quietly computes something else.

Default output is unchanged: `TestEcFlagsDefaultOffIsByteIdentical` pins that
a nil options pointer reproduces the shipping hash for every emitter, and the
existing op-count goldens are untouched. Full Go suite green.
Byte-exact against the TypeScript reference for all 24 EC emitters under all
4 flag combinations (`tests/ec_flag_parity_tests.rs`), and end-to-end through
the CLI: `runar-compiler-rust --ec-fixed-base-comb` produces hex identical to
the TS and Go compilers for the same contract.

New: `codegen/cost_model.rs`, `codegen/comb.rs`, and the flags
`--ec-constant-pool`, `--ec-reduction-sinking`, `--ec-fixed-base-comb`,
threaded through `CompileOptions` -> `lower_to_stack_with_ec` ->
`LoweringContext`.

Three things this tier needed that Go did not:

1. The EC constants were pushed as pre-encoded script-number BYTE blobs
   because they exceed `i128`. `PushValue::Int` carries a `BigInt`, so the blob
   was never necessary — and it cost real things: a `Bytes` push is invisible
   to the peephole's constant folding (which is why `k + 3n` had to be
   hand-folded here and only here) and invisible to the sign lattice. Switched
   to `Int`, emitted `k + 3n` as the reference's three `+n` steps, and let
   fold-chain-add collapse them back. Shipped bytes unchanged; the raw op tree
   now AGREES WITH GO, where it was 4 ops short before. Op-count goldens
   restamped to the Go values with that explanation.

2. `p256_p384.rs` carried its own hand-copy of `ECTracker` — "duplicated since
   it's private there". That was tolerable for 200 lines of stack bookkeeping
   and stopped being tolerable once the tracker carried a sign lattice whose
   transfer functions decide which reduction shape is emitted. Two
   independently-maintained copies is two chances to prove `Reduced` where only
   `NonNegative` holds, and the resulting script is smaller, passes every local
   test, and is wrong. Widened the `ec.rs` tracker to `pub(crate)` and deleted
   the copy.

3. `compile_from_source_str_with_options` built its backend options with
   `..Default::default()`, silently dropping every caller-set field before
   stack lowering. `--ec-constant-pool` reached the frontend and vanished; the
   compile succeeded and emitted the unoptimized script. Now `..opts.clone()`.

Also fixed here, as in Go: `c_decompose_point` did not record that BIN2NUM of
an unsigned coordinate is `NonNegative`, and six entry points never released
the pooled prime. Both were caught only by the parity fixture.

Default output unchanged: `ec_flags_default_off_is_byte_identical` pins that
`None` options reproduce the shipping hash for every emitter, and the
conformance hex goldens are untouched. Full Rust suite green.
Byte-exact against the TypeScript reference for all 24 EC emitters under all
4 flag combinations (`tests/test_ec_flag_parity.py`, 48 assertions), and
end-to-end through the CLI: `python3 -m runar_compiler --ec-fixed-base-comb`
produces hex identical to the TS, Go and Rust compilers for the same contract.

New: `codegen/cost_model.py`, `codegen/comb.py`, and the flags
`--ec-constant-pool`, `--ec-reduction-sinking`, `--ec-fixed-base-comb`,
threaded through `compile_from_source` -> `lower_to_stack` ->
`_LoweringContext`.

Two defects the parity fixture caught: the second (`u2*Q`) ladder inside
`_c_emit_verify_ecdsa` was still called without options, so the verifier came
out at 628,923 bytes where the reference emits 319,693 — the flag was applied
to exactly half of it; and the verifier never released its two pooled
constants, a 4-byte tail. Neither would have failed any Python-side test.

Default output unchanged: `test_ec_flags_default_off_is_byte_identical` pins
that `None` options reproduce the shipping hash for every emitter. Full
Python suite green (1,208 passed, 1 skipped).
Byte-exact against the TypeScript reference for all 24 EC emitters under all
4 flag combinations (`test/codegen/test_ec_flag_parity.rb`), and end-to-end
through the CLI: `runar-compiler-ruby --ec-fixed-base-comb` produces hex
identical to the TS, Go, Rust and Python compilers for the same contract.

New: `codegen/cost_model.rb`, `codegen/comb.rb`, and the flags
`--ec-constant-pool`, `--ec-reduction-sinking`, `--ec-fixed-base-comb`,
threaded through `compile_from_source` -> `lower_to_stack` ->
`LoweringContext`.

One tier-specific detail: Ruby's `Integer#pow` rejects a negative exponent, so
there is no `x.pow(-1, m)` modular-inverse shortcut as in Python. `comb.rb`
carries an explicit extended-Euclid `mod_inverse` instead.

The dispatch tables split flag-aware emitters from the rest explicitly
(`EC_FLAG_AWARE`, the `EncodeCompressed` check) rather than passing options to
everything: an emitter the flags cannot reach taking an ignored argument is a
silent no-op today and a latent divergence the day someone gives it a body.

Default output unchanged: `test_ec_flags_default_off_is_byte_identical` pins
that `nil` options reproduce the shipping hash for every emitter. Full Ruby
suite green (67 test files).
Byte-exact against the TypeScript reference for all 24 EC emitters under all
4 flag combinations (`codegen/EcFlagParityTest`), and end-to-end through the
CLI: `runar-java --ec-fixed-base-comb` produces hex identical to the TS, Go,
Rust, Python and Ruby compilers for the same contract.

New: `codegen/CostModel.java`, `codegen/Comb.java`, and the flags
`--ec-constant-pool`, `--ec-reduction-sinking`, `--ec-fixed-base-comb`,
threaded through `Cli.Args` -> `StackLower.run` -> `LoweringContext`.

Every public emitter keeps its old one-argument signature as an overload that
delegates with `null` options, so no existing caller or test changed.
`Emit.OPCODES` is now public: the cost model has to be able to reject an
unknown mnemonic loudly rather than costing it zero, or a codegen typo becomes
a size report that is quietly wrong.

The parity fixture is parsed by hand rather than by adding a JSON dependency
to a module that has none — the shape is a fixed two-level map and the scan is
anchored on the emitter key, so `EcMul` cannot match inside `EcMulGen`. The
test also asserts the fixture is non-vacuous (the flags really do move the
reference's bytes) and that the comb does NOT fire for `ecMul` / `p256Mul` /
`p384Mul`, whose bases arrive at run time.

Nested `LoweringContext`s for if-arms inherit the options explicitly; without
that an EC call inside a branch would silently fall back to the shipping path.

Default output unchanged: `ecFlagsDefaultOffIsByteIdentical` pins that `null`
options reproduce the shipping hash for every emitter. Full Java suite green
(683 tests).
Completes the seven-tier port. New: `passes/helpers/comb.zig`,
`passes/helpers/ec_cost_model.zig`, and the flags `--ec-constant-pool`,
`--ec-reduction-sinking`, `--ec-fixed-base-comb`, threaded through
`CompileOptions` -> `stack_lower.lowerOpts` -> `LowerCtx`.

End-to-end, `runar-zig --ec-fixed-base-comb` produces hex identical to the
TypeScript, Go, Rust, Python, Ruby and Java compilers for the same contract —
all seven tiers now agree byte for byte with every flag on.

Two things are genuinely different here, and both are asserted rather than
assumed:

1. This tier's `StackOp.roll` / `.pick` carry the depth in the op itself and
   `emitStackOp` writes the depth push while emitting them. Every other tier's
   tracker emits a separate depth `push` and charges the roll one byte. So the
   cost model here charges `scriptNumberCost(depth) + 1`. Same emitted bytes,
   different spelling; the cost-model equality is what keeps it honest.

2. `emitEcMul` keeps `k + 3n` pre-folded on the DEFAULT path. The reference
   emits three `+n` steps and lets its peephole reassociate them; this
   peephole reassociates only i64 `push_int` chains (rule 27) and a 256-bit
   constant is a `push_data` blob in this IR, so emitting three steps would
   ship 68 extra bytes rather than collapsing. Under `--ec-constant-pool` the
   three steps ARE emitted, each served from the pooled slot, which is what
   makes the pooled variants byte-identical to the reference.

Because of (2) the Zig parity test gates on the raw byte COUNT with that single
divergence asserted exactly (`allowedDelta`) — if it ever widens, or appears
anywhere else, the test fails. The fixture now carries a `postPeephole`
measurement alongside the raw one to make the distinction explicit, and the
README explains which tiers assert which and why.

`ec_cost_model.zig` re-exports the implementation that lives in
`ec_emitters.zig` rather than duplicating it: the tracker's constant pool needs
the estimator to price a call site before emitting anything, and a second copy
would be free to drift from the one the pool actually consults.

Also adds a test pinning that the comb's window-width search picks w=3, so a
future change to the candidate set fails with a clear message rather than as an
opaque byte count.

Not ported: `nist_ec_emitters.zig`. Zig's secp256k1 side is complete; its NIST
emitters keep their shipping path, and the parity fixture covers what is ported.

Docs: `script-size-optimizer-results.md` gains §8 with the corpus figure
updated for the secp256k1 comb (13,526,563 -> 4,726,567, -65.1 %), the six
defects the parity fixture caught across the tiers, the tier-specific findings,
and what remains before any of this could land as default.

Default output unchanged: all 758 Zig tests pass, including the conformance
goldens.
`gofmt -w codegen/` reformats the whole directory, and running it while porting
pulled 43 unrelated files into the Go commit — babybear, bn254, koalabear,
sp1_fri, wots, rabin, slh_dsa, blake3, and two frontend files, none of which the
EC work touches. Every hunk in them is whitespace: statements split off shared
lines, comment columns realigned.

That is noise in a diff a reviewer has to read for correctness, and it puts
unrelated crypto emitters in the blast radius of a change that has nothing to do
with them. Restored to their pre-port bytes; the net diff for those files is now
zero.

Go builds and the full Go suite passes.
@icellan icellan changed the title Script-size optimizer: p256-wallet 958,792 → 147,113 B (−84.7%), corpus −63.7% Script-size optimizer, all 7 tiers: p256-wallet 958,792 → 147,113 B (−84.7%), corpus −65.1% Aug 30, 2026
…ters

Completes the port: every EC emitter in all seven tiers now honours
--ec-constant-pool, --ec-reduction-sinking and --ec-fixed-base-comb.

`NistTracker` is deleted and aliased to `ec_emitters.ECTracker`. The old copy
predates the sign lattice; keeping it would have meant two independently
maintained lattices, which is two chances to prove `Reduced` where only
`NonNegative` holds — and the resulting script is smaller, passes every local
test, and is wrong. Its one curve-specific field became a parameter; nothing
about a tracker is per-curve. The duplicate `beToUnsignedScriptNumAlloc` goes
too.

Field ops gain sinking, the pool, and the transfer functions.
`decomposePoint` records `.non_negative` and NOT `.reduced` on both
coordinates — a 0x00 sign byte before BIN2NUM proves >= 0 but not < p.
`groupMod` deliberately neither sinks nor marks its result `.reduced`: a mod-n
result can exceed p, and marking it reduced would license a later `fieldSub`
to take the cheap path unsoundly.

The p256 and p384 arms of `buildBuiltinOps` were two verbatim copies; folded
into curve-generic helpers so the comb is wired once rather than twice.

Byte-exact against the reference for all 14 NIST emitters under all 4 flag
combinations, with two divergences priced exactly rather than waved through:

1. `k + 3n`, worth -70 B per P-256 ladder and -102 B per P-384 ladder. Unlike
   secp256k1 — where the reference pools those three pushes, so this tier
   matches raw-for-raw once the pool is on — the NIST reference (`cEmitMul`)
   pushes raw literals under EVERY variant, so the divergence is constant. The
   two cases stay in separate branches with a comment against merging them.
2. MINIMALDATA on one-byte blobs: the reference writes `push [0x02]` as
   `OP_2`, this tier's encoder always length-prefixes. +2 per site. This one is
   PRE-EXISTING and flag-independent — visible with every flag off, on
   `pNNNEncodeCompressed`, which no flag reaches. Nothing gated it before.

`allowedDelta` derives both from `ladderCount(name, variant) * THREE_N` rather
than a table of constants, so a tier that combed `u2*Q` — which would be
combing an attacker-supplied base, outside the interval argument — changes the
ladder count and fails rather than absorbing the difference.

Verified: 761/761 Zig tests, including the conformance goldens and both
op-count golden tests, so flags-off output is unchanged for the NIST and
secp256k1 families alike. End-to-end, a p256MulGen contract compiles
byte-identically through this CLI and the TypeScript one with all three flags.

The parity test's NIST loops run on an arena: 56 bundles including a 3.9M-op
verifyECDSA_P384, where GPA bookkeeping was 178 s of 191 s. A separate
testing.allocator test covers the comb path, the pooled slots and the
verifier's transferred bundle so a leak there is still caught.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant