Skip to content

feat(vapor): sparse conditional constant propagation for the reactive graph - #267

Merged
doodlewind merged 1 commit into
pocket-stack:mainfrom
lfkdsk:feat/vapor-sccp
Aug 14, 2026
Merged

feat(vapor): sparse conditional constant propagation for the reactive graph#267
doodlewind merged 1 commit into
pocket-stack:mainfrom
lfkdsk:feat/vapor-sccp

Conversation

@lfkdsk

@lfkdsk lfkdsk commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

What

The dependency masks the compiler bakes into ROM are a syntactic over-approximation: flag.value ? a.value : b.value subscribes to all three refs. This PR adds a sparse conditional constant propagation pass (vapor/compiler/sccp.ts) that runs before setup analysis and asks a prior question: which refs can ever hold more than one value at runtime?

A ref whose every reachable write either sits behind a decidably-false guard or stores the value the ref already holds is a compile-time constant. Its reads fold, decidable ternaries and ifs compile only the taken arm, and the dead arm's refs leave the effect mask — and the ROM — entirely.

How

  • Optimistic fixpoint in the classic SCCP sense: every num/bool ref starts at const(seed) and writes only lower it. Guard reachability is judged under the current environment, so mutually-gated refs converge (if (a.value) b.value = true; if (b.value) a.value = true; with both seeded false keeps both constant — a pessimistic pass could not conclude that).
  • Soundness rests on two subset rules the compiler already enforces: assignments occur only in statement position (if/ternary conditions are the complete guard vocabulary), and no closure escapes setup (every function in the component body is assumed callable).
  • Wiring: constNum folds SCCP-constant ref reads, so the existing ternary/branch folding paths do the rest; constBool learns !x and short-circuit &&/||; dead branches drop from if statements, ternaries, conditional rows, and view expressions; the graph report annotates folded refs (bit 0: flag (bool) = const 0 (sccp: reads folded, never dirty)).
  • Oracle parity preserved: folded refs keep their state slot, seed, and debug-block entry, so oracle and device grids stay comparable. Folding only ever removes runtime-impossible transitions; observable semantics are unchanged.
  • Deliberately conservative (v1): locals are not tracked (a write whose RHS reads a local lowers the ref to NAC), helper params are NAC, loop bodies count as reachable, and only num/bool refs fold.

Bonus fix

constNum now folds prefix minus. Seeding used constNum(seed) ?? 0, so ref(-1) previously initialized device state to 0 while the oracle held -1. The parity suite never caught it because the todo demo has no negative seeds; a regression test is included.

Tests

  • vapor/tests/sccp.test.ts — 8 new tests: mask splitting for never-written refs, guard-pruned write chains (conditional propagation), optimistic mutual-gate convergence, no-fold for genuinely mutated refs, same-value writes, dep-free computeds, debug-slot/seed preservation, negative seeds.
  • Full bun run vapor:test: 69/70 pass; the one failure is the pre-existing libmgba harness build on machines without mgba headers (fails identically on a clean checkout).
  • Todo demo output is unchanged (all six refs are genuinely mutated): same 4 effects, same masks, same memory plan.

vapor/DESIGN.md §2 documents the refinement.

🤖 Generated with Claude Code

… graph

The dependency masks the compiler bakes into ROM are a syntactic
over-approximation: `flag.value ? a.value : b.value` subscribes to all
three refs. This adds an SCCP pass that runs before setup analysis and
asks which refs can ever hold more than one value at runtime.

Every ref starts at its seed constant; write sites collected across the
component body lower it, except writes behind a guard that is decidably
false under the current environment. The pass is optimistic and iterates
to a fixpoint, so mutually-gated refs converge to constants. A ref proven
constant folds at every read: it registers no dependencies, decidable
ternaries and ifs compile only the taken arm, and the dead arm's refs
leave the effect mask - and the ROM - entirely. Folded refs keep their
state slot, seed, and debug-block entry, so oracle and device grids stay
comparable.

Wiring in compile.ts: constNum folds SCCP-constant ref reads and prefix
minus (fixing `ref(-1)` seeding state to 0 instead of -1); constBool
folds !x and short-circuit &&/||; dead branches drop from if statements,
ternaries, conditional rows, and view expressions; the graph report
annotates folded refs.

Conservative by design (v1): locals are not tracked, helper params are
not-a-constant, loop bodies count as reachable, and only num/bool refs
fold - str/list refs never do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lfkdsk

lfkdsk commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Benchmark: what SCCP buys, measured end to end

Since frame content is a pure function of tick index + input, the effect of this PR can be demonstrated exactly: same fixture, same 60-press button tape, per-frame grid hashes to prove the screens are identical, counters and timing to show what work disappeared.

Fixture — a HUD in the shape real apps take: two refs fixed at build time, three live ones.

const debugHud = ref(false); // build-time flag, never toggled in this build
const theme = ref(0);        // reserved for v2, never written
const hp = ref(100);
const ammo = ref(30);
const score = ref(0);
onButton((b) => {
  if (b === Button.A) ammo.value -= 1;
  if (b === Button.B) hp.value -= 1;
  if (b === Button.Select) score.value += 10;
  if (debugHud.value) score.value += 1;
});
// rows: HP {debugHud.value ? score.value : hp.value} / theme ternary /
//       AMMO {ammo.value} / SCORE {score.value} /
//       {debugHud.value ? <row y={5}>DBG {score.value} {ammo.value} {hp.value}</row> : null}

Reactive graph

