From 5b422e9ea3d1c9e08060edbf8baacb2a46ebd299 Mon Sep 17 00:00:00 2001 From: lfkdsk Date: Thu, 13 Aug 2026 14:35:05 -0700 Subject: [PATCH] feat(vapor): sparse conditional constant propagation for the reactive 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) --- vapor/DESIGN.md | 18 +++ vapor/compiler/compile.ts | 80 +++++++++++- vapor/compiler/sccp.ts | 254 ++++++++++++++++++++++++++++++++++++++ vapor/tests/sccp.test.ts | 157 +++++++++++++++++++++++ 4 files changed, 504 insertions(+), 5 deletions(-) create mode 100644 vapor/compiler/sccp.ts create mode 100644 vapor/tests/sccp.test.ts diff --git a/vapor/DESIGN.md b/vapor/DESIGN.md index 22edb3e7..511ebccf 100644 --- a/vapor/DESIGN.md +++ b/vapor/DESIGN.md @@ -70,6 +70,24 @@ graph is a superset, never a subset. This is the one deliberate divergence from Vue's dynamic dependency collection, and the E2E oracle keeps it honest. +Before setup analysis, a sparse conditional constant propagation pass +(`vapor/compiler/sccp.ts`) tightens that superset. 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 (`if (a.value) b.value = true; +if (b.value) a.value = true;` with both seeded `false` keeps both +constant). A ref proven constant folds at every read: it registers no +dependencies, decidable ternaries and `if`s 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 the +oracle and device grids stay comparable. 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). Locals are not tracked — a write whose right side reads a +local lowers the ref to not-a-constant. + ## 3. Memory: arenas, not GC The runtime never calls `malloc` and never frees: diff --git a/vapor/compiler/compile.ts b/vapor/compiler/compile.ts index 9ae59949..c083aee9 100644 --- a/vapor/compiler/compile.ts +++ b/vapor/compiler/compile.ts @@ -18,6 +18,7 @@ import ts from "typescript"; import { FONT8 } from "./font.gen.ts"; +import { sccpRefConstants } from "./sccp.ts"; import { rgb555, rgb565, StyleTable, type StyleIssue } from "./styles.ts"; import { BACKDROP } from "./styles.ts"; @@ -260,6 +261,11 @@ class AppCompiler { private styleErrors: string[] = []; private styleWarnings: string[] = []; + /** SCCP result: refs proven constant (name -> value). Reads of these fold + * through constNum, so they never register dependencies and decidable + * ternary/if branches drop their dead arm from masks and ROM alike. */ + private sccpFolded: Map | null = null; + constructor( private sf: ts.SourceFile, private title: string, @@ -298,6 +304,14 @@ class AppCompiler { } if (!component) this.err(this.sf, "missing `export default () => ...` component"); if (!this.vueRef || !this.vueComputed) this.err(this.sf, 'component must import { ref, computed } from "vue"'); + // SCCP runs before setup analysis so computed/effect dep collection sees + // folded ref reads. foldConst here is module-level only: sccpFolded is + // still null, so constNum cannot consult the result being computed. + this.sccpFolded = sccpRefConstants({ + component, + refLocalName: this.vueRef, + foldConst: (e) => this.constNum(e) ?? this.constBool(e), + }); this.scanSetup(component); return this.emit(); } @@ -821,8 +835,11 @@ class AppCompiler { private viewMaxLen(e: ts.Expression): number { e = this.unparen(e); - if (ts.isConditionalExpression(e)) + if (ts.isConditionalExpression(e)) { + const cf = this.constNum(e.condition) ?? this.constBool(e.condition); + if (cf !== null) return this.viewMaxLen(cf ? e.whenTrue : e.whenFalse); return Math.max(this.viewMaxLen(e.whenTrue), this.viewMaxLen(e.whenFalse)); + } if (ts.isCallExpression(e) && ts.isPropertyAccessExpression(e.expression)) { const inner = this.viewMaxLen(e.expression.expression); if (e.expression.name.text === "filter") return inner; @@ -840,15 +857,30 @@ class AppCompiler { return this.target.poolCap; } - /** Comparisons over compile-time numbers fold to 0/1 (SCREEN.width < 30). */ + /** Comparisons over compile-time numbers fold to 0/1 (SCREEN.width < 30), + * plus !x and short-circuit &&/|| over foldable operands — SCCP-folded + * ref reads arrive through constNum, so `!locked.value` folds too. */ private constBool(e: ts.Expression): number | null { e = this.unparen(e); + const K = ts.SyntaxKind; + if (ts.isPrefixUnaryExpression(e) && e.operator === K.ExclamationToken) { + const v = this.constNum(e.operand) ?? this.constBool(e.operand); + return v === null ? null : v ? 0 : 1; + } if (!ts.isBinaryExpression(e)) return null; + const op = e.operatorToken.kind; + if (op === K.AmpersandAmpersandToken || op === K.BarBarToken) { + const l = this.constNum(e.left) ?? this.constBool(e.left); + if (l === null) return null; // short-circuit needs a decided left + if (op === K.AmpersandAmpersandToken && !l) return 0; + if (op === K.BarBarToken && l) return 1; + const r = this.constNum(e.right) ?? this.constBool(e.right); + return r === null ? null : r ? 1 : 0; + } const l = this.constNum(e.left); const r = this.constNum(e.right); if (l === null || r === null) return null; - const K = ts.SyntaxKind; - switch (e.operatorToken.kind) { + switch (op) { case K.LessThanToken: return l < r ? 1 : 0; case K.GreaterThanToken: return l > r ? 1 : 0; case K.LessThanEqualsToken: return l <= r ? 1 : 0; @@ -875,6 +907,19 @@ class AppCompiler { const sub = this.substProp(e); if (sub) return this.constNum(sub); if (ts.isNumericLiteral(e)) return Number(e.text); + if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.MinusToken) { + const v = this.constNum(e.operand); + return v === null ? null : -v; + } + // SCCP-folded ref reads: `flag.value` where flag is proven constant. + // Scope check keeps shadowing locals (map/filter params) out. + { + const base = this.valueBase(e); + if (base !== null && this.sccpFolded?.has(base)) { + const b = this.scope.get(base); + if (!b || b.kind === "ref") return this.sccpFolded.get(base)!; + } + } if (ts.isIdentifier(e)) { const b = this.scope.get(e.text); if (b?.kind === "const" && typeof b.value === "number") return b.value; @@ -918,6 +963,11 @@ class AppCompiler { private compileViewInto(e: ts.Expression, target: string, out: string[], ind: string): void { e = this.unparen(e); if (ts.isConditionalExpression(e)) { + const cf = this.constNum(e.condition) ?? this.constBool(e.condition); + if (cf !== null) { + this.compileViewInto(cf ? e.whenTrue : e.whenFalse, target, out, ind); + return; + } const cond = this.compileExpr(e.condition, out, ind); out.push(`${ind}if (${this.condition(cond)}) {`); this.compileViewInto(e.whenTrue, target, out, ind + " "); @@ -1283,6 +1333,14 @@ class AppCompiler { return; } if (ts.isIfStatement(stmt)) { + // decidable condition (SCCP-folded refs, SCREEN geometry): emit only + // the taken branch — the dead arm leaves ROM and the dep set entirely + const cf = this.constNum(stmt.expression) ?? this.constBool(stmt.expression); + if (cf !== null) { + if (cf) this.compileStmt(stmt.thenStatement, out, ind); + else if (stmt.elseStatement) this.compileStmt(stmt.elseStatement, out, ind); + return; + } const cond = this.compileExpr(stmt.expression, out, ind); out.push(`${ind}if (${this.condition(cond)}) {`); this.compileStmt(stmt.thenStatement, out, ind + " "); @@ -1832,6 +1890,13 @@ class AppCompiler { e.whenFalse.kind !== ts.SyntaxKind.NullKeyword ) this.err(e, "conditional children must be {cond ? : null}"); + // decidable condition: the row is unconditionally present (a plain row + // unit, static if its paint has no deps) or not present at all + const cf = this.constNum(e.condition) ?? this.constBool(e.condition); + if (cf !== null) { + if (cf) return this.compileRowUnit(whenTrue); + return { span: [0, 0], deps: new Set(), decls: [], body: [], isStatic: true }; + } const deps = new Set(); const prev = this.curDeps; this.curDeps = deps; @@ -2165,7 +2230,12 @@ class AppCompiler { // ---- reports ---- const graphLines: string[] = []; graphLines.push("refs:"); - for (const r of this.refs) graphLines.push(` bit ${r.index}: ${r.name} (${r.refTy})`); + for (const r of this.refs) { + const folded = this.sccpFolded?.has(r.name) + ? ` = const ${this.sccpFolded.get(r.name)} (sccp: reads folded, never dirty)` + : ""; + graphLines.push(` bit ${r.index}: ${r.name} (${r.refTy})${folded}`); + } graphLines.push("computeds:"); for (const comp of this.computeds) graphLines.push( diff --git a/vapor/compiler/sccp.ts b/vapor/compiler/sccp.ts new file mode 100644 index 00000000..39721c2b --- /dev/null +++ b/vapor/compiler/sccp.ts @@ -0,0 +1,254 @@ +// vapor/compiler/sccp.ts — sparse conditional constant propagation over refs. +// +// The dependency graph the compiler bakes into ROM is a syntactic +// over-approximation: `flag.value ? a.value : b.value` subscribes to all +// three refs. This pass 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 (a) sits behind a guard that is +// decidably false, or (b) stores the value the ref already holds, is a +// compile-time constant. Its reads fold, decidable ternaries and ifs pick +// one arm, and the dead arm's dependencies — and ROM code — disappear. +// +// The analysis is optimistic in the classic SCCP sense: every ref starts +// at const(seed), and writes only lower it. Guard reachability is judged +// under the *current* environment, so mutually-gated refs converge to +// const (`if (a.value) b.value = 1; if (b.value) a.value = 1;` with both +// seeded 0 keeps both at 0 — a pessimistic pass could not conclude that). +// Iteration re-examines every write site until the environment stabilizes; +// at fixpoint every pruning decision is consistent with the final env, +// which makes the result sound. +// +// Soundness relies on two subset properties the compiler enforces later: +// assignments only occur in statement position (so if/ternary conditions +// are the complete guard vocabulary), and no function or closure escapes +// setup (so every function-like node in the component body is the whole +// universe of callable code — all are conservatively assumed reachable; +// only statement-level guards prune). +// +// Deliberately conservative (v1): locals are not tracked (a write whose +// RHS reads a local is NAC), helper params are NAC, loop bodies count as +// reachable, and only num/bool refs fold — str/list refs start at NAC. + +import ts from "typescript"; + +export type SccpValue = { kind: "const"; value: number } | { kind: "nac" }; + +const NAC: SccpValue = { kind: "nac" }; +const cval = (v: number): SccpValue => ({ kind: "const", value: v | 0 }); + +function join(a: SccpValue, b: SccpValue): SccpValue { + if (a.kind === "nac" || b.kind === "nac") return NAC; + return a.value === b.value ? a : NAC; +} + +interface Guard { + cond: ts.Expression; + whenTruthy: boolean; +} + +type WriteOp = "=" | "+=" | "-=" | "*=" | "%=" | "++" | "--" | "nac"; + +interface WriteSite { + ref: string; + op: WriteOp; + rhs: ts.Expression | null; // null for ++/-- + guards: Guard[]; +} + +export interface SccpOptions { + component: ts.ArrowFunction; + /** local name of vue's `ref` import */ + refLocalName: string; + /** module-level folding (consts, SCREEN, Button, folded comparisons) */ + foldConst: (e: ts.Expression) => number | null; +} + +/** `name.value` -> name. */ +function valueBase(e: ts.Expression): string | null { + e = unparen(e); + if (ts.isPropertyAccessExpression(e) && e.name.text === "value" && ts.isIdentifier(e.expression)) + return e.expression.text; + return null; +} + +function unparen(e: ts.Expression): ts.Expression { + while (ts.isParenthesizedExpression(e)) e = e.expression; + return e; +} + +/** + * Run the analysis over one component. Returns only the refs proven + * constant: name -> value (booleans as 0/1). Everything absent is NAC. + */ +export function sccpRefConstants(opts: SccpOptions): Map { + const { component, refLocalName, foldConst } = opts; + if (!ts.isBlock(component.body)) return new Map(); + + // -- discover refs (top-level `const x = ref(seed)` only, like scanSetup) -- + const env = new Map(); + for (const stmt of component.body.statements) { + if (!ts.isVariableStatement(stmt)) continue; + for (const decl of stmt.declarationList.declarations) { + if (!ts.isIdentifier(decl.name) || !decl.initializer) continue; + const init = decl.initializer; + if (!ts.isCallExpression(init) || !ts.isIdentifier(init.expression)) continue; + if (init.expression.text !== refLocalName) continue; + const seed = init.arguments[0]; + if (!seed) continue; + env.set(decl.name.text, seedValue(seed, foldConst)); + } + } + if (env.size === 0) return new Map(); + + // -- collect write sites with their statement-level guard chains ---------- + const writes: WriteSite[] = []; + const K = ts.SyntaxKind; + const compound: Partial> = { + [K.EqualsToken]: "=", + [K.PlusEqualsToken]: "+=", + [K.MinusEqualsToken]: "-=", + [K.AsteriskEqualsToken]: "*=", + [K.PercentEqualsToken]: "%=", + }; + + const walk = (node: ts.Node, guards: Guard[]): void => { + if (ts.isIfStatement(node)) { + walk(node.expression, guards); + walk(node.thenStatement, [...guards, { cond: node.expression, whenTruthy: true }]); + if (node.elseStatement) walk(node.elseStatement, [...guards, { cond: node.expression, whenTruthy: false }]); + return; + } + if (ts.isConditionalExpression(node)) { + walk(node.condition, guards); + walk(node.whenTrue, [...guards, { cond: node.condition, whenTruthy: true }]); + walk(node.whenFalse, [...guards, { cond: node.condition, whenTruthy: false }]); + return; + } + if (ts.isBinaryExpression(node)) { + const op = compound[node.operatorToken.kind]; + const isAssign = + op !== undefined || + node.operatorToken.kind >= K.FirstAssignment && node.operatorToken.kind <= K.LastAssignment; + if (isAssign) { + const base = valueBase(node.left); + if (base && env.has(base)) writes.push({ ref: base, op: op ?? "nac", rhs: node.right, guards }); + walk(node.right, guards); + return; + } + } + if ( + (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && + (node.operator === K.PlusPlusToken || node.operator === K.MinusMinusToken) + ) { + const base = valueBase(node.operand); + if (base && env.has(base)) { + writes.push({ ref: base, op: node.operator === K.PlusPlusToken ? "++" : "--", rhs: null, guards }); + return; + } + } + ts.forEachChild(node, (c) => walk(c, guards)); + }; + walk(component.body, []); + + // -- abstract evaluation under the current environment -------------------- + const evalExpr = (e: ts.Expression): SccpValue => { + e = unparen(e); + const k = foldConst(e); + if (k !== null) return cval(k); + if (e.kind === K.TrueKeyword) return cval(1); + if (e.kind === K.FalseKeyword) return cval(0); + const base = valueBase(e); + if (base) return env.get(base) ?? NAC; + if (ts.isPrefixUnaryExpression(e)) { + const v = evalExpr(e.operand); + if (v.kind !== "const") return NAC; + if (e.operator === K.ExclamationToken) return cval(v.value ? 0 : 1); + if (e.operator === K.MinusToken) return cval(-v.value); + return NAC; + } + if (ts.isBinaryExpression(e)) { + const op = e.operatorToken.kind; + if (op === K.AmpersandAmpersandToken || op === K.BarBarToken) { + const l = evalExpr(e.left); + if (l.kind !== "const") return NAC; // short-circuit needs a decided left + const lTruthy = l.value !== 0; + if (op === K.AmpersandAmpersandToken) return lTruthy ? evalExpr(e.right) : cval(0); + return lTruthy ? cval(1) : evalExpr(e.right); + } + const l = evalExpr(e.left); + const r = evalExpr(e.right); + if (l.kind !== "const" || r.kind !== "const") return NAC; + switch (op) { + case K.PlusToken: return cval(l.value + r.value); + case K.MinusToken: return cval(l.value - r.value); + case K.AsteriskToken: return cval(Math.imul(l.value, r.value)); + case K.LessThanToken: return cval(l.value < r.value ? 1 : 0); + case K.GreaterThanToken: return cval(l.value > r.value ? 1 : 0); + case K.LessThanEqualsToken: return cval(l.value <= r.value ? 1 : 0); + case K.GreaterThanEqualsToken: return cval(l.value >= r.value ? 1 : 0); + case K.EqualsEqualsEqualsToken: return cval(l.value === r.value ? 1 : 0); + case K.ExclamationEqualsEqualsToken: return cval(l.value !== r.value ? 1 : 0); + default: return NAC; // / stays unfolded: device semantics live in codegen + } + } + if (ts.isConditionalExpression(e)) { + const c = evalExpr(e.condition); + if (c.kind === "const") return evalExpr(c.value ? e.whenTrue : e.whenFalse); + return join(evalExpr(e.whenTrue), evalExpr(e.whenFalse)); + } + return NAC; + }; + + const writeValue = (w: WriteSite): SccpValue => { + const cur = env.get(w.ref)!; + if (w.op === "++" || w.op === "--") { + if (cur.kind !== "const") return NAC; + return cval(cur.value + (w.op === "++" ? 1 : -1)); + } + const rhs = w.rhs ? evalExpr(w.rhs) : NAC; + if (w.op === "=") return rhs; + if (cur.kind !== "const" || rhs.kind !== "const") return NAC; + switch (w.op) { + case "+=": return cval(cur.value + rhs.value); + case "-=": return cval(cur.value - rhs.value); + case "*=": return cval(Math.imul(cur.value, rhs.value)); + case "%=": return rhs.value === 0 ? NAC : cval(cur.value % rhs.value); + default: return NAC; + } + }; + + // -- fixpoint: env only descends; pruning re-judged each round ------------ + let changed = true; + let rounds = 0; + while (changed && rounds++ < 64) { + changed = false; + for (const w of writes) { + const cur = env.get(w.ref)!; + if (cur.kind === "nac") continue; + const dead = w.guards.some((g) => { + const v = evalExpr(g.cond); + return v.kind === "const" && (v.value !== 0) !== g.whenTruthy; + }); + if (dead) continue; + const merged = join(cur, writeValue(w)); + if (merged.kind !== cur.kind || (merged.kind === "const" && cur.kind === "const" && merged.value !== cur.value)) { + env.set(w.ref, merged); + changed = true; + } + } + } + + const folded = new Map(); + for (const [name, v] of env) if (v.kind === "const") folded.set(name, v.value); + return folded; +} + +function seedValue(seed: ts.Expression, foldConst: (e: ts.Expression) => number | null): SccpValue { + seed = unparen(seed); + if (seed.kind === ts.SyntaxKind.TrueKeyword) return cval(1); + if (seed.kind === ts.SyntaxKind.FalseKeyword) return cval(0); + const v = foldConst(seed); + if (v !== null) return cval(v); + return NAC; // str/list refs (and anything else) never fold +} diff --git a/vapor/tests/sccp.test.ts b/vapor/tests/sccp.test.ts new file mode 100644 index 00000000..a08023aa --- /dev/null +++ b/vapor/tests/sccp.test.ts @@ -0,0 +1,157 @@ +// vapor/tests/sccp.test.ts — sparse conditional constant propagation. +// +// The dependency graph is normally a syntactic over-approximation: +// `flag.value ? a.value : b.value` subscribes to all three. These tests pin +// the SCCP refinement: refs proven constant fold at compile time, decidable +// branches drop their dead arm from both the effect masks and the ROM. + +import { describe, expect, test } from "bun:test"; +import { compileVaporApp } from "../compiler/compile.ts"; + +const HEADER = ` +import { computed, ref } from "vue"; +import { Button, onButton } from "../../host/input.ts"; +`; + +function app(setup: string, handler: string, jsx: string): string { + return `${HEADER} +export default () => { +${setup} + onButton((b) => { +${handler} + }); + return ( + <> +${jsx} + + ); +}; +`; +} + +describe("sccp ref constant propagation", () => { + test("a never-written ref folds and splits the ternary mask", () => { + const src = app( + ` const flag = ref(false); + const a = ref(1); + const bb = ref(2);`, + ` if (b === Button.A) a.value += 1; + if (b === Button.B) bb.value += 1;`, + ` {flag.value ? a.value : bb.value}`, + ); + const out = compileVaporApp("sccp.tsx", src); + expect(out.graph).toContain("flag (bool) = const 0 (sccp: reads folded, never dirty)"); + // the effect subscribes to bb alone — a and flag are gone from the mask + expect(out.graph).toMatch(/eff_0: rows \[0, 1\) mask 0x4 \{bb\}/); + // and the dead arm never reaches ROM + expect(out.c).not.toContain("g_flag ?"); + expect(out.c).not.toContain("g_a :"); + }); + + test("writes behind a decidably-false guard are pruned (conditional propagation)", () => { + const src = app( + ` const locked = ref(true); + const secret = ref(0); + const count = ref(0);`, + ` if (b === Button.A) count.value += 1; + if (!locked.value) secret.value += 1;`, + ` {secret.value ? 9 : count.value} + {locked.value ? "LOCKED" : "OPEN"}`, + ); + const out = compileVaporApp("sccp.tsx", src); + // locked never written -> const 1; that kills secret's only write -> + // secret const 0; both rows and the handler's dead if fold + expect(out.graph).toContain("locked (bool) = const 1"); + expect(out.graph).toContain("secret (num) = const 0"); + expect(out.graph).toMatch(/eff_0: rows \[0, 1\) mask 0x4 \{count\}/); + // the pruned write's set-gate never reaches ROM (seeding `g_secret = 0;` + // legitimately remains for oracle/debug parity) + expect(out.c).not.toContain("g_secret !="); + expect(out.c).not.toContain("!g_locked"); + // row 1 folded to a static paint: exactly one effect remains + expect(out.graph.match(/eff_\d+:/g)?.length).toBe(1); + expect(out.c).toContain("LOCKED"); + expect(out.c).not.toContain("OPEN"); + }); + + test("optimistic fixpoint: mutually-gated refs converge to const", () => { + const src = app( + ` const a = ref(false); + const b2 = ref(false); + const count = ref(0);`, + ` if (b === Button.A) count.value += 1; + if (a.value) b2.value = true; + if (b2.value) a.value = true;`, + ` {a.value || b2.value ? 1 : count.value}`, + ); + const out = compileVaporApp("sccp.tsx", src); + expect(out.graph).toContain("a (bool) = const 0"); + expect(out.graph).toContain("b2 (bool) = const 0"); + expect(out.graph).toMatch(/mask 0x4 \{count\}/); + }); + + test("a genuinely-mutated ref does not fold; both arms stay subscribed", () => { + const src = app( + ` const flag = ref(false); + const a = ref(1); + const bb = ref(2);`, + ` if (b === Button.Select) flag.value = !flag.value; + if (b === Button.A) a.value += 1; + if (b === Button.B) bb.value += 1;`, + ` {flag.value ? a.value : bb.value}`, + ); + const out = compileVaporApp("sccp.tsx", src); + expect(out.graph).not.toContain("= const"); + expect(out.graph).toMatch(/eff_0: rows \[0, 1\) mask 0x7 \{flag, a, bb\}/); + }); + + test("writes that store the ref's current value do not break folding", () => { + const src = app( + ` const mode = ref(3); + const count = ref(0);`, + ` if (b === Button.A) { count.value += 1; mode.value = 3; } + if (b === Button.B) mode.value = 2 + 1;`, + ` {mode.value === 3 ? count.value : 0}`, + ); + const out = compileVaporApp("sccp.tsx", src); + expect(out.graph).toContain("mode (num) = const 3"); + expect(out.graph).toMatch(/mask 0x2 \{count\}/); + }); + + test("computed over folded refs becomes dep-free and leaves masks", () => { + const src = app( + ` const base = ref(10); + const count = ref(0); + const offset = computed(() => base.value * 2);`, + ` if (b === Button.A) count.value += 1;`, + ` {offset.value + count.value}`, + ); + const out = compileVaporApp("sccp.tsx", src); + expect(out.graph).toContain("base (num) = const 10"); + expect(out.graph).toContain("offset: num <- {}"); + expect(out.graph).toMatch(/mask 0x2 \{count\}/); + }); + + test("folded refs still seed state and keep their debug slots (oracle parity)", () => { + const src = app( + ` const flag = ref(true); + const count = ref(0);`, + ` if (b === Button.A) count.value += 1;`, + ` {flag.value ? count.value : 0}`, + ); + const out = compileVaporApp("sccp.tsx", src); + expect(out.c).toContain("g_flag = 1;"); + expect(out.debugSlots.map((s) => s.name)).toEqual(["flag", "count"]); + }); + + test("negative ref seeds initialize correctly (prefix-minus folding)", () => { + const src = app( + ` const cursor = ref(-1); + const count = ref(0);`, + ` if (b === Button.A) { cursor.value = 0; count.value += 1; }`, + ` {cursor.value + count.value}`, + ); + const out = compileVaporApp("sccp.tsx", src); + expect(out.c).toContain("g_cursor = -1;"); + }); +});