feat(compiler): element-wise array comparison masks + per-slot condition propagation#60
feat(compiler): element-wise array comparison masks + per-slot condition propagation#60thiremani wants to merge 37 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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
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>
d876faf to
0bc3bf9
Compare
…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>
…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>
…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>
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.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):
a, b = Pair(5,7) > Pair(1,8) < Pair(9,9)→5 keep-old; as a gate it ANDs like a single chain.(Pair(5,7) > Pair(1,8)) + Pair(0,0)→5 0fresh; the combine runs only when its slot commits.||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.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], 5keepsa, setsb; 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.