Skip to content

feat(compiler): element-wise array comparison masks + per-slot condition propagation#60

Open
thiremani wants to merge 37 commits into
masterfrom
feat-array-comparison-mask
Open

feat(compiler): element-wise array comparison masks + per-slot condition propagation#60
thiremani wants to merge 37 commits into
masterfrom
feat-array-comparison-mask

Conversation

@thiremani

@thiremani thiremani commented Jun 29, 2026

Copy link
Copy Markdown
Owner

Two connected changes: array comparisons become shape-preserving element-wise masks, and value-position lowering is unified on per-slot condition propagation so every conditional form — comparisons, chains, arithmetic, || — resolves slot by slot through one mechanism.

Array comparison = element-wise mask

Array comparisons (>, <, <=, >=, ==, !=) produce a shape-preserving, element-wise mask instead of a shrinking filter: each position yields its LHS element where the comparison holds, else 0, in place. Array-array zips to the min length; array-scalar spans the array and broadcasts the scalar. This is the scalar "comparison yields LHS-or-0" applied per element, consistent with array arithmetic (+, *), which already zips to min length.

[1 3 5 7] > [0 4 4 8]   ->  [1 0 5 0]   (was [1 5])
[4 8 1]   > 3           ->  [4 8 0]     (was [4 8])

Strings stay lexicographic; array arithmetic is unchanged; range-driven stream comparisons still skip (see Range Semantics).

Per-slot condition propagation

Extraction now returns a condition per output slot, and conditions merge along dataflow: a comparison ANDs its operands' slot conditions with its own cell; slot-aligned arithmetic zips; a call ANDs every argument condition into all of its outputs (a failing argument comparison skips the call and keeps old — the scalar hoist rule at tuple width); array cells and (cond value) remain self-resolving. Gates fold all slots into one AND. Statement commits gate each destination slot independently against seeded temps: array slots mask (overwrite), scalar slots keep old on failure — exactly what a single comparison does, per slot.

This makes the following first-class (no special cases, no all-or-nothing fallbacks):

  • Chained tuple comparisons: a, b = Pair(5,7) > Pair(1,8) < Pair(9,9)5 keep-old; as a gate it ANDs like a single chain.
  • Arithmetic over tuple comparisons: (Pair(5,7) > Pair(1,8)) + Pair(0,0)5 0 fresh; the combine runs only when its slot commits.
  • Per-slot || fallback: slot i takes the right side only when slot i of the left failed; fallback beats keep-old; both-fail keeps old; masks always yield so || never affects array slots. || inside comparison operands works.
  • Ranged statement conditions gate iterations without changing per-slot value semantics.

The old || yield lowering, the tuple-comparison special case, and both interim solver rejections are deleted. The one rejection kept is a || in a (cond value) value arm — a position that previously panicked the compiler (also on master) and is now a clean, deduplicated diagnostic; the misleading "not converging" cascade after a function-body type error is gone.

Memory safety

The ownership protocol was adversarially reviewed and each fix is pinned by a leak-checked e2e test: static strings are never stored as leaking heap copies (nor freed as if they were heap); slot commits derive their store target from the slot's element type (static winner over a heap destination is copied by coercion); spine-leaf values deref before the copy decision (a Ptr-wrapped call output previously aliased into a || slot and double-freed); named array operands survive their masks; runtime-skipped mask stores free on the else arm and failed bounds guards free all unresolved masks; a parenthesized right-operand comparison no longer double-frees on the false path (pre-existing on master).

Out-of-bounds reads fail their lanes

An OOB read was context-dependent (whole-statement no-op in plain/tuple assignments, checked-get zero in scalar comparisons and || fallbacks). It is now a failed condition on the lanes it feeds, merged by the same dataflow rules: a plain read no-ops its assignment; sibling expressions in one statement commit independently (a, b = arr[oob], 5 keeps a, sets b; simultaneous-assignment order preserved, so swaps still work); a call merges lanes, so an OOB argument keeps that call's outputs old as a unit; comparisons and || fallbacks fed by an OOB read keep old instead of judging a fabricated zero. Collector cells over explicit ranges keep the documented OOB-reads-zero opt-in.

Tests / docs

E2E coverage for masks (position stability, arr-scalar, unequal lengths, heap-string elements), per-slot tuples (keep-old, chains, arithmetic spines, gated call leaves, ranged gates, per-slot || incl. heap fallbacks, static/heap string ownership, runtime-skipped masks, OOB operands) — all leak-checked. Solver unit tests updated (chains and inner-|| now type cleanly; value-arm || rejected exactly once even across fixpoint passes). Docs (README, Conditional Value Semantics, Range Semantics) updated.