main this PR
effects 5 3
HP row mask {debugHud, hp, score} {hp}
DBG row mask {debugHud, hp, ammo, score} — clears and re-runs on every one of those effect removed from ROM
theme row dynamic effect static boot paint

Runtime, 60-press tape (generated C + vapor_core.c compiled on the host)

main this PR
effect executions 135 60 (−56%)
rows cleared+repainted 135 60
VRAM rows recommitted (commit loop mirrored from vapor_gba.c) 75 60 (−20%)
per-frame grid hash, all 60 frames byte-identical

The last row is the point: every eliminated execution was work that repainted pixels to the values they already had. On main, every ammo pickup re-runs the DBG-row effect (clear row, test debugHud, paint nothing), and every score change repaints an unchanged HP row.

Frame cost and ROM

Timing covers the full frame path — handler, flush, row commit — over 1.2M frames, uninstrumented C at -O2:

main this PR
mean per-frame handler+flush+commit 201.5 ns 115.0 ns (−43%)
hud.gba (arm-none-eabi-gcc, real build pipeline) 6372 B 6092 B (−280 B)
todo.gba (control) 9.1 KB byte-identical

Host x86 timing is a proxy; the eliminated work is full-row clears, per-cell glyph paints and integer formatting — memory-bound loops that ARM7TDMI cannot hide behind any microarchitecture, so the relative saving on device should be at least this. The counters above are architecture-independent.

Compiler cost

todo.tsx 2.00 → 1.83 ms/compile, hud.tsx 0.32 → 0.38 ms/compile (300 iterations after warmup): the pass — one AST walk to collect write sites plus a fixpoint over a ≤16-ref lattice — is under 0.1 ms and inside the noise.

Honest boundary

The benefit is proportional to how much build-time-fixed state an app carries (feature flags, platform switches, reserved fields, difficulty presets). The todo demo has none, and its ROM is byte-identical — the only textual change in its generated C is (-1) vs -1 from the prefix-minus fold. An app where every ref is genuinely mutated sees exactly zero change.

🤖 Generated with Claude Code

@lfkdsk
lfkdsk marked this pull request as ready for review August 13, 2026 22:04
@lfkdsk

lfkdsk commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Validation update: the three-console parity suite passes on this branch

Follow-up to the synthetic benchmark above — two more validation layers, both against real code.

Every Pocket Vapor app in the repo, compiled by both compilers

app result
todo.tsx × gba / gb / nes / esp32 ROM byte-identical (todo.gba compared byte-for-byte)
todo.playdate.tsx only textual change in generated C is (-1)-1 from the prefix-minus fold
playdate-six-button.tsx generated C identical

None of the shipped examples carry a foldable ref — every ref is genuinely mutated — so the pass is a strict no-op on all of them. That is the regression story: apps without build-time-constant state compile to the same bytes.

The full oracle↔ROM suite, on Linux, on this branch

With the toolchain-path overrides from #268 (libmgba 0.10 built from source, sdcc/rgbds/cc65 from Linuxbrew):

75 pass, 0 fail, 7,414 expect() calls

That includes the three-console parity run: the todo ROMs produced by the SCCP compiler, driven through the 31-press tape under libmgba (GBA, GB) and jsnes (NES), compared cell-for-cell — characters and palettes, logical grid and decoded video memory — against real Vue 3.6 running the same tape, with tripwires asserted zero. Every folding decision this pass makes is downstream of that oracle.

🤖 Generated with Claude Code

@doodlewind

Copy link
Copy Markdown
Collaborator

Verdict: merging. Reviewed on a local checkout; every load-bearing claim below was reproduced, not read.

The bonus fix is a real parity bug, reproduced on base. classifyRefSeed accepts a prefix-unary seed as num, but base constNum didn't fold prefix minus, so seeding fell through constNum(ref.seed) ?? 0: a ref(-1) app emits g_cursor = 0; on base and g_cursor = -1; on this branch, while the oracle holds -1 both ways. The todo demo has no negative seeds, which is exactly why the parity suite never saw it.

The soundness prerequisites hold empirically. The pass is only sound if writes can't hide from valueBase collection. I probed the escape hatches on both base and branch: ref aliasing (const y = x; y.value = 1), helper-param writes ((r) => { r.value += 1 }), assignment in expression position (cond ? (x.value = 1) : ...), logical assignment (||=), and while loops — all five are rejected by the compiler on both sides, so "assignments in statement position, no aliasing" is enforced, not assumed. The fixpoint's 64-round bound is unreachable: the 16-ref subset budget means the env can descend at most 16 times, so ≤17 rounds.

Suite: 75/75, 7414 assertions on this machine — including the three-console oracle↔ROM parity your environment couldn't run (mgba headers present here). tsc --noEmit clean.

One footnote on the description: "Todo demo output is unchanged" is true for the graph, masks, and memory plan (verified byte-identical against base on gba/gb/nes), but the C text differs by 3 cosmetic lines — the new prefix-minus folding emits -1 where base emitted (-1). Same semantics, worth knowing if anyone diffs generated C across the boundary.

Thanks @lfkdsk — an optimistic-fixpoint pass whose soundness argument leans on subset rules the compiler already enforces (and says so) is exactly how vapor's compiler was meant to grow. The oracle-parity preservation (folded refs keep slot, seed, and debug entry) shows real care for the determinism contract.

@doodlewind
doodlewind merged commit 3c06482 into pocket-stack:main Aug 14, 2026
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.

2 participants