diff --git a/docs/perf/ir-stress-bytecode-baseline.edn b/docs/perf/ir-stress-bytecode-baseline.edn index 60d98379d..b1a200a0b 100644 --- a/docs/perf/ir-stress-bytecode-baseline.edn +++ b/docs/perf/ir-stress-bytecode-baseline.edn @@ -1,9 +1,9 @@ -{:failed 719 - :total 2466 - :passed 1747 - :captured "2026-07-22" +{:failed 496 + :total 2468 + :passed 1972 + :captured "2026-08-04" :how "LG_STRESS_PASSES=1 make ir-stress-bytecode-gate (corpus = every shipped/test/example .lg, LG_STRESS_BACKEND=lower)" :note "BYTECODE-path lowering coverage — the backend `(set! *ir-compile* true)`\n actually drives at runtime. Sibling of docs/perf/ir-stress-baseline.edn,\n which measures lower-go (IR->Go) and is NOT a proxy for this one: the two\n backends fail on different things, so a form can lower natively to Go and\n still fail to bytecode. First capture of a path that had no census.\n\n The gap is large: 713/2432 fail here (70% coverage) against 11 failures\n (99.5%) on lower-go. The buckets say why, and :lower/not-on-stack/* (535)\n is self-inflicted rather than inherent.\n\n The IR is indexed RPN. :insts is a vector indexed by InstId, and every\n operand in :refs is already an InstId — the index is in hand at the moment\n of lowering. lower.lg discards it and reconstructs a STACK DEPTH instead\n (value-stack-pos, DUP_NTH n). Depth is fragile where the index is stable:\n it shifts as the stack moves, and a value defined in another block or\n displaced by an earlier operand's materialization has no computable depth\n at all. That is the failure. The VM offers no alternative today — its\n entire addressing vocabulary is LOAD_ARG / LOAD_CLOSEDOVER (read-only,\n indexed) and DUP_NTH (depth-addressed), and Frame carries no writable\n slots — so lowering has nowhere to spill what it cannot keep in position.\n\n Give the frame indexed slots and the class dissolves: InstId maps straight\n to a slot, block-args become 'both predecessors write the same slot', and\n the DUP_NTH copies (with the junk-below they strand and the RECUR that\n cleans it up) stop being needed. EPIC-016 is that unblock; EPIC-017 (#574)\n and EPIC-018 (#575) build on it.\n\n :lower/unsupported-op/:try (144) is a separate gap — the bytecode lowerer\n has no emission case for the form at all.\n\n Same ratchet rule as its sibling: only tightens. A rise is a real coverage\n regression on the runtime path; never loosen without investigating." :buckets - {":lower/not-on-stack/block-arg" 20, ":lower/not-on-stack/call" 428, ":unresolved/a" 1, ":lower/not-on-stack/gt" 2, ":lower/not-on-stack/try" 3, ":lower/not-on-stack/eq" 68, ":lower/unsupported-op/try" 145, ":lower/unsupported-op/div" 21, ":lower/not-on-stack/lte" 2, ":lower/not-on-stack/lt" 6, ":lower/not-on-stack/gte" 14, ":build/unrecognized-form" 1, ":unresolved/n" 7, ":unresolved/read-json" 1}} + {":lower/not-on-stack/block-arg" 99, ":lower/not-on-stack/call" 69, ":unresolved/a" 1, ":other | ir/lower: junk-below mismatch for block 3 (0 vs 1); unsupported shape" 6, ":other | ir/lower: junk-below mismatch for block 22 (0 vs 2); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 9 (0 vs 2); unsupported shape" 4, ":other | ir/lower: junk-below mismatch for block 12 (0 vs 2); unsupported shape" 6, ":other | ir/lower: junk-below mismatch for block 3 (0 vs 3); unsupported shape" 2, ":other | ir/lower: junk-below mismatch for block 3 (0 vs 2); unsupported shape" 8, ":other | ir/lower: junk-below mismatch for block 18 (0 vs 2); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 6 (1 vs 0); unsupported shape" 4, ":other | ir/lower: junk-below mismatch for block 6 (0 vs 5); unsupported shape" 2, ":other | ir/lower: junk-below mismatch for block 9 (3 vs 0); unsupported shape" 2, ":other | ir/lower: junk-below mismatch for block 12 (3 vs 0); unsupported shape" 5, ":other | ir/lower: junk-below mismatch for block 3 (1 vs 0); unsupported shape" 10, ":other | ir/lower: junk-below mismatch for block 3 (3 vs 0); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 9 (1 vs 0); unsupported shape" 3, ":other | ir/lower: junk-below mismatch for block 12 (1 vs 0); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 9 (2 vs 0); unsupported shape" 4, ":lower/unsupported-op/try" 157, ":lower/unsupported-op/div" 21, ":other | ir/lower: junk-below mismatch for block 15 (0 vs 5); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 9 (0 vs 6); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 3 (9 vs 0); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 6 (2 vs 0); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 9 (4 vs 0); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 12 (4 vs 0); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 21 (0 vs 2); unsupported shape" 5, ":other | ir/lower: junk-below mismatch for block 39 (0 vs 1); unsupported shape" 1, ":lower/not-on-stack/inc" 1, ":other | ir/lower: junk-below mismatch for block 6 (0 vs 2); unsupported shape" 9, ":other | ir/lower: junk-below mismatch for block 45 (0 vs 26); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 12 (0 vs 7); unsupported shape" 1, ":build/unrecognized-form" 1, ":other | ir/lower: junk-below mismatch for block 3 (0 vs 4); unsupported shape" 4, ":other | ir/lower: junk-below mismatch for block 4 (1 vs 0); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 9 (0 vs 5); unsupported shape" 2, ":other | ir/lower: junk-below mismatch for block 15 (2 vs 0); unsupported shape" 2, ":other | ir/lower: junk-below mismatch for block 24 (0 vs 2); unsupported shape" 1, ":unresolved/n" 7, ":other | ir/lower: junk-below mismatch for block 12 (0 vs 3); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 6 (0 vs 1); unsupported shape" 12, ":unresolved/read-json" 1, ":other | ir/lower: junk-below mismatch for block 9 (0 vs 1); unsupported shape" 10, ":other | ir/lower: junk-below mismatch for block 3 (0 vs 29); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 12 (0 vs 1); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 15 (0 vs 2); unsupported shape" 3, ":other | ir/lower: junk-below mismatch for block 3 (2 vs 0); unsupported shape" 14, ":other | ir/lower: junk-below mismatch for block 15 (0 vs 3); unsupported shape" 1, ":other | ir/lower: junk-below mismatch for block 15 (4 vs 0); unsupported shape" 2, ":other | ir/lower: junk-below mismatch for block 3 (0 vs 5); unsupported shape" 1}} diff --git a/pkg/rt/core/ir/lower.lg b/pkg/rt/core/ir/lower.lg index 4fb129218..e7cb7ffde 100644 --- a/pkg/rt/core/ir/lower.lg +++ b/pkg/rt/core/ir/lower.lg @@ -84,6 +84,9 @@ ;; minus 1 below the target's params; OP_RECUR/clean BRANCH ;; leave 0). Used to compute true runtime SP for max-stack. :block-junk (vec (repeat n-blocks 0)) + ;; Exact junk each pred reported per target (nil = none yet); + ;; disagreement aborts lowering — see record-block-junk!. + :block-junk-seen {} :current-block 0}))) ;; Field accessors that read the atom — keep call sites tidy. @@ -126,11 +129,26 @@ (get (:block-junk @l) bid 0)) (defn- record-block-junk! [l bid junk] - "Record (or raise) junk-below for block bid. Multiple predecessors may - set this; take the max." - (let [cur (block-junk-of l bid)] - (when (> junk cur) - (swap! l update :block-junk assoc bid junk)))) + "Record junk-below for block bid. Every fall-through predecessor must + agree: junk is baked into the target's OP_RECUR drop counts, so a + path entering with different junk over- or under-drops the runtime + stack (observed as `slice bounds out of range` underflows). The old + take-the-max rule was harmless only while mismatching shapes could + not lower at all; the deferral/RPO fixes let them lower, so a + disagreement now aborts this fn's lowering (the defn hybrid falls + back to the plain bytecode compiler, exactly as before). RECUR-edge + preds report zero junk: they rebuild the target's stack from scratch, + and registering that zero is recorded so disagreement with another + pred's nonzero junk is caught (→ fallback) instead of silently accepted." + (let [seen (:block-junk-seen @l) + cur (get seen bid)] + (if (nil? cur) + (do (swap! l update :block-junk-seen assoc bid junk) + (when (pos? junk) + (swap! l update :block-junk assoc bid junk))) + (when (not= cur junk) + (throw (str "ir/lower: junk-below mismatch for block " bid + " (" cur " vs " junk "); unsupported shape")))))) (defn- bump-max-stack! [l] "If current runtime SP (= lower-sp + current block's junk-below) @@ -571,6 +589,11 @@ ;; branch-if's leftovers) leaks `junk` slots per ;; iteration until the frame stack overflows. ignore (+ (- drop-count argc) cur-junk) + ;; RECUR rebuilds the target's stack from scratch: junk 0. + ;; Register that as this pred's exact junk report so a + ;; mismatch with another pred's nonzero junk is caught + ;; (→ fallback) instead of silently over-dropping. + _ (record-block-junk! l target 0) off-ip (ir/chunk-emit-recur (chunk-of l) cur-sp argc ignore)] (record-source-info! l nid) (add-patch! l {:src-ip (dec off-ip) @@ -615,9 +638,10 @@ :offset-slot 1 :negate? false}) (bump-stack-sp! l -1) ; cond popped - ;; If true-target is the immediately-following block, fall through. + ;; If true-target is the next block in EMISSION order (RPO, not + ;; id order), fall through; otherwise jump explicitly. (let [my-block (ir/block-of nid f) - next-block-id (inc my-block)] + next-block-id (get (:next-of @l) my-block -1)] (when (not= tt-target next-block-id) (let [arg-ip2 (emit-placeholder! l nid :branch)] (add-patch! l {:src-ip (dec arg-ip2) @@ -724,20 +748,38 @@ (defn- deferrable-branch-if-cond? [l term cond-ref] "True iff cond-ref is safe to defer past the body walk: it has - exactly one use (use-count==1) and that user is the branch-if - terminator itself. build-if always produces such conds; only - hand-built IR could violate this." + exactly one use (use-count==1), that user is the branch-if + terminator itself, AND it is not also one of the terminator's + branch-target args. The last condition matters because the use + bitset counts USERS, not uses: a value that is both the cond and + a branch arg (e.g. `(let [v (f x)] (when v ...) v)` threads v as + the join's block-arg) still reads as one user, but the terminator + materializes the branch args BEFORE the deferred cond — asking for + a value whose emission was skipped. For non-cheap ops (:call) that + is an ir/lower throw and the whole fn silently falls back to the + plain bytecode compiler." ;; cond-ref is typed as native int (InstId) by gogen; :branch-if ;; always has exactly one ref so cond-ref is never nil here. - (and (= 1 (use-count-of l cond-ref)) - ;; use-count==1 → bitset has exactly one user-id; read via - ;; ir/uses-first (bitsets are vec-of-int64-words, so raw - ;; `first`/`count` would return a word and the word-count). - (let [uses (:uses @l) - us (when (< cond-ref (count uses)) (nth uses cond-ref))] - (and us - (not (ir/uses-empty? us)) - (= term (ir/uses-first us)))))) + (and + ;; A :block-arg cond must NOT defer: deferral means "emit at the + ;; terminator", but emitting a :block-arg emits nothing (its value + ;; sits at a param slot) — BRANCH_F would then pop whatever is on + ;; top (usually a just-materialized branch arg) as the cond. The + ;; normal path DUPs it from its slot correctly. + (not= :block-arg (ir/op cond-ref (f-of l))) + (= 1 (use-count-of l cond-ref)) + ;; use-count==1 → bitset has exactly one user-id; read via + ;; ir/uses-first (bitsets are vec-of-int64-words, so raw + ;; `first`/`count` would return a word and the word-count). + (let [uses (:uses @l) + us (when (< cond-ref (count uses)) (nth uses cond-ref))] + (and us + (not (ir/uses-empty? us)) + (= term (ir/uses-first us)))) + ;; Not a branch arg of its own terminator (targets are symmetric + ;; by validate-fn!, so checking the true side suffices). + (let [tt (ir/cond-target-true (ir/aux term (f-of l)))] + (not (some (fn [a] (= a cond-ref)) (ir/branch-target-args tt)))))) (defn- lower-block! [l bid] (swap! l assoc :current-block bid) @@ -848,6 +890,50 @@ (+ src-ip (:offset-slot p)) offset)))) +;; --- emission order -------------------------------------------------- + +(defn- block-succs [f bid] + (let [term (ir/block-term bid f)] + (if (or (nil? term) (zero? term)) + [] + (let [op (ir/op term f) + aux (ir/aux term f)] + (cond + (= op :branch) + [(ir/branch-target-target aux)] + (= op :branch-if) + [(ir/branch-target-target (ir/cond-target-true aux)) + (ir/branch-target-target (ir/cond-target-false aux))] + :else []))))) + +(defn- rpo-block-order [f] + "Reverse-postorder over the CFG from the entry block. Emission MUST + follow an order where every non-back edge points forward: a block's + junk-below (values a pred's BRANCH_F leaves beneath its params) is + recorded by the pred's lowering, so lowering a block before one of + its preds bakes in a stale junk count — its RECUR under-drops and + every later slot reference shifts (miscompile, e.g. a loop whose + cond is an `and`: build-loop gives the body a LOWER id than the + cond-chain blocks). Raw id order only worked for CFGs where preds + happen to have lower ids. Back-edge preds are exempt: OP_RECUR + rebuilds the stack, junk 0. Successors are visited highest-id-first + so the order coincides with id order whenever id order was already + valid (keeps existing output byte-stable). Unreachable blocks are + appended in id order so block-ips stays total." + (let [n (count (ir/blocks f)) + state (atom {:visited #{} :post []})] + ((fn dfs [b] + (when-not (contains? (:visited @state) b) + (swap! state update :visited conj b) + (doseq [s (reverse (sort (distinct (block-succs f b))))] + (dfs s)) + (swap! state update :post conj b))) + 0) + (let [order (vec (reverse (:post @state))) + seen (set order)] + (vec (concat order + (filter (fn [b] (not (contains? seen b))) (range n))))))) + ;; --- entry point ----------------------------------------------------- (defn lower [f] @@ -858,12 +944,16 @@ (throw "ir/lower: nil function")) (let [l (new-lowerer f)] (check-cross-block! f (:uses @l)) - (let [n-blocks (count (ir/blocks f))] - (loop [bid 0] - (when (< bid n-blocks) - (set-block-ip! l bid (ir/chunk-length (chunk-of l))) - (lower-block! l bid) - (recur (inc bid))))) + (let [order (rpo-block-order f) + next-of (loop [i 0 acc {}] + (if (>= i (dec (count order))) + acc + (recur (inc i) + (assoc acc (nth order i) (nth order (inc i))))))] + (swap! l assoc :next-of next-of) + (doseq [bid order] + (set-block-ip! l bid (ir/chunk-length (chunk-of l))) + (lower-block! l bid))) (patch-branches! l) ;; Conservative max-stack guard: bump by the max number of params ;; across all blocks. The block-junk tracking above handles the diff --git a/pkg/rt/generated.sums b/pkg/rt/generated.sums index 2fe6a4b69..a32734f50 100644 --- a/pkg/rt/generated.sums +++ b/pkg/rt/generated.sums @@ -2,4 +2,4 @@ # Content digest of all .lg + lgbgen sources that feed the .lgb # bundle and the lowered Go tree. The genmanifest staleness test # fails if this no longer matches the sources on disk. -88ee4dbce3dd12ab8a3e9fac6e2b9dfcae175e1ff7602b99cae4f8c6295c6ee3 +6e713ff2c9b821fed54494b2a429d24319ac79664bc5b623c08c7dbe9874dee1 diff --git a/test/ir_and_cond_rpo.lg b/test/ir_and_cond_rpo.lg new file mode 100644 index 000000000..7b315d323 --- /dev/null +++ b/test/ir_and_cond_rpo.lg @@ -0,0 +1,92 @@ +;; Test: and-cond loop RPO fix +;; +;; The RPO (reverse-postorder) block-order fix ensures blocks are emitted in an +;; order where every non-back edge points forward. Loops with (and ...) conditions +;; create a CFG where the old id-order emission was wrong: the (and ...) creates +;; multiple conditions that must be evaluated in the correct order, and the short-circuit +;; logic creates a specific CFG structure that requires RPO ordering. +;; +;; The test constructs loops with (and ...) and (or ...) conditions that expose +;; the block-ordering issue. +(ns test.ir-and-cond-rpo-test + (:require + [ir.passes.pipeline] + [test :refer :all])) + +;; Bytecode path: loop with (and ...) condition +(defn and-loop-bytecode [n] + (loop [i 0 s 0] + (if (and (< i n) (>= s 0) (not (neg? i))) + (recur (inc i) (+ s i)) + s))) + +;; IR-compile path: should match bytecode +(set! *ir-compile* true) + +(defn and-loop-ir [n] + (loop [i 0 s 0] + (if (and (< i n) (>= s 0) (not (neg? i))) + (recur (inc i) (+ s i)) + s))) + +(set! *ir-compile* false) + +;; Additional shape: loop with (or ...) condition for short-circuit testing +(defn or-loop-bytecode [n] + (loop [i 0 s 0] + (if (or (>= i n) (< s 0)) + s + (recur (inc i) (+ s i))))) + +(set! *ir-compile* true) + +(defn or-loop-ir [n] + (loop [i 0 s 0] + (if (or (>= i n) (< s 0)) + s + (recur (inc i) (+ s i))))) + +(set! *ir-compile* false) + +;; Complex nested conditionals creating complex CFG +(defn nested-cond-bytecode [n] + (loop [i 0 s 0] + (if (< i n) + (if (even? i) + (if (< s 100) + (recur (inc i) (+ s i)) + s) + (recur (+ i 2) s)) + s))) + +(set! *ir-compile* true) + +(defn nested-cond-ir [n] + (loop [i 0 s 0] + (if (< i n) + (if (even? i) + (if (< s 100) + (recur (inc i) (+ s i)) + s) + (recur (+ i 2) s)) + s))) + +(set! *ir-compile* false) + +(deftest ir-and-cond-rpo-parity + (testing "and-cond loop under *ir-compile* with multiple guards" + ;; The IR path should emit blocks in RPO order, producing the same result as bytecode + (is (= (and-loop-bytecode 5) (and-loop-ir 5))) + (is (= 10 (and-loop-ir 5))) ; 0+1+2+3+4 + (is (= (and-loop-bytecode 100) (and-loop-ir 100))) + (is (= 4950 (and-loop-ir 100)))) + (testing "or-cond loop with short-circuit" + ;; Exit condition using (or ...) + (is (= (or-loop-bytecode 10) (or-loop-ir 10))) + (is (= 45 (or-loop-ir 10))) ; 0+1+2+...+9 + (is (= (or-loop-bytecode 100) (or-loop-ir 100)))) + (testing "nested conditionals creating complex CFG" + ;; Multiple nested ifs create a more complex block structure + (is (= (nested-cond-bytecode 20) (nested-cond-ir 20))) + ;; Accumulates even numbers until sum >= 100 + (is (= (nested-cond-bytecode 100) (nested-cond-ir 100))))) diff --git a/test/ir_blockarg_cond.lg b/test/ir_blockarg_cond.lg new file mode 100644 index 000000000..5cf0857c3 --- /dev/null +++ b/test/ir_blockarg_cond.lg @@ -0,0 +1,69 @@ +;; Test: block-arg cond deferral fix (#625 fix 3) +;; +;; The block-arg cond deferral fix ensures that if a condition is also a +;; block-arg (a value flowing in from a predecessor as a phi/parameter), it +;; cannot be deferred. Deferring a block-arg means "emit at the terminator", +;; but block-args have no emission (their value sits at a param slot). BRANCH_F +;; would then pop whatever is on top (usually a just-materialized branch arg) +;; as the cond, leading to wrong results. +;; +;; The test constructs an if whose condition is a block argument: a value +;; produced in a predecessor and passed to the current block as a parameter. +(ns test.ir-blockarg-cond-test + (:require + [ir.passes.pipeline] + [test :refer :all])) + +;; Bytecode path: condition flows in as block argument +(defn blockarg-cond-bytecode [n initial-flag] + (loop [i 0 flag initial-flag] + (if (>= i n) + :done + (let [next-flag (if flag (< i 5) false)] + (recur (inc i) next-flag))))) + +;; IR-compile path: should match bytecode +(set! *ir-compile* true) + +(defn blockarg-cond-ir [n initial-flag] + (loop [i 0 flag initial-flag] + (if (>= i n) + :done + (let [next-flag (if flag (< i 5) false)] + (recur (inc i) next-flag))))) + +(set! *ir-compile* false) + +;; Additional shape: more complex block-arg flow +(defn blockarg-cond-complex-bytecode [n] + (loop [i 0 flag true] + (if flag + (if (< i n) + (recur (inc i) (> i 3)) + :outer-done) + :inner-done))) + +(set! *ir-compile* true) + +(defn blockarg-cond-complex-ir [n] + (loop [i 0 flag true] + (if flag + (if (< i n) + (recur (inc i) (> i 3)) + :outer-done) + :inner-done))) + +(set! *ir-compile* false) + +(deftest ir-blockarg-cond-parity + (testing "block-arg cond under *ir-compile* (condition flows as phi parameter)" + (is (= (blockarg-cond-bytecode 10 true) (blockarg-cond-ir 10 true))) + (is (= :done (blockarg-cond-ir 10 true))) + (is (= (blockarg-cond-bytecode 10 false) (blockarg-cond-ir 10 false))) + (is (= :done (blockarg-cond-ir 10 false))) + (is (= (blockarg-cond-bytecode 100 true) (blockarg-cond-ir 100 true)))) + (testing "block-arg cond with nested if" + (is (= (blockarg-cond-complex-bytecode 10) (blockarg-cond-complex-ir 10))) + ;; flag starts true, becomes false after i=1, then returns :inner-done + (is (= :inner-done (blockarg-cond-complex-ir 10))) + (is (= (blockarg-cond-complex-bytecode 100) (blockarg-cond-complex-ir 100))))) diff --git a/test/ir_junk_agreement.lg b/test/ir_junk_agreement.lg new file mode 100644 index 000000000..351dd3cae --- /dev/null +++ b/test/ir_junk_agreement.lg @@ -0,0 +1,74 @@ +;; Test: block-junk-agreement parity guard +;; +;; The block-junk-agreement fix requires that all fall-through predecessors +;; reaching a block agree on the junk count. Before the fix, mismatched junk +;; was silently accepted (taking the max), causing stack underflows under +;; *ir-compile*. This test constructs a shape with multiple paths to the same +;; block that have different stack effects, and verifies parity with bytecode. +;; +;; This is a parity guard that passes unchanged on main and does not exercise +;; the disagreement/fallback path — it serves as a regression guard, not as +;; a test of the new disagreement-handling behavior. +(ns test.ir-junk-agreement-test + (:require + [ir.passes.pipeline] + [test :refer :all])) + +;; Bytecode path: loop with conditional that creates complex CFG paths +(defn junk-bytecode [n] + (loop [i 0 sum 0] + (let [flag (< i n)] + (if flag + (recur (inc i) (+ sum (* i 2))) + (if (zero? sum) 0 sum))))) + +;; IR-compile path: should match bytecode +(set! *ir-compile* true) + +(defn junk-ir [n] + (loop [i 0 sum 0] + (let [flag (< i n)] + (if flag + (recur (inc i) (+ sum (* i 2))) + (if (zero? sum) 0 sum))))) + +(set! *ir-compile* false) + +;; Test that uses nested blocks with various stack states +(defn junk-nested-bytecode [n] + (loop [x 0] + (let [skip? (>= x 3)] + (if skip? + (loop [y 0 acc 0] + (if (< y n) + (recur (inc y) (+ acc y)) + [x acc])) + (recur (inc x)))))) + +(set! *ir-compile* true) + +(defn junk-nested-ir [n] + (loop [x 0] + (let [skip? (>= x 3)] + (if skip? + (loop [y 0 acc 0] + (if (< y n) + (recur (inc y) (+ acc y)) + [x acc])) + (recur (inc x)))))) + +(set! *ir-compile* false) + +(deftest ir-junk-agreement-parity + (testing "junk-agreement with conditional creating complex CFG paths" + ;; The bytecode and IR paths should produce identical results + (is (= (junk-bytecode 100) (junk-ir 100))) + (is (= 9900 (junk-ir 100))) ; 0*2 + 2*2 + 4*2 + ... + 98*2 + (is (= (junk-bytecode 10) (junk-ir 10))) + (is (= 90 (junk-ir 10)))) + (testing "junk-agreement with nested loops" + ;; Inner loop sums 0..n-1, outer loop runs 3 times (x goes 0,1,2,3...) + ;; At x=3, skips outer recur and runs inner loop + (is (= (junk-nested-bytecode 10) (junk-nested-ir 10))) + (is (= [3 45] (junk-nested-ir 10))) ; x=3, sum=0+1+2+...+9 + (is (= (junk-nested-bytecode 100) (junk-nested-ir 100)))))