thiremani and others added 2 commits June 29, 2026 18:53
Array comparisons (>, <, <=, >=, ==, !=) now produce a shape-preserving,
element-wise mask instead of a shrinking filter: each position yields its LHS
element where the comparison holds, else 0, in place. Array-array zips to the min
length; array-scalar spans the array and broadcasts the scalar. This is the scalar
"comparison yields LHS-or-0" applied per element, keeping it consistent with array
arithmetic (+, *), which already zips to min length.

  [1 3 5 7] > [0 4 4 8]   ->  [1 0 5 0]   (was [1 5])
  [4 8 1]   > 3           ->  [4 8 0]     (was [4 8])

Value-position multi-return comparisons (Pair > Pair, Mix > Mix) now resolve per
slot, each behaving like the single-value comparison: array slots mask, scalar
slots keep the prior value on a failed compare (0 for a fresh target). A failing
slot affects only itself; the cell-wise AND survives only in gate/condition
position and behind a value-position || fallback. The tuple path is bounds-guarded,
so an out-of-bounds operand skips the whole assignment and keeps prior values,
matching normal assignment semantics.

Strings stay lexicographic; array arithmetic is unchanged.

Tests cover masks (position stability, arr-scalar, unequal lengths, heap-string
elements), per-slot tuple keep-old (int, mixed, heap-string ownership), gated
tuples, and out-of-bounds operands, all leak-checked. Docs (README, Conditional
Value Semantics, Range Semantics) updated to mask semantics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A chained comparison over multi-return values (e.g. Pair > Pair < Pair) was routed
to the per-slot tuple lowering, which can't reconstruct chaining's leftmost binding
— it bound each link to the masked result, so an existing scalar slot could commit
0 instead of keeping its prior value.

Per-slot tuple comparisons don't support chaining yet, so reject it at type-check
time (TypeInfixExpression -> rejectChainedTupleComparison) rather than silently
falling back to all-or-nothing gating, which would contradict the documented
per-slot value-position semantics. Single-value chained comparisons (a > 2 < 8)
are unaffected. Per-slot tuple chaining is planned for the value-extraction
unification.

Adds TestChainedTupleComparisonRejected (one diagnostic) and a doc note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@thiremani thiremani left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(compiler): make array comparison an element-wise mask, not a filter

Clean semantic redesign. The filter→mask change is well-motivated (position-preserving, consistent with array arithmetic zip-to-min), and the per-slot tuple comparison is a natural extension. The test coverage is thorough — mask position stability, unequal lengths, heap-string ownership, keep-old-on-false for existing vars, gated tuples, OOB operands, and chained rejection — all leak-checked. The chained-rejection guard in commit 2 is the right call: reject now rather than silently regressing to all-or-nothing. Docs updated consistently.

A few observations (none blocking):


1. Duplicated result-symbol construction (compiler/array.go)

compileArrayArrayMask and compileArrayScalarMask both end with:

i8p := llvm.PointerType(c.Context.Int8Type(), 0)
return &Symbol{
    Type: Array{Headers: nil, ColTypes: []Type{resElem}, Length: 0},
    Val:  c.builder.CreateBitCast(resVec, i8p, "arr_i8p"),
}

That is 4 lines at 2 call sites — small, but if the Length: 0 / Headers: nil contract ever changes, both must update. A one-liner helper (maskResult(resElem, resVec)) would centralize it. Not critical at two sites — up to you.

2. Generic LLVM IR block names (compiler/cond.go)

assignUnderStmtCond creates blocks named "if" and "continue" — the most generic names possible. Every other helper in this PR uses descriptive prefixes (mask_set, tuple_slot_if, tuple_bounds_ok, etc.), which makes IR dumps and crash stacks much easier to navigate. Something like "stmt_cond_if" / "stmt_cond_cont" would be consistent with the rest of the file.

3. Double-negative guard in independentTupleComparison (compiler/cond.go)

if !info.HasCondArray() && !info.HasCondScalar() {
    return nil, false
}

Reads as "if it has neither comparison mode, bail." Correct, but a moment of hesitation on first read. A positive helper like info.HasAnyComparison() (returning HasCondArray() || HasCondScalar()) would make this guard read as if !info.HasAnyComparison() — clearer intent. Minor style nit.

