diff --git a/README.md b/README.md index 25112f39..5ba5d47b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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. --- diff --git a/compiler/array.go b/compiler/array.go index b8618e40..ea05abd3 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -607,6 +607,16 @@ func (c *Compiler) forEachArrayPair( }) } +// arraySym wraps an array vector and element type as a Symbol, bitcast to the +// opaque i8* array handle used for array values. +func (c *Compiler) arraySym(array llvm.Value, elemType Type) *Symbol { + i8p := llvm.PointerType(c.Context.Int8Type(), 0) + return &Symbol{ + Type: Array{Headers: nil, ColTypes: []Type{elemType}, Length: 0}, + Val: c.builder.CreateBitCast(array, i8p, "arr_i8p"), + } +} + func (c *Compiler) compileArrayArrayInfix(op string, left *Symbol, right *Symbol, resElem Type) *Symbol { leftArrType := left.Type.(Array) rightArrType := right.Type.(Array) @@ -639,10 +649,7 @@ func (c *Compiler) compileArrayArrayInfix(op string, left *Symbol, right *Symbol }) // Return result array - i8p := llvm.PointerType(c.Context.Int8Type(), 0) - resSym := &Symbol{Type: Array{Headers: nil, ColTypes: []Type{resElem}, Length: 0}} - resSym.Val = c.builder.CreateBitCast(resVec, i8p, "arr_i8p") - return resSym + return c.arraySym(resVec, resElem) } func (c *Compiler) compileArrayConcat(left *Symbol, right *Symbol, leftElem Type, rightElem Type, resElem Type) *Symbol { @@ -666,10 +673,7 @@ func (c *Compiler) compileArrayConcat(left *Symbol, right *Symbol, leftElem Type c.CopyArrayInto(resVec, right, rightElem, resElem, leftLen, true) // Return concatenated array - i8p := llvm.PointerType(c.Context.Int8Type(), 0) - resSym := &Symbol{Type: Array{Headers: nil, ColTypes: []Type{resElem}, Length: 0}} - resSym.Val = c.builder.CreateBitCast(resVec, i8p, "arr_i8p") - return resSym + return c.arraySym(resVec, resElem) } func (c *Compiler) compileArrayScalarInfix(op string, arr *Symbol, scalar *Symbol, resElem Type, arrayOnLeft bool) *Symbol { @@ -695,83 +699,74 @@ func (c *Compiler) compileArrayScalarInfix(op string, arr *Symbol, scalar *Symbo c.ArraySetOwnForType(resElem, resVec, iter, resultVal) }) - i8p := llvm.PointerType(c.Context.Int8Type(), 0) - resSym := &Symbol{Type: Array{Headers: nil, ColTypes: []Type{resElem}, Length: 0}} - resSym.Val = c.builder.CreateBitCast(resVec, i8p, "arr_i8p") - return resSym + return c.arraySym(resVec, resElem) } -// 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) + return c.arraySym(resVec, resElem) } -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) + return c.arraySym(resVec, resElem) } func (c *Compiler) compileArrayUnaryPrefix(op string, arr *Symbol, result Array) *Symbol { @@ -799,10 +794,7 @@ func (c *Compiler) compileArrayUnaryPrefix(op string, arr *Symbol, result Array) c.ArraySetOwnForType(resElem, resVec, idx, resultVal) }) - i8p := llvm.PointerType(c.Context.Int8Type(), 0) - resSym := &Symbol{Type: result} - resSym.Val = c.builder.CreateBitCast(resVec, i8p, "arr_i8p") - return resSym + return c.arraySym(resVec, resElem) } // Array string conversion function diff --git a/compiler/compiler.go b/compiler/compiler.go index cb950351..741d7fa3 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -2,6 +2,7 @@ package compiler import ( "fmt" + "slices" "strings" "github.com/thiremani/pluto/ast" @@ -39,7 +40,7 @@ type Symbol struct { // For function calls (x = f(y)): // - Function produces a new value and writes it to output slot // - This value is MOVED to the destination (ownership transferred) -// - Old value in destination is NOT freed (see freeOldValues) +// - Old value in destination is NOT freed (see freeExprOldValues) // - Temps passed as inputs are freed by caller after call returns // - Function cleanup skips borrowed params/slots (caller owns them) // @@ -812,53 +813,54 @@ func (c *Compiler) makeZeroValue(symType Type) *Symbol { return s } -// writeTo stores compiled RHS symbols into destination identifiers. +// writeTo stores each slot's compiled value into its destination. // It delegates per-destination ownership and in-place pointer-slot updates to storeValue. -func (c *Compiler) writeTo(idents []*ast.Identifier, syms []*Symbol, needsCopy []bool) { - for i, ident := range idents { - c.storeValue(ident.Value, syms[i], needsCopy[i]) +func (c *Compiler) writeTo(slots []slotAssign) { + for _, slot := range slots { + c.storeValue(slot.dest.Value, slot.value, slot.needsCopy) } } -// computeCopyRequirements determines whether each RHS value needs copying or can transfer ownership. -// It also returns movedSources - the set of RHS variable names whose ownership was transferred. -func (c *Compiler) computeCopyRequirements(idents []*ast.Identifier, syms []*Symbol, rhsNames []string) ([]bool, map[string]struct{}) { - needsCopy := make([]bool, len(syms)) +// markCopyRequirements decides whether each slot's store must deep-copy or can +// transfer ownership, setting slot.needsCopy. Returns movedSources — the RHS +// variable names whose ownership was transferred. +func (c *Compiler) markCopyRequirements(slots []slotAssign) map[string]struct{} { movedSources := make(map[string]struct{}) - for i, rhsSym := range syms { + for i := range slots { + slot := &slots[i] // StrG (static strings): immutable, live forever - no copy needed. - if IsStrG(rhsSym.Type) { + if IsStrG(slot.value.Type) { continue } // Temporaries (array literals, function results, expressions): transfer ownership directly. // No copy needed - the temporary's memory becomes owned by the LHS variable. - if rhsNames[i] == "" { + if slot.rhsName == "" { continue } // Named variable on RHS - default to copying for safety - needsCopy[i] = true + slot.needsCopy = true // Check if RHS variable is being overwritten in LHS (enables ownership transfer). // Only allow transfer if this source hasn't already been moved. // This prevents double-free in cases like: a, b = a, a // where the second use of 'a' must copy, not transfer. - if _, moved := movedSources[rhsNames[i]]; moved { + if _, moved := movedSources[slot.rhsName]; moved { continue } - for _, lhsIdent := range idents { - if lhsIdent.Value != rhsNames[i] { + for _, other := range slots { + if other.owner.Value != slot.rhsName { continue } - needsCopy[i] = false - movedSources[rhsNames[i]] = struct{}{} + slot.needsCopy = false + movedSources[slot.rhsName] = struct{}{} break } } - return needsCopy, movedSources + return movedSources } func (c *Compiler) coerceSymbolForType(sym *Symbol, target Type, loadName string) *Symbol { @@ -921,9 +923,10 @@ func (c *Compiler) storeValue(name string, rhsSym *Symbol, shouldCopy bool) { if !TypeEqual(ptrType.Elem, stored.Type) { updated := GetCopy(oldSym) updated.Type = Ptr{Elem: stored.Type} - if !SetExisting(c.Scopes, name, updated) { - Put(c.Scopes, name, updated) - } + // oldSym came from Get above and nothing since touched the scope stack, so + // SetExisting always finds the binding — and it must update it in its own + // scope (which may be an outer block), not shadow it via Put in the current. + SetExisting(c.Scopes, name, updated) } } @@ -979,13 +982,13 @@ func (c *Compiler) freeSymbolValue(sym *Symbol, loadName string) { // - CallExpression writing directly through destination pointers: // The caller passes output pointers to the callee. The callee then applies // normal assignment cleanup when writing to those output params, so caller -// freeOldValues must skip. +// freeExprOldValues must skip. // // - InfixExpression/PrefixExpression/ArrayRangeExpression with pending ranges: // Range-lowered paths free previous output values per iteration inside the // loop body before storing the next value. // -// All other expressions return false so freeOldValues handles cleanup with full +// All other expressions return false so freeExprOldValues handles cleanup with full // assignment context (moved sources and borrowed/non-owning guards). func (c *Compiler) shouldSkipOldValueFree(expr ast.Expression, dest []*ast.Identifier) bool { if ce, isCall := expr.(*ast.CallExpression); isCall { @@ -1011,6 +1014,29 @@ func (c *Compiler) shouldSkipOldValueFree(expr ast.Expression, dest []*ast.Ident } } +// slotAssign is one destination slot of an assignment: where the value is +// written, which identifier owns move/copy decisions (the real destination, +// even when writing through a temp), the compiled value and the RHS variable +// it came from ("" for a temporary), whether the store must deep-copy, the +// value it replaces, and whether the value is the destination's own storage. +type slotAssign struct { + dest *ast.Identifier + owner *ast.Identifier + value *Symbol + rhsName string + needsCopy bool + oldValue *Symbol + destBacked bool +} + +// exprAssign is one right-hand-side expression's assignment: its compiled +// destination slots and the bounds bit its evaluation recorded (nil = clean). +type exprAssign struct { + expr ast.Expression + bit llvm.Value + slots []slotAssign +} + // compileAssignments writes expression results into writeIdents while applying // ownership/copy rules based on ownershipIdents. // @@ -1022,61 +1048,166 @@ func (c *Compiler) shouldSkipOldValueFree(expr ast.Expression, dest []*ast.Ident // that name must resolve to the corresponding write slot during RHS compilation. // Conditional lowering guarantees this via compileCondAssignments. func (c *Compiler) compileAssignments(writeIdents []*ast.Identifier, ownershipIdents []*ast.Identifier, exprs []ast.Expression) { - // Capture old values BEFORE compiling RHS expressions. - // This is critical for function calls with Ptr outputs: by the time RHS compilation - // finishes, Ptrs already contain NEW values. We must capture old values first. - oldValues := c.captureOldValues(writeIdents) - - // Collect bounds checks emitted while compiling RHS expressions. If any - // check fails, skip this assignment and keep prior destination values. - guardPtr := c.pushBoundsGuard("stmt_bounds_guard") - defer c.popBoundsGuard() - - syms, rhsNames, resCounts := c.compileAssignmentValues(writeIdents, exprs) - c.finishAssignmentsWithGuard(writeIdents, ownershipIdents, exprs, oldValues, syms, rhsNames, resCounts, guardPtr) + assigns := c.compileExprAssigns(writeIdents, ownershipIdents, exprs) + guarded := slices.ContainsFunc(assigns, func(e exprAssign) bool { return !e.bit.IsNil() }) + if !guarded { + c.commitAssignments(assigns) + return + } + c.commitAssignmentsPerExpr(assigns) } -func (c *Compiler) compileAssignmentValues(writeIdents []*ast.Identifier, exprs []ast.Expression) ([]*Symbol, []string, []int) { - syms := []*Symbol{} - rhsNames := []string{} // Track RHS variable names (or "" if not a variable) - resCounts := []int{} // Track result counts per expression to identify call destinations +// compileExprAssigns captures old destination values, then evaluates each +// expression under its own bounds guard: a failed check is a failed condition +// on that expression's lanes only, so sibling expressions in the same +// statement still commit (a, b = oarr[10], 5 keeps a, sets b). +func (c *Compiler) compileExprAssigns(writeIdents []*ast.Identifier, ownershipIdents []*ast.Identifier, exprs []ast.Expression) []exprAssign { + // Capture old values BEFORE compiling RHS expressions. This is critical + // for function calls with Ptr outputs: by the time RHS compilation + // finishes, Ptrs already contain NEW values. + oldValues := c.captureOldValues(writeIdents) + + assigns := make([]exprAssign, 0, len(exprs)) i := 0 for _, expr := range exprs { + guardPtr := c.pushBoundsGuard("assign_bounds_guard") res := c.compileExpression(expr, writeIdents[i:]) - resCounts = append(resCounts, len(res)) + var bit llvm.Value + if c.stmtBoundsUsed() { + bit = c.createLoad(guardPtr, Int{Width: 1}, "assign_bounds_ok") + } + c.popBoundsGuard() + + assigns = append(assigns, c.newExprAssign(expr, bit, res, writeIdents[i:], ownershipIdents[i:], oldValues[i:])) + i += len(res) + } + return assigns +} + +// newExprAssign zips one expression's compiled results with its destination +// slots; the ident and old-value slices start at the expression's first slot. +func (c *Compiler) newExprAssign(expr ast.Expression, bit llvm.Value, res []*Symbol, dests, owners []*ast.Identifier, olds []*Symbol) exprAssign { + var rhsName string + if ident, ok := expr.(*ast.Identifier); ok { + rhsName = ident.Value + } + slots := make([]slotAssign, len(res)) + for j, sym := range res { + slots[j] = slotAssign{ + dest: dests[j], + owner: owners[j], + value: sym, + rhsName: rhsName, + oldValue: olds[j], + destBacked: c.aliasesDestSlot(dests[j], sym), + } + } + return exprAssign{expr: expr, bit: bit, slots: slots} +} - var rhsName string - if ident, ok := expr.(*ast.Identifier); ok { - rhsName = ident.Value +// aliasesDestSlot reports whether a compiled value is the destination's own +// storage: an indirect-return call writes through existing destination slots +// and returns them, so a skipped commit must not free through such a value — +// the slot still holds the old value the skip is keeping. +func (c *Compiler) aliasesDestSlot(ident *ast.Identifier, sym *Symbol) bool { + if sym == nil || sym.Type.Kind() != PtrKind { + return false + } + destSym, ok := Get(c.Scopes, ident.Value) + return ok && destSym.Type.Kind() == PtrKind && destSym.Val == sym.Val +} + +// commitAssignmentsPerExpr commits each expression's destinations under that +// expression's bounds bit (nil = unconditional), preserving the statement's +// simultaneous-assignment order: all writes land before any old value is +// freed, so a swap (a, b = b, a) never reads a freed payload. A skipped +// expression frees its temporaries and restores destinations its evaluation +// may have written through (call outputs), keeping prior values. +func (c *Compiler) commitAssignmentsPerExpr(assigns []exprAssign) { + // Guarded destinations must be pointer-backed so the write and skip paths + // converge; a fresh one gets a zero seed to keep. + for _, e := range assigns { + if e.bit.IsNil() { + continue } - for range res { - rhsNames = append(rhsNames, rhsName) + for _, slot := range e.slots { + c.ensureSeededDest(slot.dest, slot.value) } + } - syms = append(syms, res...) - i += len(res) + moved := make([]map[string]struct{}, len(assigns)) + for k, e := range assigns { + moved[k] = c.markCopyRequirements(e.slots) + c.withCondBranch(e.bit, "assign_write", func() { + c.writeTo(e.slots) + }, func() { + c.keepPriorOnSkip(e) + }) + } + + for k, e := range assigns { + c.withCondBranch(e.bit, "assign_free", func() { + c.freeExprOldValues(e, moved[k]) + }, nil) } - return syms, rhsNames, resCounts } -func (c *Compiler) finishAssignmentsWithGuard( - writeIdents []*ast.Identifier, - ownershipIdents []*ast.Identifier, - exprs []ast.Expression, - oldValues []*Symbol, - syms []*Symbol, - rhsNames []string, - resCounts []int, - guardPtr llvm.Value, -) { +// keepPriorOnSkip is the skip path for one expression's assignment (guard +// false or bounds failure): free the temporaries its evaluation produced and +// restore its destinations to their captured prior values, so the skipped +// assignment leaves everything as it was. +func (c *Compiler) keepPriorOnSkip(e exprAssign) { + c.freeSkippedTemps(e) + c.restoreOldValues(e.slots) +} + +// freeSkippedTemps frees a skipped expression's temporaries, except values +// backed by destination storage: a skipped call never wrote those slots, so +// they still hold the old value the skip is keeping. +func (c *Compiler) freeSkippedTemps(e exprAssign) { + if _, isIdent := e.expr.(*ast.Identifier); isIdent { + return + } + for _, slot := range e.slots { + if slot.destBacked { + continue + } + c.freeTemporarySymbol(slot.value, "temp_free") + } +} + +// ensureSeededDest makes a guarded destination pointer-backed so conditional +// commit paths converge; a fresh destination gets a zero seed as its keep-old +// value. +func (c *Compiler) ensureSeededDest(ident *ast.Identifier, valSym *Symbol) { + if _, exists := Get(c.Scopes, ident.Value); exists { + c.promoteExistingSym(ident.Value) + return + } + t := valSym.Type + if p, ok := t.(Ptr); ok { + t = p.Elem + } + t = c.bindingSlotType(ident.Value, t) + ptr := c.createEntryBlockAlloca(c.mapToLLVMType(t), ident.Value+".mem") + zero := c.makeZeroValue(t) + c.createStore(zero.Val, ptr, t) + Put(c.Scopes, ident.Value, &Symbol{Val: ptr, Type: Ptr{Elem: t}}) +} + +func (c *Compiler) finishAssignmentsWithGuard(assigns []exprAssign, guardPtr llvm.Value) { if !c.stmtBoundsUsed() { - c.commitAssignments(writeIdents, ownershipIdents, syms, rhsNames, oldValues, exprs, resCounts) + c.commitAssignments(assigns) return } // Guarded assignments must converge through pointer-backed destinations so // runtime write/skip paths both feed subsequent reads correctly. - c.promoteIdentifiersIfNeeded(writeIdents) + for _, e := range assigns { + for _, slot := range e.slots { + c.promoteExistingSym(slot.dest.Value) + } + } c.withGuardedBranch( guardPtr, "stmt_bounds_ok", @@ -1084,40 +1215,61 @@ func (c *Compiler) finishAssignmentsWithGuard( "stmt_bounds_skip", "stmt_bounds_cont", func() { - c.commitAssignments(writeIdents, ownershipIdents, syms, rhsNames, oldValues, exprs, resCounts) + c.commitAssignments(assigns) }, func() { - c.freeAssignmentTemps(exprs, syms, resCounts) - c.restoreOldValues(writeIdents, oldValues) + for _, e := range assigns { + c.keepPriorOnSkip(e) + } }, ) } -func (c *Compiler) commitAssignments( - writeIdents []*ast.Identifier, - ownershipIdents []*ast.Identifier, - syms []*Symbol, - rhsNames []string, - oldValues []*Symbol, - exprs []ast.Expression, - resCounts []int, -) { - needsCopy, movedSources := c.computeCopyRequirements(ownershipIdents, syms, rhsNames) - c.writeTo(writeIdents, syms, needsCopy) - c.freeOldValues(ownershipIdents, oldValues, movedSources, exprs, resCounts) +// commitAssignments commits a whole statement unconditionally. Copy/move +// decisions span the statement — the slots are marked as one group, so +// a, b = b, a moves both sides — and all writes land before any old value is +// freed, so a swap never reads a freed payload. +func (c *Compiler) commitAssignments(assigns []exprAssign) { + var slots []slotAssign + for _, e := range assigns { + slots = append(slots, e.slots...) + } + + movedSources := c.markCopyRequirements(slots) + c.writeTo(slots) + for _, e := range assigns { + c.freeExprOldValues(e, movedSources) + } } -func (c *Compiler) promoteExistingSym(name string) { - if _, exists := Get(c.Scopes, name); !exists { +// freeExprOldValues frees the destination values one expression's commit +// replaced, skipping fresh destinations, moved sources, borrowed storage, and +// expressions that manage destination cleanup themselves (calls writing +// through destination pointers; ranged lowerings free per iteration). +func (c *Compiler) freeExprOldValues(e exprAssign, movedSources map[string]struct{}) { + owners := make([]*ast.Identifier, len(e.slots)) + for j, slot := range e.slots { + owners[j] = slot.owner + } + if c.shouldSkipOldValueFree(e.expr, owners) { return } - c.promoteToMemory(name) + for _, slot := range e.slots { + if slot.oldValue == nil || c.skipBorrowedOldValueFree(slot.oldValue) { + continue + } + if _, isMoved := movedSources[slot.owner.Value]; isMoved { + continue + } + c.freeSymbolValue(slot.oldValue, "old_assign") + } } -func (c *Compiler) promoteIdentifiersIfNeeded(idents []*ast.Identifier) { - for _, ident := range idents { - c.promoteExistingSym(ident.Value) +func (c *Compiler) promoteExistingSym(name string) { + if _, exists := Get(c.Scopes, name); !exists { + return } + c.promoteToMemory(name) } // This returns a pointer into stmtCtxStack storage. Callers must not keep @@ -1174,67 +1326,24 @@ func (c *Compiler) popCondLHSFrame() { ctx.condStack = ctx.condStack[:len(ctx.condStack)-1] } -// freeAssignmentTemps frees RHS temporaries when assignment writes are skipped. -func (c *Compiler) freeAssignmentTemps(exprs []ast.Expression, syms []*Symbol, resCounts []int) { - offset := 0 - for exprIdx, expr := range exprs { - count := resCounts[exprIdx] - c.freeTemporary(expr, syms[offset:offset+count]) - offset += count - } -} - // restoreOldValues writes captured destination values back after a skipped // assignment path where RHS evaluation may have updated pointer-backed slots. -func (c *Compiler) restoreOldValues(writeIdents []*ast.Identifier, oldValues []*Symbol) { - for i, ident := range writeIdents { - oldVal := oldValues[i] - if oldVal == nil { +func (c *Compiler) restoreOldValues(slots []slotAssign) { + for _, slot := range slots { + if slot.oldValue == nil { continue } - sym, exists := Get(c.Scopes, ident.Value) + sym, exists := Get(c.Scopes, slot.dest.Value) if !exists { continue } if ptrType, ok := sym.Type.(Ptr); ok { - c.createStore(oldVal.Val, sym.Val, ptrType.Elem) + c.createStore(slot.oldValue.Val, sym.Val, ptrType.Elem) continue } // Fallback for non-pointer symbols (defensive; guarded assignment paths // normally promote existing destinations before branching). - Put(c.Scopes, ident.Value, oldVal) - } -} - -// freeOldValues frees old values after stores complete. -// Skips: nil values (new variables), moved values, and expressions that -// manage old-value cleanup internally. -// -// Call results are skipped because function return values are moved (ownership -// transferred) to destination identifiers. -func (c *Compiler) freeOldValues(ownershipIdents []*ast.Identifier, oldValues []*Symbol, movedSources map[string]struct{}, exprs []ast.Expression, resCounts []int) { - i := 0 - for exprIdx, expr := range exprs { - // Skip expressions that handle destination old-value ownership themselves. - dest := ownershipIdents[i : i+resCounts[exprIdx]] - if c.shouldSkipOldValueFree(expr, dest) { - i += resCounts[exprIdx] - continue - } - for j := 0; j < resCounts[exprIdx]; j++ { - idx := i + j - if oldValues[idx] == nil { - continue - } - if c.skipBorrowedOldValueFree(oldValues[idx]) { - continue - } - if _, moved := movedSources[ownershipIdents[idx].Value]; moved { - continue - } - c.freeSymbolValue(oldValues[idx], "old_assign") - } - i += resCounts[exprIdx] + Put(c.Scopes, slot.dest.Value, slot.oldValue) } } @@ -1721,7 +1830,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). diff --git a/compiler/cond.go b/compiler/cond.go index 046294fe..c1c08d76 100644 --- a/compiler/cond.go +++ b/compiler/cond.go @@ -7,8 +7,8 @@ import ( "tinygo.org/x/go-llvm" ) -// condTemp holds a pre-compiled LHS operand and its source expression, used to -// free heap temporaries on the false branch of conditional expression lowering. +// condTemp holds a pre-compiled operand and its source expression, used to +// free heap temporaries that a skipped or false path leaves unconsumed. type condTemp struct { expr ast.Expression syms []*Symbol @@ -22,32 +22,17 @@ type condTemp struct { // gate must not leak). Returns a nil value when expr contributes no comparison // (e.g. a bare range driver, whose admitted domain is handled by the loop). func (c *Compiler) compileGate(expr ast.Expression) llvm.Value { - // No || in the tree: the gate is the conjunction of the comparisons, which - // extractCondExprs computes directly while freeing the retained LHS temps - // (a heap LHS tested only for the gate must not leak). - if !c.hasFallbackOrInTree(expr) { - c.pushCondLHSFrame() - defer c.popCondLHSFrame() - cond, temps := c.extractCondExprs(expr, llvm.Value{}, nil) - c.cleanupCondExprElse(temps) - return cond - } - - // A value-position || is a left-biased fallback, so its gate is "did the - // expression yield?" — a > 2 || b > 3 gates on a>2 OR b>3, not AND. Reuse the - // value lowering: record the gate on the yield path and free the yielded value - // (a gate discards it; freeTemporary skips borrowed operands, so variables are - // safe and heap temporaries are released). - i1 := Int{Width: 1} - i1Ty := c.mapToLLVMType(i1) - gatePtr := c.createEntryBlockAlloca(i1Ty, "gate.mem") - c.createStore(llvm.ConstInt(i1Ty, 0, false), gatePtr, i1) - c.compileCondExprValue(expr, llvm.Value{}, func() { - vals := c.compileExpression(expr, nil) - c.freeTemporary(expr, vals) - c.createStore(llvm.ConstInt(i1Ty, 1, false), gatePtr, i1) - }) - return c.createLoad(gatePtr, i1, "gate") + // The gate is the conjunction of the per-slot conditions extraction + // returns: for comparisons "did every cell hold", for a value-position || + // "did every slot yield" — a > 2 || b > 3 gates on a>2 OR b>3, which IS its + // yield flag. The retained LHS values drive the gate but are not a result, + // so they are freed here (a heap LHS computed only to test a gate must not + // leak). + c.pushCondLHSFrame() + defer c.popCondLHSFrame() + conds, temps := c.extractSlotConds(expr, nil) + c.cleanupCondExprElse(temps) + return c.foldSlotConds(conds) } // andGates ANDs the i1 gates of a list of condition expressions ("did every @@ -170,13 +155,46 @@ func (c *Compiler) resolveDestSeed(ident *ast.Identifier, outType Type) *Symbol return c.valueSymbol(ident.Value, existing, ident.Value+"_cond_seed") } -func (c *Compiler) createConditionalTempOutputs(stmt *ast.LetStatement) ([]*ast.Identifier, []Type) { +// OutputSlot bundles a conditional assignment's three parallel facts: the real +// destination, the borrowed temp slot its value is staged in, and the resolved +// slot type. Threading one []OutputSlot instead of three lockstep slices keeps +// dest/temp/outType aligned and turns sub-range slicing into a single +// expression. The temp name embeds a unique counter, so the dest-to-temp +// binding is established once at creation and carried, never recomputed. +type OutputSlot struct { + dest *ast.Identifier + temp *ast.Identifier + outType Type +} + +// slotAssignIdents returns the temp slots each value is written into and the +// real destinations that own move/copy and old-value cleanup — the write and +// ownership identifiers compileAssignments/newExprAssign consume, in that order. +func slotAssignIdents(slots []OutputSlot) (temps, dests []*ast.Identifier) { + temps = make([]*ast.Identifier, len(slots)) + dests = make([]*ast.Identifier, len(slots)) + for i, s := range slots { + temps[i] = s.temp + dests[i] = s.dest + } + return temps, dests +} + +func slotTempStrings(slots []OutputSlot) []string { + names := make([]string, len(slots)) + for i, s := range slots { + names[i] = s.temp.Value + } + return names +} + +func (c *Compiler) createConditionalTempOutputs(stmt *ast.LetStatement) []OutputSlot { outTypes := c.resolvedDestTypes(stmt.Name, c.collectOutTypes(stmt)) - return c.createConditionalTempOutputsFor(stmt.Name, outTypes), outTypes + return c.createConditionalTempOutputsFor(stmt.Name, outTypes) } -func (c *Compiler) createConditionalTempOutputsFor(dest []*ast.Identifier, outTypes []Type) []*ast.Identifier { - tempNames := make([]*ast.Identifier, len(dest)) +func (c *Compiler) createConditionalTempOutputsFor(dest []*ast.Identifier, outTypes []Type) []OutputSlot { + slots := make([]OutputSlot, len(dest)) for i, ident := range dest { tempName := fmt.Sprintf("condtmp_%s_%d", ident.Value, c.tmpCounter) c.tmpCounter++ @@ -194,70 +212,63 @@ func (c *Compiler) createConditionalTempOutputsFor(dest []*ast.Identifier, outTy // Temporary conditional outputs are borrowed so scope cleanup does not free // values that are transferred to real destinations in the merge block. Put(c.Scopes, tempName, tempSym) - tempNames[i] = tempIdent + slots[i] = OutputSlot{dest: ident, temp: tempIdent, outType: outTypes[i]} } - return tempNames + return slots } -func (c *Compiler) commitConditionalOutputs(dest []*ast.Identifier, tempNames []*ast.Identifier, outTypes []Type) { - for i, ident := range dest { - tempSym, _ := Get(c.Scopes, tempNames[i].Value) +func (c *Compiler) commitConditionalOutputs(slots []OutputSlot) { + for _, s := range slots { + tempSym, _ := Get(c.Scopes, s.temp.Value) - finalType := outTypes[i] - finalVal := c.createLoad(tempSym.Val, finalType, ident.Value+"_cond_final") + finalType := s.outType + finalVal := c.createLoad(tempSym.Val, finalType, s.dest.Value+"_cond_final") finalSym := &Symbol{ Val: finalVal, Type: finalType, } - oldSym, exists := Get(c.Scopes, ident.Value) + oldSym, exists := Get(c.Scopes, s.dest.Value) if !exists { - Put(c.Scopes, ident.Value, finalSym) + Put(c.Scopes, s.dest.Value, finalSym) continue } if _, ok := oldSym.Type.(Ptr); ok { - c.storeSymbolToSlot(oldSym, finalSym, oldSym.Type.(Ptr).Elem, ident.Value+"_cond_commit") + c.storeSymbolToSlot(oldSym, finalSym, oldSym.Type.(Ptr).Elem, s.dest.Value+"_cond_commit") // Keep pointer element type in sync (important for string ownership flags). updated := GetCopy(oldSym) updated.Type = Ptr{Elem: finalType} - if !SetExisting(c.Scopes, ident.Value, updated) { - Put(c.Scopes, ident.Value, updated) - } + // oldSym came from Get above and nothing since touched the scope stack, so + // SetExisting always finds the binding — and it must update it in its own + // scope (which may be an outer block), not shadow it via Put in the current. + SetExisting(c.Scopes, s.dest.Value, updated) continue } // Non-pointer symbols are replaced directly. Old value ownership is already // handled in the IF branch assignment into temp slots. - Put(c.Scopes, ident.Value, finalSym) + Put(c.Scopes, s.dest.Value, finalSym) } } -func tempNamesToStrings(tempNames []*ast.Identifier) []string { - names := make([]string, len(tempNames)) - for i, ident := range tempNames { - names[i] = ident.Value - } - return names -} - // aliasCondDests maps existing destination names to conditional temp slots so // RHS reads during IF-branch assignment see the latest temp writes. -func (c *Compiler) aliasCondDests(dest []*ast.Identifier, tempNames []*ast.Identifier) map[string]*Symbol { - aliases := make(map[string]*Symbol, len(dest)) +func (c *Compiler) aliasCondDests(slots []OutputSlot) map[string]*Symbol { + aliases := make(map[string]*Symbol, len(slots)) - for i, ident := range dest { - oldSym, exists := Get(c.Scopes, ident.Value) + for _, s := range slots { + oldSym, exists := Get(c.Scopes, s.dest.Value) if !exists { continue } - tempSym, ok := Get(c.Scopes, tempNames[i].Value) + tempSym, ok := Get(c.Scopes, s.temp.Value) if !ok { continue } - aliases[ident.Value] = oldSym - SetExisting(c.Scopes, ident.Value, tempSym) + aliases[s.dest.Value] = oldSym + SetExisting(c.Scopes, s.dest.Value, tempSym) } return aliases @@ -272,37 +283,43 @@ func (c *Compiler) restoreCondDests(aliases map[string]*Symbol) { // compileCondAssignments wraps compileAssignments for conditional lowering. // Existing destination names are temporarily aliased to temp slots so // self-referential RHS expressions read/write the same evolving slot. -func (c *Compiler) compileCondAssignments(tempNames []*ast.Identifier, dest []*ast.Identifier, exprs []ast.Expression) { - aliases := c.aliasCondDests(dest, tempNames) - c.compileAssignments(tempNames, dest, exprs) +func (c *Compiler) compileCondAssignments(slots []OutputSlot, exprs []ast.Expression) { + aliases := c.aliasCondDests(slots) + temps, dests := slotAssignIdents(slots) + c.compileAssignments(temps, dests, exprs) c.restoreCondDests(aliases) } -func (c *Compiler) compileCondAssignmentValues( - tempNames []*ast.Identifier, - dest []*ast.Identifier, - exprs []ast.Expression, -) ([]*Symbol, []*Symbol, []string, []int) { - aliases := c.aliasCondDests(dest, tempNames) - oldValues := c.captureOldValues(tempNames) - syms, rhsNames, resCounts := c.compileAssignmentValues(tempNames, exprs) - c.restoreCondDests(aliases) - return oldValues, syms, rhsNames, resCounts +// compileCondExprAssigns compiles staged RHS expressions with destinations +// aliased to their temp slots. Bounds bits stay nil: the caller's guard +// (cond_value_guard) owns skip/commit for the whole staged group. +func (c *Compiler) compileCondExprAssigns(slots []OutputSlot, exprs []ast.Expression) []exprAssign { + aliases := c.aliasCondDests(slots) + defer c.restoreCondDests(aliases) + + temps, dests := slotAssignIdents(slots) + oldValues := c.captureOldValues(temps) + assigns := make([]exprAssign, 0, len(exprs)) + i := 0 + for _, expr := range exprs { + res := c.compileExpression(expr, temps[i:]) + assigns = append(assigns, c.newExprAssign(expr, llvm.Value{}, res, temps[i:], dests[i:], oldValues[i:])) + i += len(res) + } + return assigns } -func (c *Compiler) compileCondAssignmentsWithGuard(tempNames []*ast.Identifier, dest []*ast.Identifier, exprs []ast.Expression, guardPtr llvm.Value) { - oldValues, syms, rhsNames, resCounts := c.compileCondAssignmentValues(tempNames, dest, exprs) - c.finishAssignmentsWithGuard(tempNames, dest, exprs, oldValues, syms, rhsNames, resCounts, guardPtr) +func (c *Compiler) compileCondAssignmentsWithGuard(slots []OutputSlot, exprs []ast.Expression, guardPtr llvm.Value) { + assigns := c.compileCondExprAssigns(slots, exprs) + c.finishAssignmentsWithGuard(assigns, guardPtr) } -func (c *Compiler) createStageTempOutputsFor(dest []*ast.Identifier) []*ast.Identifier { - tempNames := make([]*ast.Identifier, len(dest)) - for i, ident := range dest { - commitTempSym, _ := Get(c.Scopes, ident.Value) - ptrType := commitTempSym.Type.(Ptr) - outType := ptrType.Elem +func (c *Compiler) createStageTempOutputsFor(commit []OutputSlot) []OutputSlot { + stage := make([]OutputSlot, len(commit)) + for i, cs := range commit { + outType := cs.outType - tempName := fmt.Sprintf("condstage_%s_%d", ident.Value, c.tmpCounter) + tempName := fmt.Sprintf("condstage_%s_%d", cs.temp.Value, c.tmpCounter) c.tmpCounter++ tempIdent := &ast.Identifier{Value: tempName} @@ -312,51 +329,45 @@ func (c *Compiler) createStageTempOutputsFor(dest []*ast.Identifier) []*ast.Iden Type: Ptr{Elem: outType}, Borrowed: true, } - seed := c.resolveDestSeed(ident, outType) + // Seed each iteration's private stage slot from the commit temp's current + // running value, so a local skip keeps the value carried across iterations. + seed := c.resolveDestSeed(cs.temp, outType) seed = c.deepCopyIfNeeded(seed) c.storeSymbolToSlot(stageTempSym, seed, outType, tempName+"_seed") Put(c.Scopes, tempName, stageTempSym) - tempNames[i] = tempIdent + stage[i] = OutputSlot{dest: cs.dest, temp: tempIdent, outType: outType} } - return tempNames + return stage } -func (c *Compiler) commitStageTempOutputs(dest []*ast.Identifier, stageTempNames []*ast.Identifier) { - for i, ident := range dest { - stageSym, _ := Get(c.Scopes, stageTempNames[i].Value) - destSym, _ := Get(c.Scopes, ident.Value) - - oldValue := c.valueSymbol(ident.Value, destSym, ident.Value+"_stage_old") - ptrType := destSym.Type.(Ptr) - - stagedValue := c.valueSymbol(stageTempNames[i].Value, stageSym, stageTempNames[i].Value+"_stage_final") - c.storeSymbolToSlot(destSym, stagedValue, ptrType.Elem, ident.Value+"_stage_commit") - - if c.skipBorrowedOldValueFree(oldValue) { - continue - } - c.freeSymbolValue(oldValue, ident.Value+"_stage_old") +func (c *Compiler) commitStageTempOutputs(commit []OutputSlot, stage []OutputSlot) { + for i := range stage { + stageSym, _ := Get(c.Scopes, stage[i].temp.Value) + stagedValue := c.valueSymbol(stage[i].temp.Value, stageSym, stage[i].temp.Value+"_stage_final") + c.commitSlotValue(commit[i].temp, stagedValue, false) } } -type condStageGroup struct { - commitTempNames []*ast.Identifier - stageTempNames []*ast.Identifier -} - -func (c *Compiler) stageCondRangedExpr(expr ast.Expression, dest []*ast.Identifier, stageTempNames []*ast.Identifier) { +func (c *Compiler) stageCondRangedExpr(expr ast.Expression, stage []OutputSlot) { info := c.ExprCache[key(c.FuncNameMangled, expr)] - stageAliases := c.aliasCondDests(dest, stageTempNames) + stageAliases := c.aliasCondDests(stage) defer c.restoreCondDests(stageAliases) compileStageAssign := func() { guardPtr := c.pushBoundsGuard("cond_value_guard") defer c.popBoundsGuard() - c.compileCondAssignmentsWithGuard(stageTempNames, dest, []ast.Expression{expr}, guardPtr) + c.compileCondAssignmentsWithGuard(stage, []ast.Expression{expr}, guardPtr) } c.withLoopNestVersioned(info.Ranges, []ast.Expression{expr}, func() { + // Iterators bound by this loop leave no pending ranges, so a per-slot + // value expression commits slot-by-slot here too — a ranged statement + // condition must not change the value's per-slot semantics. + if c.perSlotCommittable(expr, info) { + c.compilePerSlotAssign(expr, info, stage, llvm.Value{}) + return + } if c.hasCondExprInTree(expr) { c.compileCondExprValue(expr, llvm.Value{}, compileStageAssign) return @@ -366,44 +377,39 @@ func (c *Compiler) stageCondRangedExpr(expr ast.Expression, dest []*ast.Identifi }) } -func (c *Compiler) stageCondRangedAssignments( - assignExprs []ast.Expression, - assignDests []*ast.Identifier, - commitTempNames []*ast.Identifier, -) []condStageGroup { +// stageCondRangedAssignments stages every RHS into a fresh per-iteration stage +// temp and returns the stage slots flat, aligned 1:1 with commit. The outer +// alias resolves destination reads to commit temps (this iteration's running +// values) throughout staging; stageCondRangedExpr adds an inner alias to each +// expression's own stage temp for self-referential reads/writes. No stage folds +// into a commit temp until commitCondRangedStages, so sibling RHS expressions +// read the iteration-start values. +func (c *Compiler) stageCondRangedAssignments(assignExprs []ast.Expression, commit []OutputSlot) []OutputSlot { + stage := make([]OutputSlot, 0, len(commit)) assignTargetIdx := 0 - groups := make([]condStageGroup, 0, len(assignExprs)) - // Two alias layers are active here. The outer alias makes destination reads - // resolve to commit temps while stage temps are seeded. stageCondRangedExpr - // then temporarily aliases the same destinations to private stage temps so - // self-referential RHS expressions read/write only that staged result. - allAliases := c.aliasCondDests(assignDests, commitTempNames) + allAliases := c.aliasCondDests(commit) defer c.restoreCondDests(allAliases) for _, expr := range assignExprs { info := c.ExprCache[key(c.FuncNameMangled, expr)] numOutputs := len(info.OutTypes) - exprCommitTempNames := commitTempNames[assignTargetIdx : assignTargetIdx+numOutputs] - exprDestNames := assignDests[assignTargetIdx : assignTargetIdx+numOutputs] - stageTempNames := c.createStageTempOutputsFor(exprCommitTempNames) - - c.stageCondRangedExpr(expr, exprDestNames, stageTempNames) - groups = append(groups, condStageGroup{ - commitTempNames: exprCommitTempNames, - stageTempNames: stageTempNames, - }) + exprStage := c.createStageTempOutputsFor(commit[assignTargetIdx : assignTargetIdx+numOutputs]) + + c.stageCondRangedExpr(expr, exprStage) + stage = append(stage, exprStage...) assignTargetIdx += numOutputs } - return groups + return stage } -func (c *Compiler) commitCondRangedStages(groups []condStageGroup) { - for _, group := range groups { - c.commitStageTempOutputs(group.commitTempNames, group.stageTempNames) - DeleteBulk(c.Scopes, tempNamesToStrings(group.stageTempNames)) - } +// commitCondRangedStages folds every staged value back into its commit temp +// (flat, 1:1 with commit) once all RHS expressions have been staged, then drops +// the stage temps from scope. +func (c *Compiler) commitCondRangedStages(commit, stage []OutputSlot) { + c.commitStageTempOutputs(commit, stage) + DeleteBulk(c.Scopes, slotTempStrings(stage)) } // compileCondStatement lowers: @@ -423,17 +429,17 @@ func (c *Compiler) compileCondStatement(stmt *ast.LetStatement, cond llvm.Value) // uninitialized memory. Pre-promote here so storage is initialized on all paths. c.prePromoteConditionalCallArgs(stmt.Value) - tempNames, outTypes := c.createConditionalTempOutputs(stmt) + slots := c.createConditionalTempOutputs(stmt) ifBlock, contBlock := c.createIfCont(cond, "if", "continue") c.builder.SetInsertPointAtEnd(ifBlock) - c.compileCondAssignments(tempNames, stmt.Name, stmt.Value) + c.compileCondAssignments(slots, stmt.Value) c.builder.CreateBr(contBlock) c.builder.SetInsertPointAtEnd(contBlock) - c.commitConditionalOutputs(stmt.Name, tempNames, outTypes) - DeleteBulk(c.Scopes, tempNamesToStrings(tempNames)) + c.commitConditionalOutputs(slots) + DeleteBulk(c.Scopes, slotTempStrings(slots)) } // valuesHaveCondExpr returns true if any value expression contains an @@ -462,63 +468,60 @@ func (c *Compiler) hasCondExprInTree(expr ast.Expression) bool { return false } -func (c *Compiler) fallbackOrExpr(expr ast.Expression) (*ast.InfixExpression, bool) { - infix, ok := ast.IsLogicalOr(expr) - if !ok { - return nil, false +// andConds ANDs two i1 conditions where either may be nil (unconditional). +func (c *Compiler) andConds(a, b llvm.Value, name string) llvm.Value { + if a.IsNil() { + return b } - - info := c.ExprCache[key(c.FuncNameMangled, expr)] - return infix, info.HasFallbackOr() + if b.IsNil() { + return a + } + return c.builder.CreateAnd(a, b, name) } -func (c *Compiler) hasFallbackOrInTree(expr ast.Expression) bool { - if _, ok := c.fallbackOrExpr(expr); ok { - return true - } - // Array cells and (cond value) nodes resolve any || at their own level (see - // extractCondExprs), so they are boundaries for statement-level fallback - // detection — don't descend into them. - switch expr.(type) { - case *ast.ArrayLiteral, *ast.CondValueExpr: - return false +// foldSlotConds ANDs all per-slot conditions into a single gate. Returns nil +// when every slot is unconditional. +func (c *Compiler) foldSlotConds(conds []llvm.Value) llvm.Value { + var cond llvm.Value + for _, sc := range conds { + cond = c.andConds(cond, sc, "and_slots") } - for _, child := range ast.ExprChildren(expr) { - if c.hasFallbackOrInTree(child) { - return true - } + return cond +} + +func slotCondAt(conds []llvm.Value, i int) llvm.Value { + if i >= len(conds) { + return llvm.Value{} } - return false + return conds[i] } -// 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). -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 { - switch info.CompareModes[i] { - case CondScalar: - lSym, cmpVal := c.compareScalars(op, left[i], right[i]) - if cond.IsNil() { - cond = cmpVal - } else { - cond = c.builder.CreateAnd(cond, cmpVal, fmt.Sprintf("and_cond_%d", i)) - } - lhsSyms[i] = lSym - case CondArray: - // compileArrayFilter handles deref internally - lhsSyms[i] = c.compileArrayFilter(op, left[i], right[i], info.OutTypes[i]) - c.freeSymbolValue(left[i], "") - left[i].Borrowed = true - } +// broadcastConds spreads one condition over n slots (all nil when cond is nil). +func broadcastConds(cond llvm.Value, n int) []llvm.Value { + conds := make([]llvm.Value, n) + if cond.IsNil() { + return conds + } + for i := range conds { + conds[i] = cond } - return lhsSyms, cond + return conds } -// extractCondExprs evaluates value-position comparisons and stores their LHS -// values for substitution during later value compilation. -func (c *Compiler) extractCondExprs(expr ast.Expression, cond llvm.Value, temps []condTemp) (llvm.Value, []condTemp) { +// extractSlotConds evaluates the value-position comparisons in expr and +// returns per-slot conditions aligned to expr's output slots — a nil entry +// (or an empty slice, for condition-free subtrees whose arity is not cached) +// means the slot always yields. Comparison LHS values and array masks are +// stashed in the condLHS frame for substitution during later value +// compilation; retained operand temporaries are appended to temps. +// +// Conditions merge along dataflow. A slot-aligned infix combines its +// children's conditions slot-wise, so each slot of Pair > Pair < Pair or +// (Pair > Pair) + Pair(0, 0) carries only its own comparisons. Any other node +// (call, index, range, ...) produces its slots together from all of its +// children, so their conditions AND into every output slot — for a +// single-output expression that is exactly the old single gate. +func (c *Compiler) extractSlotConds(expr ast.Expression, temps []condTemp) ([]llvm.Value, []condTemp) { info := c.ExprCache[key(c.FuncNameMangled, expr)] // Array-literal cells and (cond value) nodes are local-resolution boundaries: @@ -529,29 +532,106 @@ func (c *Compiler) extractCondExprs(expr ast.Expression, cond llvm.Value, temps // and double-evaluates the node, leaking its heap LHS on the pass-through path. switch expr.(type) { case *ast.ArrayLiteral, *ast.CondValueExpr: - return cond, temps + return nil, temps + } + + // A value-position || resolves per slot: slot i falls back only when slot i + // of the left side failed to yield. + if or, ok := ast.IsLogicalOr(expr); ok && info != nil && info.HasFallbackOr() { + return c.extractFallbackOrSlots(or, info, temps) } // Comparisons with ranges can be extracted only when all required iterators // are already bound by an outer loop (no pending ranges). - if infix, ok := expr.(*ast.InfixExpression); ok && info.HasCondScalar() && len(c.pendingLoopRanges(info.Ranges)) == 0 { - cond, temps = c.extractCondExprs(infix.Left, cond, temps) - cond, temps = c.extractCondExprs(infix.Right, cond, temps) - return c.extractCondComparison(infix, info, cond, temps) + if infix, ok := expr.(*ast.InfixExpression); ok && info.HasAnyComparison() && len(c.pendingLoopRanges(info.Ranges)) == 0 { + return c.extractComparisonSlots(infix, info, temps) } + switch e := expr.(type) { + case *ast.InfixExpression: + var lConds, rConds []llvm.Value + lConds, temps = c.extractSlotConds(e.Left, temps) + rConds, temps = c.extractSlotConds(e.Right, temps) + if len(lConds) == 0 && len(rConds) == 0 { + return nil, temps + } + // The solver enforces operand arity == node arity for non-comparison + // infix, so operand conditions are either absent or slot-aligned; + // anything else would zip lane i's condition onto lane j (a miscompile). + n := len(info.OutTypes) + if (len(lConds) != 0 && len(lConds) != n) || (len(rConds) != 0 && len(rConds) != n) { + panic(fmt.Sprintf("internal: slot conditions misaligned with infix arity (left %d, right %d, slots %d)", len(lConds), len(rConds), n)) + } + conds := make([]llvm.Value, n) + for i := range conds { + conds[i] = c.andConds(slotCondAt(lConds, i), slotCondAt(rConds, i), fmt.Sprintf("slot_and_%d", i)) + } + return conds, temps + case *ast.PrefixExpression: + return c.extractSlotConds(e.Right, temps) + } + + // Non-aligned nodes (calls, indexing, ranges, ...): every child feeds all + // output slots, so child conditions AND together and broadcast. + var all llvm.Value for _, child := range ast.ExprChildren(expr) { - cond, temps = c.extractCondExprs(child, cond, temps) + var childConds []llvm.Value + childConds, temps = c.extractSlotConds(child, temps) + all = c.andConds(all, c.foldSlotConds(childConds), "child_and") } - return cond, temps + if all.IsNil() { + return nil, temps + } + return broadcastConds(all, len(info.OutTypes)), temps } -func (c *Compiler) extractCondComparison(infix *ast.InfixExpression, info *ExprInfo, cond llvm.Value, temps []condTemp) (llvm.Value, []condTemp) { +// extractComparisonSlots evaluates one value-position comparison. Operands are +// compiled once (an inner comparison substitutes its retained LHS, so chains +// share one evaluation); each scalar slot yields its comparison bit ANDed with +// the operands' own conditions for that slot, and each array slot becomes an +// element-wise mask, which always yields and so contributes no condition. LHS +// values are retained in the condLHS frame; the left operand is tracked as a +// temporary unless it is a chained comparison that is already tracked. +func (c *Compiler) extractComparisonSlots(infix *ast.InfixExpression, info *ExprInfo, temps []condTemp) ([]llvm.Value, []condTemp) { + var lConds, rConds []llvm.Value + lConds, temps = c.extractSlotConds(infix.Left, temps) + rConds, temps = c.extractSlotConds(infix.Right, temps) + + // An out-of-bounds read while evaluating an operand is a failed condition + // on every lane the operand feeds (the read yields nothing, so nothing + // computed from it does either). + guardPtr := c.pushBoundsGuard("cmp_bounds_guard") left := c.compileExpression(infix.Left, nil) right := c.compileExpression(infix.Right, nil) + var boundsOK llvm.Value + if c.stmtBoundsUsed() { + boundsOK = c.createLoad(guardPtr, Int{Width: 1}, "cmp_bounds_ok") + } + c.popBoundsGuard() - var lhsSyms []*Symbol - lhsSyms, cond = c.handleComparisons(infix.Operator, left, right, info, cond) + conds := make([]llvm.Value, len(left)) + lhsSyms := make([]*Symbol, len(left)) + for i := range left { + operandCond := c.andConds(slotCondAt(lConds, i), slotCondAt(rConds, i), fmt.Sprintf("operand_cond_%d", i)) + operandCond = c.andConds(operandCond, boundsOK, fmt.Sprintf("operand_bounds_%d", i)) + switch info.CompareModes[i] { + case CondArray: + // compileArrayMask handles deref internally. The mask copies the + // elements it keeps, so an owned left operand (call result, literal, + // inner mask) is consumed here — but a named variable's loaded value + // is the variable's live payload and must survive. + lhsSyms[i] = c.compileArrayMask(infix.Operator, left[i], right[i], info.OutTypes[i]) + if _, isIdent := infix.Left.(*ast.Identifier); !isIdent { + c.freeSymbolValue(left[i], "") + left[i].Borrowed = true + } + conds[i] = operandCond + default: + lSym, cmpVal := c.compareScalars(infix.Operator, left[i], right[i]) + conds[i] = c.andConds(operandCond, cmpVal, fmt.Sprintf("slot_cond_%d", i)) + lhsSyms[i] = lSym + } + } frame := c.requireCondLHSFrame() frame[key(c.FuncNameMangled, infix)] = lhsSyms @@ -561,154 +641,219 @@ func (c *Compiler) extractCondComparison(infix *ast.InfixExpression, info *ExprI // retained comparison, so it is the SAME symbol already tracked. Re-tracking it // would free it twice (double-free / crash) for a heap LHS. if _, chained := frame[key(c.FuncNameMangled, infix.Left)]; !chained { - temps = append(temps, condTemp{infix.Left, left}) + temps = append(temps, condTemp{expr: infix.Left, syms: left}) } - // Right is comparison-only; left is retained for condLHS substitution. - c.freeTemporary(infix.Right, right) - return cond, temps + // Right is comparison-only; left is retained for condLHS substitution. A + // parenthesized comparison on the right (a > (b < c)) substituted the inner + // comparison's retained LHS — the same symbol already tracked — so freeing + // it here would double-free on the cleanup path. + if _, chainedRight := frame[key(c.FuncNameMangled, infix.Right)]; !chainedRight { + c.freeTemporary(infix.Right, right) + } + return conds, temps } -// cleanupCondExprElse frees temporaries retained during cond-expr extraction -// that are not consumed when the condition evaluates to false. -func (c *Compiler) cleanupCondExprElse(temps []condTemp) { - for _, tmp := range temps { - c.freeTemporary(tmp.expr, tmp.syms) +// extractFallbackOrSlots resolves a value-position || into the shared +// extraction form: chosen values stashed in the condLHS frame under the || +// node, yield flags returned as the slot conditions. Every result slot ends +// up owned or a freeable zero (an StrH zero is an owned heap copy, an array +// zero is null), so the slots travel as one unconditional temporary across +// both commit conventions (move-on-true vs copy-then-free). +func (c *Compiler) extractFallbackOrSlots(or *ast.InfixExpression, info *ExprInfo, temps []condTemp) ([]llvm.Value, []condTemp) { + fs := c.newFallbackSlots(info.OutTypes) + + lConds := c.resolveFallbackSide(fs, or.Left, false) + + // The right side evaluates at most once, as a unit, when any slot missed; + // its per-slot stores then fill only still-empty slots. The solver rejects + // a left that cannot fail, so the fold is never nil. + leftAllYielded := c.foldSlotConds(lConds) + if leftAllYielded.IsNil() { + panic("internal: value-position || requires a failable left operand") + } + someMissed := c.builder.CreateNot(leftAllYielded, "or_some_missed") + c.withCondBranch(someMissed, "or_rhs", func() { + c.resolveFallbackSide(fs, or.Right, true) + }, nil) + + conds, loaded := c.loadFallbackResults(fs) + frame := c.requireCondLHSFrame() + frame[key(c.FuncNameMangled, or)] = loaded + temps = append(temps, condTemp{expr: or, syms: loaded}) + return conds, temps +} + +// fallbackSlots is the result state of one value-position || lowering: per +// output slot, an alloca for the resolved value and an i1 yield flag. +type fallbackSlots struct { + outTypes []Type + slots []llvm.Value + yields []llvm.Value +} + +func (c *Compiler) newFallbackSlots(outTypes []Type) fallbackSlots { + i1 := Int{Width: 1} + i1Ty := c.mapToLLVMType(i1) + fs := fallbackSlots{ + outTypes: outTypes, + slots: make([]llvm.Value, len(outTypes)), + yields: make([]llvm.Value, len(outTypes)), } - for exprKey, lhsSyms := range c.requireCondLHSFrame() { - exprInfo := c.ExprCache[exprKey] - for i, mode := range exprInfo.CompareModes { - if mode == CondArray { - c.freeSymbolValue(lhsSyms[i], "") - } - } + for i, outType := range outTypes { + fs.slots[i] = c.createEntryBlockAlloca(c.mapToLLVMType(outType), fmt.Sprintf("or_slot_%d.mem", i)) + fs.yields[i] = c.createEntryBlockAlloca(i1Ty, fmt.Sprintf("or_yield_%d.mem", i)) + c.createStore(llvm.ConstInt(i1Ty, 0, false), fs.yields[i], i1) } + return fs } -// compileCondExprValue gates value-position cond expressions. Comparisons -// compose as AND; fallback OR tries the right side only after the left fails. -func (c *Compiler) compileCondExprValue(expr ast.Expression, baseCond llvm.Value, onTrue func()) { - c.pushCondLHSFrame() - defer c.popCondLHSFrame() - - if c.hasFallbackOrInTree(expr) { - c.compileYield(expr, baseCond, onTrue, func() {}) - return +// resolveFallbackSide resolves one || operand into the result slots. +// needFlags (the right side) restricts each store to slots the left did not +// already fill. +func (c *Compiler) resolveFallbackSide(fs fallbackSlots, side ast.Expression, needFlags bool) []llvm.Value { + i1 := Int{Width: 1} + before := c.frameMaskKeys() + conds, sideTemps := c.prepareFallbackOperand(side, nil) + + for i, outType := range fs.outTypes { + cond := slotCondAt(conds, i) + if needFlags { + need := c.builder.CreateNot(c.createLoad(fs.yields[i], i1, fmt.Sprintf("or_need_%d", i)), fmt.Sprintf("or_miss_%d", i)) + cond = c.andConds(need, cond, fmt.Sprintf("or_take_%d", i)) + } + c.withSlotCondBranch(side, i, cond, fmt.Sprintf("or_store_%d", i), func() { + c.storeFallbackValue(side, i, outType, fs.slots[i], fs.yields[i]) + }) } - cond, temps := c.extractCondExprs(expr, baseCond, nil) - c.branchCond(cond, temps, onTrue, func() {}) + c.freeCondTemps(sideTemps) + c.freeUnmovedMasksSince(before) + return conds } -// compileCondOperands leaves expr itself to the caller. -func (c *Compiler) compileCondOperands(expr ast.Expression, baseCond llvm.Value, onTrue func()) { - c.pushCondLHSFrame() - defer c.popCondLHSFrame() +// loadFallbackResults zero-seeds the slots that never yielded and returns the +// yield flags and loaded values per slot. +func (c *Compiler) loadFallbackResults(fs fallbackSlots) ([]llvm.Value, []*Symbol) { + i1 := Int{Width: 1} + conds := make([]llvm.Value, len(fs.outTypes)) + loaded := make([]*Symbol, len(fs.outTypes)) + for i, outType := range fs.outTypes { + yield := c.createLoad(fs.yields[i], i1, fmt.Sprintf("or_yield_%d", i)) + c.withCondBranch(c.builder.CreateNot(yield, fmt.Sprintf("or_zero_%d", i)), fmt.Sprintf("or_seed_%d", i), func() { + zero := c.makeZeroValue(outType) + c.createStore(zero.Val, fs.slots[i], outType) + }, nil) + conds[i] = yield + loaded[i] = &Symbol{Val: c.createLoad(fs.slots[i], outType, fmt.Sprintf("or_slot_%d", i)), Type: outType} + } + return conds, loaded +} - if c.hasFallbackOrInTree(expr) { - c.compileChildYields(ast.ExprChildren(expr), baseCond, onTrue, func() {}) - return +// prepareFallbackOperand prepares one side of a value-position || for per-slot +// reads. A (cond value) operand is failable here — it yields only when its +// condition holds — unlike everywhere else, where it self-resolves; its value +// arm is compiled behind its own gate. Everything else prepares like a spine. +func (c *Compiler) prepareFallbackOperand(expr ast.Expression, temps []condTemp) ([]llvm.Value, []condTemp) { + cv, ok := expr.(*ast.CondValueExpr) + if !ok { + return c.prepareSpine(expr, temps) } - cond := baseCond - var temps []condTemp - for _, child := range ast.ExprChildren(expr) { - cond, temps = c.extractCondExprs(child, cond, temps) - } - c.branchCond(cond, temps, onTrue, func() {}) -} + info := c.ExprCache[key(c.FuncNameMangled, cv)] + gate := c.andGates(cv.Conds) -func (c *Compiler) branchCond(cond llvm.Value, temps []condTemp, onTrue func(), onFalse func()) { - if cond.IsNil() { - onTrue() - return + slots := make([]llvm.Value, len(info.OutTypes)) + for i, outType := range info.OutTypes { + slots[i] = c.createEntryBlockAlloca(c.mapToLLVMType(outType), fmt.Sprintf("or_cv_%d.mem", i)) } - ifBlock, elseBlock, contBlock := c.createIfElseCont(cond, "cond_if", "cond_else", "cond_cont") + // The guard alloca lives in the entry block and is seeded in-bounds, so a + // skipped value arm reads back as clean. + guardPtr := c.pushBoundsGuard("or_cv_bounds_guard") + ifBlock, elseBlock, contBlock := c.createIfElseCont(gate, "or_cv_if", "or_cv_else", "or_cv_cont") c.builder.SetInsertPointAtEnd(ifBlock) - onTrue() + syms := c.compileExpression(cv.Value, nil) + for i, outType := range info.OutTypes { + coerced := c.coerceSymbolForType(syms[i], outType, fmt.Sprintf("or_cv_val_%d", i)) + c.createStore(coerced.Val, slots[i], coerced.Type) + } c.builder.CreateBr(contBlock) c.builder.SetInsertPointAtEnd(elseBlock) - c.cleanupCondExprElse(temps) - onFalse() + for i, outType := range info.OutTypes { + zero := c.makeZeroValue(outType) + c.createStore(zero.Val, slots[i], outType) + } c.builder.CreateBr(contBlock) c.builder.SetInsertPointAtEnd(contBlock) -} - -func (c *Compiler) compileChildYields(children []ast.Expression, baseCond llvm.Value, onTrue func(), onFalse func()) { - if len(children) == 0 { - c.branchCond(baseCond, nil, onTrue, onFalse) - return + if c.stmtBoundsUsed() { + boundsOK := c.createLoad(guardPtr, Int{Width: 1}, "or_cv_bounds_ok") + gate = c.andConds(gate, boundsOK, "or_cv_and_bounds") } + c.popBoundsGuard() - child := children[0] - rest := children[1:] - if c.hasCondExprInTree(child) { - c.compileYield(child, baseCond, func() { - c.compileChildYields(rest, llvm.Value{}, onTrue, onFalse) - }, onFalse) - return + loaded := make([]*Symbol, len(info.OutTypes)) + for i, outType := range info.OutTypes { + loaded[i] = &Symbol{Val: c.createLoad(slots[i], outType, fmt.Sprintf("or_cv_%d", i)), Type: outType} } - - c.compileChildYields(rest, baseCond, onTrue, onFalse) + frame := c.requireCondLHSFrame() + frame[key(c.FuncNameMangled, cv)] = loaded + temps = append(temps, condTemp{expr: cv, syms: loaded}) + return broadcastConds(gate, len(info.OutTypes)), temps } -func (c *Compiler) compileYield(expr ast.Expression, baseCond llvm.Value, onTrue func(), onFalse func()) { - if logicalOr, ok := c.fallbackOrExpr(expr); ok { - c.compileFallbackOr(logicalOr, baseCond, onTrue, onFalse) - return +// cleanupCondExprElse frees temporaries retained during cond-expr extraction +// that are not consumed when the condition evaluates to false, plus the frame +// masks a nested resolution has not already released. +func (c *Compiler) cleanupCondExprElse(temps []condTemp) { + for _, tmp := range temps { + c.freeTemporary(tmp.expr, tmp.syms) } + c.freeFrameMasks() +} - // A (cond value) yields its value only when its condition holds; otherwise it - // fails to onFalse (so an enclosing || tries the next alternative). - if cv, ok := expr.(*ast.CondValueExpr); ok { - cond := c.andGates(cv.Conds) - if !baseCond.IsNil() { - cond = c.builder.CreateAnd(baseCond, cond, "cv_and") - } - c.branchCond(cond, nil, func() { - vals := c.compileExpression(cv.Value, nil) - c.withCondLHS(cv, vals, onTrue) - }, onFalse) - return - } +// compileCondExprValue gates value-position cond expressions on the ANDed +// per-slot conditions: comparisons compose as AND, and a value-position || +// contributes its yield flags (fallback resolved during extraction). +func (c *Compiler) compileCondExprValue(expr ast.Expression, baseCond llvm.Value, onTrue func()) { + c.pushCondLHSFrame() + defer c.popCondLHSFrame() - if !c.hasFallbackOrInTree(expr) { - cond, temps := c.extractCondExprs(expr, baseCond, nil) - c.branchCond(cond, temps, onTrue, onFalse) - return - } + conds, temps := c.extractSlotConds(expr, nil) + cond := c.andConds(baseCond, c.foldSlotConds(conds), "base_and") + c.branchCond(cond, temps, onTrue, func() {}) +} - c.compileChildYields(ast.ExprChildren(expr), baseCond, func() { - if infix, ok := expr.(*ast.InfixExpression); ok { - info := c.ExprCache[key(c.FuncNameMangled, infix)] - if info != nil && info.HasCondScalar() && len(c.pendingLoopRanges(info.Ranges)) == 0 { - cond, temps := c.extractCondComparison(infix, info, llvm.Value{}, nil) - c.branchCond(cond, temps, onTrue, onFalse) - return - } - } +// compileCondOperands leaves expr itself to the caller. +func (c *Compiler) compileCondOperands(expr ast.Expression, baseCond llvm.Value, onTrue func()) { + c.pushCondLHSFrame() + defer c.popCondLHSFrame() - onTrue() - }, onFalse) + cond := baseCond + var temps []condTemp + for _, child := range ast.ExprChildren(expr) { + var childConds []llvm.Value + childConds, temps = c.extractSlotConds(child, temps) + cond = c.andConds(cond, c.foldSlotConds(childConds), "child_gate") + } + c.branchCond(cond, temps, onTrue, func() {}) } -func (c *Compiler) withCondLHS(expr ast.Expression, syms []*Symbol, body func()) { - frame := c.requireCondLHSFrame() - exprKey := key(c.FuncNameMangled, expr) - frame[exprKey] = syms - defer delete(frame, exprKey) - body() +func (c *Compiler) branchCond(cond llvm.Value, temps []condTemp, onTrue func(), onFalse func()) { + c.withCondBranch(cond, "cond", onTrue, func() { + c.cleanupCondExprElse(temps) + onFalse() + }) } // compileCondValueExpr lowers a parenthesized conditional value (cond value). -// When a surrounding gate (a value-position ||, via compileYield) has already -// branched on Cond and bound the chosen value under this node's key, that -// pre-bound value is returned. With pending ranges (e.g. the root `r = (i>2 i)`) -// it drives a loop; otherwise it compiles the scalar branch/phi, using the +// When a surrounding || (via prepareFallbackOperand) has already branched on +// Cond and bound the chosen value under this node's key, that pre-bound value +// is returned. With pending ranges (e.g. the root `r = (i>2 i)`) it drives a +// loop; otherwise it compiles the scalar branch/phi, using the // range-scalarized rewrite when an outer loop already bound the iterators. func (c *Compiler) compileCondValueExpr(expr *ast.CondValueExpr) []*Symbol { if frame := c.currentCondLHSFrame(); frame != nil { @@ -812,28 +957,6 @@ func (c *Compiler) compileCondValueExprRanges(expr *ast.CondValueExpr, info *Exp return out } -func (c *Compiler) compileFallbackOr(expr *ast.InfixExpression, baseCond llvm.Value, onTrue func(), onFalse func()) { - if !baseCond.IsNil() { - c.branchCond(baseCond, nil, func() { - c.compileFallbackOr(expr, llvm.Value{}, onTrue, onFalse) - }, onFalse) - return - } - - leftTrue := func() { - left := c.compileExpression(expr.Left, nil) - c.withCondLHS(expr, left, onTrue) - } - rightTrue := func() { - right := c.compileExpression(expr.Right, nil) - c.withCondLHS(expr, right, onTrue) - } - - c.compileYield(expr.Left, llvm.Value{}, leftTrue, func() { - c.compileYield(expr.Right, llvm.Value{}, rightTrue, onFalse) - }) -} - func (c *Compiler) isRangeDriverCond(expr ast.Expression) bool { info := c.ExprCache[key(c.FuncNameMangled, expr)] if len(info.OutTypes) != 1 { @@ -968,21 +1091,18 @@ func (c *Compiler) compileCondRangedStatement(stmt *ast.LetStatement, condRanges hasAssigns := len(assignDests) > 0 defer c.cleanupMaterializedCollectors(assignCollectorTemps) - var assignTempNames []*ast.Identifier + var assignSlots []OutputSlot if hasAssigns { - assignTempNames = c.createConditionalTempOutputsFor(assignDests, assignOutTypes) + assignSlots = c.createConditionalTempOutputsFor(assignDests, assignOutTypes) } c.withCondRangeLoop(condRanges, condExprs, loopProbes, "cond_iter_guard", "cond_iter_if", "cond_iter_cont", func() { - c.compileCondRangedIteration( - assignExprs, assignDests, assignTempNames, - accumAccs, accumLits, - ) + c.compileCondRangedIteration(assignExprs, assignSlots, accumAccs, accumLits) }) if hasAssigns { - c.commitConditionalOutputs(assignDests, assignTempNames, assignOutTypes) - DeleteBulk(c.Scopes, tempNamesToStrings(assignTempNames)) + c.commitConditionalOutputs(assignSlots) + DeleteBulk(c.Scopes, slotTempStrings(assignSlots)) } for i, acc := range accumAccs { @@ -1000,13 +1120,12 @@ func (c *Compiler) compileCondRangedStatement(stmt *ast.LetStatement, condRanges // and appends array literal cells to accumulators. func (c *Compiler) compileCondRangedIteration( assignExprs []ast.Expression, - assignDests []*ast.Identifier, - commitTempNames []*ast.Identifier, + assignSlots []OutputSlot, accumAccs []*ArrayAccumulator, accumLits []*ast.ArrayLiteral, ) { // Accum-only: no assigns, just push cells. - if len(assignDests) == 0 { + if len(assignSlots) == 0 { c.appendArrayLiterals(accumAccs, accumLits) return } @@ -1016,13 +1135,13 @@ func (c *Compiler) compileCondRangedIteration( // writes into a private stage temp under its own bounds guard, so a local skip // leaves that stage temp seeded with the prior value without suppressing // sibling RHS writes. - stageGroups := c.stageCondRangedAssignments(assignExprs, assignDests, commitTempNames) + stage := c.stageCondRangedAssignments(assignExprs, assignSlots) if len(accumLits) > 0 { c.appendArrayLiterals(accumAccs, accumLits) } - c.commitCondRangedStages(stageGroups) + c.commitCondRangedStages(assignSlots, stage) } // compileCondExprStatement handles let statements that have conditional @@ -1034,23 +1153,388 @@ func (c *Compiler) compileCondRangedIteration( func (c *Compiler) compileCondExprStatement(stmt *ast.LetStatement, stmtCond llvm.Value) { c.prePromoteConditionalCallArgs(stmt.Value) - tempNames, outTypes := c.createConditionalTempOutputs(stmt) + slots := c.createConditionalTempOutputs(stmt) targetIdx := 0 for _, expr := range stmt.Value { info := c.ExprCache[key(c.FuncNameMangled, expr)] numOutputs := len(info.OutTypes) - exprTempNames := tempNames[targetIdx : targetIdx+numOutputs] - exprDestNames := stmt.Name[targetIdx : targetIdx+numOutputs] - exprValues := []ast.Expression{expr} - - c.compileCondExprValue(expr, stmtCond, func() { - c.compileCondAssignments(exprTempNames, exprDestNames, exprValues) - }) + exprSlots := slots[targetIdx : targetIdx+numOutputs] + + // A multi-return value expression whose slots carry independent + // conditions (Pair > Pair, (Pair > Pair) + Pair(1, 1), Mix > Mix, ...) + // commits each slot under its own condition: array slots mask + // (overwrite), scalar slots keep the seeded prior value when their + // condition fails — the same keep-old a single comparison gives. A + // failing slot affects only itself. The cell-wise AND is reserved for + // gate/condition position and || fallback; everything else keeps the + // single ANDed gate below. + if c.perSlotCommittable(expr, info) { + c.compilePerSlotAssign(expr, info, exprSlots, stmtCond) + } else { + c.compileCondExprValue(expr, stmtCond, func() { + c.compileCondAssignments(exprSlots, []ast.Expression{expr}) + }) + } targetIdx += numOutputs } - c.commitConditionalOutputs(stmt.Name, tempNames, outTypes) - DeleteBulk(c.Scopes, tempNamesToStrings(tempNames)) + c.commitConditionalOutputs(slots) + DeleteBulk(c.Scopes, slotTempStrings(slots)) +} + +// perSlotCommittable reports whether expr commits slot-by-slot in value +// position: a multi-return expression whose root is a comparison, a +// value-position ||, or a slot-aligned arithmetic spine over one, so each +// output slot carries its own condition. Excluded (they keep the single ANDed +// gate): single-return expressions, ranged expressions, and roots whose slots +// merge — a call's outputs are produced together, so every argument condition +// gates them all. +func (c *Compiler) perSlotCommittable(expr ast.Expression, info *ExprInfo) bool { + if len(info.OutTypes) <= 1 { + return false + } + if len(c.pendingLoopRanges(info.Ranges)) > 0 { + return false + } + if !c.hasCondExprInTree(expr) { + return false + } + return c.isSlotAlignedSpine(expr) +} + +// isSlotAlignedSpine reports whether expr's root is a comparison, a +// value-position ||, or an arithmetic infix tree over one — the shapes whose +// output slots stay aligned with their operands' slots, so per-slot conditions +// are well-defined all the way to the root. +func (c *Compiler) isSlotAlignedSpine(expr ast.Expression) bool { + infix, ok := expr.(*ast.InfixExpression) + if !ok { + return false + } + info := c.ExprCache[key(c.FuncNameMangled, expr)] + if len(c.pendingLoopRanges(info.Ranges)) > 0 { + return false + } + if info.HasAnyComparison() || info.HasFallbackOr() { + return true + } + return c.isSlotAlignedSpine(infix.Left) || c.isSlotAlignedSpine(infix.Right) +} + +// compilePerSlotAssign lowers a per-slot-committable value expression into its +// pre-seeded temp slots. Extraction evaluates the comparisons and spine leaves +// once (folding any out-of-bounds read into the affected lanes' conditions); +// each slot then commits under its own condition — array slots store their +// mask, scalar slots store their value when the slot condition holds and keep +// the seeded prior value otherwise. Spine arithmetic for a slot compiles +// inside that slot's branch, so a combine (e.g. a division) runs only when its +// slot commits. The whole lowering sits behind any statement condition. +func (c *Compiler) compilePerSlotAssign(expr ast.Expression, info *ExprInfo, slots []OutputSlot, stmtCond llvm.Value) { + c.withCondBranch(stmtCond, "stmt_cond", func() { + c.pushCondLHSFrame() + defer c.popCondLHSFrame() + + conds, temps := c.prepareSpine(expr, nil) + for i, outType := range info.OutTypes { + c.commitSpineSlot(expr, i, slotCondAt(conds, i), slots[i].temp, outType) + } + + // Masks not moved into a slot (freed by a slot's else arm, or consumed + // by arithmetic) are swept here; moved ones were marked at store. + c.freeFrameMasks() + c.freeCondTemps(temps) + }, nil) +} + +// prepareSpine extracts per-slot conditions for a spine and pre-evaluates its +// leaves. Comparisons are evaluated by extraction (retaining LHS values and +// masks in the condLHS frame); any other leaf (call, literal, identifier, ...) +// is evaluated once here and its slot values stashed in the frame, gated on +// its own condition when it has one — a call whose argument comparison fails +// must not run, and the slots reading it are gated by that same condition. +func (c *Compiler) prepareSpine(expr ast.Expression, temps []condTemp) ([]llvm.Value, []condTemp) { + info := c.ExprCache[key(c.FuncNameMangled, expr)] + if _, ok := ast.IsLogicalOr(expr); ok && info.HasFallbackOr() { + return c.extractSlotConds(expr, temps) + } + + infix, isInfix := expr.(*ast.InfixExpression) + if isInfix && info.HasAnyComparison() && len(c.pendingLoopRanges(info.Ranges)) == 0 { + return c.extractComparisonSlots(infix, info, temps) + } + + if isInfix && c.isSlotAlignedSpine(expr) { + var lConds, rConds []llvm.Value + lConds, temps = c.prepareSpine(infix.Left, temps) + rConds, temps = c.prepareSpine(infix.Right, temps) + conds := make([]llvm.Value, len(info.OutTypes)) + for i := range conds { + conds[i] = c.andConds(slotCondAt(lConds, i), slotCondAt(rConds, i), fmt.Sprintf("spine_and_%d", i)) + } + return conds, temps + } + + return c.prepareSpineLeaf(expr, info, temps) +} + +// prepareSpineLeaf evaluates one non-comparison spine operand and stashes its +// slot values in the condLHS frame. A leaf with nested comparisons (e.g. a +// call with a conditional argument) is evaluated behind its own gate — the +// false arm stores zero values instead, so the loaded slots always hold +// freeable values (an StrH zero is an owned heap copy, an array zero is null) +// and cleanup stays unconditional. Slots reading a gated leaf carry its +// condition, so a zero seed is never committed. +func (c *Compiler) prepareSpineLeaf(expr ast.Expression, info *ExprInfo, temps []condTemp) ([]llvm.Value, []condTemp) { + var leafConds []llvm.Value + leafConds, temps = c.extractSlotConds(expr, temps) + + frame := c.requireCondLHSFrame() + // A node that resolved itself during extraction (a value-position ||) has + // its slot values stashed and tracked already; reuse them and its per-slot + // conditions as-is. + if _, ok := frame[key(c.FuncNameMangled, expr)]; ok { + return leafConds, temps + } + + leafCond := c.foldSlotConds(leafConds) + if leafCond.IsNil() { + guardPtr := c.pushBoundsGuard("leaf_bounds_guard") + syms := c.compileExpression(expr, nil) + var boundsOK llvm.Value + if c.stmtBoundsUsed() { + boundsOK = c.createLoad(guardPtr, Int{Width: 1}, "leaf_bounds_ok") + } + c.popBoundsGuard() + frame[key(c.FuncNameMangled, expr)] = syms + temps = append(temps, condTemp{expr: expr, syms: syms}) + return broadcastConds(boundsOK, len(info.OutTypes)), temps + } + + slots := make([]llvm.Value, len(info.OutTypes)) + for i, outType := range info.OutTypes { + slots[i] = c.createEntryBlockAlloca(c.mapToLLVMType(outType), fmt.Sprintf("spine_leaf_%d.mem", i)) + } + + // The guard alloca lives in the entry block and is seeded in-bounds, so a + // skipped leaf reads back as clean. + guardPtr := c.pushBoundsGuard("leaf_bounds_guard") + ifBlock, elseBlock, contBlock := c.createIfElseCont(leafCond, "spine_leaf_if", "spine_leaf_else", "spine_leaf_cont") + + c.builder.SetInsertPointAtEnd(ifBlock) + syms := c.compileExpression(expr, nil) + for i, outType := range info.OutTypes { + coerced := c.coerceSymbolForType(syms[i], outType, fmt.Sprintf("spine_leaf_val_%d", i)) + c.createStore(coerced.Val, slots[i], coerced.Type) + } + c.builder.CreateBr(contBlock) + + c.builder.SetInsertPointAtEnd(elseBlock) + for i, outType := range info.OutTypes { + zero := c.makeZeroValue(outType) + c.createStore(zero.Val, slots[i], outType) + } + c.builder.CreateBr(contBlock) + + c.builder.SetInsertPointAtEnd(contBlock) + if c.stmtBoundsUsed() { + boundsOK := c.createLoad(guardPtr, Int{Width: 1}, "leaf_bounds_ok") + leafCond = c.andConds(leafCond, boundsOK, "leaf_and_bounds") + } + c.popBoundsGuard() + + loaded := make([]*Symbol, len(info.OutTypes)) + for i, outType := range info.OutTypes { + loaded[i] = &Symbol{Val: c.createLoad(slots[i], outType, fmt.Sprintf("spine_leaf_%d", i)), Type: outType} + } + frame[key(c.FuncNameMangled, expr)] = loaded + temps = append(temps, condTemp{expr: expr, syms: loaded}) + return broadcastConds(leafCond, len(info.OutTypes)), temps +} + +// commitSpineSlot commits output slot i of a per-slot spine into its temp +// slot, keeping the seeded prior value when the slot condition fails. +func (c *Compiler) commitSpineSlot(expr ast.Expression, i int, cond llvm.Value, tempName *ast.Identifier, outType Type) { + c.withSlotCondBranch(expr, i, cond, "slot", func() { + val, owned := c.spineSlotValue(expr, i, outType) + c.commitSlotValue(tempName, val, !owned) + if owned { + // Ownership moved into the slot; mark the source so mask cleanup + // skips it (a fresh combine is simply discarded). + val.Borrowed = true + } + }) +} + +// withSlotCondBranch emits store for slot i of expr under cond (unconditionally +// when nil). The skipped arm frees the mask that slot would have moved: masks +// are built during extraction, so a runtime-skipped store must release one. +func (c *Compiler) withSlotCondBranch(expr ast.Expression, i int, cond llvm.Value, name string, store func()) { + c.withCondBranch(cond, name, store, func() { + c.freeSkippedSlotMask(expr, i) + }) +} + +// storeFallbackValue writes slot i of side into a || result slot and marks it +// yielded. A borrowed view must deref before the copy decision — a leaf's +// slot value may be Ptr-wrapped, and copying the wrapper aliases the pointee +// into a slot that outlives the freed source (a past double-free). A static +// string is left to the store's coercion, which copies only into heap-owned +// slot types. +func (c *Compiler) storeFallbackValue(side ast.Expression, i int, outType Type, slot, yield llvm.Value) { + val, owned := c.spineSlotValue(side, i, outType) + if owned { + val.Borrowed = true + } else { + val = c.derefIfPointer(val, fmt.Sprintf("or_take_val_%d", i)) + if !IsStrG(val.Type) { + val = c.deepCopyIfNeeded(val) + } + } + coerced := c.coerceSymbolForType(val, outType, fmt.Sprintf("or_val_%d", i)) + c.createStore(coerced.Val, slot, coerced.Type) + i1 := Int{Width: 1} + c.createStore(llvm.ConstInt(c.mapToLLVMType(i1), 1, false), yield, i1) +} + +// freeSkippedSlotMask frees the frame mask that slot i of expr would have +// moved, on the runtime path where the slot's store was skipped. The Borrowed +// mark set while building the store arm is ignored — these are exclusive +// runtime paths, and on this one the mask was never moved. +func (c *Compiler) freeSkippedSlotMask(expr ast.Expression, i int) { + info := c.ExprCache[key(c.FuncNameMangled, expr)] + if info == nil || i >= len(info.CompareModes) || info.CompareModes[i] != CondArray { + return + } + syms, ok := c.requireCondLHSFrame()[key(c.FuncNameMangled, expr)] + if !ok || syms[i] == nil { + return + } + c.freeSymbolValue(syms[i], "skipped_mask") +} + +// spineSlotValue returns slot i's value for a spine node: retained frame +// values for comparisons and pre-evaluated leaves, freshly combined arithmetic +// otherwise. owned reports whether the caller receives ownership (fresh +// combines and array masks) or a borrowed view it must copy on commit. +func (c *Compiler) spineSlotValue(expr ast.Expression, i int, outType Type) (*Symbol, bool) { + frame := c.requireCondLHSFrame() + if syms, ok := frame[key(c.FuncNameMangled, expr)]; ok { + info := c.ExprCache[key(c.FuncNameMangled, expr)] + if info != nil && i < len(info.CompareModes) && info.CompareModes[i] == CondArray { + return syms[i], true + } + return syms[i], false + } + + infix, ok := expr.(*ast.InfixExpression) + if !ok { + panic("internal: spine node missing pre-evaluated slot values") + } + l, _ := c.spineSlotValue(infix.Left, i, outType) + r, _ := c.spineSlotValue(infix.Right, i, outType) + return c.compileInfix(infix.Operator, l, r, outType), true +} + +// freeFrameMasks frees the array masks retained in the current condLHS frame +// that were not moved into a result slot (moved masks are marked borrowed at +// store time). Freed masks are marked too, so later cleanups skip them. +func (c *Compiler) freeFrameMasks() { + c.freeUnmovedMasksSince(nil) +} + +// frameMaskKeys snapshots the current condLHS frame's keys, so a nested +// resolution (a || operand) can clean up only the masks it stashed itself. +func (c *Compiler) frameMaskKeys() map[ExprKey]struct{} { + keys := make(map[ExprKey]struct{}) + for exprKey := range c.requireCondLHSFrame() { + keys[exprKey] = struct{}{} + } + return keys +} + +// freeUnmovedMasksSince frees array masks stashed in the condLHS frame since +// the snapshot (nil means all) that were not moved into a result slot, marking +// them borrowed so outer cleanups skip them. +func (c *Compiler) freeUnmovedMasksSince(before map[ExprKey]struct{}) { + for exprKey, lhsSyms := range c.requireCondLHSFrame() { + if _, ok := before[exprKey]; ok { + continue + } + exprInfo := c.ExprCache[exprKey] + if exprInfo == nil { + continue + } + for i, mode := range exprInfo.CompareModes { + if mode != CondArray || lhsSyms[i].Borrowed { + continue + } + c.freeSymbolValue(lhsSyms[i], "") + lhsSyms[i].Borrowed = true + } + } +} + +// freeCondTemps releases retained operand temporaries after per-slot commits. +// Committed values are copies (or transferred masks), so the sources are freed +// unconditionally; a skipped gated leaf holds freeable zero values. +func (c *Compiler) freeCondTemps(temps []condTemp) { + for _, tmp := range temps { + c.freeTemporary(tmp.expr, tmp.syms) + } +} + +// withCondBranch emits onTrue under cond and onFalse (nillable) otherwise. A nil +// cond means unconditional: onTrue runs inline and onFalse is dead. Blocks are +// named name_if / name_else / name_cont. +func (c *Compiler) withCondBranch(cond llvm.Value, name string, onTrue, onFalse func()) { + if cond.IsNil() { + onTrue() + return + } + + if onFalse == nil { + ifBlock, contBlock := c.createIfCont(cond, name+"_if", name+"_cont") + c.builder.SetInsertPointAtEnd(ifBlock) + onTrue() + c.builder.CreateBr(contBlock) + c.builder.SetInsertPointAtEnd(contBlock) + return + } + + ifBlock, elseBlock, contBlock := c.createIfElseCont(cond, name+"_if", name+"_else", name+"_cont") + + c.builder.SetInsertPointAtEnd(ifBlock) + onTrue() + c.builder.CreateBr(contBlock) + + c.builder.SetInsertPointAtEnd(elseBlock) + onFalse() + 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. A static string is not copied here: under a +// StrG-typed slot it lives forever (a heap copy there would never be freed), +// and under an StrH-typed slot the store's coercion makes the owned copy. +func (c *Compiler) commitSlotValue(tempIdent *ast.Identifier, value *Symbol, copyValue bool) { + tempSym, _ := Get(c.Scopes, tempIdent.Value) + slotElem := tempSym.Type.(Ptr).Elem + oldValue := c.valueSymbol(tempIdent.Value, tempSym, tempIdent.Value+"_slot_old") + + toStore := value + if copyValue && !IsStrG(value.Type) { + toStore = c.deepCopyIfNeeded(value) + } + c.storeSymbolToSlot(tempSym, toStore, slotElem, tempIdent.Value+"_slot_store") + + if c.skipBorrowedOldValueFree(oldValue) { + return + } + c.freeSymbolValue(oldValue, tempIdent.Value+"_slot_old") } diff --git a/compiler/solver.go b/compiler/solver.go index da9c567e..71120415 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -21,7 +21,7 @@ type CondMode int const ( CondNone CondMode = iota // Normal expression (not a comparison in value position) CondScalar // Scalar: extract LHS value, branch on condition - CondArray // Array: element-wise filter, keep LHS where condition holds + CondArray // Array: element-wise mask, LHS where condition holds else 0 (length-preserving) CondOr // Fallback OR in value position: first true operand wins CondValue // Parenthesized (cond value): yield value when cond holds, else zero ) @@ -49,6 +49,17 @@ func (info *ExprInfo) HasCondScalar() bool { return false } +// HasAnyComparison returns true if any slot is a comparison in value position +// (a scalar conditional or an array mask). +func (info *ExprInfo) HasAnyComparison() bool { + for _, m := range info.CompareModes { + if m == CondScalar || m == CondArray { + return true + } + } + return false +} + func (info *ExprInfo) HasFallbackOr() bool { for _, m := range info.CompareModes { if m == CondOr { @@ -779,9 +790,10 @@ func (ts *TypeSolver) validateStatementCondition(expr ast.Expression, condTypes // A multi-cell comparison (Pair > Pair) gates on the cell-wise conjunction, // like a single comparison; it is accepted by the conditionCanFail check - // below. Every cell must be a scalar: an array cell is a filter, which gate - // lowering would silently drop (it ANDs only scalar cells), so reject a - // condition with any array cell — not just the first. + // below. Every cell must be a scalar: an array cell is a mask (an array + // value, not a boolean), which gate lowering would silently drop (it ANDs + // only scalar cells), so reject a condition with any array cell — not just + // the first. condType := condTypes[0] if anyArrayCell(condTypes) { ts.Errors = append(ts.Errors, &token.CompileError{ @@ -1489,9 +1501,9 @@ func (ts *TypeSolver) TypeIdentifier(ident *ast.Identifier) (t Type) { return } -// typeInfixArrayFilter validates that element-wise comparison is supported -// for an array filter (comparison in value position with at least one array operand). -func (ts *TypeSolver) typeInfixArrayFilter(leftType, rightType Type, op string, tok token.Token) { +// typeInfixArrayMask validates that element-wise comparison is supported +// for an array mask (comparison in value position with at least one array operand). +func (ts *TypeSolver) typeInfixArrayMask(leftType, rightType Type, op string, tok token.Token) { cmpLeft := leftType cmpRight := rightType if leftType.Kind() == ArrayKind { @@ -1517,11 +1529,12 @@ func (ts *TypeSolver) typeInfixSlot(expr *ast.InfixExpression, leftType, rightTy return Unresolved{}, CondNone } - // Array filter: comparison in value position with an array operand. - // Keep LHS values where comparison holds. For scalar-array, this means - // producing an array of scalar LHS values (one per true element). + // Array mask: comparison in value position with an array operand. Each cell + // yields its LHS where the comparison holds, else 0 (length-preserving). For + // scalar-array, this produces an array of the scalar LHS type that broadcasts + // the scalar across the array's length. if isValueCmp && (leftType.Kind() == ArrayKind || rightType.Kind() == ArrayKind) { - ts.typeInfixArrayFilter(leftType, rightType, expr.Operator, expr.Token) + ts.typeInfixArrayMask(leftType, rightType, expr.Operator, expr.Token) if leftType.Kind() == ArrayKind { return leftType, CondArray } @@ -1586,8 +1599,8 @@ func typesResolved(types []Type) bool { // anyArrayCell reports whether any cell of a value-position condition is an array. // A gate is the cell-wise conjunction of scalar comparisons; an array comparison -// is a filter (it yields an array, not a boolean), so gate lowering only ANDs the -// scalar cells and silently drops an array cell. A gate condition must therefore +// is a mask (it yields an array value, not a boolean), so gate lowering only ANDs +// the scalar cells and silently drops an array cell. A gate condition must therefore // have no array cell — checked across every cell, not just the first, so a mixed // scalar/array multi-return comparison (Pair > Pair where one slot is an array) // is rejected. Shared by statement gates and (cond value) conditions. @@ -1630,7 +1643,7 @@ func (ts *TypeSolver) typeCondValueExpr(expr *ast.CondValueExpr, isRoot bool) [] condTypes := ts.TypeExpression(cond, true) if typesResolved(condTypes) { if anyArrayCell(condTypes) { - // An array cell is a filter, not a scalar boolean; gate lowering + // An array cell is a mask, not a scalar boolean; gate lowering // would silently drop it (see anyArrayCell), so reject it here too. ts.Errors = append(ts.Errors, &token.CompileError{ Token: cond.Tok(), @@ -1653,6 +1666,16 @@ func (ts *TypeSolver) typeCondValueExpr(expr *ast.CondValueExpr, isRoot bool) [] // resolves to its iterator type, not a Range. valueTypes := ts.TypeExpression(expr.Value, false) + // A || in the value arm is the one position still lowered inline with no + // branching context (it would panic in compileInfixBasic), so reject it — + // value-position || resolves per slot everywhere else. + if ts.valueArmHasFallbackOr(expr.Value) { + ts.Errors = append(ts.Errors, &token.CompileError{ + Token: expr.Tok(), + Msg: "value-position || inside a (cond value) value arm is not supported yet (e.g. (c > 0 a > 2 || 7)); compute the value first", + }) + } + valInfo := ts.ExprCache[key(ts.FuncNameMangled, expr.Value)] types := make([]Type, len(valueTypes)) @@ -1803,6 +1826,31 @@ func (ts *TypeSolver) TypeInfixExpression(expr *ast.InfixExpression) (types []Ty return } +// valueArmHasFallbackOr reports whether expr's tree contains a bare +// value-position ||. An array literal resolves a || in a cell locally, so it +// stays a boundary; a nested (cond value) is scanned through its value arm. +// Used to reject a || in a (cond value) value arm, the one position the +// compiler still lowers inline with no branching context. +func (ts *TypeSolver) valueArmHasFallbackOr(expr ast.Expression) bool { + if _, ok := ast.IsLogicalOr(expr); ok { + if info := ts.ExprCache[key(ts.FuncNameMangled, expr)]; info != nil && info.HasFallbackOr() { + return true + } + } + switch e := expr.(type) { + case *ast.ArrayLiteral: + return false + case *ast.CondValueExpr: + return ts.valueArmHasFallbackOr(e.Value) + } + for _, child := range ast.ExprChildren(expr) { + if ts.valueArmHasFallbackOr(child) { + return true + } + } + return false +} + func (ts *TypeSolver) TypeArrayInfix(left, right Type, op string, tok token.Token) Type { // Handle string concatenation early - always returns heap-allocated string if op == token.SYM_CONCAT && left.Kind() == StrKind && right.Kind() == StrKind { @@ -2254,6 +2302,7 @@ func (ts *TypeSolver) InferFuncTypes(ce *ast.CallExpression, args []Type, mangle func (ts *TypeSolver) TypeScriptFunc(mangled string, template *ast.FuncStatement, f *Func) []Type { ts.ScriptFunc = f.Name defer func() { ts.ScriptFunc = "" }() + errsAtEntry := len(ts.Errors) // multiple passes may be needed to infer types for script level function for range 100 { ts.Converging = false @@ -2263,13 +2312,22 @@ func (ts *TypeSolver) TypeScriptFunc(mangled string, template *ast.FuncStatement return f.OutTypes } + // A typing error is fatal for this function: TypeBlock aborts on it + // before outputs infer, and the error is deterministic, so another + // pass would only append the same diagnostic again. Stopping here + // keeps the error list append-only with no duplicates, and skips the + // "not converging" message — the reported errors are the cause, not + // cyclic recursion. + if len(ts.Errors) > errsAtEntry { + return f.OutTypes + } + // no further progress possible if !ts.Converging { - ce := &token.CompileError{ + ts.Errors = append(ts.Errors, &token.CompileError{ Token: template.Token, Msg: fmt.Sprintf("Function %s is not converging. Check for cyclic recursion and that each function has a base case", f.Name), - } - ts.Errors = append(ts.Errors, ce) + }) return f.OutTypes } } diff --git a/compiler/solver_test.go b/compiler/solver_test.go index 423296be..9b9ad564 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -479,7 +479,7 @@ j = 0:5:i`, } } -func TestArrayComparisonInValuePositionIsFilter(t *testing.T) { +func TestArrayComparisonInValuePositionIsMask(t *testing.T) { ctx := llvm.NewContext() cc := NewCodeCompiler(ctx, "arrayComparisonValue", "", ast.NewCode()) funcCache := make(map[string]*Func) @@ -505,7 +505,7 @@ func TestArrayComparisonInValuePositionIsFilter(t *testing.T) { info := ts.ExprCache[key(ts.FuncNameMangled, infix)] require.NotNil(t, info) require.Len(t, info.CompareModes, 1, "should have one compare mode entry") - require.Equal(t, CondArray, info.CompareModes[0], "array comparison in value position should be tagged as filter") + require.Equal(t, CondArray, info.CompareModes[0], "array comparison in value position should be tagged as element-wise mask (CondArray)") } func TestArrayConditionEmitsSingleDiagnostic(t *testing.T) { @@ -557,6 +557,96 @@ func TestMixedArrayScalarStatementConditionRejected(t *testing.T) { require.Truef(t, found, "expected array-cell rejection, got: %v", ts.Errors) } +func TestChainedTupleComparisonTypes(t *testing.T) { + ctx := llvm.NewContext() + // Pair returns two values; a chained comparison over them (Pair < Pair > Pair) + // resolves per slot — each slot chains like a single-value comparison — so + // the solver accepts it and types both outputs. + code := "p, q = Pair(x, y)\n p = x\n q = y" + cc := NewCodeCompiler(ctx, "chainedTupleCmp", "", mustParseCode(t, code)) + require.Empty(t, cc.Compile()) + + script := "a, b = Pair(5, 7) < Pair(4, 9) > Pair(2, 6)" + sl := lexer.New("chainedTupleCmp.spt", script) + sp := parser.NewScriptParser(sl) + program := sp.Parse() + require.Empty(t, sp.Errors(), "unexpected parse errors: %v", sp.Errors()) + + sc := NewScriptCompiler(ctx, program, cc, make(map[string]*Func), make(map[ExprKey]*ExprInfo)) + ts := NewTypeSolver(sc) + ts.Solve() + + require.Emptyf(t, ts.Errors, "chained tuple comparison should type cleanly, got: %v", ts.Errors) +} + +func TestInnerFallbackOrTupleComparisonTypes(t *testing.T) { + ctx := llvm.NewContext() + // A value-position || nested in a multi-return comparison operand + // (Pair(5 > 2 || 7, 9) > Pair(1, 1)) resolves during extraction like any + // other per-slot condition, so the solver accepts it. + code := "p, q = Pair(x, y)\n p = x\n q = y" + cc := NewCodeCompiler(ctx, "innerOrTupleCmp", "", mustParseCode(t, code)) + require.Empty(t, cc.Compile()) + + script := "px, py = Pair(5 > 2 || 7, 9) > Pair(1, 1)" + sl := lexer.New("innerOrTupleCmp.spt", script) + sp := parser.NewScriptParser(sl) + program := sp.Parse() + require.Empty(t, sp.Errors(), "unexpected parse errors: %v", sp.Errors()) + + sc := NewScriptCompiler(ctx, program, cc, make(map[string]*Func), make(map[ExprKey]*ExprInfo)) + ts := NewTypeSolver(sc) + ts.Solve() + + require.Emptyf(t, ts.Errors, "inner-|| tuple comparison should type cleanly, got: %v", ts.Errors) +} + +func TestInnerFallbackOrInCondValueArmRejected(t *testing.T) { + ctx := llvm.NewContext() + // A || in a (cond value) value arm is still lowered inline with no branching + // context (it would panic in compileInfixBasic), so the solver rejects it — + // in any context, and exactly once despite fixpoint re-typing. + code := "p, q = Pair(x, y)\n p = x\n q = y" + cc := NewCodeCompiler(ctx, "condValArmOrCmp", "", mustParseCode(t, code)) + require.Empty(t, cc.Compile()) + + script := "px, py = Pair((1 > 0 0 > 1 || 7), 9) > Pair(1, 1)" + sl := lexer.New("condValArmOrCmp.spt", script) + sp := parser.NewScriptParser(sl) + program := sp.Parse() + require.Empty(t, sp.Errors(), "unexpected parse errors: %v", sp.Errors()) + + sc := NewScriptCompiler(ctx, program, cc, make(map[string]*Func), make(map[ExprKey]*ExprInfo)) + ts := NewTypeSolver(sc) + ts.Solve() + + require.Lenf(t, ts.Errors, 1, "|| in a (cond value) value arm should emit one diagnostic, got: %v", ts.Errors) + require.Contains(t, ts.Errors[0].Msg, "value-position || inside a (cond value) value arm is not supported") +} + +func TestCondValueArmOrRejectedOnceInFunction(t *testing.T) { + ctx := llvm.NewContext() + // Function bodies are re-typed on every solver fixpoint pass; a rejection + // inside one must be emitted exactly once — not duplicated per pass, and + // with no spurious "not converging" cascade. + code := "r = BadOr(x)\n r = (x > 0 x > 2 || 7)" + cc := NewCodeCompiler(ctx, "condValArmOrFunc", "", mustParseCode(t, code)) + require.Empty(t, cc.Compile()) + + script := "m = BadOr(5)\n\"-m\"" + sl := lexer.New("condValArmOrFunc.spt", script) + sp := parser.NewScriptParser(sl) + program := sp.Parse() + require.Empty(t, sp.Errors(), "unexpected parse errors: %v", sp.Errors()) + + sc := NewScriptCompiler(ctx, program, cc, make(map[string]*Func), make(map[ExprKey]*ExprInfo)) + ts := NewTypeSolver(sc) + ts.Solve() + + require.Lenf(t, ts.Errors, 1, "function-body rejection should emit exactly one diagnostic, got: %v", ts.Errors) + require.Contains(t, ts.Errors[0].Msg, "value-position || inside a (cond value) value arm is not supported") +} + func TestScalarConditionEmitsTypeDiagnostic(t *testing.T) { ctx := llvm.NewContext() cc := NewCodeCompiler(ctx, "scalarConditionDiagnostic", "", ast.NewCode()) @@ -615,9 +705,9 @@ func TestCondValueDiagnostics(t *testing.T) { }, { // A mixed scalar/array multi-return comparison cannot gate: the array - // cell is a filter, not a boolean, and gate lowering would silently drop - // it. MixSA returns (scalar, array) so the array cell is not first — - // exercising the all-cells check, not just cell 0. + // 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 — exercising the all-cells check, not just cell 0. name: "MixedArrayCellRejected", code: "s, arr = MixSA(x)\n s = x\n arr = [x x + 1]", script: "y = (MixSA(5) > MixSA(3) 7)", @@ -724,7 +814,7 @@ func TestLogicalOrDiagnostics(t *testing.T) { } } -func TestScalarArrayComparisonInValuePositionIsFilter(t *testing.T) { +func TestScalarArrayComparisonInValuePositionIsMask(t *testing.T) { ctx := llvm.NewContext() cc := NewCodeCompiler(ctx, "scalarArrayComparisonValue", "", ast.NewCode()) funcCache := make(map[string]*Func) @@ -750,11 +840,11 @@ func TestScalarArrayComparisonInValuePositionIsFilter(t *testing.T) { info := ts.ExprCache[key(ts.FuncNameMangled, infix)] require.NotNil(t, info) require.Len(t, info.CompareModes, 1, "should have one compare mode entry") - require.Equal(t, CondArray, info.CompareModes[0], "scalar-array comparison in value position should be tagged as filter") + require.Equal(t, CondArray, info.CompareModes[0], "scalar-array comparison in value position should be tagged as element-wise mask (CondArray)") outArr, ok := info.OutTypes[0].(Array) - require.True(t, ok, "expected scalar-array filter output type to be array") - require.Equal(t, IntKind, outArr.ColTypes[0].Kind(), "scalar-array filter should keep scalar LHS element type") + require.True(t, ok, "expected scalar-array mask output type to be array") + require.Equal(t, IntKind, outArr.ColTypes[0].Kind(), "scalar-array mask should keep scalar LHS element type") } func TestArrayLiteralRangesRecording(t *testing.T) { diff --git a/docs/Pluto Conditional Value Semantics.md b/docs/Pluto Conditional Value Semantics.md index 09df1433..6bc3f7c2 100644 --- a/docs/Pluto Conditional Value Semantics.md +++ b/docs/Pluto Conditional Value Semantics.md @@ -35,6 +35,20 @@ comparison (`Pair > Pair`, including string pairs) likewise gates on the conjunction of its cells — every cell must hold. Both forms work in any condition slot (statement gate, `(cond value)`, array cell). +```pluto +g = 99 +g = Pair(1, 2) > Pair(0, 1) 7 # 1 > 0 and 2 > 1 -> gate on, g = 7 +g = Pair(1, 2) > Pair(1, 5) 8 # 1 > 1 fails -> gate off, g stays 7 +``` + +The multi-cell AND is not a conjunction operator smuggled into the gate — it is +the only single-bit reading of a per-slot expression in a position that asks one +question: *did it yield?* A multi-value expression yields when it produces its +complete value, i.e. every slot yields. The same reading gives a `||` gate its +OR semantics (its yield flag — `a > 2 || b > 3` yields when either side does), +and is why an **array cell is rejected** in a gate: a mask always yields, so a +gate over one would look meaningful while testing nothing. + ### Conditions are value positions A condition is itself a value position: a comparison yields its LHS and chains, @@ -173,8 +187,80 @@ Spacing separates cells, so parentheses also control cell count: [(a > 3 b < 5) || 10] # one cell ``` -A filter is different: `arr > k` drops elements that fail rather than zeroing -them. See [Pluto Range Semantics](Pluto%20Range%20Semantics.md). +A direct array comparison follows the same rule element-wise: `arr > k` is a +**mask** — each cell keeps its left value where the comparison holds, else 0, *in +place* (length- and position-preserving). It is the scalar "yield the left +operand, else 0" applied per element, so it stays consistent with the collector +cell above and with array arithmetic (`+`, `*`): `arr1 > arr2` zips to the +shorter length, `arr > scalar` spans the array and broadcasts the scalar. + +```pluto +[1 3 5 7] > [0 4 4 8] # [1 0 5 0] (failed cells masked to 0, in place) +[4 8 1] > 3 # [4 8 0] (array length, scalar broadcasts) +[1 3 5 7] > [0 4] # [1 0] (zip to min length; surplus dropped) +``` + +A failed comparison means "this element did not pass" (0 in place); a missing +element from a length mismatch is dropped, never zero-filled — so no fabricated 0 +ever reaches a value. Strings stay lexicographic. (A range-driven comparison over +a *stream* still skips rather than masks — see +[Pluto Range Semantics](Pluto%20Range%20Semantics.md).) + +## Multi-return comparisons + +A comparison over multi-valued operands (`Pair(...) > Pair(...)`, +`Mix(...) > Mix(...)`) resolves **per slot** in value position — each slot behaves +exactly as the single-value comparison would, independent of its siblings, with no +all-or-nothing. An array slot is an element-wise mask (it overwrites its target). A +scalar slot yields its left operand where the comparison holds, and otherwise keeps +the target's prior value — `0` for a fresh target, the existing value for an +existing one — the same keep-old-on-false a bare comparison gives. + +```pluto +a, b = Pair(1, 5) > Pair(2, 4) # 0 5 (fresh: 1 > 2 keeps 0; 5 > 4 -> 5) +r, n = Mix(2) > Mix(1) # [2 3] 2 (array slot masks; scalar slot yields) +n2 = 99 +r2, n2 = Mix(1) > Mix(2) # [0 0] 99 (array overwrites; scalar 1 > 2 keeps 99) +``` + +Per-slot resolution follows the value through its expression. A slot's conditions +merge along dataflow: chaining a multi-return comparison chains each slot +(`Pair > Pair < Pair` — the leftmost operand binds per slot, its conditions AND +slot-wise), and arithmetic over one stays per slot (`(Pair(5, 7) > Pair(1, 8)) + +Pair(0, 0)` → `5 0` fresh — the failing slot keeps old and its combine never +runs). A `||` falls back **per slot**: slot *i* takes the right side only when +slot *i* of the left failed to yield, a fallback beats keep-old, and a slot where +both sides fail keeps its old value (`Pair(5, 7) > Pair(1, 8) || Pair(0, 0)` → +`5 0`). A `||` inside an operand resolves to its value before the compare +(`Pair(a > 2 || 7, b) > Pair(1, 1)`). Array slots always yield (a mask is a +value), so `||` only ever affects scalar slots. + +Slots merge where an expression produces its outputs together: a **call's** +outputs all share every argument's condition (ANDed), so `Sum2(Pair(5, 7) > +Pair(1, 8))` is gated whole — a failing argument comparison skips the call and +keeps old, the same hoisting rule a scalar call argument gets +(`q = Sum2(k > 9, 3)` keeps `q` when `k <= 9`). + +The cell-wise **AND** (every cell must hold) applies only where a multi-cell +comparison is a **gate/condition** (`Pair(1, 2) > Pair(0, 1) value`, including +chained gates `Pair > Pair < Pair value`) — never to the value itself. A ranged +statement condition gates iterations without changing the value's per-slot +semantics. + +Operands are evaluated eagerly (once); only the yielding combine and the commit +are gated per slot. One form is **rejected at compile time**: a value-position +`||` inside a `(cond value)`'s **value arm** (`(c > 0 a > 2 || 7)`) — that arm +still lowers inline with no branching context. Compute the value first. + +An **out-of-bounds read is a failed condition on the lanes it feeds**, merged +by the same rules: a plain read no-ops its assignment (`x = arr[oob]` keeps +old), sibling expressions in one statement commit independently +(`a, b = arr[oob], 5` keeps `a`, sets `b`), a call merges its lanes (an OOB +argument keeps that call's outputs old as a unit), comparisons and `||` +fallbacks fed by an OOB read keep old rather than judging a fabricated zero, +and an unevaluated `||` right side cannot fail anything. The one place OOB +still reads as `0` is a **collector cell over an explicit range** — the +documented full-control opt-in. ## Why this model @@ -188,18 +274,21 @@ them. See [Pluto Range Semantics](Pluto%20Range%20Semantics.md). ## Status - **Implemented:** statement gates (keep-old); `||` fallback in value and - condition position; value-position comparisons (yield the left operand); - conditions are value positions, so chained comparisons gate directly - (`i > 2 < 8`, leftmost-binding) in every condition slot; collector zero-fill; - array filters; the parenthesized `(cond value)` expression (local resolution to + condition position (per slot over multi-return values); value-position + comparisons (yield the left operand), resolved per slot through chains, + arithmetic, and `||` by one extraction pass; conditions are value positions, + so chained comparisons gate directly (`i > 2 < 8`, leftmost-binding, including + chained multi-return gates) in every condition slot; collector zero-fill; + array masks (element-wise, length-preserving); the parenthesized `(cond value)` expression (local resolution to zero, with `|| fallback` and array-cell zero-fill), including per-cell use inside array literals. - **Conjunction conditions:** every condition slot accepts a comma-AND list (`a > 2, b > 3`) and a multi-cell comparison (`Pair(1, 2) > Pair(0, 1)`, gating on the cell-wise AND — every cell must hold). Heap operands (e.g. string cells via `⊕`) are freed after gating. -- **Transitional inconsistency:** `(cond value)` resolves **locally** to zero, - but a bare value-position comparison (`a > 2`) still **propagates** (keep-old). +- **Transitional inconsistency:** `(cond value)`, array cells, and array masks + resolve **locally** to zero, but a bare value-position comparison (`a > 2`) and a + multi-return comparison's scalar slots still **propagate** (keep-old on false). So `(a > 2 10) + 1` is `1` when `a <= 2` (local zero), while `(a > 2) + 1` keeps the old value. They agree for new variables and inside array cells; they differ only for existing variables and nested arithmetic. diff --git a/docs/Pluto Range Semantics.md b/docs/Pluto Range Semantics.md index 917b1ce0..80afc2a7 100644 --- a/docs/Pluto Range Semantics.md +++ b/docs/Pluto Range Semantics.md @@ -29,6 +29,21 @@ x = i + 1 This iterates `i` over `0, 1, 2, 3, 4` and the root assignment keeps the final value, so `x = 5`. +Distinct drivers nest in source order, so collecting over two ranges walks +their cartesian product: + +```pluto +a = 0:2 +b = 0:3 +[a + b] +``` + +produces: + +```pluto +[0 1 2 1 2 3] +``` + ## Calls, Infix, And Prefix Calls, infix operators, and prefix operators all follow the same rule: @@ -63,7 +78,10 @@ each yielded value, and the root assignment keeps the final result. ## Comparisons, Skip, And Fallback -Comparisons in value position are filters, not booleans. +Comparisons in value position over a range *stream* are filters (yield-or-skip), +not booleans. (A comparison on a materialized array is instead an element-wise +*mask* that keeps each element or zeros it — see +[Pluto Conditional Value Semantics](Pluto%20Conditional%20Value%20Semantics.md).) ```pluto i > 2 @@ -346,6 +364,46 @@ arr = [i + 1] `arr` becomes `[1 2 3 4 5]`. +## Self-Reference: Fold + +When the destination also appears on the right-hand side of a ranged +assignment, each iteration reads the value the previous iteration wrote — the +statement folds over the iteration domain instead of keeping only the last +independent value. + +```pluto +i = 1:5 +res = 0 +res = res + i +``` + +steps through `1, 3, 6, 10`, so `res` ends as `10`. The same rule accumulates +arrays through concatenation: + +```pluto +m = 0:3 +acc = [] +acc = acc ⊕ [m] +``` + +grows `acc` one element per iteration, ending as `[0 1 2]`. Indexed reads fold +the same way: `x = x + arr[k]` over `k = 0:5` sums the array into `x`. + +## Collection Commutes With Element-Wise Operations + +Applying an element-wise operation to a collected array gives the same result +as collecting the per-iteration values: + +```pluto +n = 0:5 +√[n] # collect n, then element-wise √ over the array +[√n] # √ per iteration, then collect +``` + +Both produce `[0 1 1.41421 1.73205 2]`. Streams and materialized arrays agree +wherever both readings exist; the difference is only *when* the array comes +into being. + ## Singleton Arrays If no range drivers are open inside `[]`, the literal evaluates once and diff --git a/tests/cond/value_cond_expr.exp b/tests/cond/value_cond_expr.exp index 76ae0f6b..1fbc6e54 100644 --- a/tests/cond/value_cond_expr.exp +++ b/tests/cond/value_cond_expr.exp @@ -33,14 +33,32 @@ CallLhsFalse: 0 NestedCondTrue: 10 5 NestedCondFalse: 0 0 PairTrue: 5 7 -PairOneFalse: 0 0 -PairFirstFalse: 0 0 -MixedFalse: [] 0 +PairOneFalse: 5 0 +PairFirstFalse: 0 7 +MixedFalse: [0 0] 0 MixedTrue: [2 3] 2 -ArrayArray: [2 6] -ArrayScalar: [5 6] -ScalarArrayGt: [2] -ScalarArrayLt: [2 2 2] +ArrayArray: [0 2 6] +ArrayScalar: [0 0 0 5 6] +ScalarArrayGt: [2 0 0 0 0] +ScalarArrayLt: [0 0 2 2 2] +MaskPosition: [1 0 5 0] +MaskScalar: [4 8 0] +MaskUnequalLen: [1 0] +MaskHeapStr: [s3 s5] +IntTupleKeepOldBefore: 11 22 +IntTupleKeepOld: 5 22 +MixTupleKeepOldBefore: [9 9] 99 +MixTupleKeepOld: [0 0] 99 +HeapTupleKeepOldBefore: oldA oldB +HeapTupleKeepOld: s3! oldB +NewHeapTuple: s5! +GatedTupleBefore: [7 7] 5 +GatedTupleFalse: [7 7] 5 +GatedTupleTrue: [3 4] 3 +OobTupleBefore: 77 66 +OobTupleKeepsPrev: 77 66 +HeapOobBefore: keepC keepD +HeapOobKeepsPrev: keepC keepD IntAddTrue: 7 IntAddFalse: 0 StrCmpTrue: banana @@ -72,6 +90,8 @@ LogicalOrCallArg: 14 LogicalOrHeapFallback: fallback LogicalOrPairFallback: 7 8 LogicalOrNestedSum: 10 +LogicalOrArithFallbackTaken: 7 +LogicalOrArithFallbackLeft: 25 LogicalOrDefault: -1 LogicalOrDefaultArg: -2 LogicalOrArrayDefault: [-1 -1 -1 3 4 5 6 7 -1 -1] @@ -147,3 +167,92 @@ SmcCondTrue: 7 SmcCondKeepOld: 99 SmcCondMulti: 8 9 SmcCondStr: 42 +ChainTupleBefore: 11 22 +ChainTuple: 5 22 +ChainTupleFresh: 5 7 +ChainTupleGateBefore: 100 +ChainTupleGate: 42 +ChainTupleGateFalseBefore: 100 +ChainTupleGateFalse: 100 +ArithWrapBefore: 11 22 +ArithWrap: 7 22 +ArithWrapChain: 6 0 +MixArith: [3 4] 0 +CallArgBefore: 111 +CallArgMergesSlots: 111 +GatedLeafBefore: 77 88 +GatedLeaf: 77 88 +GatedLeafPass: 8 9 +RangedGateBefore: 11 22 +RangedGate: 5 22 +TupleOrMixed: 5 0 +TupleOrOldBefore: 11 22 +TupleOrOld: 5 -1 +TupleOrBothFailBefore: 11 22 +TupleOrBothFail: 11 22 +InnerOrOperand: 7 9 +HeapChainBefore: oldA oldB +HeapChain: s3! oldB +LitTupleA: zzz +LitTupleB: | +HeapTupleOr: s3! s9! +HeapTupleOrBefore: oldC oldD +HeapTupleOrBothFallback: s7! s7! +NamedArrMask: [0 2 3] 5 [1 2 3] +StaticIntoHeapBefore: x1 x2 +StaticIntoHeap: zzz x2 +GatedMaskBefore: [9 9] 99 +GatedMask: [9 9] 99 +GatedMaskOrBefore: [9 9] 99 +GatedMaskOr: [7 7] 17 +ParenRightBefore: seed0 +ParenRight: seed0 +TripleChainBefore: 11 22 +TripleChain: 5 22 +MidLinkFail: 0 7 +ChainFallback: 5 -2 +OrChain3: 5 -2 +OrInArith: 15 10 +MixChain: [2 3] 2 +MixChainFailBefore: [9 9] 99 +MixChainFail: [0 0] 99 +ChainInOperand: 5 9 +ChainInOperandFailBefore: 77 88 +ChainInOperandFail: 77 88 +ChainOrInOperand: -1 9 +SmcFirstCellOffBefore: 88 +SmcFirstCellOff: 88 +SmcFreshOff: 0 +CvCellsOn: 7 +CvCellsOff: 0 +BothAlignedBefore: 11 22 +BothAligned: 6 22 +CallFoldBefore: 99 +CallFold: 9 +CallFoldFailBefore: 99 +CallFoldFail: 99 +PrefixPassBefore: 99 +PrefixPass: -4 +PrefixPassFailBefore: 99 +PrefixPassFail: 99 +LazyRhsSkippedBefore: 11 22 +LazyRhsSkipped: 5 7 +LazyRhsOobBefore: 11 22 +LazyRhsOob: 5 22 +ScalarLazySkippedBefore: 99 +ScalarLazySkipped: 5 +PlainMultiOobBefore: 11 22 +PlainMultiOob: 11 5 +PlainMultiOobFresh: 0 5 +PlainSingleOobBefore: 99 +PlainSingleOob: 99 +ScalarCmpOobBefore: 99 +ScalarCmpOob: 99 +ScalarOrOobBefore: 99 +ScalarOrOob: 99 +CallMergeOobBefore: 11 22 +CallMergeOob: 11 22 +SwapBefore: aax bby +Swap: bby aax +SwapPlusOobBefore: 7 8 99 +SwapPlusOob: 8 7 99 diff --git a/tests/cond/value_cond_expr.pt b/tests/cond/value_cond_expr.pt index 9a958bae..a5ad2f56 100644 --- a/tests/cond/value_cond_expr.pt +++ b/tests/cond/value_cond_expr.pt @@ -25,3 +25,15 @@ res = Inc(x) p, q = PairStr(x, y) p = MkStr(x) ⊕ "!" q = MkStr(y) ⊕ "!" + +p, q = LitPair() + p = "zzz" + q = "bbb" + +p, q = LitPair2() + p = "aaa" + q = "bbb" + +a, b = MixK(x) + a = [x x] + b = x + 10 diff --git a/tests/cond/value_cond_expr.spt b/tests/cond/value_cond_expr.spt index ce1d24f5..0c91060c 100644 --- a/tests/cond/value_cond_expr.spt +++ b/tests/cond/value_cond_expr.spt @@ -134,38 +134,44 @@ v2 = -3 m2, n2 = CondDouble(v2), v2 > 0 "NestedCondFalse: -m2 -n2" -# Multi-return conditional expression: all true +# Multi-return comparison in value position: all slots pass. px, py = Pair(5, 7) > Pair(1, 2) "PairTrue: -px -py" -# Multi-return conditional expression: one false (all-or-nothing skip) +# Multi-return comparison resolves each slot independently (per-slot LHS-or-0): a +# failing slot is 0 and does not drop the passing slot. Here 7 > 8 fails -> 0 while +# 5 > 1 keeps 5. (The cell-wise AND is reserved for gate/condition position and || +# fallback, not the value form.) px2, py2 = Pair(5, 7) > Pair(1, 8) "PairOneFalse: -px2 -py2" -# Multi-return: first slot false, second would pass (AND covers both) +# Same, with the first slot failing: 5 > 6 -> 0, 7 > 2 -> 7. px3, py3 = Pair(5, 7) > Pair(6, 2) "PairFirstFalse: -px3 -py3" -# Mixed array/scalar cond-expr: false case (scalar comparison fails) +# Mixed array/scalar tuple comparison: the array slot is an independent mask and +# the scalar slot gates only itself. Here the scalar fails (1 > 2 -> 0) but does +# NOT drop the array mask, so the array slot still masks element-wise to [0 0]. r2, n2 = Mix(1) > Mix(2) "MixedFalse: -r2 -n2" -# Mixed array/scalar cond-expr: array slot filtered, scalar slot conditional +# Same form, all cells pass: array slot masks to [2 3], scalar slot yields 2. r, n = Mix(2) > Mix(1) "MixedTrue: -r -n" -# Array comparison in value position (normal infix, not cond-expr) +# Array comparison in value position: element-wise mask, zip to min length. +# Each cell keeps its LHS where the comparison holds, else 0 (position-preserving). arr1 = [1 2 6] arr2 = [1 1 3 5 7] ax = arr1 > arr2 "ArrayArray: -ax" -# Array-scalar comparison in value position +# Array-scalar comparison: element-wise mask over the array length (scalar broadcasts). arr3 = [1 2 3 5 6] ay = arr3 > 3 "ArrayScalar: -ay" -# Scalar-array comparison in value position keeps scalar LHS values +# Scalar-array comparison: mask keeps the scalar LHS where it holds, else 0. az = 2 > arr3 "ScalarArrayGt: -az" @@ -173,6 +179,88 @@ az = 2 > arr3 az2 = 2 < arr3 "ScalarArrayLt: -az2" +# Element-wise mask preserves position: a failed compare becomes 0 in place (the +# element is masked out, not dropped), so a passing element keeps its index. +maskPos1 = [1 3 5 7] +maskPos2 = [0 4 4 8] +maskPos = maskPos1 > maskPos2 +"MaskPosition: -maskPos" + +# Array-scalar mask spans the array length; the scalar broadcasts. +maskScalar = [4 8 1] > 3 +"MaskScalar: -maskScalar" + +# Unequal lengths zip to the min; the surplus beyond the overlap is dropped. +maskShort = [1 3 5 7] > [0 4] +"MaskUnequalLen: -maskShort" + +# Heap-string elements: a passing cell is copied into the result, a failing cell +# becomes "" — both owned by the result and freed with it (leak-checked). +maskStrArr = [MkStr(3) MkStr(1) MkStr(5)] +maskStr = maskStrArr > "s2" +"MaskHeapStr: -maskStr" + +# Existing-variable tuple comparison: each scalar slot keeps its old value on a +# failed compare (per-slot keep-old, like a single comparison), while a passing slot +# commits its LHS. Int slots: 5 > 1 commits 5, 7 > 8 keeps 22. +itkA = 11 +itkB = 22 +"IntTupleKeepOldBefore: -itkA -itkB" +itkA, itkB = Pair(5, 7) > Pair(1, 8) +"IntTupleKeepOld: -itkA -itkB" + +# Mixed tuple, existing vars: the array slot overwrites (mask, no keep-old), the +# scalar slot keeps its old value on failure. +mtkArr = [9 9] +mtkN = 99 +"MixTupleKeepOldBefore: -mtkArr -mtkN" +mtkArr, mtkN = Mix(1) > Mix(2) +"MixTupleKeepOld: -mtkArr -mtkN" + +# Heap-string slots exercise per-slot ownership: the committed slot frees its old +# heap value and owns a copy of the new one; the kept slot retains its old heap +# value. The dropped PairStr returns are freed. (All leak-checked.) +htkA = "old" ⊕ "A" +htkB = "old" ⊕ "B" +"HeapTupleKeepOldBefore: -htkA -htkB" +htkA, htkB = PairStr(3, 1) > PairStr(2, 5) +"HeapTupleKeepOld: -htkA -htkB" + +# Fresh heap-string targets: a passing slot frees its "" zero seed and owns a copy +# of the winning string; a failing slot keeps the "" zero. All PairStr heap returns +# (kept, copied, and dropped) are freed (leak-checked). +nhA, nhB = PairStr(1, 5) > PairStr(2, 3) +"NewHeapTuple: -nhA -nhB" + +# A statement condition still gates a tuple-comparison value as a whole: a false +# gate keeps the old values; a true gate commits the per-slot result. +gtr = [7 7] +gtn = 5 +"GatedTupleBefore: -gtr -gtn" +gtr, gtn = 1 > 2 Mix(3) > Mix(1) +"GatedTupleFalse: -gtr -gtn" +gtr, gtn = 2 > 1 Mix(3) > Mix(1) +"GatedTupleTrue: -gtr -gtn" + +# An out-of-bounds read is a failed condition on the lanes it feeds. Here it +# feeds a Pair call, and a call merges its argument conditions into ALL of its +# outputs — so both slots keep their prior values, the passing comparison +# (5 > 1) included. oobArr has length 2, so oobArr[10] is OOB. +oobArr = [10 20] +oc = 77 +od = 66 +"OobTupleBefore: -oc -od" +oc, od = Pair(5, oobArr[10]) > Pair(1, 8) +"OobTupleKeepsPrev: -oc -od" + +# The same call-merge keep-old with heap-string operands: the heap values +# built for the failed lanes are freed (leak-checked). +hoc = "keep" ⊕ "C" +hod = "keep" ⊕ "D" +"HeapOobBefore: -hoc -hod" +hoc, hod = PairStr(3, oobArr[10]) > PairStr(2, 0) +"HeapOobKeepsPrev: -hoc -hod" + # Heap-allocating LHS in cond-expr: (a + b) > threshold # The addition creates a temporary; on false path it must be freed. ia = 3 @@ -291,6 +379,15 @@ lorPairA, lorPairB = Pair(1, 2) > Pair(5, 5) || Pair(7, 8) lorNestedSum = (b > 2 || d < 10) + (a > 10 || c > 2) "LogicalOrNestedSum: -lorNestedSum" +# A plain-value || fallback nested in scalar arithmetic, TAKEN (b=1 fails b>2): +# the -1 replaces the failed comparison and flows through the * and + -> 7. This +# is distinct from the bare (b > 2) form, which would propagate and keep old +# (fresh -> 0). Left operand winning (a=5 yields 5) scales it -> 25. +lorArithFbTaken = 10 + 3 * (b > 2 || -1) +"LogicalOrArithFallbackTaken: -lorArithFbTaken" +lorArithFbLeft = 10 + 3 * (a > 2 || -1) +"LogicalOrArithFallbackLeft: -lorArithFbLeft" + lorDefault = b > 2 || -1 "LogicalOrDefault: -lorDefault" @@ -563,3 +660,311 @@ smcM, smcN = Pair(3, 4) > Pair(1, 1) 8, 9 # the returned heap cells are freed after gating (no leak). smcStr = PairStr(3, 1) > PairStr(2, 0) 42 "SmcCondStr: -smcStr" + +# Chained tuple comparisons resolve per slot: each slot chains like a single +# comparison (leftmost LHS binds), a failing slot keeps only its own old value. +ctA = 11 +ctB = 22 +"ChainTupleBefore: -ctA -ctB" +ctA, ctB = Pair(5, 7) > Pair(1, 8) < Pair(9, 9) +"ChainTuple: -ctA -ctB" +ctC, ctD = Pair(5, 7) > Pair(4, 2) < Pair(9, 9) +"ChainTupleFresh: -ctC -ctD" + +# In gate position the same chain is the cell-wise AND, like a single chain. +ctGate = 100 +"ChainTupleGateBefore: -ctGate" +ctGate = Pair(5, 7) > Pair(1, 2) < Pair(9, 9) 42 +"ChainTupleGate: -ctGate" +ctGateF = 100 +"ChainTupleGateFalseBefore: -ctGateF" +ctGateF = Pair(5, 7) > Pair(1, 2) < Pair(9, 6) 42 +"ChainTupleGateFalse: -ctGateF" + +# Arithmetic over a tuple comparison stays per slot: each output slot carries +# only its own comparisons, and the combine runs only when its slot commits. +# slot 0 commits 5 + 2 = 7 (the add is exercised); slot 1's comparison fails, +# so its add never runs and it keeps the prior 22. +awA = 11 +awB = 22 +"ArithWrapBefore: -awA -awB" +awA, awB = (Pair(5, 7) > Pair(1, 8)) + Pair(2, 3) +"ArithWrap: -awA -awB" +# Chained: slot 0 = (5+1)=6, 6<9 holds -> 6. slot 1's inner 7>8 fails, so the +# whole lane keeps old (fresh -> 0); the +1 and <9 never run for it. Not 6 1 — +# that would be zero-fill-then-add, the model propagation replaced. +awC, awD = ((Pair(5, 7) > Pair(1, 8)) + Pair(1, 1)) < Pair(9, 9) +"ArithWrapChain: -awC -awD" + +# A mixed tuple through arithmetic: the array slot's mask [0 0] flows through +# the combine (+ Mix(3)'s [3 4] -> [3 4], so the add is visibly exercised, not +# masked by a zero addend), the intermediate mask is freed, and the scalar slot +# keeps old (1 > 2 fails, so its + 3 never runs -> fresh 0). +mxA, mxB = (Mix(1) > Mix(2)) + Mix(3) +"MixArith: -mxA -mxB" + +# Call arguments merge slots: a call's outputs are produced together, so every +# argument condition gates the whole call (keep-old), and a failing argument +# comparison skips the call entirely. +caSum = 111 +"CallArgBefore: -caSum" +caSum = Accum(Pair(5, 7) > Pair(1, 8)) +"CallArgMergesSlots: -caSum" + +# A comparison nested in a tuple operand gates the slots that read it: the call +# does not run when its argument comparison fails, and those slots keep old. +k5 = 5 +glA = 77 +glB = 88 +"GatedLeafBefore: -glA -glB" +glA, glB = Pair(Accum(k5 > 9, 3), 9) > Pair(-1, 1) +"GatedLeaf: -glA -glB" +glC, glD = Pair(Accum(k5 > 4, 3), 9) > Pair(-1, 1) +"GatedLeafPass: -glC -glD" + +# A ranged statement condition does not change the value's per-slot semantics. +rgA = 11 +rgB = 22 +"RangedGateBefore: -rgA -rgB" +rgJ = 1:3 +rgA, rgB = rgJ > 0 Pair(5, 7) > Pair(1, 8) +"RangedGate: -rgA -rgB" + +# || over tuples falls back per slot: slot i takes the right side only when +# slot i of the left failed; a fallback beats keep-old, and when both sides +# fail a slot keeps its old value. +poA, poB = Pair(5, 7) > Pair(1, 8) || Pair(0, 0) +"TupleOrMixed: -poA -poB" +poC = 11 +poD = 22 +"TupleOrOldBefore: -poC -poD" +poC, poD = Pair(5, 7) > Pair(1, 8) || Pair(-1, -1) +"TupleOrOld: -poC -poD" +poE = 11 +poF = 22 +"TupleOrBothFailBefore: -poE -poF" +poE, poF = Pair(5, 7) > Pair(9, 8) || Pair(5, 7) > Pair(8, 9) +"TupleOrBothFail: -poE -poF" + +# A || inside a comparison operand resolves to its value before the compare. +ioA, ioB = Pair(0 > 2 || 7, 9) > Pair(1, 1) +"InnerOrOperand: -ioA -ioB" + +# Heap-string chained tuple: the winning slot owns a copy, the failing slot +# keeps its old heap value, and every intermediate is freed (leak-checked). +hcA = "old" ⊕ "A" +hcB = "old" ⊕ "B" +"HeapChainBefore: -hcA -hcB" +hcA, hcB = PairStr(3, 1) > PairStr(2, 5) < PairStr(9, 9) +"HeapChain: -hcA -hcB" + +# Static-string (literal) slots: a passing slot stores the static string as-is +# (no heap copy to leak), a failing slot keeps its "" seed. +lsA, lsB = LitPair() > LitPair2() +lsB2 = lsB ⊕ "|" +"LitTupleA: -lsA" +"LitTupleB: -lsB2" + +# Heap-string tuple ||: a slot that takes the heap fallback owns a copy of it +# (the fallback temporaries are freed exactly once — this path once double-freed). +hoA, hoB = PairStr(3, 1) > PairStr(1, 5) || PairStr(9, 9) +"HeapTupleOr: -hoA -hoB" +hoC = "old" ⊕ "C" +hoD = "old" ⊕ "D" +"HeapTupleOrBefore: -hoC -hoD" +hoC, hoD = PairStr(1, 1) > PairStr(5, 5) || PairStr(7, 7) +"HeapTupleOrBothFallback: -hoC -hoD" + +# A named array operand survives its mask (the mask copies elements; only +# owned operands — call results, literals, inner masks — are consumed). +nmArr = [1 2 3] +nmK = 5 +nmM, nmS = nmArr > 1, nmK > 2 +"NamedArrMask: -nmM -nmS -nmArr" + +# Static-string winners committed into existing heap-string destinations: the +# store's coercion makes the owned copy, and the old heap values are freed. +ehA = "x" ⊕ "1" +ehB = "x" ⊕ "2" +"StaticIntoHeapBefore: -ehA -ehB" +ehA, ehB = LitPair() > LitPair2() +"StaticIntoHeap: -ehA -ehB" + +# A mask whose slot condition fails at runtime (here: the arg comparison gates +# the whole MixK call) is freed, not leaked; the slots keep old. +gmK = 1 +gmA = [9 9] +gmB = 99 +"GatedMaskBefore: -gmA -gmB" +gmA, gmB = MixK(gmK > 2) > MixK(0) +"GatedMask: -gmA -gmB" +gmC = [9 9] +gmD = 99 +"GatedMaskOrBefore: -gmC -gmD" +gmC, gmD = MixK(gmK > 2) > MixK(0) || MixK(7) +"GatedMaskOr: -gmC -gmD" + +# A parenthesized comparison as the right operand substitutes the inner's +# retained LHS; the false path frees each heap value exactly once. +prR = "seed" ⊕ "0" +"ParenRightBefore: -prR" +prR = MkStr(1) ⊕ "!" > (MkStr(2) ⊕ "!" > MkStr(3) ⊕ "!") +"ParenRight: -prR" + +# Deeper chains: three links resolve per slot like one; a middle link failing +# kills only its own slot (leftmost LHS still binds the whole chain). +tc3A = 11 +tc3B = 22 +"TripleChainBefore: -tc3A -tc3B" +tc3A, tc3B = Pair(5, 7) > Pair(1, 8) < Pair(9, 9) > Pair(2, 0) +"TripleChain: -tc3A -tc3B" +tcmA, tcmB = Pair(5, 7) > Pair(1, 2) < Pair(4, 9) +"MidLinkFail: -tcmA -tcmB" + +# A chained tuple comparison with a || fallback: the failing slot's chain +# falls back, the passing slot keeps its chained value. +cfA, cfB = Pair(5, 7) > Pair(1, 8) < Pair(9, 9) || Pair(-1, -2) +"ChainFallback: -cfA -cfB" + +# Three-way || over tuples: each slot takes the first side that yields. +o3A, o3B = Pair(5, 7) > Pair(9, 8) || Pair(5, 7) > Pair(1, 9) || Pair(-1, -2) +"OrChain3: -o3A -o3B" + +# A || resolved inside an arithmetic spine: per-slot values feed the combine. +oaA, oaB = (Pair(5, 7) > Pair(1, 8) || Pair(0, 0)) + Pair(10, 10) +"OrInArith: -oaA -oaB" + +# Mixed tuple chains: the array slot chains masks element-wise (intermediate +# masks are consumed), the scalar slot chains with keep-old. +mcA, mcB = Mix(2) > Mix(1) < Mix(9) +"MixChain: -mcA -mcB" +mfA = [9 9] +mfB = 99 +"MixChainFailBefore: -mfA -mfB" +mfA, mfB = Mix(2) > Mix(3) < Mix(9) +"MixChainFail: -mfA -mfB" + +# A chained scalar comparison as a call ARGUMENT gates the WHOLE call, not just +# the slot that uses it: Pair is opaque (either output could depend on either +# arg), so a failed cf > 2 < 8 keeps BOTH outputs old — the same call-merge rule +# an OOB argument gets. Not 77 9: the unconditional 9 is an argument to the same +# failing call, so it is gated too. A || after the chain resolves the argument +# locally, letting the call run (see ChainOrInOperand). When ck > 2 < 8 holds, +# the call runs and both slots yield. +ck = 5 +cf = 1 +ccA, ccB = Pair(ck > 2 < 8, 9) > Pair(1, 1) +"ChainInOperand: -ccA -ccB" +cdA = 77 +cdB = 88 +"ChainInOperandFailBefore: -cdA -cdB" +cdA, cdB = Pair(cf > 2 < 8, 9) > Pair(-10, 1) +"ChainInOperandFail: -cdA -cdB" +coA, coB = Pair(cf > 2 < 8 || -1, 9) > Pair(-10, 1) +"ChainOrInOperand: -coA -coB" + +# A multi-cell gate asks "did it yield?": every cell must hold. A first-cell +# failure turns the gate off just like a later cell; a fresh target keeps its +# zero seed; the same rule holds inside a (cond value) condition. +smcFirstOff = 88 +"SmcFirstCellOffBefore: -smcFirstOff" +smcFirstOff = Pair(1, 2) > Pair(1, 5) 7 +"SmcFirstCellOff: -smcFirstOff" +smcFreshOff = Pair(1, 2) > Pair(1, 5) 7 +"SmcFreshOff: -smcFreshOff" +cvCellsOn = (Pair(1, 2) > Pair(0, 1) 7) +"CvCellsOn: -cvCellsOn" +cvCellsOff = (Pair(1, 2) > Pair(1, 5) 7) +"CvCellsOff: -cvCellsOff" + +# Slot conditions merge along dataflow lanes. Both operands aligned: lane i +# ANDs lane i's bits from each side. A call merges lanes: every argument's +# conditions (however many comparisons each carries) AND into all outputs. +# A prefix passes its operand's lanes through untouched. +baA = 11 +baB = 22 +"BothAlignedBefore: -baA -baB" +baA, baB = (Pair(5, 7) > Pair(1, 8)) + (Pair(1, 1) > Pair(0, 9)) +"BothAligned: -baA -baB" +cfa = 5 +cfb = 4 +cfc = 2 +cfX = 99 +"CallFoldBefore: -cfX" +cfX = Accum(cfa > 2, cfb > 3 < 9) +"CallFold: -cfX" +cfY = 99 +"CallFoldFailBefore: -cfY" +cfY = Accum(cfa > 2, cfc > 3 < 9) +"CallFoldFail: -cfY" +pfZ = 99 +"PrefixPassBefore: -pfZ" +pfZ = -(cfa > 2) + 1 +"PrefixPass: -pfZ" +pfW = 99 +"PrefixPassFailBefore: -pfW" +pfW = -(cfc > 3) + 1 +"PrefixPassFail: -pfW" + +# The || right side is evaluated at most once, and only when a slot missed. +# Observable via bounds checks: an OOB in an unevaluated right side has no +# effect (all-left-yield commits), while an evaluated right side's OOB fails +# that side's lanes — slots the left already filled still commit. +lzArr = [10 20] +lzA = 11 +lzB = 22 +"LazyRhsSkippedBefore: -lzA -lzB" +lzA, lzB = Pair(5, 7) > Pair(1, 1) || Pair(lzArr[10], 0) +"LazyRhsSkipped: -lzA -lzB" +lzC = 11 +lzD = 22 +"LazyRhsOobBefore: -lzC -lzD" +lzC, lzD = Pair(5, 7) > Pair(1, 8) || Pair(lzArr[10], 0) +"LazyRhsOob: -lzC -lzD" +lzS = 5 +lzX = 99 +"ScalarLazySkippedBefore: -lzX" +lzX = lzS > 2 || lzArr[10] +"ScalarLazySkipped: -lzX" + +# An out-of-bounds read is a failed condition on the lanes it feeds. Sibling +# expressions in one statement commit independently; a call merges its lanes, +# so an OOB argument keeps that call's outputs old as a unit; a single plain +# read still no-ops; scalar comparisons and || fallbacks keep old, not zero. +obArr = [10 20] +obA = 11 +obB = 22 +"PlainMultiOobBefore: -obA -obB" +obA, obB = obArr[10], 5 +"PlainMultiOob: -obA -obB" +obC, obD = obArr[10], 5 +"PlainMultiOobFresh: -obC -obD" +obE = 99 +"PlainSingleOobBefore: -obE" +obE = obArr[10] +"PlainSingleOob: -obE" +obF = 99 +"ScalarCmpOobBefore: -obF" +obF = obArr[10] < 1 +"ScalarCmpOob: -obF" +obG0 = 1 +obG = 99 +"ScalarOrOobBefore: -obG" +obG = obG0 > 2 || obArr[10] +"ScalarOrOob: -obG" +obH = 11 +obI = 22 +"CallMergeOobBefore: -obH -obI" +obH, obI = Pair(obArr[10], 5) > Pair(1, 1) +"CallMergeOob: -obH -obI" +obS1 = "aa" ⊕ "x" +obS2 = "bb" ⊕ "y" +"SwapBefore: -obS1 -obS2" +obS1, obS2 = obS2, obS1 +"Swap: -obS1 -obS2" +obM1 = 7 +obM2 = 8 +obM3 = 99 +"SwapPlusOobBefore: -obM1 -obM2 -obM3" +obM1, obM2, obM3 = obM2, obM1, obArr[10] +"SwapPlusOob: -obM1 -obM2 -obM3" diff --git a/tests/math/func.exp b/tests/math/func.exp index 445e8a06..220c2caa 100644 --- a/tests/math/func.exp +++ b/tests/math/func.exp @@ -4,7 +4,7 @@ SquareArr: [1 4 9 25] SquareRange: 4 SquareArrRange: 9 -SquareArrFilter: [25] +SquareArrMask: [0 0 0 25] SquareArrRangeFilter: 0 SquareArrRangeFilterKeepPrev: 99 SquareCallOOBKeepPrev: 11 @@ -21,8 +21,8 @@ SquareArrSelfCondFail: [1 2 3] SquareArrSelfNoMatch: [0 0 0] SquareArrSelfMixed: [4 9 0] -NestedFilter: [5] -NestedSquareFilter: [25] +NestedMask: [0 0 0 5] +NestedSquareMask: [0 0 0 25] SumSquaresBothTrue: 74 SumSquaresOneFalse: 0 diff --git a/tests/math/func.spt b/tests/math/func.spt index bcc48b0c..35798714 100644 --- a/tests/math/func.spt +++ b/tests/math/func.spt @@ -9,10 +9,10 @@ arrVal = Square(arr) "SquareFloat: -floatVal" "SquareArr: -arrVal\n" -# Range and filter (non-accumulated) +# Range filters and array masks (non-accumulated) rangeVal = Square(1:3) arrRangeVal = Square(arr[1:3]) -arrFilterVal = Square(arr > 3) +arrMaskVal = Square(arr > 3) arrRangeFilterVal = Square(arr[1:3] > 3) arrRangeFilterKeepPrev = 99 arrRangeFilterKeepPrev = Square(arr[1:3] > 3) @@ -22,7 +22,7 @@ rangeFilterTailFalseVal = Square((0:5) < 3) "SquareRange: -rangeVal" "SquareArrRange: -arrRangeVal" -"SquareArrFilter: -arrFilterVal" +"SquareArrMask: -arrMaskVal" "SquareArrRangeFilter: -arrRangeFilterVal" "SquareArrRangeFilterKeepPrev: -arrRangeFilterKeepPrev" "SquareCallOOBKeepPrev: -callOOBKeepPrev" @@ -58,12 +58,12 @@ arrSelf = [Square(arrSelf[1:4] > 20)] "SquareArrSelfNoMatch: -arrSelf" "SquareArrSelfMixed: -arrSelfMixed\n" -# Nested filter composition -nestedFilterVal = (arr > 3) < 8 -nestedSquareFilterVal = Square((arr > 3) < 8) +# Nested mask composition: (arr > 3) masks to [0 0 0 5], then < 8 masks again. +nestedMaskVal = (arr > 3) < 8 +nestedSquareMaskVal = Square((arr > 3) < 8) -"NestedFilter: -nestedFilterVal" -"NestedSquareFilter: -nestedSquareFilterVal\n" +"NestedMask: -nestedMaskVal" +"NestedSquareMask: -nestedSquareMaskVal\n" # Multi-argument conditional gating a = 5 diff --git a/tests/mem/leak/leak.pt b/tests/mem/leak/leak.pt index 2d38c84c..5b16a9e0 100644 --- a/tests/mem/leak/leak.pt +++ b/tests/mem/leak/leak.pt @@ -3,3 +3,7 @@ out = Id(x) out = Static() out = "static" + +p, q = PairH(x, y) + p = "p" ⊕ x + q = "q" ⊕ y diff --git a/tests/mem/leak/oob_paths.exp b/tests/mem/leak/oob_paths.exp index d04698ec..336236ef 100644 --- a/tests/mem/leak/oob_paths.exp +++ b/tests/mem/leak/oob_paths.exp @@ -1,6 +1,13 @@ LeakCallStrOOB: abb LeakInfixStrOOB: abb keep seed -LeakGuardedCallKeepsOld: keep seed +LeakGuardedCallKeepsOld: static seed LeakArrayAccOOB: [a bb ] LeakPrefixNumOOB: -2 +DirectCallOobBefore: keep +DirectCallOob: keep +DirectCallOk: bb +MixSkippedBefore: oldM +MixSkipped: oldM | +MixOkBefore: old2 +MixOk: pa qz diff --git a/tests/mem/leak/oob_paths.spt b/tests/mem/leak/oob_paths.spt index f83bd04c..19876fda 100644 --- a/tests/mem/leak/oob_paths.spt +++ b/tests/mem/leak/oob_paths.spt @@ -12,8 +12,9 @@ sInfix = "" sInfix = sInfix ⊕ arr[j] "LeakInfixStrOOB: -sInfix" -# Direct call path: a StrG return flowing into an inferred-StrH slot must not -# free the old destination when a later RHS value trips the statement bounds guard. +# Direct call path: expressions commit independently — the clean StrG-returning +# call overwrites (freeing the old heap value exactly once) while the OOB +# sibling keeps its prior value. guardKeep = "keep" ⊕ "" guardOther = "seed" ⊕ "" guardKeep, guardOther @@ -30,3 +31,22 @@ nums = [1 2] p = 123 p = -nums[0:4] "LeakPrefixNumOOB: -p" + +# A skipped indirect-return call leaves existing destination slots untouched: +# cleanup must not free through them (the slot holds the value being kept). +# Fresh temp outputs of the same skipped call are freed (their zero seeds). +dcS = "keep" ⊕ "" +"DirectCallOobBefore: -dcS" +dcS = Id(arr[9]) +"DirectCallOob: -dcS" +dcS = Id(arr[1]) +"DirectCallOk: -dcS" +dcM = "old" ⊕ "M" +"MixSkippedBefore: -dcM" +dcM, dcN = PairH(arr[9], "z") +dcN2 = dcN ⊕ "|" +"MixSkipped: -dcM -dcN2" +dcM2 = "old" ⊕ "2" +"MixOkBefore: -dcM2" +dcM2, dcN3 = PairH(arr[0], "z") +"MixOk: -dcM2 -dcN3"