From 956bdf4ec0045ee6aca671dc720075409bbd7f3b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 23:49:13 -0500 Subject: [PATCH] Bound while-loop counters that nested scans advance A while loop's counter gets an integer range when the loop only adds positive literals to it and its condition bounds it. A counter written inside a nested loop was refused, which is every hand-written scanner: `while (pos < end) { ... while (pos < end && isDigit(...)) pos++; ... }`. A nested while loop may now advance the counter when its own condition bounds it the same way and does not write it. Its passes start at most at that bound, so it leaves the counter at its entry value or at the highest value one of its passes reaches, and the outer loop's range follows from there. Other nested loops, and nested loops that do not bound the counter, still refuse it. --- .../compiler/src/ir/integer-loop-facts.ts | 74 ++++++++++++++++--- .../compiler/src/ir/integer-ranges.test.ts | 68 +++++++++++++++++ 2 files changed, 130 insertions(+), 12 deletions(-) diff --git a/packages/compiler/src/ir/integer-loop-facts.ts b/packages/compiler/src/ir/integer-loop-facts.ts index 23982aa07..b075ef29a 100644 --- a/packages/compiler/src/ir/integer-loop-facts.ts +++ b/packages/compiler/src/ir/integer-loop-facts.ts @@ -246,10 +246,15 @@ export const STRING_MAX_UNITS = 0x7fffffff; /** Bound counters of a while loop by its condition. A counter qualifies when * the loop (condition and body) changes it only by increments of positive - * integer literals outside nested loops, switch and exception regions, and a - * conjunct of the condition compares it below a literal, a binding the loop - * never writes, or the length of a string binding the loop never writes - * (`while (x * x + y * y <= 4 && it < MAX) it++`, `while (pos < text.length)`). + * integer literals outside switch and exception regions and loops other + * than nested while loops, and a conjunct of the condition compares it below + * a literal, a binding the loop never writes, or the length of a string + * binding the loop never writes (`while (x * x + y * y <= 4 && it < MAX) + * it++`, `while (pos < text.length)`). A nested while loop may advance the + * counter only when its own condition bounds it the same way and does not + * write it (a scanner's `while (pos < end && isDigit(...)) pos++`): its + * passes start at most at that bound, so it leaves the counter at its entry + * value or at the highest value one of its passes reaches. * The body runs only after that conjunct held, and the condition never * writes the counter, so every pass starts at most at the conjunct's bound. * The pass then climbs by the increments on its path; an increment inside an @@ -270,19 +275,29 @@ export function guardedLoopCounterFacts( if (opaque || delta === null || delta <= 0 || delta > 2147483647) refused.add(id); else budget.set(id, (budget.get(id) ?? 0) + delta); }; + // Nested while loops outside opaque regions, checked against their own + // conditions once the stable bounds are known. + const nested: (IrStmt & { kind: "while" })[] = []; const expr = (e: IrExpr): boolean => { if (e.kind === "assignExpr") write(e.localId, increment(e.localId, e.value)); if (e.kind === "incDec") write(e.localId, e.op === "+" ? 1 : null); return everyExprChild(e, expr, stmt); }; + const condition = new Set(); const stmt = (s: IrStmt): boolean => { + const bounded = s.kind === "while" && opaque === 0 && !s.labels?.length; const region = - s.kind === "for" || - s.kind === "while" || - s.kind === "doWhile" || - s.kind === "forOf" || - s.kind === "tryCatch" || - s.kind === "switch"; + !bounded && + (s.kind === "for" || + s.kind === "while" || + s.kind === "doWhile" || + s.kind === "forOf" || + s.kind === "tryCatch" || + s.kind === "switch"); + if (bounded) { + nested.push(s); + conditionWrites(s.cond); + } if (region) opaque++; if (s.kind === "assign") write(s.localId, increment(s.localId, s.value)); if (s.kind === "varDecl" || s.kind === "forOf") write(s.localId, null); @@ -293,7 +308,6 @@ export function guardedLoopCounterFacts( }; // The condition is evaluated before every pass; a write there would // separate the compared value from the one the body sees. - const condition = new Set(); const conditionWrites = (e: IrExpr): boolean => { if (e.kind === "assignExpr" || e.kind === "incDec") condition.add(e.localId); return everyExprChild(e, conditionWrites, (s) => { @@ -304,8 +318,9 @@ export function guardedLoopCounterFacts( }); }; conditionWrites(loop.cond); - for (const id of condition) refused.add(id); loop.body.every(stmt); + // Nested loops' conditions are collected during the walk. + for (const id of condition) refused.add(id); const stable = (id: string): boolean => { const local = locals.get(id); return local !== undefined && !local.boxed && !local.tdz && !budget.has(id) && !refused.has(id); @@ -359,6 +374,11 @@ export function guardedLoopCounterFacts( } return ceiling; }; + // A nested while loop advances only the counters its condition bounds. + for (const inner of nested) { + const caps = ceilings(inner.cond); + for (const id of writtenLocals(inner.body)) if (!caps.has(id)) refused.add(id); + } const ceiling = ceilings(loop.cond); for (const id of budget.keys()) { const local = locals.get(id), @@ -416,6 +436,21 @@ function highestCount( count = Math.max(then, else_); } else if (s.kind === "block" && !s.labels?.length) { count = walk(s.body, count); + } else if ( + s.kind === "while" && + !s.labels?.length && + increments(s.cond, true) === 0 && + ceilings(s.cond).has(id) + ) { + // Every pass starts at most at the bound; the loop ends at its entry + // value or where a pass left the counter. + const cap = ceilings(s.cond).get(id)!; + const outer = highest; + highest = cap; + walk(s.body, cap); + const inner = highest; + highest = Math.max(outer, inner); + count = Math.max(count, inner); } else { count += increments(s, false); highest = Math.max(highest, count); @@ -435,6 +470,21 @@ function highestCount( return highest; } +/** The bindings `body` assigns or increments anywhere. */ +function writtenLocals(body: readonly IrStmt[]): Set { + const ids = new Set(); + const expr = (e: IrExpr): boolean => { + if (e.kind === "assignExpr" || e.kind === "incDec") ids.add(e.localId); + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => { + if (s.kind === "assign") ids.add(s.localId); + return everyStmtChild(s, expr, stmt); + }; + body.every(stmt); + return ids; +} + /** Recognize only exits whose target cannot be inside this statement list. * Labeled blocks, loops, switch and exception regions retain the join. */ export function integerPathFallsThrough(body: readonly IrStmt[]): boolean { diff --git a/packages/compiler/src/ir/integer-ranges.test.ts b/packages/compiler/src/ir/integer-ranges.test.ts index 32caea672..b495eb814 100644 --- a/packages/compiler/src/ir/integer-ranges.test.ts +++ b/packages/compiler/src/ir/integer-ranges.test.ts @@ -797,6 +797,74 @@ test("a while condition on a string's length bounds a counter, and guarded incre expect(run((inside) => [observe(inside), bump, rebound]).after).toBeNull(); }); +test("a nested while loop may advance a counter its own condition bounds", () => { + // while (pos < text.length) { observe(pos); while (pos < text.length && unknown === 1) pos++; pos += 2; } + const text: IrExpr = { loc, kind: "varRef", localId: "text", type: STRING }; + const length: IrExpr = { + loc, + kind: "strIntrinsic", + method: "length", + receiver: text, + args: [], + type: F64, + }; + const bump: IrStmt = { + loc, + kind: "exprStmt", + expr: { loc, kind: "incDec", op: "+", prefix: false, localId: "pos", type: F64 }, + }; + const skip: IrStmt = { loc, kind: "assign", localId: "pos", value: bin("+", ref("pos"), num(2)) }; + const and = (left: IrExpr, right: IrExpr): IrExpr => ({ + loc, + kind: "logical", + op: "&&", + left, + right, + type: BOOL, + }); + const scan = (cond: IrExpr): IrStmt => ({ loc, kind: "while", cond, body: [bump] }); + const run = (inner: IrStmt) => { + const inside = ref("pos"), + after = ref("pos"); + const f = fn([ + { loc, kind: "assign", localId: "pos", value: num(0) }, + { + loc, + kind: "while", + cond: condition("<", ref("pos"), length), + body: [observe(inside), inner, skip], + }, + observe(after), + ]); + f.locals.push( + { id: "pos", name: "pos", type: F64, mutable: true }, + { id: "text", name: "text", type: STRING, mutable: false }, + ); + const ranges = analyzeIntegerRanges(f); + return { inside: ranges.get(inside), after: ranges.get(after) }; + }; + // Each inner pass starts below the length, so the scan stops at it at the + // latest; the skip then adds 2. + const bounded = scan( + and(condition("<", ref("pos"), length), condition("===", ref("unknown"), num(1))), + ); + expect(run(bounded)).toEqual({ + inside: { min: 0, max: 2147483646 }, + after: { min: 0, max: 2147483649 }, + }); + // A nested loop that does not bound the counter, or writes it in its + // condition, leaves it unbounded. + expect(run(scan(condition("===", ref("unknown"), num(1)))).after).toBeNull(); + const writing = scan( + condition( + "<", + { loc, kind: "incDec", op: "+", prefix: true, localId: "pos", type: F64 }, + length, + ), + ); + expect(run(writing).after).toBeNull(); +}); + test("an array length bounds while and for counters even when the body resizes the array", () => { // let i = 0; while (i < items.length) { observe(i); items.push(i); i++; } observe(i); const ARR = { kind: "array", elem: F64 } as const;