4. Test comment wrapping (compiler/solver_test.go:642)

// cell is a mask (an array value), not a boolean, and gate lowering would
// silently drop it. MixSA returns (scalar, array) so the array cell is not first —

The second line extends past the ~80-col guide where the surrounding comments wrap. Very minor; easy to fix if you are already touching the area.


Overall: Solid work. The semantics are clear, the per-slot decomposition is well-guarded (bounds, statement-cond, chained rejection), and the ownership model (mask owns copies, source retains) is correctly handled for both scalar and heap types. Good to merge after addressing any of the above you agree with.

Co-Reviewed-By: Claude Opus 4.6

thiremani and others added 2 commits June 30, 2026 11:01
No behavior change; cleanups from the PR #60 review.

- Extract arrayResultSym helper to centralize the single-column array result
  construction (Array{ColTypes:[resElem]} + bitcast to the i8* array handle),
  removing the duplicated tail across compileArrayArrayInfix / compileArrayConcat /
  compileArrayScalarInfix and the new compileArray{Array,Scalar}Mask.
- Add ExprInfo.HasAnyComparison() and use it in independentTupleComparison (drops a
  double-negative guard) and isValuePositionComparison (de-duplicates the check).
- Use descriptive IR block names in assignUnderStmtCond (stmt_cond_if / _cont).
- Rewrap an over-long test comment.

All unit + integration tests and the tests/cond leak check pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…efix

Generalize the name (it wraps any array vector, not just a "result") and
convert compileArrayUnaryPrefix to use it — all 6 array-construction sites
now go through the same helper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@thiremani thiremani force-pushed the feat-array-comparison-mask branch from d876faf to 0bc3bf9 Compare June 30, 2026 15:02
thiremani and others added 4 commits June 30, 2026 21:45
…path

