Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
f3baffd
feat(compiler): make array comparison an element-wise mask, not a filter
thiremani Jun 29, 2026
ebc1078
fix(solver): reject chained multi-return comparisons
thiremani Jun 30, 2026
5a24316
refactor(compiler): address review polish on array-comparison PR
thiremani Jun 30, 2026
0bc3bf9
refactor(compiler): rename arrayResultSym → arraySym, use in unary pr…
thiremani Jun 30, 2026
a8ac6fc
fix(compiler): defer tuple comparisons with a nested || to the gated …
thiremani Jun 30, 2026
e8e8cab
fix(solver): reject value-position || inside a multi-return compariso…
thiremani Jul 1, 2026
fbe8166
fix(solver): scan (cond value) value arm when rejecting inner || in t…
thiremani Jul 1, 2026
33074e5
feat(compiler): unify value-position lowering on per-slot condition p…
thiremani Jul 2, 2026
6656823
test(cond): pin deep per-slot compositions — long chains, chain+|| co…
thiremani Jul 2, 2026
1a12a36
docs+test(cond): pin the gate rule — a multi-cell condition yields on…
thiremani Jul 2, 2026
ec1c153
refactor(solver): replace the Rejected map with one error dedupe at t…
thiremani Jul 2, 2026
7fddb44
refactor(solver): keep the error list append-only — stop re-typing a …
thiremani Jul 2, 2026
628fcd2
test(cond): pin the dataflow-lane rules of slot-condition extraction
thiremani Jul 3, 2026
bffec35
refactor(cond): panic on misaligned slot conditions instead of degrading
thiremani Jul 3, 2026
ee6e71f
refactor(cond): name the || store-emission pieces instead of nesting …
thiremani Jul 3, 2026
b91443a
refactor(cond): hoist the || side-store closure into named methods
thiremani Jul 3, 2026
220cec3
docs(cond): explain extractFallbackOrSlots' contract and lowering shape
thiremani Jul 3, 2026
e0e86ab
docs(cond): trim || lowering comments to constraints the code cannot …
thiremani Jul 3, 2026
faaad5e
refactor(cond): panic when a || left folds unconditional; name the fo…
thiremani Jul 4, 2026
bdae02b
test(cond): pin || right-side laziness — evaluated at most once, only…
thiremani Jul 4, 2026
3f66ec9
feat(compiler): out-of-bounds reads fail their lanes, not the whole s…
thiremani Jul 4, 2026
8effc6f
fix(compiler): do not free destination-backed call outputs on a skipp…
thiremani Jul 4, 2026
d903e43
docs(test): align the OOB tuple comment with the lane rule
thiremani Jul 4, 2026
390817a
refactor(compiler): one underCond with a nillable else replaces three…
thiremani Jul 4, 2026
06b9e49
refactor(compiler): rename underCond/underSlotCond to the with* helpe…
thiremani Jul 4, 2026
a812548
refactor(compiler): row-oriented slotAssign/exprAssign replace parall…
thiremani Jul 4, 2026
1ec3f4a
refactor(compiler): assignment primitives take slot rows; delete the …
thiremani Jul 4, 2026
3902d18
docs(ranges): document fold, the cartesian example, and stream/array …
thiremani Jul 5, 2026
714fc00
refactor(compiler): extract keepPriorOnSkip for the assignment skip path
thiremani Jul 6, 2026
90e0475
test(cond): make ArithWrap exercise the add; document why the chain i…
thiremani Jul 6, 2026
0e25b1a
test(cond): pin a plain-value || fallback taken through scalar arithm…
thiremani Jul 6, 2026
45c9479
test(cond): make MixArith exercise the array add with a non-zero addend
thiremani Jul 6, 2026
3affc83
test(cond): fix the misleading ChainInOperand comment (call-merge gat…
thiremani Jul 6, 2026
0ab44a6
refactor(compiler): bundle conditional output slots into OutputSlot
thiremani Jul 7, 2026
b5cd782
refactor(compiler): merge slot ident accessors into slotAssignIdents
thiremani Jul 7, 2026
fdb4874
refactor(compiler): drop condStageGroup for flat staged-slot slice
thiremani Jul 7, 2026
c29de9e
refactor(compiler): drop dead Put fallback after SetExisting
thiremani Jul 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,11 @@ b = Square(2.2) # float specialization
c = Square(arr) # squares each array element
a, b, c

# Range and filter (non-accumulated)
# Range, mask, and filter (non-accumulated)
d = Square(1:3) # range: final iteration result
e = Square(arr[1:3]) # array-range: final iteration result
f = Square(arr > 3) # filtered array
g = Square(arr[1:3] > 3) # filtered array-range
f = Square(arr > 3) # element-wise mask: each element kept where > 3, else 0
g = Square(arr[1:3] > 3) # array-range filter: final iteration result
d, e, f, g

# Accumulation forms
Expand All @@ -162,11 +162,11 @@ Output:

```text
25 4.84 [1 4 9 25]
4 9 [25] 0
4 9 [0 0 0 25] 0
[1 4] [4 9] [0 4] [0 9 25]
```

No generics syntax, no type parameters. Write the template once — call it with a scalar, an array, or a filtered view. `arr > 3` filters the array to elements greater than 3 and passes that subset through.
No generics syntax, no type parameters. Write the template once — call it with a scalar, an array, or a masked array. `arr > 3` masks the array element-wise — each element kept where it's greater than 3, else 0 (same length) — and passes that through.

Generated code is equivalent to handwritten specialized code. There is no runtime overhead.

Expand Down Expand Up @@ -195,7 +195,7 @@ x = [1 2 3 4 5]
y = [1.1 2.2 3.3]
```

Arrays are safe by construction — out-of-bounds access is not possible. Comparisons like `arr > 2` produce filtered views that work anywhere an array does.
Arrays are safe by construction — out-of-bounds access is not possible. Comparisons like `arr > 2` produce element-wise masks (each element kept where it holds, else 0) that work anywhere an array does.

---

Expand Down
96 changes: 49 additions & 47 deletions compiler/array.go
Original file line number Diff line number Diff line change
Expand Up @@ -701,77 +701,79 @@ func (c *Compiler) compileArrayScalarInfix(op string, arr *Symbol, scalar *Symbo
return resSym
}

// compileArrayFilter dispatches value-position comparison filtering when at least
// one operand is an array. It keeps LHS values where the comparison holds.
// For scalar-op-array, the scalar LHS is repeated for each true element.
func (c *Compiler) compileArrayFilter(op string, left *Symbol, right *Symbol, expected Type) *Symbol {
// compileArrayMask dispatches value-position comparison masking when at least one
// operand is an array. Each position yields the LHS element where the comparison
// holds, otherwise 0 — preserving length and position (a 0 marks a failed compare,
// never a dropped element). This is Pluto's scalar "comparison yields LHS-or-0"
// applied per element. Array-array zips to min length; array-scalar uses the
// array's length and broadcasts the scalar.
func (c *Compiler) compileArrayMask(op string, left *Symbol, right *Symbol, expected Type) *Symbol {
l := c.derefIfPointer(left, "")
r := c.derefIfPointer(right, "")

resArr := expected.(Array)
acc := c.NewArrayAccumulator(resArr)
resElem := expected.(Array).ColTypes[0]

if l.Type.Kind() == ArrayKind && r.Type.Kind() == ArrayKind {
return c.compileArrayArrayFilter(op, l, r, acc)
return c.compileArrayArrayMask(op, l, r, resElem)
}
if l.Type.Kind() == ArrayKind {
return c.compileArrayScalarFilter(op, l, r, acc, true)
return c.compileArrayScalarMask(op, l, r, resElem, true)
}
if r.Type.Kind() == ArrayKind {
return c.compileArrayScalarFilter(op, r, l, acc, false)
return c.compileArrayScalarMask(op, r, l, resElem, false)
}
panic(fmt.Sprintf("compileArrayFilter expects at least one array operand, got %s and %s", l.Type, r.Type))
panic(fmt.Sprintf("compileArrayMask expects at least one array operand, got %s and %s", l.Type, r.Type))
}

// filterPush conditionally appends sym to acc based on cond.
// Emits a branch: if cond is true, push sym; otherwise skip.
func (c *Compiler) filterPush(acc *ArrayAccumulator, sym *Symbol, cond llvm.Value) {
copyBlock, nextBlock := c.createIfCont(cond, "filter_copy", "filter_next")
// maskStore writes lhs into resVec[iter] when cond holds; on failure it leaves the
// slot at its preallocated zero (0 for numbers, "" for strings), realizing
// `cond ? lhs : 0` per cell. The set copies the (borrowed) element, so the source
// array keeps ownership and the result owns an independent copy.
func (c *Compiler) maskStore(resElem Type, resVec llvm.Value, iter llvm.Value, lhs *Symbol, cond llvm.Value) {
setBlock, contBlock := c.createIfCont(cond, "mask_set", "mask_cont")

c.builder.SetInsertPointAtEnd(copyBlock)
c.PushVal(acc, sym)
c.builder.CreateBr(nextBlock)
c.builder.SetInsertPointAtEnd(setBlock)
c.ArraySetForType(resElem, resVec, iter, lhs.Val)
c.builder.CreateBr(contBlock)

c.builder.SetInsertPointAtEnd(nextBlock)
c.builder.SetInsertPointAtEnd(contBlock)
}

func (c *Compiler) compileArrayArrayFilter(op string, left *Symbol, right *Symbol, acc *ArrayAccumulator) *Symbol {
c.forEachArrayPair(left, right, c.arrayPairMinLen(left, right), func(_ llvm.Value, leftSym *Symbol, rightSym *Symbol) {
cmpResult := defaultOps[opKey{
Operator: op,
LeftType: opType(leftSym.Type.Key()),
RightType: opType(rightSym.Type.Key()),
}](c, leftSym, rightSym, true)

c.filterPush(acc, leftSym, cmpResult.Val)
func (c *Compiler) compileArrayArrayMask(op string, left *Symbol, right *Symbol, resElem Type) *Symbol {
loopLen := c.arrayPairMinLen(left, right)
resVec := c.CreateArrayForType(resElem, loopLen)
c.forEachArrayPair(left, right, loopLen, func(iter llvm.Value, leftSym *Symbol, rightSym *Symbol) {
lhs, cond := c.compareScalars(op, leftSym, rightSym)
c.maskStore(resElem, resVec, iter, lhs, cond)
})

return c.ArrayAccResult(acc)
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"),
}
}

func (c *Compiler) compileArrayScalarFilter(op string, arr *Symbol, scalar *Symbol, acc *ArrayAccumulator, arrayOnLeft bool) *Symbol {
func (c *Compiler) compileArrayScalarMask(op string, arr *Symbol, scalar *Symbol, resElem Type, arrayOnLeft bool) *Symbol {
arrElem := arr.Type.(Array).ColTypes[0]
c.forEachArrayPair(arr, scalar, c.ArrayLen(arr, arrElem), func(_ llvm.Value, elemSym *Symbol, scalarSym *Symbol) {
leftSym := elemSym
rightSym := scalarSym
pushSym := elemSym
loopLen := c.ArrayLen(arr, arrElem)
resVec := c.CreateArrayForType(resElem, loopLen)
c.forEachArrayPair(arr, scalar, loopLen, func(iter llvm.Value, elemSym *Symbol, scalarSym *Symbol) {
// Preserve operand order for non-commutative comparisons. The masked value
// is always the comparison's LHS (the array element, or the scalar when it
// is on the left), which compareScalars returns alongside the result.
leftSym, rightSym := elemSym, scalarSym
if !arrayOnLeft {
// Preserve original operand order for non-commutative comparisons,
// and keep scalar LHS values on true comparisons.
leftSym = scalarSym
rightSym = elemSym
pushSym = scalarSym
leftSym, rightSym = scalarSym, elemSym
}
cmpResult := defaultOps[opKey{
Operator: op,
LeftType: opType(leftSym.Type.Key()),
RightType: opType(rightSym.Type.Key()),
}](c, leftSym, rightSym, true)

c.filterPush(acc, pushSym, cmpResult.Val)
lhs, cond := c.compareScalars(op, leftSym, rightSym)
c.maskStore(resElem, resVec, iter, lhs, cond)
})

return c.ArrayAccResult(acc)
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"),
}
}

func (c *Compiler) compileArrayUnaryPrefix(op string, arr *Symbol, result Array) *Symbol {
Expand Down
2 changes: 1 addition & 1 deletion compiler/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -1721,7 +1721,7 @@ func (c *Compiler) compileInfixBasic(expr *ast.InfixExpression, info *ExprInfo)

switch mode {
case CondArray:
res = append(res, c.compileArrayFilter(expr.Operator, left[i], right[i], info.OutTypes[i]))
res = append(res, c.compileArrayMask(expr.Operator, left[i], right[i], info.OutTypes[i]))
case CondScalar:
// Usually pre-extracted via condLHS, but can still occur when range
// comparisons are scalarized by an outer loop (e.g. call arg vectorization).
Expand Down
129 changes: 123 additions & 6 deletions compiler/cond.go
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,7 @@ func (c *Compiler) hasFallbackOrInTree(expr ast.Expression) bool {

// handleComparisons processes each slot of a multi-return comparison based on
// its CondMode. CondScalar slots are compared and ANDed into cond. CondArray
// slots are compiled as array filters (source freed, marked borrowed).
// slots are compiled as element-wise array masks (source freed, marked borrowed).
func (c *Compiler) handleComparisons(op string, left, right []*Symbol, info *ExprInfo, cond llvm.Value) ([]*Symbol, llvm.Value) {
lhsSyms := make([]*Symbol, len(left))
for i := range left {
Expand All @@ -507,8 +507,8 @@ func (c *Compiler) handleComparisons(op string, left, right []*Symbol, info *Exp
}
lhsSyms[i] = lSym
case CondArray:
// compileArrayFilter handles deref internally
lhsSyms[i] = c.compileArrayFilter(op, left[i], right[i], info.OutTypes[i])
// compileArrayMask handles deref internally
lhsSyms[i] = c.compileArrayMask(op, left[i], right[i], info.OutTypes[i])
c.freeSymbolValue(left[i], "")
left[i].Borrowed = true
}
Expand Down Expand Up @@ -1044,13 +1044,130 @@ func (c *Compiler) compileCondExprStatement(stmt *ast.LetStatement, stmtCond llv
exprDestNames := stmt.Name[targetIdx : targetIdx+numOutputs]
exprValues := []ast.Expression{expr}

c.compileCondExprValue(expr, stmtCond, func() {
c.compileCondAssignments(exprTempNames, exprDestNames, exprValues)
})
// A value-position multi-return comparison (Pair > Pair, Mix > Mix, ...)
// commits each slot independently: array slots mask (overwrite), scalar slots
// gate on their own condition — commit the LHS when it holds, keep the seeded
// prior value otherwise (the same keep-old-on-false a single comparison gives).
// A failing slot affects only itself, never the tuple. The cell-wise AND is
// reserved for gate/condition position and for || fallback; a single-return
// comparison keeps the gated path below.
if infix, ok := c.independentTupleComparison(expr, info); ok {
c.compileTupleComparisonPerSlot(infix, info, exprTempNames, stmtCond)
} else {
c.compileCondExprValue(expr, stmtCond, func() {
c.compileCondAssignments(exprTempNames, exprDestNames, exprValues)
})
}

targetIdx += numOutputs
}

c.commitConditionalOutputs(stmt.Name, tempNames, outTypes)
DeleteBulk(c.Scopes, tempNamesToStrings(tempNames))
}

// independentTupleComparison returns the comparison and true when expr is a
// value-position multi-return comparison whose slots commit independently (array
// masks plus per-slot-gated scalars) rather than as one all-or-nothing AND-gate.
// True for a multi-slot comparison (Pair > Pair, Mix > Mix, ...) that is neither a
// fallback-|| nor ranged. A single-return comparison is excluded (it keeps the
// gated path), and the cell-wise AND still applies in gate/condition position and
// for ||. Chained tuple comparisons (Pair > Pair < Pair) never reach here — the
// type solver rejects them (rejectChainedTupleComparison).
func (c *Compiler) independentTupleComparison(expr ast.Expression, info *ExprInfo) (*ast.InfixExpression, bool) {
infix, ok := expr.(*ast.InfixExpression)
if !ok {
return nil, false
}
if info.HasFallbackOr() || len(c.pendingLoopRanges(info.Ranges)) > 0 {
return nil, false
}
if !info.HasCondArray() && !info.HasCondScalar() {
return nil, false
}
if len(info.OutTypes) <= 1 {
return nil, false
}
return infix, true
}

// compileTupleComparisonPerSlot commits a value-position multi-return comparison
// slot by slot into the pre-seeded temps. Array slots mask and overwrite; scalar
// slots commit their LHS only when the per-slot comparison holds, otherwise leaving
// the seeded prior value in place (keep-old-on-false). The whole commit is guarded
// by any statement condition. Operands are evaluated once and freed after.
func (c *Compiler) compileTupleComparisonPerSlot(infix *ast.InfixExpression, info *ExprInfo, tempNames []*ast.Identifier, stmtCond llvm.Value) {
c.assignUnderStmtCond(stmtCond, func() {
// Guard operand evaluation so an out-of-bounds access (e.g. arr[oob]) in any
// operand fails the whole tuple assignment and keeps the prior values, like a
// normal assignment. Bounds checks are recorded while the operands compile.
guardPtr := c.pushBoundsGuard("tuple_bounds_guard")
defer c.popBoundsGuard()

left := c.compileExpression(infix.Left, nil)
right := c.compileExpression(infix.Right, nil)

commitSlots := func() {
for i := range left {
elemType := info.OutTypes[i]
if info.CompareModes[i] == CondArray {
mask := c.compileArrayMask(infix.Operator, left[i], right[i], elemType)
c.commitSlotValue(tempNames[i], mask, elemType, false)
continue
}

lhs, cond := c.compareScalars(infix.Operator, left[i], right[i])
ifBlock, contBlock := c.createIfCont(cond, "tuple_slot_if", "tuple_slot_cont")
c.builder.SetInsertPointAtEnd(ifBlock)
c.commitSlotValue(tempNames[i], lhs, elemType, true)
c.builder.CreateBr(contBlock)
c.builder.SetInsertPointAtEnd(contBlock)
}
}

if c.stmtBoundsUsed() {
c.withGuardedBranch(guardPtr, "tuple_bounds_ok", "tuple_bounds_commit", "tuple_bounds_skip", "tuple_bounds_cont", commitSlots, nil)
} else {
commitSlots()
}

c.freeTemporary(infix.Left, left)
c.freeTemporary(infix.Right, right)
})
}

// assignUnderStmtCond runs commit unconditionally when there is no statement
// condition, or guarded by it (keeping the seeded prior values on the false path).
func (c *Compiler) assignUnderStmtCond(stmtCond llvm.Value, commit func()) {
if stmtCond.IsNil() {
commit()
return
}

ifBlock, contBlock := c.createIfCont(stmtCond, "if", "continue")
c.builder.SetInsertPointAtEnd(ifBlock)
commit()
c.builder.CreateBr(contBlock)
c.builder.SetInsertPointAtEnd(contBlock)
}

// commitSlotValue stores value into the pre-seeded temp slot, freeing the slot's
// previous value unless it is a borrowed view. copyValue deep-copies the value
// first (for a borrowed scalar LHS that the temp must own); an array mask is
// already owned and passes through. Mirrors the seed-commit protocol used by
// commitStageTempOutputs.
func (c *Compiler) commitSlotValue(tempIdent *ast.Identifier, value *Symbol, elemType Type, copyValue bool) {
tempSym, _ := Get(c.Scopes, tempIdent.Value)
oldValue := c.valueSymbol(tempIdent.Value, tempSym, tempIdent.Value+"_slot_old")

toStore := value
if copyValue {
toStore = c.deepCopyIfNeeded(value)
}
c.storeSymbolToSlot(tempSym, toStore, elemType, tempIdent.Value+"_slot_store")

if c.skipBorrowedOldValueFree(oldValue) {
return
}
c.freeSymbolValue(oldValue, tempIdent.Value+"_slot_old")
}
Loading
Loading