independentTupleComparison used the flat info.HasFallbackOr(), which only inspects
the top expression's CompareModes. A value-position || nested in an operand (e.g.
Pair(a > 2 || 7, b) > Pair(1, 1)) leaves the top comparison's modes free of CondOr,
so the expression was routed to the per-slot path. That path evaluates operands
directly, without the yield/branching context a value-position || needs, so
compiling the operand panicked ("value-position logical OR must be lowered through
conditional expression branching").

Use the recursive hasFallbackOrInTree instead, so any tuple comparison containing a
|| (one not already resolved behind an array-cell / (cond value) boundary) defers to
the gated path, which sets up the || branching. compileInfixBasic only panics on
CondOr, and (cond value) operands self-resolve, so this closes the gap. Top-level ||
tuples and plain tuples are unaffected.

Adds NestedOrOperand{True,Fallback} regression tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n operand

A || nested in an operand (e.g. Pair(a > 2 || 7, b) > Pair(1, 1)) leaves the top
comparison's CompareModes free of CondOr, so the per-slot lowering took it and
panicked evaluating the operand's || inline. The previous commit deferred such
expressions to the gated path instead, but that silently changed the per-slot
semantics to all-or-nothing: Pair(a > 2 || 7, b) > Pair(10, 1) gave 0 0 instead of
the expected 0 9 (the || just computes the operand; the comparison should stay
per-slot).

Reject it at type-check time (rejectInnerFallbackOrTupleComparison, in the same
isValueCmp && len>1 block as the chained-tuple reject) rather than mis-lower it.
operandHasFallbackOr mirrors the compiler's hasFallbackOrInTree boundaries, so a ||
behind a (cond value) / array literal (which self-resolves) is left alone, and a
top-level || fallback (Pair > Pair || Pair) is unaffected (typed by
typeLogicalOrExpression, stays gated). The compiler keeps the recursive
hasFallbackOrInTree guard as a defensive backstop. Real per-slot support for
inner-|| and chained tuples is planned for the value-extraction unification.

Replaces the NestedOrOperand runtime tests with the diagnostic test
TestInnerFallbackOrTupleComparisonRejected; updates the design doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…uple comparisons

operandHasFallbackOr treated a whole CondValueExpr as a safe boundary, but a
value-position || in the cond-value's VALUE arm is lowered inline (not self-resolved),
so Pair((1 > 0  0 > 1 || 7), 9) > Pair(1, 1) slipped past the reject, took the per-slot
path, and panicked in compileInfixBasic ("value-position logical OR must be lowered
through conditional expression branching").

Recurse into CondValueExpr.Value instead of skipping the node. The array-literal
boundary stays (a || in a cell self-resolves), and a || in the condition arm is a parse
error, so only the value arm is scanned.

(A standalone x = (cond  v || fb) also panics — a pre-existing (cond value) value-arm-||
bug, out of scope here — but the tuple reject now catches it in this context rather than
panicking.)

Adds TestInnerFallbackOrInCondValueArmRejected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ropagation

Replace the single-ANDed-gate extraction and the || yield world with one
mechanism: extraction returns a condition per output slot, conditions merge
along dataflow, and each consumer folds or commits them. Slot rules: a
comparison ANDs its operands' slot conditions with its own cell; slot-aligned
infix arithmetic zips; a call ANDs every argument condition into all of its
outputs (so a failing argument comparison skips the call and keeps old — the
scalar hoist rule at tuple width); array-literal cells and (cond value) stay
self-resolving boundaries. Gates fold all slots into one AND; statement
commits gate each destination slot on its own condition against the seeded
temps; spine combines compile inside their slot's branch, so e.g. a division
never runs for a slot that keeps old.

User-visible semantics this unlocks (each contradiction below existed on the
previous per-slot-at-statement-root design):

- Chained tuple comparisons work per slot (Pair > Pair < Pair), and in gate
  position they AND like a single chain — restoring a form master compiled
  that the interim solver rejection had broken.
- Arithmetic over a tuple comparison stays per slot ((Pair(5,7) > Pair(1,8))
  + Pair(0,0) -> 5, keep-old) instead of silently reverting to all-or-nothing.
- || falls back per slot: slot i takes the right side only when slot i of the
  left failed; a fallback beats keep-old; both-fail keeps old; masks always
  yield, so || never affects array slots. A || inside a comparison operand
  (Pair(a > 2 || 7, b) > Pair(1, 1)) now just works.
- Ranged statement conditions gate iterations without changing the value's
  per-slot semantics.

Deleted: compileYield/compileChildYields/compileFallbackOr/fallbackOrExpr/
hasFallbackOrInTree, extractCondExprs/extractCondComparison/handleComparisons,
compileTupleComparisonPerSlot/independentTupleComparison, and both solver
rejections (chained tuple, inner ||). The one remaining rejection is a || in a
(cond value) value arm — that position still lowers inline with no branching
context and previously PANICKED the compiler (also on master); it is now a
clean, deduplicated diagnostic in every context, and "not converging" is no
longer emitted when the function's own type errors are the cause.

Memory-safety fixes baked into the new protocol (all leak-checked, each
pinned by an e2e test): static-string slots are stored as-is instead of
leaking a heap copy under a StrG type; slot commits derive the store target
from the slot's element type, so a static winner over a heap-string
destination is copied by coercion rather than aborting in free; spine-leaf
values are dereferenced before the copy decision (a Ptr-wrapped call output
previously aliased into a || result slot and double-freed); a named array
operand survives its mask (only owned operands are consumed); a mask whose
slot store is skipped at runtime is freed on the else arm, and a failed
bounds guard frees all unresolved masks; a parenthesized comparison as a
right operand no longer double-frees its substituted LHS on the false path
(pre-existing on master).

Operands evaluate eagerly (once); only the yielding combine and the commit
are gated per slot. Docs updated; tests cover chains, arithmetic spines,
per-slot ||, gated call leaves, ranged gates, heap/static string ownership,
and runtime-skipped masks, all under leak check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@thiremani thiremani changed the title feat(compiler): make array comparison an element-wise mask, not a filter feat(compiler): element-wise array comparison masks + per-slot condition propagation Jul 2, 2026
thiremani and others added 19 commits July 2, 2026 13:46
…mbos, mask chains

The unification's first test round covered two-link tuple chains, arithmetic
wrapping, and || basics; the compositions of those mechanisms were verified
manually but not pinned. Add e2e cases (all leak-checked): three-link tuple
chains, a middle link failing only its own slot, a chained comparison with a
|| fallback, three-way || chains, a || resolved inside an arithmetic spine,
mixed-tuple chains where the array slot chains masks element-wise (the
intermediate mask must be consumed, not leaked), and chained comparisons
inside a tuple operand — bare (gating the slots that read it, keep-old on
failure) and with a || supplying the operand value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ly when every cell holds

Document the principle behind multi-cell gates: a gate asks one question of a
value-position expression — did it yield? — and a multi-value expression yields
only when every slot does. That single-bit reading is what gives Pair > Pair
its cell-wise AND in condition position, a value-position || its OR (yield
flag), and why an array cell is rejected in a gate (a mask always yields, so
the gate would test nothing).

Pin the missing gate cases: a FIRST-cell failure turns the gate off
(Pair(1, 2) > Pair(1, 5) — existing coverage only failed a later cell), a
fresh target keeps its zero seed on a gated-off statement, and the same
on/off pair inside a (cond value) condition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he end of Solve

The Rejected set deduplicated a single emission site (the (cond value)
value-arm || rejection) across fixpoint passes, but the duplication it guarded
against is general: function bodies are re-typed on every pass and once per
call-site specialization, so ANY diagnostic inside one repeats whenever
another function's progress keeps the loop spinning. Track no per-emission
state; instead collapse identical diagnostics (same position and message,
first occurrence kept in order) once when Solve finishes. This also fixes the
latent duplication for every other function-body error, not just the one the
map covered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…function after its typing errors

The end-of-Solve dedupe rewrote ts.Errors, breaking the append-only invariant
(mid-solve readers and holders of slices would see the list change under
them). Remove it and kill the duplication at its source instead:
TypeScriptFunc now stops after any pass that reported errors. A typing error
is deterministic and aborts TypeBlock before outputs infer, so further passes
could never succeed — they only re-appended the same diagnostic. This also
keeps the "not converging" message reserved for genuinely error-free
non-convergence (cyclic recursion), without the previous suppression check.

One benign shape can still emit a repeated diagnostic: two calls to the same
broken function within a single statement re-enter typing once per call site.
Each call site reporting is defensible, and Solve stops at the first erroring
statement, so it cannot compound further.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Slot conditions compose along dataflow lanes, and each structural rule now
has a pinned example: parallel lanes zip (both operands of an arithmetic
infix carrying aligned conditions — lane 0 commits 6 while lane 1 keeps old),
merging lanes fold (a call ANDs every argument's conditions into all outputs
regardless of how many comparisons each argument carries — one bit from a
bare comparison, two from a chain), and a prefix passes its operand's lanes
through untouched. The remaining extraction branches were already pinned:
boundaries (cvArrFb), || yield flags (TupleOr*), comparison zip+chain
(ChainVal/TripleChain), and one-sided arithmetic (ArithWrap). The infix
fold-broadcast fallback stays untested by design — it is unreachable for
well-typed programs (solver enforces operand/node arity agreement) and exists
only as a conservative floor against internal invariant breaks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The infix arm of extractSlotConds silently folded-and-broadcast when an
operand's condition count matched neither zero nor the node's arity. That
state is unreachable for well-typed programs (the solver enforces operand
arity == node arity for non-comparison infix), so reaching it means a broken
internal invariant — and masking it as coarser all-or-nothing gating would
hide the bug. Panic instead, matching the compiler's convention for internal
invariants, and flatten the zip into the main path behind a guard clause.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…closures

extractFallbackOrSlots carried a 40-line storeSide closure with a second
closure inside it, and its per-slot emission shape (run under the slot
condition, free the skipped mask on the else arm) duplicated commitSpineSlot.
Extract that shape as underSlotCond — both call sites now share it — and the
store body as storeFallbackValue, shrinking storeSide to a few lines that
only compute each slot's take-condition. The closures that remain are the
ones earning their keep: storeSide is emitted twice (left side inline, right
side inside the lazy or_rhs branch), and the store callback lets one body be
emitted at whichever insertion point is current.

No behavior change; IR block labels for spine slots become
slot_take/slot_skip/slot_cont.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the storeSide closure in extractFallbackOrSlots with top-level
methods carrying their state explicitly: newFallbackSlots allocates the
per-slot result/yield allocas (bundled as a fallbackSlots value),
resolveFallbackSide prepares one operand, stores its slots under each
take-condition, and releases its temporaries and unmoved masks, and
loadFallbackResults zero-seeds unfilled slots and loads the outcome. The
driver now reads as four lines of algorithm. Remaining function literals are
short emission callbacks passed to underCond/underSlotCond, where the body
must be emitted at the caller's insertion point.

No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The comment described the ownership contract but never stated what the
function produces or how. Lead with the contract — a value-position ||
resolves to ordinary per-slot data (frame values + yield flags as slot
conditions), so downstream code needs no fallback special cases — then the
per-slot semantics, the four named lowering steps, and the ownership rule
that makes cleanup unconditional.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…show

The hoisting refactor made the step structure self-documenting
(newFallbackSlots -> resolveFallbackSide -> lazy right ->
loadFallbackResults), and the comments then narrated those same steps back.
Cut the narration; keep only cross-function contracts and constraints: the
frame/yield-flag protocol, the owned-or-freeable-zero invariant that lets the
slots travel as one unconditional temporary, the needFlags meaning, and the
deref-before-copy rule (a past double-free).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ld for what it answers

leftAll read as if a nil fold ("left always yields") were a real path the
code skips. It is unreachable: the solver rejects every left that could fold
to nil — a non-failable left operand, an array-mask-only left, and ranged or
bare-driver (cond value) conditions all error at type-check (verified each).
Rename to leftAllYielded and panic on nil per the internal-invariant
convention, and state the actual contract the guard serves: the right side
evaluates at most once, as a unit, when any slot missed — slot selection
stays per-slot via the yield flags.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… on a miss

The right side of a value-position || materializes as a unit, at most once,
when any slot failed to yield; slot selection stays per-slot via the yield
flags. Made observable through bounds checks: an OOB inside an unevaluated
right side cannot trip the statement guard (all-left-yield still commits),
while an evaluated right side's OOB skips the whole assignment. Also pins the
scalar skip case. Multi-return calls as the fallback operand were already
pinned (TupleOr*/HeapTupleOr use Pair/PairStr calls).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tatement

An OOB read was context-dependent: plain assignments no-op'd the entire
statement (a sibling expression's commit vetoed with it), scalar value
comparisons and || fallbacks judged the checked-get zero and committed it
(x = arr[oob] < 1 became 0), and the per-slot tuple path vetoed all commits.
Unify on the owner-settled rule: an OOB read is a failed condition on the
lanes it feeds, merged by the same dataflow rules as comparisons.

- Plain assignments evaluate each expression under its own bounds guard and
  commit per expression: a, b = arr[oob], 5 keeps a and sets b (fresh targets
  keep a zero seed — previously accidental, now by construction). All writes
  land before any old value is freed, preserving simultaneous-assignment
  semantics: a, b = b, a still swaps, including heap strings, and works
  alongside an OOB sibling.
- Extraction folds a bounds bit into the lanes of the operand that read OOB
  (comparison operands, spine leaves, || cv value arms), so scalar
  comparisons and || fallbacks keep old instead of committing zero, and a ||
  slot the left side filled commits regardless of the right side's OOB
  (LazyRhsOob: 5 22, was all-keep-old).
- The per-slot tuple path's whole-statement veto is deleted (lane conditions
  carry the bits; skipped masks are freed by the existing slot else-arms), and
  a call still keeps its outputs old as a unit on an OOB argument — lanes
  merge at call boundaries (OobTupleKeepsPrev unchanged).
- Collector cells over explicit ranges keep the documented OOB-reads-zero
  opt-in.

The oob_paths leak test's guarded-call case now expects the clean expression
to commit while its OOB sibling keeps old. New e2e coverage pins every rule
above, all leak-checked; docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed commit

An indirect-return call assigned to an existing destination writes through
the destination's own slot (makeOutputs returns it), so when an argument OOB
skips the call, that slot still holds the old value being kept. The skipped-
expression cleanup freed through it anyway and then restored the captured old
pointer — leaving the destination pointing at freed memory (abort on next
use). Pre-existing on master via the whole-statement skip path
(freeAssignmentTemps had the identical flaw); the per-expression commit
carried it forward.

Track per slot whether a compiled value aliases its destination's storage
(pointer identity against the scope symbol) and skip exactly those on the
skip path — fresh temp outputs of the same skipped call still free their
zero seeds, per the reviewer's mixed-output case. The ranged staging path was
already safe: stage temps are Borrowed, which the temp free skips.

Pins: skipped and committing direct calls into existing heap destinations,
and a skipped multi-output call mixing a destination-backed output with a
fresh one — all leak-checked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The comment still described the pre-lane semantics ("skips the WHOLE tuple
assignment"). The observable is unchanged, but the reason is now the general
rule: the OOB read is a failed condition on the lanes it feeds, and a call
merges its argument conditions into all outputs — which is why both slots
keep old here, the passing comparison included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… branch emitters

underCond, underCondElse, and underSlotCond's hand-rolled body were the same
emission shape in three spellings, grown incrementally. Collapse to a single
underCond(cond, name, onTrue, onFalse) — nil cond runs onTrue inline, nil
onFalse emits if/cont, otherwise if/else/cont with name_if/name_else/name_cont
labels. underSlotCond keeps its fixed skipped-mask else but delegates, and
branchCond sheds its manual block plumbing, keeping only what distinguishes
it: the extraction-cleanup else arm. The remaining emitters are distinct on
purpose — withGuardedBranch loads from a guard pointer, branchCond owns
extraction cleanup.

No behavior change; some IR block labels shift to the name+suffix scheme.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r convention

Callback-scoped emission helpers here are named with* (withGuardedBranch,
withStmtBoundsGuard, withCondRangeLoop, withPreparedCall); the under* pair was
a synonym for the same concept. underCond becomes withCondBranch and
underSlotCond becomes withSlotCondBranch. Mechanical rename, no behavior
change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…el-slice plumbing

commitAssignmentsPerExpr and finishAssignmentsWithGuard carried a statement's
compiled right-hand side as up to nine parallel slices, with offset arithmetic
(writeIdents[i+j], oldValues[offset:offset+n]) re-derived at every consumer.
Model the domain nouns instead: slotAssign is one destination slot — where
the value is written, which identifier owns move/copy decisions, the value
and the RHS variable it came from, the value it replaces, and whether it is
destination-backed; exprAssign is one expression's slots plus its bounds bit.
A statement is []exprAssign, so the grouping IS the nesting and no offset
math survives anywhere — newExprAssign zips each expression's results as they
compile.

freeOldValues and freeAssignmentTemps become per-expression row loops
(freeExprOldValues, inline frees), compileAssignmentValues folds into
compileCondExprAssigns, and both former 8-9 parameter functions now take
([]exprAssign) or ([]exprAssign, guardPtr). Whole-statement copy/move
decisions (swap support) are unchanged: the unguarded commit still gathers
statement-wide columns for computeCopyRequirements and writes everything
before freeing anything.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…column accessors

The five exprAssign accessors (dests/owners/values/rhsNames/oldValues)
existed only to re-materialize columns for writeTo, computeCopyRequirements,
and restoreOldValues. Make the primitives speak rows instead: needsCopy
becomes a slotAssign field set by markCopyRequirements (the rename reflects
that it now annotates slots rather than returning a parallel array), writeTo
and restoreOldValues iterate slots, and the guarded skip arm reuses
freeSkippedTemps. All accessors and the one-shot anyBoundsGuarded/
promoteIdentifiersIfNeeded helpers dissolve into their call sites; the only
column materializations left are the two with a semantic reason — the
statement-wide slot concat that lets copy/move decisions span expressions
(swaps), and the owner list shouldSkipOldValueFree's signature needs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
thiremani and others added 10 commits July 5, 2026 10:52
…agreement

Audit against an older external range-semantics sketch found one real gap:
self-referential ranged assignment (res = res + i folding to 10, acc = acc
⊕ [m] growing per iteration, x = x + arr[k] summing) is implemented and
tested but was never documented here. Also add the cartesian worked example
for distinct drivers and the consistency remark that collection commutes
with element-wise operations (√[n] equals [√n]) — every claim verified
against the current compiler. The sketch's remaining content was either
already covered (drivers, dedup, last-value, collectors) or stale (comma
array literals, +=, the function-owns-the-loop framing superseded by the
driver model).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
freeSkippedTemps + restoreOldValues were always called as a pair — the
skip-path cleanup for one expression's assignment — at both the per-expression
and whole-statement guard sites. Wrap them in keepPriorOnSkip, named for the
intent (a skipped assignment leaves prior values intact). No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s 6 0

ArithWrap used Pair(0, 0), so slot 0's 5 + 0 = 5 was indistinguishable from
no add at all. Use Pair(2, 3): slot 0 commits 5 + 2 = 7 (add proven), slot 1's
failed comparison still keeps the prior 22. Add a comment on ArithWrapChain
explaining its 6 0 result — slot 1's inner 7 > 8 fails, so propagation keeps
the lane old (fresh 0) and the +1/<9 never run; 6 1 would be the zero-fill
model propagation replaced, and would contradict ArithWrap's keep-old slot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…etic

We covered neighbors — a fallback that is itself a comparison in `+1`
(lorNestedAdd), a plain -1 into a call (lorDefaultArg), and `*...||-1` inside
a collector (orWrapRepeatedCond) — but not a plain-value fallback taken and
flowing through bare scalar multiply+add. Add `10 + 3 * (b > 2 || -1)` -> 7
(b=1 fails, -1 flows through * and +, distinct from the bare form's keep-old
0) and the left-wins `10 + 3 * (a > 2 || -1)` -> 25.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MixArith used + Mix(0), so the array slot's mask [0 0] + [0 1] = [0 1] left
the leading 0 ambiguous (mask default vs 0+0). Use + Mix(3): [0 0] + [3 4] =
[3 4], so the element-wise add is visibly exercised. The scalar slot still
keeps old (1 > 2 fails, its + 3 never runs -> 0). Same fix as ArithWrap's
Pair(0,0) -> Pair(2,3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…es all outputs)

The comment said a failing chain argument "gates the slots that read it,"
which reads as "only slot 0," implying ChainInOperandFail should be 77 9. It's
77 88: the 9 is an argument to the same opaque Pair call, and the call-merge
rule gates ALL of a call's outputs when any argument fails (we don't see that
b = y independent of the first arg) — the same rule an OOB argument gets.
Reword to say the whole call is gated; 77 9 is the separate-lanes behavior
(cf > 2 < 8 > -10, 9 > 1), not this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the three parallel slices threaded through the conditional lowering
path — destination identifier, borrowed temp slot, and resolved output type —
with a single []OutputSlot. The positional dest/temp/outType mapping is now
explicit in one struct instead of three lockstep slices, and sub-range slicing
(per-expression, per-stage) becomes a single expression.

The temp name embeds a unique counter, so the dest-to-temp binding can only be
established once at creation and carried — it cannot be recomputed from the
destination name. Bundling makes that invariant structural rather than a
convention spread across call sites.

Scope is the temp-staged output shape: the conditional statement, per-slot
value expressions, and the two-level ranged staging (commit temp and stage
temp), where all three fields are always populated. The collector/accumulator
outputs keep their own slices — a different protocol (grows an array, no temp
slot, distinct commit), so folding them in would be a false union. The shared
compileAssignments seam still takes parallel identifier slices (it serves the
plain assignment path too); the conditional caller unbundles via
slotTemps/slotDests.

No behavior change. Also drops a redundant scope lookup in
createStageTempOutputsFor now that the slot carries its type directly.

Validation: go test -race ./compiler; python3 test.py (60 passed); leak-check
on tests/cond and tests/mem (no leaks).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
slotDests and slotTemps were always called together — both call sites feed the
two results straight into compileAssignments/newExprAssign, which take
(writeIdents, ownershipIdents). Merge them into one slotAssignIdents that
returns both in a single pass, named (temps, dests) to mirror the OutputSlot
fields it projects; the write-vs-owner roles live in the doc comment and the
consumer's param names. slotTempStrings stays separate — DeleteBulk needs a
[]string.

No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
commitCondRangedStages folded stage temps into commit temps per condStageGroup,
but commitStageTempOutputs already iterates flat (commit[i]/stage[i]) — the
per-expression grouping mattered only during staging, never at commit. Return
the stage slots flat from stageCondRangedAssignments (they align 1:1 with the
commit slice, since each expression's stage temps cover commit[idx:idx+n] in
order) and let commitCondRangedStages fold them in a single pass with one
DeleteBulk. Removes the type and the per-group loop.

The two-phase structure that preserves within-iteration simultaneity is
unchanged: all RHS expressions are still staged (reading commit temps, i.e. the
iteration-start values) before any stage folds back. Freeing is unchanged too —
Borrowed temps are unregistered by DeleteBulk after their value transfers
stage -> commit temp -> destination.

No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
commitConditionalOutputs and storeValue re-sync a pointer-backed destination's
recorded element type after storing a value of a different string flavor
(StrG vs StrH), so later cleanup frees correctly. Both guarded the update with:

    if !SetExisting(...) { Put(...) }

The fallback is unreachable: both sites are inside the branch where the
destination was just found via Get, and nothing between the Get and the
SetExisting mutates the scope stack (storeSymbolToSlot, coerceSymbolForType,
and bindingSlotType don't), so SetExisting resolves through the same
findScopeIndex and always succeeds. The fallback also wasn't a correct
recovery: Put writes to the current innermost scope, which for an outer-scope
destination would shadow the real binding instead of updating the one tied to
the slot we stored into.

Drop to a bare SetExisting (matching aliasCondDests/restoreCondDests) with a
comment explaining the invariant.

No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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