From bc67415f3e65be91b5d2093018aed128735a1615 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 7 Oct 2026 13:00:43 +0530 Subject: [PATCH 01/34] test: write every fixture function output on every path #123 rejects a function body that can leave an output unwritten. The 34 fixture functions that can are rewritten so that they compile under the rule; every rewrite already runs on master and prints the same results, apart from the three changes listed below. - Recursion with complementary gates writes its base value first and lets the recursive step override it: Fac (y = 1), FibAux (y = a), and IsEven/IsOdd ("yes", "no" and "no", "yes", their n == 0 answers, which also retires the n == 1 lines that patched the output the recursion handed up unwritten). - Functions that keep a caller's value now take it as an input and start from it. ConditionalFold, ConditionalNested, CondCount, the staging gates, ShrinkShared, GatedFold and ConditionalAcc start from the input their callers already share (siblings too: out, seen = current, current; out, tag = current, ""; out, after = current, current). ConditionalSquare, its forwarder, ResetArray, KeepRangedArray, ResetTable and maybeStatic gain a prev input, and their callers pass the destination, or the zero a fresh one starts at. - A checked access goes through a local, which reads as the element's zero out of bounds: LocalWord, LocalPlane, First, Sum3, Mixed, getArrayElem and UpperTriRowTail. - Gates no caller depends on get a literal default: maybeStr, Tags, RefineEcho, pickByHeapCompare, getStaticAt, getStaticPair. - shareStaticOutput writes both outputs from its gated local, keeping the static-output adapter coverage a heap prev input would lose. Expected output changes: ConditionalNestedSkipped prints 10 10 (seen starts from current), CallStaticShareSkipped commits two empty static strings, and the labels that named the removed seed are renamed (CallStaticPrevKeep, CallStaticPrevWrite, DefaultBlank). tests/math/acc adds Filter, a ranged filter through prev: shared it keeps 2, unshared each iteration restarts from the same prev and ends at 0. Co-Authored-By: Claude Opus 5.5 --- tests/alias_input/self_alias.exp | 2 +- tests/alias_input/self_alias.pt | 4 ++++ tests/alias_input/staging.pt | 7 +++++++ tests/array/array_func.pt | 11 +++++++---- tests/array/array_func.spt | 27 ++++++++++++++------------- tests/array/array_range.pt | 6 ++++-- tests/cond/promoted_bindings.pt | 3 ++- tests/cond/value_cond_expr.pt | 3 ++- tests/discard.exp | 2 +- tests/discard.pt | 1 + tests/discard.spt | 6 +++--- tests/math/acc.exp | 2 ++ tests/math/acc.pt | 7 +++++++ tests/math/acc.spt | 7 +++++++ tests/math/dependent_range.pt | 3 ++- tests/math/func.spt | 18 +++++++++--------- tests/math/math.pt | 7 ++++--- tests/math/rec.pt | 18 +++++++----------- tests/math/rec_infer.pt | 3 ++- tests/mem/mem_alias_refine.pt | 1 + tests/mem/mem_cmp_lhs.pt | 1 + tests/mem/mem_str.exp | 6 +++--- tests/mem/mem_str.pt | 14 +++++++++----- tests/mem/mem_str.spt | 24 ++++++++++++------------ 24 files changed, 112 insertions(+), 71 deletions(-) diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index b5105643..11902057 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -24,7 +24,7 @@ NestedRange: 13 13 NestedRangeTwice: 20 20 NestedArrayTwice: [10 1 2 3 4] [10 1 2 3 4] ConditionalNested: 15 15 -ConditionalNestedSkipped: 10 0 +ConditionalNestedSkipped: 10 10 StagedString: helloabc helloabc hello ResetArray: [ ] [1 2] [ diff --git a/tests/alias_input/self_alias.pt b/tests/alias_input/self_alias.pt index 839fff05..857d30ce 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -54,6 +54,7 @@ out, seen = NestedArrayDeep(current, item) out, seen = ArrayAfter(current, item) out, seen = ConditionalFold(current, item) + out = current out = item > 0 current + item seen = current @@ -74,6 +75,7 @@ out, seen = NestedArrayTwice(current) # A conditional call writes through synthetic destinations that stand in for # the outputs; sharing follows them to the real output. out, seen = ConditionalNested(current, item) + out, seen = current, current out, seen = item > 0 FoldAfter(current, item) out = BumpTwice(current, item) @@ -126,6 +128,7 @@ out = NextRange(i) # its live alias. left, right = Tags(n) left = "l" + right = "" right = n > 0 "r" out, tag = Count(left, right, n) @@ -133,6 +136,7 @@ out, tag = Count(left, right, n) tag = left ⊕ right out, seen, tag = CondCount(current) + out, tag = current, "" out, tag = 1 > 0 Count(Tags(current), current) seen = current diff --git a/tests/alias_input/staging.pt b/tests/alias_input/staging.pt index 61557fb3..e94e6027 100644 --- a/tests/alias_input/staging.pt +++ b/tests/alias_input/staging.pt @@ -1,18 +1,23 @@ # A range-gated statement stages its output, and an input sharing that output # follows the staged value whether it is passed by value or by pointer. out = GateInt(current) + out = current out = (1:4) > 0 current + 1 out = GateStr(current) + out = current out = (1:4) > 0 current ⊕ "." out = GateArr(current) + out = current out = (1:4) > 0 current ⊕ current out, seen = GateSibInt(current) + out, seen = current, current out, seen = (1:4) > 0 current + 1, current out, seen = GateSibStr(current) + out, seen = current, current out, seen = (1:4) > 0 current ⊕ ".", current out = Shrink(arr, v) @@ -20,10 +25,12 @@ out = Shrink(arr, v) # The loop replaces the shared array with a shorter one. out, seen = ShrinkShared(current) + out = current out = Shrink(current, current[(0:8) + 0]) seen = current # The conditional stages the output, then the ranged call inside it stages it # again; the nested call must keep sharing through both. out, after = GatedFold(current) + out, after = current, current out, after = 1 > 0 FoldAfter(current, (1:3) + 0) diff --git a/tests/array/array_func.pt b/tests/array/array_func.pt index 031ab565..93a663ce 100644 --- a/tests/array/array_func.pt +++ b/tests/array/array_func.pt @@ -29,16 +29,19 @@ res = ConcatArrays(a, b) res = a ⊕ b # An empty matrix can reset an established matrix destination. -res = ResetArray(x) +res = ResetArray(prev, x) + res = prev res = x > -1 [ ]0 -# A ranged collector can leave an indirect output unwritten. -res = KeepRangedArray(i) +# A ranged collector can leave an indirect output at its starting value. +res = KeepRangedArray(prev, i) + res = prev res = i < 0 [i] # A typed empty table can conditionally reset an established table. -res = ResetTable(x) +res = ResetTable(prev, x) + res = prev res = x > -1 [ : Name("") Score(0) ] diff --git a/tests/array/array_func.spt b/tests/array/array_func.spt index 76f7953a..bdd0c038 100644 --- a/tests/array/array_func.spt +++ b/tests/array/array_func.spt @@ -60,9 +60,9 @@ conditionalTable = [ "Grace" 30 ] "ConditionalTableBefore: -conditionalTable" -conditionalTable = ResetTable(-1) +conditionalTable = ResetTable(conditionalTable, -1) "ConditionalTableSkipped: -conditionalTable" -conditionalTable = ResetTable(1) +conditionalTable = ResetTable(conditionalTable, 1) "ConditionalTableWritten: -conditionalTable" arr1 = []0 ⊕ [0] @@ -96,7 +96,7 @@ resetMatrix = [ 3 4 ] resetDriver = 0:2 -resetMatrix = ResetArray(resetDriver) +resetMatrix = ResetArray(resetMatrix, resetDriver) "RangedRankReset: -resetMatrix" keptMatrix = [ @@ -104,7 +104,7 @@ keptMatrix = [ 7 8 ] emptyResetDriver = 0:0 -keptMatrix = ResetArray(emptyResetDriver) +keptMatrix = ResetArray(keptMatrix, emptyResetDriver) "RangedRankResetEmpty: -keptMatrix" # A statement condition stages a ranged call through a synthetic destination. @@ -114,7 +114,7 @@ gatedWrittenMatrix = [ 43 44 ] gatedResetDriver = 0:2 -gatedWrittenMatrix = 1 > 0 ResetArray(gatedResetDriver + 0) +gatedWrittenMatrix = 1 > 0 ResetArray(gatedWrittenMatrix, gatedResetDriver + 0) "GatedRangedRankResetWritten: -gatedWrittenMatrix" # With an empty driver, the staged slot must retain its rank-2 value. @@ -123,22 +123,23 @@ gatedKeptMatrix = [ 33 34 ] gatedEmptyResetDriver = 0:0 -gatedKeptMatrix = 1 > 0 ResetArray(gatedEmptyResetDriver + 0) +gatedKeptMatrix = 1 > 0 ResetArray(gatedKeptMatrix, gatedEmptyResetDriver + 0) "GatedRangedRankResetEmpty: -gatedKeptMatrix" -# A blocked ranged collector inside a function leaves its caller's output intact. +# A blocked ranged collector inside a function leaves the caller's value, +# passed in as prev, intact. rangedArrayKept = [4 5] -rangedArrayKept = KeepRangedArray(0:3) +rangedArrayKept = KeepRangedArray(rangedArrayKept, 0:3) "RangedArrayKept: -rangedArrayKept" # An ordinary conditional empty return keeps an established matrix destination -# when the callee leaves its output unwritten. +# when the callee keeps the value it was passed. conditionalResetMatrix = [ 11 12 13 14 ] "ConditionalRankResetBefore: -conditionalResetMatrix" -conditionalResetMatrix = ResetArray(-1) +conditionalResetMatrix = ResetArray(conditionalResetMatrix, -1) "ConditionalRankResetSkipped: -conditionalResetMatrix" rangedConditionalResetMatrix = [ @@ -146,14 +147,14 @@ rangedConditionalResetMatrix = [ 23 24 ] "RangedConditionalRankResetBefore: -rangedConditionalResetMatrix" -rangedConditionalResetMatrix = ResetArray(-1:0) +rangedConditionalResetMatrix = ResetArray(rangedConditionalResetMatrix, -1:0) "RangedConditionalRankResetSkipped: -rangedConditionalResetMatrix" zeroWidthMatrix = [[]0 []0] "ZeroWidthRankResetBefore: -zeroWidthMatrix" -zeroWidthMatrix = ResetArray(-1) +zeroWidthMatrix = ResetArray(zeroWidthMatrix, -1) "ZeroWidthRankResetSkipped: -zeroWidthMatrix" -zeroWidthMatrix = ResetArray(1) +zeroWidthMatrix = ResetArray(zeroWidthMatrix, 1) "ZeroWidthRankResetWritten: -zeroWidthMatrix" lockedArray = [9] diff --git a/tests/array/array_range.pt b/tests/array/array_range.pt index c3cb0cbf..57725935 100644 --- a/tests/array/array_range.pt +++ b/tests/array/array_range.pt @@ -3,7 +3,8 @@ res = Double(x) word = LocalWord(i) words = ["zero" "one" "two"] - word = words[i] + t = words[i] + word = t plane = LocalPlane(i) cube = [ @@ -11,4 +12,5 @@ plane = LocalPlane(i) [5 6] [7 8] [9 10] [11 12] ] - plane = cube[i] + t = cube[i] + plane = t diff --git a/tests/cond/promoted_bindings.pt b/tests/cond/promoted_bindings.pt index 8e96b764..e730d59e 100644 --- a/tests/cond/promoted_bindings.pt +++ b/tests/cond/promoted_bindings.pt @@ -4,7 +4,8 @@ y = Twice(v) y = v ⊕ v y = First(v) - y = v[0] + t = v[0] + y = t y = SI(t, j) y = t diff --git a/tests/cond/value_cond_expr.pt b/tests/cond/value_cond_expr.pt index 3fce3382..7dd1fcb7 100644 --- a/tests/cond/value_cond_expr.pt +++ b/tests/cond/value_cond_expr.pt @@ -39,4 +39,5 @@ a, b = MixK(x) b = x + 10 s = Sum3(xs) - s = xs[0] + xs[1] + xs[2] + t = xs[0] + xs[1] + xs[2] + s = t diff --git a/tests/discard.exp b/tests/discard.exp index b80db782..40ec3447 100644 --- a/tests/discard.exp +++ b/tests/discard.exp @@ -9,4 +9,4 @@ GatedBlank: done RangedBlank: done RangedMultiBlank: done CheckedBlank: done -MayWriteBlank: done +DefaultBlank: done diff --git a/tests/discard.pt b/tests/discard.pt index 7452f07c..3857e5c2 100644 --- a/tests/discard.pt +++ b/tests/discard.pt @@ -5,4 +5,5 @@ tag = mkTag(v) tag = "tag-v" res = maybeStr(k) + res = "" res = k > 0 "big" ⊕ "value" diff --git a/tests/discard.spt b/tests/discard.spt index 8ed09c50..d739bb02 100644 --- a/tests/discard.spt +++ b/tests/discard.spt @@ -60,8 +60,8 @@ _ = arr[1] _ = sarr[1] "CheckedBlank: done" -# A conditionally-writing callee with its output discarded, on both the -# writing and non-writing paths. +# A callee that overrides its default conditionally, with its output +# discarded, on both paths. _ = maybeStr(1) _ = maybeStr(-1) -"MayWriteBlank: done" +"DefaultBlank: done" diff --git a/tests/math/acc.exp b/tests/math/acc.exp index ea9e9b29..840b7509 100644 --- a/tests/math/acc.exp +++ b/tests/math/acc.exp @@ -4,5 +4,7 @@ 10 10 13 +FilterShared: 2 +FilterUnshared: 0 16 3 ReversedHalf: 1.5 ReversedSum: 7 diff --git a/tests/math/acc.pt b/tests/math/acc.pt index f55dfba5..b770da91 100644 --- a/tests/math/acc.pt +++ b/tests/math/acc.pt @@ -9,8 +9,15 @@ res = Acc(a, x) res = a + x res = ConditionalAcc(a, x) + res = a res = x > 0 a + x +# A filter keeps its value through prev. Shared, each iteration starts from the +# value the last one kept; unshared, each starts from the same prev. +res = Filter(prev, x) + res = prev + res = x < 3 x + sum, last = AccPair(a, x) sum = a + x last = x diff --git a/tests/math/acc.spt b/tests/math/acc.spt index deffa3ad..a18fe8bb 100644 --- a/tests/math/acc.spt +++ b/tests/math/acc.spt @@ -22,6 +22,13 @@ res = 10 res = ConditionalAcc(res, -2:3) res +kept = 0 +kept = Filter(kept, 1:5) +"FilterShared: -kept" + +fresh = Filter(0, 1:5) +"FilterUnshared: -fresh" + sum = 10 sum, last = AccPair(sum, 1:4) sum, last diff --git a/tests/math/dependent_range.pt b/tests/math/dependent_range.pt index 00fcd6cf..8b55db42 100644 --- a/tests/math/dependent_range.pt +++ b/tests/math/dependent_range.pt @@ -1,3 +1,4 @@ res = UpperTriRowTail(a, n, i) j = (i + 1):n - res = a[i * n + j] + t = a[i * n + j] + res = t diff --git a/tests/math/func.spt b/tests/math/func.spt index 4752b9b4..1ee1403e 100644 --- a/tests/math/func.spt +++ b/tests/math/func.spt @@ -9,19 +9,19 @@ arrVal = Square(arr) "SquareFloat: -floatVal" "SquareArr: -arrVal\n" -# A skipped direct-scalar function output preserves an existing caller -# destination, while a fresh destination keeps its zero seed. +# A function keeps an old value through an input: the caller passes its +# destination, and a fresh destination passes the zero it starts from. conditionalSquareKeep = 14 -conditionalSquareKeep = ConditionalSquare(conditionalSquareKeep - 12) +conditionalSquareKeep = ConditionalSquare(conditionalSquareKeep, conditionalSquareKeep - 12) conditionalSquareWrite = 14 -conditionalSquareWrite = ConditionalSquare(conditionalSquareWrite - 7) -conditionalSquareFresh = ConditionalSquare(2) +conditionalSquareWrite = ConditionalSquare(conditionalSquareWrite, conditionalSquareWrite - 7) +conditionalSquareFresh = ConditionalSquare(0, 2) conditionalSquareForwarded = 14 -conditionalSquareForwarded = ForwardConditionalSquare(conditionalSquareForwarded - 12) +conditionalSquareForwarded = ForwardConditionalSquare(conditionalSquareForwarded, conditionalSquareForwarded - 12) conditionalSquareFloatKeep = 14.5 -conditionalSquareFloatKeep = ConditionalSquare(conditionalSquareFloatKeep - 12.5) -conditionalSquareFloatWrite = ConditionalSquare(7.5) -conditionalSquareFloatFresh = ConditionalSquare(2.0) +conditionalSquareFloatKeep = ConditionalSquare(conditionalSquareFloatKeep, conditionalSquareFloatKeep - 12.5) +conditionalSquareFloatWrite = ConditionalSquare(0.0, 7.5) +conditionalSquareFloatFresh = ConditionalSquare(0.0, 2.0) "ConditionalSquareKeep: -conditionalSquareKeep" "ConditionalSquareWrite: -conditionalSquareWrite" diff --git a/tests/math/math.pt b/tests/math/math.pt index f9166b25..318efbb7 100644 --- a/tests/math/math.pt +++ b/tests/math/math.pt @@ -7,11 +7,12 @@ greeting = "hello" res = Square(x) res = x * x -res = ConditionalSquare(x) +res = ConditionalSquare(prev, x) + res = prev res = x > 5 x * x -res = ForwardConditionalSquare(x) - res = ConditionalSquare(x) +res = ForwardConditionalSquare(prev, x) + res = ConditionalSquare(prev, x) z = SumSquares(a, b) z = Square(a) + Square(b) diff --git a/tests/math/rec.pt b/tests/math/rec.pt index 50fe0c8d..3badbb93 100644 --- a/tests/math/rec.pt +++ b/tests/math/rec.pt @@ -1,28 +1,24 @@ y = Fac(n) + y = 1 y = n != 1 && n != 0 n * Fac(n-1) - y = n == 0 1 - y = n == 1 1 y = Fib(n) y = FibAux(n, 0, 1) y = FibAux(n, a, b) - y = n == 0 a + y = a y = n != 0 FibAux(n - 1, b, a + b) # define IsEven: returns (x, y) = (is-even?, is-odd?) x, y = IsEven(n) + # base case: 0 is even, not odd + x, y = "yes", "no" # recursive step: if n≠0, flip the pair returned by IsOdd(n-1) x, y = n != 0 IsOdd(n - 1) - # base case: 0 is even, not odd - x = n == 1 "no" - x = n == 0 "yes" # define IsOdd: returns (x, y) = (is-odd?, is-even?) -# this function only infers type for y x, y = IsOdd(n) - # recursive step: if n≠0, flip the pair returned by IsEven(n-1) - x, y = n != 0 IsEven(n - 1) # base case: 0 is not odd, but even - y = n == 1 "no" - y = n == 0 "yes" \ No newline at end of file + x, y = "no", "yes" + # recursive step: if n≠0, flip the pair returned by IsEven(n-1) + x, y = n != 0 IsEven(n - 1) \ No newline at end of file diff --git a/tests/math/rec_infer.pt b/tests/math/rec_infer.pt index e3f0ad92..a9dd63dd 100644 --- a/tests/math/rec_infer.pt +++ b/tests/math/rec_infer.pt @@ -49,7 +49,8 @@ y, z = Mixed(n) 1 prior ] - z = tab.a[0] + tab.a[1] + t = tab.a[0] + tab.a[1] + z = t # An operator waits for a column the result still leaves pending. y = Bump(n, x) diff --git a/tests/mem/mem_alias_refine.pt b/tests/mem/mem_alias_refine.pt index f13d3726..eb09f759 100644 --- a/tests/mem/mem_alias_refine.pt +++ b/tests/mem/mem_alias_refine.pt @@ -3,4 +3,5 @@ # observes the replacement even though its expression produces a static string. out, echo = RefineEcho(s, x) out = "static" + echo = "" echo = x > -1 s ⊕ "!" diff --git a/tests/mem/mem_cmp_lhs.pt b/tests/mem/mem_cmp_lhs.pt index fd167560..2c9c8635 100644 --- a/tests/mem/mem_cmp_lhs.pt +++ b/tests/mem/mem_cmp_lhs.pt @@ -2,4 +2,5 @@ # named binding permanently borrowed, or scope cleanup never frees its payload. out = pickByHeapCompare(k) label = "he" ⊕ "llo" + out = 0 out = label > "abc" k diff --git a/tests/mem/mem_str.exp b/tests/mem/mem_str.exp index 4095782c..cb2e5150 100644 --- a/tests/mem/mem_str.exp +++ b/tests/mem/mem_str.exp @@ -25,10 +25,10 @@ reset heap from func static from func keep_heap -CallStaticSeedKeep: keep_heap -CallStaticSeedWrite: static changed +CallStaticPrevKeep: keep_heap +CallStaticPrevWrite: static changed CallStaticShareBefore: < shared_left > < shared_right > -CallStaticShareSkipped: < shared_left > < shared_right > +CallStaticShareSkipped: < > < > CallStaticShareWritten: < shared static > < shared static > slot plain diff --git a/tests/mem/mem_str.pt b/tests/mem/mem_str.pt index 934b7eb2..227aae5a 100644 --- a/tests/mem/mem_str.pt +++ b/tests/mem/mem_str.pt @@ -9,20 +9,23 @@ s = getStatic() # Return a static string while consuming a scalar/range argument. s = getStaticAt(x) + s = "" s = x > -1 "static ranged" -# Conditionally replace a caller-supplied heap output with a static string. -s = maybeStatic(flag) +# Start from the caller's value and conditionally replace it with a static string. +s = maybeStatic(prev, flag) + s = prev s = flag > 0 "static changed" # Write one conditionally chosen static value into two outputs. s, t = shareStaticOutput(flag) shared = flag > 0 "shared static" - s = flag > 0 shared - t = flag > 0 shared + s = shared + t = shared # Mixed indirect outputs exercise one widened string slot and one exact scalar slot. s, n = getStaticPair(x) + s = "" s = x > -1 "pair static" n = x @@ -34,7 +37,8 @@ s = getHeap() # Tests that: 1) local array is cleaned up, 2) returned string is owned copy s = getArrayElem(idx) arr = ["zero" "one" "two"] - s = arr[idx] + t = arr[idx] + s = t # Use array element inside function without returning it # Tests that temporary from arr[i] is freed after use diff --git a/tests/mem/mem_str.spt b/tests/mem/mem_str.spt index 579bdcf4..b9ec3ec4 100644 --- a/tests/mem/mem_str.spt +++ b/tests/mem/mem_str.spt @@ -81,18 +81,18 @@ callReset callReset = getStatic() callReset -# A StrG-returning conditional callee still sees the established StrH -# destination seed when its write is skipped. -staticCallSeed = "keep" ⊕ "_heap" -staticCallSeed -staticCallSeed = maybeStatic(0) -"CallStaticSeedKeep: -staticCallSeed" -staticCallSeed = maybeStatic(1) -"CallStaticSeedWrite: -staticCallSeed" - -# Refinement adapters are ABI-zero-seeded inside the callee. An unwritten StrG -# output preserves its established StrH destination, while an explicit sibling -# copy of that ABI value commits the empty StrG value safely. +# A callee that starts from the caller's heap value, passed in as prev, keeps +# it when its static override is skipped. +staticCallPrev = "keep" ⊕ "_heap" +staticCallPrev +staticCallPrev = maybeStatic(staticCallPrev, 0) +"CallStaticPrevKeep: -staticCallPrev" +staticCallPrev = maybeStatic(staticCallPrev, 1) +"CallStaticPrevWrite: -staticCallPrev" + +# Static outputs reach owned destinations through refinement adapters. With the +# flag off the gated local stays empty, and both outputs commit that empty +# static value safely. sharedStaticLeft = "shared" ⊕ "_left" sharedStaticRight = "shared" ⊕ "_right" "CallStaticShareBefore: < -sharedStaticLeft > < -sharedStaticRight >" From 52f9c8fe3b0f62364c0ac8433db0b0b325a29bce Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 7 Oct 2026 13:19:05 +0530 Subject: [PATCH 02/34] feat(compiler)!: require every function output to be definitely assigned Every body that runs now writes every output (#123). A template that can leave an output unwritten is rejected at its definition, whether or not anything calls it: output "out" may be left unassigned; assign it unconditionally first, or pass the previous value as an input and initialize from it (out = prev), with each caller passing its destination as that input The check reads the template's text, the same for every argument type: a statement gate, a value that can fail (a value-position comparison, &&, a checked access, a || whose last alternative can fail, or a call with such an argument), or a range that may be empty can skip a write. A call counts as writing every output, so a caller of a rejected function is not reported again. Only a range literal written with constant, nonempty bounds always runs; a named range may be empty. Reads before definite assignment, dead stores and write-after-write move into the same pass, which runs once per template instead of once per settled specialization. Templates nothing calls now get these checks, `out = prev` followed by `out = x > 0` is accepted for scalar and array arguments alike, and a flow error in any template fails the whole package. The per-specialization CFG pass, its cached results and the per-script replay are removed, so settlement no longer waits for a CFG result. Which values hold a Range is read from the text as well: a parameter never holds one, a binding holds one when it is assigned a descriptor, and a call's output when its template's output does, settled to a fixed point across templates. A gate iterates the ranges it names, and under || and && only a call operand passes its descriptor on. Settlement panics when the solver's Range-typed bindings disagree with that summary, and publishing body effects that may skip a write is an internal error. BREAKING CHANGE: a function body that can leave an output unwritten no longer compiles. Write a default first and override it (y = n, then y = n > 1 Fib(n - 1) + Fib(n - 2)); to keep a caller's value, take it as an input and start from it (out = prev), called as a = Maybe(a, -1); route a checked access through a local (t = arr[i], then out = t) until checked-access fallbacks land. Dead stores and write-after-write in templates nothing calls are now reported. Closes #123, closes #91, closes #92. Co-Authored-By: Claude Opus 5.5 --- compiler/cfg.go | 477 +++++++++++++++++++++--- compiler/cfg_replay_test.go | 318 ---------------- compiler/cfg_test.go | 594 +++++++++++++++++++++++++----- compiler/codecompiler.go | 5 + compiler/compiler_test.go | 10 +- compiler/effects.go | 63 ++-- compiler/effects_test.go | 154 +------- compiler/scriptcompiler.go | 61 --- compiler/solver.go | 23 +- compiler/solver_test.go | 126 ++----- compiler/types.go | 13 +- llvm_pipeline_test.go | 2 +- tests/math/dependent_range.pt | 10 + tests/math/dependent_range_fn.exp | 1 + tests/math/dependent_range_fn.spt | 5 + tests/math/func.exp | 3 + tests/math/func.spt | 9 + tests/math/math.pt | 6 + 18 files changed, 1042 insertions(+), 838 deletions(-) delete mode 100644 compiler/cfg_replay_test.go diff --git a/compiler/cfg.go b/compiler/cfg.go index 5233a16c..cc6517be 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -37,7 +37,8 @@ type BasicBlock struct { Stmts []*StmtNode } -// CFG owns structural template validation and effect-sensitive dataflow. +// CFG owns template validation, which classifies writes from the text, and +// the script's effect-sensitive dataflow. type CFG struct { CodeCompiler *CodeCompiler Blocks []*BasicBlock @@ -185,10 +186,12 @@ func (cfg *CFG) collectSpecifierReads(value string, tok token.Token, runes []run return reads, spec.end } -// AnalyzeFuncs runs syntax-stable structural validation once for every -// function template. Effect-sensitive diagnostics are deferred until a -// concrete specialization is settled. +// AnalyzeFuncs validates every function template once, whether or not +// anything calls it: structural checks, then dataflow checks that classify +// writes from the template's text alone. func (cfg *CFG) AnalyzeFuncs() { + cfg.CodeCompiler.settleRangeOutputs() + for _, stmt := range cfg.CodeCompiler.Code.Statements { fn, ok := stmt.(*ast.FuncStatement) if !ok { @@ -200,6 +203,7 @@ func (cfg *CFG) AnalyzeFuncs() { } func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { + errorsBefore := len(cfg.Errors) PushScope(&cfg.Scopes, FuncScope) defer PopScope(&cfg.Scopes) @@ -214,7 +218,7 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { body := cfg.validateTemplateBody(fn.Body.Statements, identSet(fn.Parameters), identSet(fn.Outputs)) readInputs, assignedOutputs := body.readInputs, body.assignedOutputs - cfg.CodeCompiler.outputReads[funcKey{name: fn.Token.Literal, arity: len(fn.Parameters)}] = body.readOutputs + cfg.CodeCompiler.outputReads[templateKey(fn)] = body.readOutputs for _, input := range fn.Parameters { if _, wasRead := readInputs[input.Value]; wasRead { @@ -230,6 +234,414 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { cfg.addError(output.Tok(), fmt.Sprintf("output parameter %q is never assigned", output.Value)) } + + if len(cfg.Errors) == errorsBefore { + cfg.checkTemplateFlow(fn, body.statementReads) + } +} + +// checkTemplateFlow runs the dataflow checks once per template, classifying +// each write from the text: an output is read only after a definite +// assignment and is definitely assigned by the end of the body, and no write +// is dead or overwrites a definite write that nothing read. It runs only on a +// structurally valid body, so every output read follows some write. +func (cfg *CFG) checkTemplateFlow(fn *ast.FuncStatement, statementReads [][]VarEvent) { + cfg.PushBlock() + defer cfg.PopBlock() + + outputs := identSet(fn.Outputs) + flow := newRangeFlow(cfg.CodeCompiler, fn) + assigned := make(map[string]struct{}, len(outputs)) + lastWrites := make(map[string]VarEvent) + + for i, stmt := range fn.Body.Statements { + cfg.rejectUnassignedOutputReads(statementReads[i], outputs, assigned) + events := append([]VarEvent(nil), statementReads[i]...) + if let, ok := stmt.(*ast.LetStatement); ok { + writes := flow.letWrites(let) + flow.record(let, writes) + events = append(events, textWriteEvents(let, writes)...) + } + cfg.processDataflowEvents(stmt, events, lastWrites) + + for _, event := range events { + if _, isOutput := outputs[event.Name]; isOutput && event.Kind == Write { + assigned[event.Name] = struct{}{} + } + } + } + + for _, output := range fn.Outputs { + if _, ok := assigned[output.Value]; ok { + continue + } + + cfg.addError(output.Tok(), fmt.Sprintf("output %q may be left unassigned; assign it unconditionally first, or pass the previous value as an input and initialize from it (%s = prev), with each caller passing its destination as that input", output.Value, output.Value)) + } + + cfg.backwardPass(maps.Clone(outputs)) +} + +func (cfg *CFG) rejectUnassignedOutputReads(reads []VarEvent, outputs, assigned map[string]struct{}) { + for _, read := range reads { + if _, isOutput := outputs[read.Name]; !isOutput { + continue + } + if _, ok := assigned[read.Name]; !ok { + cfg.addError(read.Token, fmt.Sprintf("output %q is read where it may still be unassigned; assign it unconditionally first, or pass the previous value as an input and initialize from it", read.Name)) + } + } +} + +// textWriteEvents turns an assignment's text classification into write events +// at its named targets. +func textWriteEvents(let *ast.LetStatement, writes []textWrite) []VarEvent { + var events []VarEvent + for i, target := range let.Name { + if isDiscard(target) { + continue + } + + kind := ConditionalWrite + if writes[i].definite { + kind = Write + } + events = append(events, VarEvent{Name: target.Value, Kind: kind, Token: target.Tok()}) + } + return events +} + +// textWrite classifies one assignment target from the text: definite unless +// the statement's gate, a value that can fail, or a range that may be empty +// can skip the write; holdsRange when the target receives a Range descriptor. +type textWrite struct { + definite bool + holdsRange bool +} + +// rangeFlow reads from a template's text which bindings hold a Range +// descriptor and which values a range drives, as the solver types them. A +// parameter never holds a range, since a range argument runs the body once +// per element; a binding holds one when it is assigned a descriptor; a call's +// output holds one when its template's output does. +type rangeFlow struct { + cc *CodeCompiler + bindings map[string]struct{} + defined map[string]struct{} +} + +func newRangeFlow(cc *CodeCompiler, fn *ast.FuncStatement) *rangeFlow { + defined := identSet(fn.Parameters) + for _, output := range fn.Outputs { + defined[output.Value] = struct{}{} + } + return &rangeFlow{cc: cc, bindings: make(map[string]struct{}), defined: defined} +} + +// letWrites classifies every target of an assignment, discards included, so +// the result lines up with its names. Values take targets by their output +// counts; when the counts do not cover the targets, which the solver reports, +// every target gets the statement's weakest class. +func (rf *rangeFlow) letWrites(let *ast.LetStatement) []textWrite { + gateNames := rf.gateRanges(let.Condition) + gated := len(let.Condition) > 0 + slots := make([]int, len(let.Value)) + total := 0 + for i, value := range let.Value { + slots[i] = rf.cc.valueSlots(value) + total += slots[i] + } + + writes := make([]textWrite, 0, len(let.Name)) + if total != len(let.Name) { + definite := !gated && !slices.ContainsFunc(let.Value, rf.maySkip) + for range let.Name { + writes = append(writes, textWrite{definite: definite}) + } + return writes + } + + for i, value := range let.Value { + definite := !gated && !rf.maySkip(value) + for _, holdsRange := range rf.slotRanges(value, slots[i], gateNames) { + writes = append(writes, textWrite{definite: definite, holdsRange: holdsRange}) + } + } + return writes +} + +// maySkip reports whether a value can leave its targets unwritten: it can +// fail (a value-position comparison, &&, a checked access, a || whose last +// alternative can fail, or a call with such an argument), or a range that may +// be empty drives it. +func (rf *rangeFlow) maySkip(value ast.Expression) bool { + return treeCanFail(value, textNodeFails) || rf.drivesValue(value) +} + +func textNodeFails(expr ast.Expression) bool { + switch e := expr.(type) { + case *ast.InfixExpression: + return e.Token.IsComparison() || e.IsLogicalAnd() + case *ast.ArrayRangeExpression: + return true + } + return false +} + +// slotRanges reports, per target a value fills, whether it receives a Range +// descriptor. +func (rf *rangeFlow) slotRanges(value ast.Expression, slots int, gateNames map[string]struct{}) []bool { + if slots == 1 { + return []bool{rf.holdsRange(value, gateNames)} + } + if call, ok := value.(*ast.CallExpression); ok { + return rf.cc.callRangeOutputs(call) + } + return make([]bool, slots) +} + +// record publishes an assignment's targets once its reads are classified: +// they become defined, and those that receive a descriptor hold a range. +func (rf *rangeFlow) record(let *ast.LetStatement, writes []textWrite) { + for i, target := range let.Name { + if isDiscard(target) { + continue + } + + rf.defined[target.Value] = struct{}{} + if writes[i].holdsRange { + rf.bindings[target.Value] = struct{}{} + } + } +} + +// holdsRange reports whether a single-slot value assigns a Range descriptor. +// The value reads a range that its statement's condition iterates as that +// range's current element. +func (rf *rangeFlow) holdsRange(value ast.Expression, gateNames map[string]struct{}) bool { + switch v := value.(type) { + case *ast.RangeLiteral: + return true + case *ast.Identifier: + _, isRange := rf.bindings[v.Value] + _, iterated := gateNames[v.Value] + return isRange && !iterated + } + return rf.forwardsRange(value) +} + +// forwardsRange reports whether expr passes a Range descriptor on whole: a +// call whose template output holds one, or a || or value-position && that +// yields such a call. A literal or binding operand of || and && is iterated. +func (rf *rangeFlow) forwardsRange(expr ast.Expression) bool { + switch e := expr.(type) { + case *ast.CallExpression: + outputs := rf.cc.callRangeOutputs(e) + return len(outputs) == 1 && outputs[0] + case *ast.InfixExpression: + if e.IsLogicalOr() || e.IsLogicalAnd() { + return rf.forwardsRange(e.Right) + } + } + return false +} + +// drivesValue reports whether a range that may be empty drives an +// assignment's value: any range the value iterates, apart from a descriptor +// it assigns or passes on whole. A range the statement's condition names is +// already driven by the gate, which can skip the write by itself. +func (rf *rangeFlow) drivesValue(value ast.Expression) bool { + switch v := value.(type) { + case *ast.RangeLiteral: + return rf.drivesAny(ast.ExprChildren(v)) + case *ast.Identifier: + return false + case *ast.CallExpression: + return rf.drivesAny(v.Arguments) + case *ast.InfixExpression: + if v.IsLogicalOr() || v.IsLogicalAnd() { + return rf.drivesOperand(v.Left) || rf.drivesOperand(v.Right) + } + } + return rf.drives(value) +} + +func (rf *rangeFlow) drivesOperand(operand ast.Expression) bool { + if rf.forwardsRange(operand) { + return rf.drivesValue(operand) + } + return rf.drives(operand) +} + +// drives reports whether expr iterates a range that may be empty. Every range +// in it is iterated, except inside an array literal, which settles its cells +// itself. Only a range literal with constant, nonempty bounds always runs. +func (rf *rangeFlow) drives(expr ast.Expression) bool { + switch e := expr.(type) { + case *ast.ArrayLiteral: + return false + case *ast.RangeLiteral: + return !rangeLiteralGuaranteedNonEmpty(e) + case *ast.Identifier: + _, isRange := rf.bindings[e.Value] + return isRange + case *ast.StringLiteral: + return len(rf.specifierRanges(e)) > 0 + case *ast.CallExpression: + if slices.Contains(rf.cc.callRangeOutputs(e), true) { + return true + } + } + return rf.drivesAny(ast.ExprChildren(expr)) +} + +func (rf *rangeFlow) drivesAny(exprs []ast.Expression) bool { + return slices.ContainsFunc(exprs, rf.drives) +} + +// gateRanges returns the range bindings that a statement's conditions iterate. +func (rf *rangeFlow) gateRanges(conditions []ast.Expression) map[string]struct{} { + names := make(map[string]struct{}) + for _, condition := range conditions { + rf.collectRangeNames(condition, names) + } + return names +} + +func (rf *rangeFlow) collectRangeNames(expr ast.Expression, names map[string]struct{}) { + switch e := expr.(type) { + case *ast.ArrayLiteral: + return + case *ast.Identifier: + if _, isRange := rf.bindings[e.Value]; isRange { + names[e.Value] = struct{}{} + } + return + case *ast.StringLiteral: + for _, name := range rf.specifierRanges(e) { + names[name] = struct{}{} + } + return + } + + for _, child := range ast.ExprChildren(expr) { + rf.collectRangeNames(child, names) + } +} + +// specifierRanges returns the range bindings a string's width or precision +// consumes as numbers; a main marker formats a range as its descriptor. +func (rf *rangeFlow) specifierRanges(lit *ast.StringLiteral) []string { + _, specs := formatMarkerIdentifiers(lit.Token.Literal, rf.isDefined) + var ranges []string + for _, name := range specs { + if _, isRange := rf.bindings[name]; isRange { + ranges = append(ranges, name) + } + } + return ranges +} + +func (rf *rangeFlow) isDefined(name string) bool { + _, ok := rf.defined[name] + return ok || rf.cc.isGlobalBinding(name) +} + +// settleRangeOutputs reads from the text which template outputs hold a Range +// descriptor, to a fixed point across templates that call one another, and +// records each template's range bindings for settlement to check. +func (cc *CodeCompiler) settleRangeOutputs() { + var templates []*ast.FuncStatement + cc.rangeOutputs = make(map[funcKey][]bool) + for _, stmt := range cc.Code.Statements { + if fn, ok := stmt.(*ast.FuncStatement); ok { + templates = append(templates, fn) + cc.rangeOutputs[templateKey(fn)] = make([]bool, len(fn.Outputs)) + } + } + + cc.rangeBindings = make(map[funcKey]map[string]struct{}, len(templates)) + for changed := true; changed; { + changed = false + for _, fn := range templates { + bindings := cc.templateRangeBindings(fn) + outputs := cc.rangeOutputs[templateKey(fn)] + for i, output := range fn.Outputs { + if _, isRange := bindings[output.Value]; isRange && !outputs[i] { + outputs[i] = true + changed = true + } + } + cc.rangeBindings[templateKey(fn)] = bindings + } + } +} + +func (cc *CodeCompiler) templateRangeBindings(fn *ast.FuncStatement) map[string]struct{} { + flow := newRangeFlow(cc, fn) + for _, stmt := range fn.Body.Statements { + if let, ok := stmt.(*ast.LetStatement); ok { + flow.record(let, flow.letWrites(let)) + } + } + return flow.bindings +} + +func templateKey(fn *ast.FuncStatement) funcKey { + return funcKey{name: fn.Token.Literal, arity: len(fn.Parameters)} +} + +// valueSlots counts the targets a value fills, read from the text the way the +// solver counts a tuple: a call fills its template's outputs, a && its right +// operand's, and any other operator its wider operand's. +func (cc *CodeCompiler) valueSlots(value ast.Expression) int { + switch v := value.(type) { + case *ast.CallExpression: + if template, ok := cc.callTemplate(v); ok { + return len(template.Outputs) + } + case *ast.InfixExpression: + if v.IsLogicalAnd() { + return cc.valueSlots(v.Right) + } + return max(cc.valueSlots(v.Left), cc.valueSlots(v.Right)) + case *ast.PrefixExpression: + return cc.valueSlots(v.Right) + } + return 1 +} + +// callTemplate finds a call's template by its name and arity, where an +// argument counts once for each value it yields. +func (cc *CodeCompiler) callTemplate(call *ast.CallExpression) (*ast.FuncStatement, bool) { + arity := 0 + for _, argument := range call.Arguments { + arity += cc.valueSlots(argument) + } + return cc.lookupFuncTemplate(call.Function.Value, arity) +} + +// callRangeOutputs reports which outputs of a call's template hold a Range, +// or nil when the call names no template. +func (cc *CodeCompiler) callRangeOutputs(call *ast.CallExpression) []bool { + template, ok := cc.callTemplate(call) + if !ok { + return nil + } + return cc.rangeOutputs[templateKey(template)] +} + +// checkSettledRanges panics when a settled specialization types a binding as +// a Range where its template's text summary does not, or the reverse: the +// template checks classified the body's writes from that summary. +func (cc *CodeCompiler) checkSettledRanges(template *ast.FuncStatement, info *FuncInfo) { + bindings := cc.rangeBindings[templateKey(template)] + for name, typ := range info.Vars { + _, textRange := bindings[name] + if textRange != (typ.Kind() == RangeKind) { + panic(fmt.Sprintf("internal: %s solves %q as %s, which disagrees with its template's text range summary", info.Sig.Name, name, typ)) + } + } } // templateBody is the structural summary of one template body. @@ -309,57 +721,6 @@ func (cfg *CFG) validateScriptTemplate(statements []ast.Statement) [][]VarEvent return cfg.validateTemplateBody(statements, nil, nil).statementReads } -// AnalyzeSpecialization runs only typed dataflow over one type -// specialization, with every input treated as its own value: a call that -// shares an input with an output only adds reads, so a body valid here is -// valid in every call. Structural diagnostics were already produced once -// from the function template. -func (cfg *CFG) AnalyzeSpecialization(template *ast.FuncStatement, info *FuncInfo) { - cfg.PushBlock() - defer cfg.PopBlock() - PushScope(&cfg.Scopes, FuncScope) - defer PopScope(&cfg.Scopes) - - // Parameters must be in scope: the shared marker collector treats an - // unknown main marker as literal text and rejects unknown specifier names. - for _, param := range template.Parameters { - cfg.declareName(param) - } - - outputs := identSet(template.Outputs) - cfg.typedForwardPass(template, info, outputs) - cfg.backwardPass(maps.Clone(outputs)) -} - -// typedForwardPass runs the forward dataflow over a body. Per statement, in -// order: an explicit read of an output needs an earlier definite assignment; -// the statement's events run; its definite targets become assigned for the -// statements after it. -func (cfg *CFG) typedForwardPass(template *ast.FuncStatement, info *FuncInfo, outputs map[string]struct{}) { - definitelyAssigned := make(map[string]struct{}, len(outputs)) - lastWrites := make(map[string]VarEvent) - for _, stmt := range template.Body.Statements { - reads := cfg.collectStatementReads(stmt) - cfg.rejectUnassignedOutputReads(reads, outputs, definitelyAssigned) - cfg.processTypedStatement(stmt, reads, info.StatementEffects, lastWrites) - - if let, ok := stmt.(*ast.LetStatement); ok { - maps.Copy(definitelyAssigned, definiteTargets(let, info.StatementEffects[let])) - } - } -} - -func (cfg *CFG) rejectUnassignedOutputReads(reads []VarEvent, outputs, definitelyAssigned map[string]struct{}) { - for _, read := range reads { - if _, isOutput := outputs[read.Name]; !isOutput { - continue - } - if _, ok := definitelyAssigned[read.Name]; !ok { - cfg.addError(read.Token, fmt.Sprintf("output %q is read where it may still be unassigned; assign it unconditionally first, or pass the previous value as an input and initialize from it", read.Name)) - } - } -} - func (cfg *CFG) typedScriptForwardPass(statements []ast.Statement, effects map[*ast.LetStatement]StatementEffect, statementReads [][]VarEvent) { if len(statementReads) != len(statements) { panic("internal: script CFG read count does not match statement count") @@ -495,8 +856,8 @@ func (cfg *CFG) backwardPass(live map[string]struct{}) { } // An output is readable once an earlier statement has assigned it; a -// statement's reads precede its own writes. The typed pass narrows this per -// specialization to writes that definitely assign. +// statement's reads precede its own writes. The template's dataflow check +// narrows this to writes that definitely assign. func (cfg *CFG) validateStructuralRead(event VarEvent, outputs, assigned map[string]struct{}) { if _, isOutput := outputs[event.Name]; isOutput { if _, isAssigned := assigned[event.Name]; !isAssigned { diff --git a/compiler/cfg_replay_test.go b/compiler/cfg_replay_test.go deleted file mode 100644 index e0ab280e..00000000 --- a/compiler/cfg_replay_test.go +++ /dev/null @@ -1,318 +0,0 @@ -package compiler - -import ( - "testing" - - "github.com/stretchr/testify/require" - "github.com/thiremani/pluto/token" - "tinygo.org/x/go-llvm" -) - -func compileCFGReplayScript(t *testing.T, ctx llvm.Context, cc *CodeCompiler, name, source string) (*ScriptCompiler, []*token.CompileError) { - t.Helper() - - sc := NewScriptCompiler(ctx, name, mustParseScript(t, source), cc) - - return sc, sc.Compile() -} - -func TestCFGReplayColdAndWarm(t *testing.T) { - code := mustParseCode(t, `result = Noisy(x) - unused = x + 1 - result = x -`) - - ctx := llvm.NewContext() - defer ctx.Dispose() - cc := NewCodeCompiler(ctx, "cfgReplayColdWarm", "", code) - require.Empty(t, cc.Compile()) - - _, coldErrors := compileCFGReplayScript(t, ctx, cc, t.Name()+"Cold", "value = Noisy(1)\nvalue") - require.Len(t, coldErrors, 1) - require.Contains(t, coldErrors[0].Msg, `"unused"`) - - noisyMangled := Mangle(cc.Compiler.MangledPath, "Noisy", []Type{I64}) - coldInfo := cc.Compiler.FuncCache[noisyMangled] - require.NotNil(t, coldInfo) - require.True(t, coldInfo.Settled) - require.NotNil(t, coldInfo.CFGResult) - require.Len(t, coldInfo.CFGResult.Errors, 1) - require.Same(t, coldInfo.CFGResult.Errors[0], coldErrors[0], - "cold replay must return the immutable cached diagnostic") - - coldResult := coldInfo.CFGResult - _, warmErrors := compileCFGReplayScript(t, ctx, cc, t.Name()+"Warm", "value = Noisy(2)\nvalue") - require.Len(t, warmErrors, 1, "each script replay must report the reachable diagnostic") - - warmInfo := cc.Compiler.FuncCache[noisyMangled] - require.Same(t, coldInfo, warmInfo, "the warm solve must reuse the settled specialization") - require.Same(t, coldResult, warmInfo.CFGResult, "the warm solve must not republish the CFG result") - require.Same(t, coldErrors[0], warmErrors[0], - "warm replay must return the same cached diagnostic pointer") -} - -func TestCFGReplayDeduplicatesConcreteTypes(t *testing.T) { - code := mustParseCode(t, `result = NoisyTypes(x) - unused = x + 1 - result = x -`) - - ctx := llvm.NewContext() - defer ctx.Dispose() - cc := NewCodeCompiler(ctx, "cfgReplayConcreteTypes", "", code) - require.Empty(t, cc.Compile()) - - _, errors := compileCFGReplayScript(t, ctx, cc, t.Name(), `integer = NoisyTypes(1) -floating = NoisyTypes(2.0) -integer, floating`) - require.Len(t, errors, 1, "one template diagnostic must be reported once per script") - - integerMangled := Mangle(cc.Compiler.MangledPath, "NoisyTypes", []Type{I64}) - floatingMangled := Mangle(cc.Compiler.MangledPath, "NoisyTypes", []Type{F64}) - integerInfo := cc.Compiler.FuncCache[integerMangled] - floatingInfo := cc.Compiler.FuncCache[floatingMangled] - require.Len(t, integerInfo.CFGResult.Errors, 1) - require.Len(t, floatingInfo.CFGResult.Errors, 1) - require.NotSame(t, integerInfo.CFGResult.Errors[0], floatingInfo.CFGResult.Errors[0], - "each specialization must retain its immutable analysis result") - require.Equal(t, integerInfo.CFGResult.Errors[0].Error(), floatingInfo.CFGResult.Errors[0].Error()) - require.Same(t, integerInfo.CFGResult.Errors[0], errors[0], - "replay must retain the first source-ordered diagnostic") -} - -func TestCFGReplayPreservesDistinctDiagnostics(t *testing.T) { - code := mustParseCode(t, `result = NoisyPair(x) - firstUnused = x + 1 - secondUnused = x + 2 - result = x -`) - - ctx := llvm.NewContext() - defer ctx.Dispose() - cc := NewCodeCompiler(ctx, "cfgReplayDistinctDiagnostics", "", code) - require.Empty(t, cc.Compile()) - - _, errors := compileCFGReplayScript(t, ctx, cc, t.Name(), `integer = NoisyPair(1) -floating = NoisyPair(2.0) -integer, floating`) - require.Len(t, errors, 2, "distinct source diagnostics must survive specialization deduplication") - require.Contains(t, errors[0].Msg, `"secondUnused"`) - require.Contains(t, errors[1].Msg, `"firstUnused"`) - - integerMangled := Mangle(cc.Compiler.MangledPath, "NoisyPair", []Type{I64}) - floatingMangled := Mangle(cc.Compiler.MangledPath, "NoisyPair", []Type{F64}) - integerInfo := cc.Compiler.FuncCache[integerMangled] - floatingInfo := cc.Compiler.FuncCache[floatingMangled] - require.Len(t, integerInfo.CFGResult.Errors, 2) - require.Len(t, floatingInfo.CFGResult.Errors, 2) - for index := range errors { - require.Same(t, integerInfo.CFGResult.Errors[index], errors[index]) - } -} - -func TestUnreachableCachedCFGDiagnosticsDoNotLeak(t *testing.T) { - code := mustParseCode(t, `result = Noisy(x) - unused = x + 1 - result = x - -result = Clean(x) - result = x -`) - - ctx := llvm.NewContext() - defer ctx.Dispose() - cc := NewCodeCompiler(ctx, "cfgReplayUnreachable", "", code) - require.Empty(t, cc.Compile()) - - _, noisyErrors := compileCFGReplayScript(t, ctx, cc, t.Name()+"Noisy", "value = Noisy(1)\nvalue") - require.Len(t, noisyErrors, 1) - - noisyMangled := Mangle(cc.Compiler.MangledPath, "Noisy", []Type{I64}) - noisyInfo := cc.Compiler.FuncCache[noisyMangled] - require.NotNil(t, noisyInfo) - require.NotNil(t, noisyInfo.CFGResult) - require.Same(t, noisyInfo.CFGResult.Errors[0], noisyErrors[0]) - - _, cleanErrors := compileCFGReplayScript(t, ctx, cc, t.Name()+"Clean", "value = Clean(1)\nvalue") - require.Empty(t, cleanErrors, - "a cached diagnostic must not replay when its specialization is unreachable") - require.Same(t, noisyInfo, cc.Compiler.FuncCache[noisyMangled]) -} - -func TestWrapperReplaysSettledCalleeDiagnostics(t *testing.T) { - code := mustParseCode(t, `result = NoisyLeaf(x) - leafDead = x + 1 - result = x - -result = Wrapper(x) - result = NoisyLeaf(x) -`) - - ctx := llvm.NewContext() - defer ctx.Dispose() - cc := NewCodeCompiler(ctx, "cfgReplaySettledCallee", "", code) - require.Empty(t, cc.Compile()) - - _, leafErrors := compileCFGReplayScript(t, ctx, cc, t.Name()+"Leaf", "value = NoisyLeaf(1)\nvalue") - require.Len(t, leafErrors, 1) - - leafMangled := Mangle(cc.Compiler.MangledPath, "NoisyLeaf", []Type{I64}) - leafInfo := cc.Compiler.FuncCache[leafMangled] - require.True(t, leafInfo.Settled) - require.NotNil(t, leafInfo.CFGResult) - require.Same(t, leafInfo.CFGResult.Errors[0], leafErrors[0]) - - _, wrapperErrors := compileCFGReplayScript(t, ctx, cc, t.Name()+"Wrapper", "value = Wrapper(1)\nvalue") - require.Len(t, wrapperErrors, 1) - require.Same(t, leafErrors[0], wrapperErrors[0]) - - wrapperMangled := Mangle(cc.Compiler.MangledPath, "Wrapper", []Type{I64}) - wrapperInfo := cc.Compiler.FuncCache[wrapperMangled] - require.True(t, wrapperInfo.Settled) - require.NotNil(t, wrapperInfo.CFGResult) - require.Equal(t, []string{leafMangled}, wrapperInfo.CFGResult.DirectCallees, - "persistent replay edges must include callees settled before the wrapper batch") -} - -func TestPrintOnlyUserCallReplaysCFGDiagnostics(t *testing.T) { - code := mustParseCode(t, `result = NoisyPrint(x) - printDead = x + 1 - result = x -`) - - ctx := llvm.NewContext() - defer ctx.Dispose() - cc := NewCodeCompiler(ctx, "cfgReplayPrintOnly", "", code) - require.Empty(t, cc.Compile()) - - sc, errors := compileCFGReplayScript(t, ctx, cc, t.Name(), "NoisyPrint(1)") - require.Len(t, errors, 1) - require.Contains(t, errors[0].Msg, `"printDead"`) - - mangled := Mangle(cc.Compiler.MangledPath, "NoisyPrint", []Type{I64}) - directCallees, _ := collectSpecializationCallEdges(sc.Compiler, sc.ScriptMangled, sc.Program.Statements) - require.Equal(t, []string{mangled}, directCallees, - "a user call reached only through print arguments must be a replay root") - require.Same(t, cc.Compiler.FuncCache[mangled].CFGResult.Errors[0], errors[0]) -} - -func TestCFGReplayDeduplicatesScalarCompanion(t *testing.T) { - code := mustParseCode(t, `result = GatherNoisy(arr) - i = 0:3 - result = [ScaleNoisy(arr[i])] - -result = ScaleNoisy(x) - unusedScale = x - result = x -`) - - ctx := llvm.NewContext() - defer ctx.Dispose() - cc := NewCodeCompiler(ctx, "cfgReplayScalarCompanion", "", code) - require.Empty(t, cc.Compile()) - - sc, errors := compileCFGReplayScript(t, ctx, cc, t.Name(), `arr = [10 20 30] -result = GatherNoisy(arr) -result`) - require.Len(t, errors, 1, "a primary specialization and scalar companion share one source diagnostic") - - gatherMangled := Mangle(cc.Compiler.MangledPath, "GatherNoisy", []Type{Array{ElemType: I64, Rank: 1}}) - gatherTemplate, ok := cc.lookupFuncTemplate("GatherNoisy", 1) - require.True(t, ok) - gatherCalls := collectBodyCalls(gatherTemplate.Body.Statements) - require.Len(t, gatherCalls, 1) - gatherCall := gatherCalls[0] - callInfo := sc.Compiler.ExprCache[key(gatherMangled, gatherCall)] - require.True(t, callInfo.ScalarCallVariantEnsured) - - primaryMangled := Mangle(cc.Compiler.MangledPath, "ScaleNoisy", callInfo.CallParamTypes) - scalarMangled := Mangle(cc.Compiler.MangledPath, "ScaleNoisy", callInfo.ScalarCallParamTypes) - require.NotEqual(t, primaryMangled, scalarMangled) - primaryInfo := cc.Compiler.FuncCache[primaryMangled] - scalarInfo := cc.Compiler.FuncCache[scalarMangled] - require.Len(t, primaryInfo.CFGResult.Errors, 1) - require.Len(t, scalarInfo.CFGResult.Errors, 1) - require.NotSame(t, primaryInfo.CFGResult.Errors[0], scalarInfo.CFGResult.Errors[0], - "both actual lowering targets must remain independently analyzed") - require.Equal(t, primaryInfo.CFGResult.Errors[0].Error(), scalarInfo.CFGResult.Errors[0].Error()) - require.Same(t, primaryInfo.CFGResult.Errors[0], errors[0]) -} - -func TestDiamondCFGReplayIsOnceAndDeterministic(t *testing.T) { - code := mustParseCode(t, `result = Shared(x) - sharedDead = x + 1 - result = x - -result = Left(x) - leftDead = x + 2 - result = Shared(x) - -result = Right(x) - rightDead = x + 3 - result = Shared(x) - -result = Diamond(x) - diamondDead = x + 4 - left = Left(x) - right = Right(x) - result = left + right -`) - - ctx := llvm.NewContext() - defer ctx.Dispose() - cc := NewCodeCompiler(ctx, "cfgReplayDiamond", "", code) - require.Empty(t, cc.Compile()) - - _, errors := compileCFGReplayScript(t, ctx, cc, t.Name(), "value = Diamond(1)\nvalue") - require.Len(t, errors, 4) - - for i, name := range []string{"diamondDead", "leftDead", "sharedDead", "rightDead"} { - require.Contains(t, errors[i].Msg, `"`+name+`"`, - "diagnostics must replay in root-first depth-first source order") - } - - sharedMangled := Mangle(cc.Compiler.MangledPath, "Shared", []Type{I64}) - sharedError := cc.Compiler.FuncCache[sharedMangled].CFGResult.Errors[0] - sharedOccurrences := 0 - for _, cfgError := range errors { - if cfgError == sharedError { - sharedOccurrences++ - } - } - require.Equal(t, 1, sharedOccurrences, - "the visited set must replay a shared diamond callee exactly once") -} - -func TestCFGResultsAreIndependentPerType(t *testing.T) { - code := mustParseCode(t, `result = MaskOrKeep(x) - result = x - result = x > 0 -`) - - ctx := llvm.NewContext() - defer ctx.Dispose() - cc := NewCodeCompiler(ctx, "cfgPerSpecialization", "", code) - require.Empty(t, cc.Compile()) - - _, scalarErrors := compileCFGReplayScript(t, ctx, cc, t.Name()+"Scalar", "value = MaskOrKeep(1)\nvalue") - require.Empty(t, scalarErrors, - "a scalar comparison may skip its write, so the preceding output remains live") - - scalarMangled := Mangle(cc.Compiler.MangledPath, "MaskOrKeep", []Type{I64}) - scalarResult := cc.Compiler.FuncCache[scalarMangled].CFGResult - require.NotNil(t, scalarResult) - require.Empty(t, scalarResult.Errors) - - _, arrayErrors := compileCFGReplayScript(t, ctx, cc, t.Name()+"Array", "value = MaskOrKeep([1 2])\nvalue") - require.NotEmpty(t, arrayErrors, - "an array comparison materializes unconditionally and overwrites the preceding output") - - arrayMangled := Mangle(cc.Compiler.MangledPath, "MaskOrKeep", []Type{ - Array{ElemType: I64, Rank: 1}, - }) - arrayResult := cc.Compiler.FuncCache[arrayMangled].CFGResult - require.NotNil(t, arrayResult) - require.NotSame(t, scalarResult, arrayResult) - require.NotEmpty(t, arrayResult.Errors) - require.Contains(t, arrayResult.Errors[0].Msg, `unconditional assignment to "result"`) -} diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index 80732e67..95f609e7 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -1,6 +1,9 @@ package compiler import ( + "fmt" + "maps" + "slices" "strings" "testing" @@ -46,7 +49,9 @@ func TestCFGAnalysis(t *testing.T) { }) } -func TestFunctionDataflowWaitsForSpecialization(t *testing.T) { +// Flow checks run once per template, from its text, whether or not anything +// calls it. +func TestFunctionDataflowRunsOncePerTemplate(t *testing.T) { code := `r = Unreachable(x) temporary = x + 1 temporary = x + 2 @@ -57,10 +62,6 @@ func TestFunctionDataflowWaitsForSpecialization(t *testing.T) { cc := NewCodeCompiler(ctx, "unreachableLocalDeadStore", "", mustParseCode(t, code)) errs := cc.Compile() - require.Empty(t, errs) - - sc := NewScriptCompiler(ctx, t.Name(), mustParseScript(t, "result = Unreachable(1)\nresult"), cc) - errs = sc.Compile() require.Len(t, errs, 3) assertHasExpectedError(t, errs, `unconditional assignment to "temporary" overwrites a previous value that was never used`) @@ -329,12 +330,22 @@ value, kept, echo`, input: `"Value: -x%s"`, }, { - // The callee may skip its write, leaving the destination's previous - // value in place, so that previous write is live. - name: "Write then Skippable Call Root", - code: `res = maybeWrite(x) + // The callee keeps the old value through an input, so the call + // reads the earlier write. + name: "Write then Call Keeping Old Value", + code: `res = maybeWrite(prev, x) + res = prev res = x > 0 42`, - input: "x = 7\nx = maybeWrite(-1)\nx", + input: "x = 7\nx = maybeWrite(x, -1)\nx", + }, + { + // One body serves scalar and array arguments: the comparison counts + // as possibly skipping for both, so the default stays live. + name: "Default Before Comparison For Scalar And Array", + code: `out = Pos(prev, x) + out = prev + out = x > 0`, + input: "s = Pos(9, -1)\nm = Pos([]0, [1 -2 3])\ns, m", }, { // A condition below the value root still leaves the whole RHS able @@ -356,13 +367,14 @@ value, kept, echo`, { // Writing an output twice never reads it, and a call may target it. name: "Output Rewritten And Targeted By Nested Call", - code: `res = maybe(x) + code: `res = maybe(prev, x) + res = prev res = x > 0 x res = refine(x) res = x res = x > 5 x * x - res = maybe(x)`, + res = maybe(res, x)`, input: "x = refine(3)\nx", }, { @@ -480,8 +492,9 @@ func getErrorTestCases() []cfgTestCase { errorContains: `output "res" is read where it may still be unassigned`, }, { - // A call that may leave its output unwritten does not assign it. - name: "Output Read After Skippable Call", + // A body that can skip its write is rejected at its definition, so + // a caller can count on the call writing its output. + name: "Skippable Body Rejected At Definition", code: `res = maybe(x) res = x > 0 x @@ -489,21 +502,7 @@ res = chained(x) res = maybe(x) res = res + 1`, input: "x = chained(-1)\nx", - errorContains: `output "res" is read where it may still be unassigned`, - }, - { - // Two seed-preserving calls in a row still leave the caller's seed - // in place, so the read after them is rejected. - name: "Output Read After Two Seed Preserving Calls", - code: `res = maybe(x) - res = x > 0 x - -res = twice(x) - res = maybe(x) - res = maybe(x) - res = res + 1`, - input: "x = twice(-1)\nx", - errorContains: `output "res" is read where it may still be unassigned`, + errorContains: `output "res" may be left unassigned`, }, { // A marker naming an output is a read even before any assignment, @@ -883,62 +882,15 @@ func TestFormattingReadsRemainStructural(t *testing.T) { } } -func TestSpecializationReadsSeedBeforeWrite(t *testing.T) { - ctx := llvm.NewContext() - defer ctx.Dispose() - - code := mustParseCode(t, `res = Seeded(x) - res = x - res = Preserve(x)`) - template := code.Statements[0].(*ast.FuncStatement) - first := template.Body.Statements[0].(*ast.LetStatement) - second := template.Body.Statements[1].(*ast.LetStatement) - info := &FuncInfo{ - StatementEffects: map[*ast.LetStatement]StatementEffect{ - first: { - Writes: []TargetWriteEffect{{TargetIndex: 0, Effect: MustWrite}}, - }, - second: { - Writes: []TargetWriteEffect{{TargetIndex: 0, Effect: MustWrite}}, - ReadsSeed: []int{0}, - }, - }, - } - cc := NewCodeCompiler(ctx, "seededSpecialization", "", code) - cfg := NewCFG(cc) - - cfg.AnalyzeSpecialization(template, info) - - require.Empty(t, cfg.Errors) -} - -func TestSpecializationPrintReadKeepsLocalLive(t *testing.T) { +func TestTemplatePrintReadKeepsLocalLive(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() - code := mustParseCode(t, `res = Printed(x) + cc := NewCodeCompiler(ctx, "printedTemplate", "", mustParseCode(t, `res = Printed(x) local = x + 1 local - res = x`) - template := code.Statements[0].(*ast.FuncStatement) - local := template.Body.Statements[0].(*ast.LetStatement) - output := template.Body.Statements[2].(*ast.LetStatement) - info := &FuncInfo{ - StatementEffects: map[*ast.LetStatement]StatementEffect{ - local: { - Writes: []TargetWriteEffect{{TargetIndex: 0, Effect: MustWrite}}, - }, - output: { - Writes: []TargetWriteEffect{{TargetIndex: 0, Effect: MustWrite}}, - }, - }, - } - cc := NewCodeCompiler(ctx, "printedSpecialization", "", code) - cfg := NewCFG(cc) - - cfg.AnalyzeSpecialization(template, info) - - require.Empty(t, cfg.Errors) + res = x`)) + require.Empty(t, cc.Compile()) } func TestTypedEventsUseSparseTargetIndices(t *testing.T) { @@ -972,15 +924,12 @@ func TestCFGRejectsMissingStatementEffects(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() - code := mustParseCode(t, `res = MissingEffects(x) - res = x`) - template := code.Statements[0].(*ast.FuncStatement) - info := &FuncInfo{StatementEffects: make(map[*ast.LetStatement]StatementEffect)} - cc := NewCodeCompiler(ctx, "missingEffects", "", code) + cc := NewCodeCompiler(ctx, t.Name(), "", ast.NewCode()) + program := mustParseScript(t, "value = 1\nvalue") cfg := NewCFG(cc) - require.PanicsWithValue(t, `internal: missing CFG effects for statement "res = x"`, func() { - cfg.AnalyzeSpecialization(template, info) + require.PanicsWithValue(t, `internal: missing CFG effects for statement "value = 1"`, func() { + cfg.AnalyzeScript(program.Statements, make(map[*ast.LetStatement]StatementEffect)) }) } @@ -1136,3 +1085,474 @@ func assertMessageFound(t *testing.T, messages []string, expectedMessage string) } assert.True(t, found, "expected an error containing %q, got: %v", expectedMessage, messages) } + +func unassignedOutputMessage(name string) string { + return fmt.Sprintf("output %q may be left unassigned; assign it unconditionally first, or pass the previous value as an input and initialize from it (%s = prev), with each caller passing its destination as that input", name, name) +} + +const makeRangeTemplate = `r = MakeRange(n) + r = 0:n + +` + +// Every body that runs writes every output. The template check reads the +// text: a statement gate, a value that can fail, or a range that may be empty +// can skip a write, for every argument type. +func TestOutputsMustBeDefinitelyAssigned(t *testing.T) { + tests := []struct { + name string + code string + unassigned []string + }{ + { + name: "Gated", + code: `out = Maybe(x) + out = x > 0 x`, + unassigned: []string{"out"}, + }, + { + // A comparison counts as possibly skipping even where an array + // argument would make it a mask. + name: "Comparison", + code: `out = Less(x, y) + out = x < y`, + unassigned: []string{"out"}, + }, + { + name: "CheckedAccess", + code: `out = At(arr, i) + out = arr[i]`, + unassigned: []string{"out"}, + }, + { + // Complementary conditions are not recognized: for floats, a NaN + // fails both. + name: "ComplementaryGates", + code: `y = Fib(n) + y = n <= 1 n + y = n > 1 Fib(n - 1) + Fib(n - 2)`, + unassigned: []string{"y"}, + }, + { + name: "DefaultThenOverride", + code: `y = Fib(n) + y = n + y = n > 1 Fib(n - 1) + Fib(n - 2)`, + }, + { + name: "PrevThenComparison", + code: `out = Pos(prev, x) + out = prev + out = x > 0`, + }, + { + name: "LocalAbsorbsCheckedAccess", + code: `out = At(arr, i) + t = arr[i] + out = t`, + }, + { + // A caller counts a call as writing every output, so only the + // body that can skip is reported. + name: "CallerIsNotReportedAgain", + code: `res = maybe(x) + res = x > 0 x + +out = chained(x) + out = maybe(x) + out = out + 1`, + unassigned: []string{"res"}, + }, + { + name: "EveryOutputIsChecked", + code: `x, y = IsEven(n) + x, y = n != 0 IsOdd(n - 1) + x = n == 0 "yes" + +x, y = IsOdd(n) + x, y = "no", "yes" + x, y = n != 0 IsEven(n - 1)`, + unassigned: []string{"x", "y"}, + }, + { + name: "LocalRangeMayBeEmpty", + code: `out = Tail(n) + j = 0:n + out = j * 2`, + unassigned: []string{"out"}, + }, + { + name: "InlineRangeMayBeEmpty", + code: `out = Shift(n) + out = n + (0:n)`, + unassigned: []string{"out"}, + }, + { + name: "NonemptyRangeLiteralAlwaysRuns", + code: `out = Last(x) + out = x + (0:3)`, + }, + { + // A range that arrives through a call may be empty. + name: "RangeFromCall", + code: makeRangeTemplate + `out = F(n) + r = MakeRange(n) + out = r * 2`, + unassigned: []string{"out"}, + }, + { + // A range a call returns runs the function it is passed to once + // per element, so it may run it not at all (#143 tracks lowering + // this form). + name: "RangeFromCallAsArgument", + code: makeRangeTemplate + `y = Sq(x) + y = x * x + +out = F(n) + out = Sq(MakeRange(n))`, + unassigned: []string{"out"}, + }, + { + name: "RangeFromCallWithDefault", + code: makeRangeTemplate + `out = F(prev, n) + r = MakeRange(n) + out = prev + out = r * 2`, + }, + { + name: "RangeThroughAnotherTemplate", + code: makeRangeTemplate + `r = Forward(n) + t = MakeRange(n) + r = t + +out = F(n) + r = Forward(n) + out = r * 2`, + unassigned: []string{"out"}, + }, + { + name: "RangeForwardedByOr", + code: makeRangeTemplate + `out = F(n) + r = MakeRange(n > 0) || MakeRange(3) + out = r * 2`, + unassigned: []string{"out"}, + }, + { + name: "RangeForwardedByAnd", + code: makeRangeTemplate + `out = F(n) + r = n > 0 && MakeRange(3) + out = r * 2`, + unassigned: []string{"out"}, + }, + { + name: "ScalarCallOutput", + code: `y = Helper(x) + y = x + 1 + +out = F(x) + y = Helper(x) + out = y + 1`, + }, + { + // A gate that names a range iterates it, so the value reads the + // element and the binding holds a scalar. + name: "GateIteratesItsRange", + code: `out = F(n) + stream = 0:n + last = stream > 2 stream + out = last + 1`, + }, + { + name: "GateOnAnotherRangeKeepsDescriptor", + code: `out = F(n) + other = 0:3 + stream = 0:n + kept = other > 1 stream + out = kept * 2`, + unassigned: []string{"out"}, + }, + { + // A literal or binding under && is iterated; only a call operand + // passes its descriptor on. + name: "AndIteratesLiteral", + code: `out = F(n) + last = n > 0 && 0:5 + out = last + 1`, + }, + { + name: "AndIteratesBinding", + code: `out = F(n) + stream = 0:n + last = n > 0 && stream + out = last + 1`, + }, + { + // Templates are summarized to a fixed point, so a caller defined + // before the template that returns a range still sees it. + name: "RangeThroughTemplatesDefinedLater", + code: `out = F(n) + r = Forward(n) + out = r * 2 + +r = Forward(n) + t = MakeRange(n) + r = t + +` + makeRangeTemplate, + unassigned: []string{"out"}, + }, + { + // A descriptor assigned or passed on whole is not iterated. + name: "OutputCopiesRangeDescriptor", + code: `out = F(n) + stream = 0:n + out = stream`, + }, + { + name: "OutputFromRangeCall", + code: makeRangeTemplate + `out = F(n) + out = MakeRange(n)`, + }, + { + name: "OutputFromForwardedRange", + code: makeRangeTemplate + `out = F(n) + out = MakeRange(n > 0) || MakeRange(3)`, + }, + { + // A width or precision consumes a range as numbers. + name: "SpecifierRangeIterates", + code: `out = F(n) + w = 1:n + out = "-n%(-w)d"`, + unassigned: []string{"out"}, + }, + { + // When the values do not cover the targets, which the solver + // reports, every target gets the statement's weakest class. + name: "MismatchedTargetsAllMaySkip", + code: `a, b = Pair(x) + a, b = x, x + +a, b, c = F(arr, x) + a, b, c = Pair(x), arr[0], x`, + unassigned: []string{"a", "b", "c"}, + }, + { + // A && fills its right operand's targets. + name: "AndTakesRightOperandSlots", + code: `a, b = Pair(x) + a, b = x, x + +a, b, c = F(x) + a, b, c = x > 0 && Pair(x), x`, + unassigned: []string{"a", "b"}, + }, + { + // A call's arity counts every value its arguments yield, so the + // targets line up with Count's outputs and only c can be skipped. + name: "TargetsFollowCallOutputs", + code: `left, right = Tags(n) + left, right = n, n + 1 + +out, tag = Count(left, right, n) + out = n + 1 + tag = left + right + +a, b, c = F(arr, n) + a, b, c = Count(Tags(n), n), arr[0]`, + unassigned: []string{"c"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + cc := NewCodeCompiler(ctx, test.name, "", mustParseCode(t, test.code)) + want := make([]string, len(test.unassigned)) + for i, name := range test.unassigned { + want[i] = unassignedOutputMessage(name) + } + + require.Equal(t, want, extractErrorMessages(cc.Compile())) + }) + } +} + +func TestUnassignedOutputIsReportedAtTheHeader(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + cc := NewCodeCompiler(ctx, t.Name(), "", mustParseCode(t, `out = Maybe(x) + out = x > 0 x`)) + errs := cc.Compile() + + require.Len(t, errs, 1) + require.Equal(t, unassignedOutputMessage("out"), errs[0].Msg) + require.Equal(t, 1, errs[0].Token.Line) + require.Equal(t, 1, errs[0].Token.Column) +} + +// The template checks read from the text which bindings hold a Range, and +// settlement checks that the solved types agree, so each way a value holds +// or iterates a range is pinned here against the solver. +func TestTextRangeSummaryAgreesWithSolver(t *testing.T) { + tests := []struct { + name string + code string + ranges []string + }{ + { + name: "DescriptorCopy", + code: `out = F(n) + stream = 0:n + kept = stream + out = [kept]`, + ranges: []string{"kept", "stream"}, + }, + { + name: "OperationIterates", + code: `out = F(n) + stream = 0:n + out = n + out = stream + 0`, + ranges: []string{"stream"}, + }, + { + name: "GateIteratesItsRange", + code: `out = F(n) + stream = 0:n + out = n + out = stream > 2 stream`, + ranges: []string{"stream"}, + }, + { + name: "GateOnAnotherRangeKeepsDescriptor", + code: `out = F(n) + other = 0:3 + stream = 0:n + kept = other > 1 stream + out = [kept]`, + ranges: []string{"kept", "other", "stream"}, + }, + { + name: "WholeLiteralUnderGateKeepsDescriptor", + code: `out = F(n) + stream = 0:n + kept = stream > 1 0:9 + out = [kept]`, + ranges: []string{"kept", "stream"}, + }, + { + // A range inside a collector in the condition is collected, so + // the gate does not iterate it. + name: "CollectorInGateKeepsDescriptor", + code: `n = Size(xs) + t = xs[0] + n = t + 2 + +out = F(n) + stream = 0:n + kept = Size([stream]) > 1 stream + out = [kept]`, + ranges: []string{"kept", "stream"}, + }, + { + name: "SpecifierInGateIterates", + code: `out = F(n) + w = 0:n + kept = "-n%(-w)d" > "" w + out = [kept]`, + ranges: []string{"w"}, + }, + { + name: "AndIteratesLiteral", + code: `out = F(n) + last = n > 0 && 0:5 + out = [last]`, + }, + { + name: "AndIteratesBinding", + code: `out = F(n) + stream = 0:n + last = n > 0 && stream + out = [last]`, + ranges: []string{"stream"}, + }, + { + name: "AndPassesCall", + code: makeRangeTemplate + `out = F(n) + kept = n > 0 && MakeRange(3) + out = [kept]`, + ranges: []string{"kept"}, + }, + { + name: "OrPassesCalls", + code: makeRangeTemplate + `out = F(n) + kept = MakeRange(n > 0) || MakeRange(3) + out = [kept]`, + ranges: []string{"kept"}, + }, + { + name: "CallOutput", + code: makeRangeTemplate + `out = F(n) + kept = MakeRange(n) + out = [kept]`, + ranges: []string{"kept"}, + }, + { + name: "RangeOutput", + code: `out = F(n) + out = 0:n`, + ranges: []string{"out"}, + }, + { + name: "RangeInOneSlotOfACall", + code: `r, k = Two(n) + r, k = 0:n, n + +out = F(n) + kept, k = Two(n) + out = [kept] ⊕ [k]`, + ranges: []string{"kept"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + cc := NewCodeCompiler(ctx, test.name, "", mustParseCode(t, test.code)) + require.Empty(t, cc.Compile()) + + ranges := slices.Sorted(maps.Keys(cc.rangeBindings[funcKey{name: "F", arity: 1}])) + if test.ranges == nil { + require.Empty(t, ranges) + } else { + require.Equal(t, test.ranges, ranges) + } + + sc := NewScriptCompiler(ctx, t.Name(), mustParseScript(t, "v = F(5)\nv"), cc) + require.Empty(t, sc.Compile()) + }) + } +} + +func TestSettledRangeDisagreementIsInternalError(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + cc := NewCodeCompiler(ctx, t.Name(), "", mustParseCode(t, `out = F(n) + stream = 0:n + out = [stream]`)) + require.Empty(t, cc.Compile()) + delete(cc.rangeBindings[funcKey{name: "F", arity: 1}], "stream") + + ts := NewTypeSolver(NewScriptCompiler(ctx, t.Name(), mustParseScript(t, "v = F(5)\nv"), cc)) + require.PanicsWithValue(t, + fmt.Sprintf("internal: F solves %q as %s, which disagrees with its template's text range summary", "stream", Range{Iter: I64}), + ts.Solve, + ) +} diff --git a/compiler/codecompiler.go b/compiler/codecompiler.go index 7fa50411..3f22b172 100644 --- a/compiler/codecompiler.go +++ b/compiler/codecompiler.go @@ -18,6 +18,11 @@ type CodeCompiler struct { // at owned storage, so every read and every nested call it feeds see // the representation lowering stores. outputReads map[funcKey]map[string]struct{} + // rangeOutputs and rangeBindings record, per template, which outputs and + // which bindings hold a Range descriptor, as the template checks read + // them from the text. Settlement checks the solved types agree. + rangeOutputs map[funcKey][]bool + rangeBindings map[funcKey]map[string]struct{} } type funcKey struct { diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 324c09f0..f272a3ab 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -202,6 +202,7 @@ a, b` func TestRangedArrayOutputMarksIndirectWrite(t *testing.T) { code := `res = LastRow(matrix) + res = []0 i = 0:2 res = matrix[i]` script := `matrix = [ @@ -545,7 +546,8 @@ func TestRangedCallDoesNotCopyUnrelatedArrayInput(t *testing.T) { // per iteration would make the call quadratic. code := `count, value = Read(data, index) count = index - value = data[index]` + t = data[index] + value = t` script := `data = [0:8] count, value = Read(data, 0:8) count, value` @@ -1411,7 +1413,8 @@ x` func TestAffineArrayIndexStmtInFuncUsesCheckedPath(t *testing.T) { code := `out = pick(arr, i) - out = arr[i + 1] + t = arr[i + 1] + out = t ` script := `arr = [10 20 30 40 50] i = 0:4 @@ -1453,7 +1456,8 @@ x` func TestNonAffineArrayIndexStmtInFuncUsesCheckedPath(t *testing.T) { code := `out = pick_poly(arr, i) - out = arr[i * i - 2i + 3] + t = arr[i * i - 2i + 3] + out = t ` script := `arr = [10 20 30 40 50] i = 0:2 diff --git a/compiler/effects.go b/compiler/effects.go index aac2754b..7c1b8815 100644 --- a/compiler/effects.go +++ b/compiler/effects.go @@ -69,8 +69,8 @@ type TargetWriteEffect struct { } // StatementEffect contains the target facts derived for one assignment. -// ReadsSeed holds LHS indices whose existing value is consumed by a direct -// MayWrite callee output at the assignment boundary. +// ReadsSeed holds LHS indices whose existing value a direct call keeps at the +// assignment boundary when its call-owned domain may run no iteration. type StatementEffect struct { Writes []TargetWriteEffect ReadsSeed []int @@ -271,14 +271,11 @@ func (analyzer *effectAnalyzer) deriveCall(expr *ast.CallExpression) []YieldEffe info.YieldEffects = make([]YieldEffect, len(info.OutTypes)) for i, effect := range callee { - switch effect { - case MustWrite: + if effect == MustWrite { info.YieldEffects[i] = invocation - case MayWrite: - info.YieldEffects[i] = joinYield(invocation, MayYield) - default: - info.YieldEffects[i] = YieldInvalid + continue } + info.YieldEffects[i] = YieldInvalid } return info.YieldEffects @@ -361,21 +358,18 @@ func (analyzer *effectAnalyzer) callInvocationEffect(expr *ast.CallExpression) Y return effect } -func (analyzer *effectAnalyzer) seedResolvedYield(expr ast.Expression, slot int, targetExists bool, conditionRanges []*RangeInfo) (YieldEffect, bool) { +func (analyzer *effectAnalyzer) seedResolvedYield(expr ast.Expression, targetExists bool, conditionRanges []*RangeInfo) (YieldEffect, bool) { call, ok := expr.(*ast.CallExpression) if !ok || !targetExists { return YieldUncomputed, false } - // Direct-return eligibility depends only on output types. Check it before - // resolving callee effects because indirect calls cannot consume a seed. + // Every body that runs writes every output, so only a call-owned domain + // that may run no iteration leaves a direct return's seed in place. if _, direct := directScalarABIReturnType(analyzer.exprInfo(call).OutTypes); !direct { return YieldUncomputed, false } - - callee := analyzer.callBodyOutputEffects(call) - needsSeed := callee[slot] == MayWrite || analyzer.callOwnsPossiblyEmptyDomain(call, conditionRanges) - if !needsSeed { + if !analyzer.callOwnsPossiblyEmptyDomain(call, conditionRanges) { return YieldUncomputed, false } @@ -442,7 +436,7 @@ func (analyzer *effectAnalyzer) deriveLet(stmt *ast.LetStatement, defined map[st yields := analyzer.deriveExpr(expr) maySkip := len(stmt.Condition) > 0 || analyzer.expressionUsesLocalDomain(expr) - for slot, yield := range yields { + for _, yield := range yields { index := targetIndex target := stmt.Name[index] targetIndex++ @@ -451,7 +445,7 @@ func (analyzer *effectAnalyzer) deriveLet(stmt *ast.LetStatement, defined map[st } _, targetExists := defined[target.Value] - if seededYield, readsSeed := analyzer.seedResolvedYield(expr, slot, targetExists, condRanges); readsSeed { + if seededYield, readsSeed := analyzer.seedResolvedYield(expr, targetExists, condRanges); readsSeed { result.ReadsSeed = append(result.ReadsSeed, index) yield = seededYield } @@ -554,13 +548,11 @@ type specializationNode struct { mangled string effectCallees []specializationNodeID effectCallers []specializationNodeID - directCallees []string componentIndex int } -// specializationCallGraph interns the newly walked batch once. Dense effect -// edges drive SCC settlement, while complete mangled edges persist for CFG -// diagnostic replay across warm-cache scripts. +// specializationCallGraph interns the newly walked batch once. Its dense +// effect edges drive SCC settlement. type specializationCallGraph struct { nodes []specializationNode byMangled map[string]specializationNodeID @@ -581,12 +573,10 @@ func newSpecializationCallGraph(walked map[string]walkedSpecialization) *special return graph } -// collectSpecializationCallEdges returns stable unique lowering and replay -// targets plus source-order primary effect dependencies. Each direct primary -// precedes its distinct scalar companion. -func collectSpecializationCallEdges(compiler *Compiler, callerMangled string, statements []ast.Statement) ([]string, []string) { - seen := make(map[string]struct{}) - var directCallees []string +// collectSpecializationCallEdges returns a body's source-order primary effect +// dependencies, checking that each call's specializations exist, a distinct +// scalar companion included. +func collectSpecializationCallEdges(compiler *Compiler, callerMangled string, statements []ast.Statement) []string { var effectCallees []string for _, call := range collectBodyCalls(statements) { @@ -598,7 +588,6 @@ func collectSpecializationCallEdges(compiler *Compiler, callerMangled string, st primary := Mangle(compiler.MangledPath, call.Function.Value, info.CallParamTypes) requireSpecializationCallTarget(compiler, callerMangled, primary) effectCallees = append(effectCallees, primary) - directCallees = appendUniqueMangled(directCallees, seen, primary) if !info.ScalarCallVariantEnsured { continue @@ -609,19 +598,9 @@ func collectSpecializationCallEdges(compiler *Compiler, callerMangled string, st panic(fmt.Sprintf("internal: call %s in %s marks a non-distinct scalar specialization", call.Function.Value, callerMangled)) } requireSpecializationCallTarget(compiler, callerMangled, scalar) - directCallees = appendUniqueMangled(directCallees, seen, scalar) } - return directCallees, effectCallees -} - -func appendUniqueMangled(names []string, seen map[string]struct{}, mangled string) []string { - if _, exists := seen[mangled]; exists { - return names - } - - seen[mangled] = struct{}{} - return append(names, mangled) + return effectCallees } func requireSpecializationCallTarget(compiler *Compiler, callerMangled, calleeMangled string) { @@ -634,8 +613,7 @@ func (ts *TypeSolver) addSpecializationGraphEdges(graph *specializationCallGraph caller := &graph.nodes[callerID] walked := ts.walkedFuncs[caller.mangled] compiler := ts.ScriptCompiler.Compiler - directCallees, effectCallees := collectSpecializationCallEdges(compiler, caller.mangled, walked.template.Body.Statements) - caller.directCallees = directCallees + effectCallees := collectSpecializationCallEdges(compiler, caller.mangled, walked.template.Body.Statements) for _, callee := range effectCallees { if calleeID, inGraph := graph.byMangled[callee]; inGraph { @@ -776,6 +754,9 @@ func (ts *TypeSolver) deriveEffectNode(graph *specializationCallGraph, working [ if !validPublishedEffects(derived, len(walked.info.Sig.OutTypes)) { panic(fmt.Sprintf("internal: invalid effects for specialization %s", node.mangled)) } + if slices.Contains(derived, MayWrite) { + panic(fmt.Sprintf("internal: specialization %s may leave an output unwritten, which its template check rejects", node.mangled)) + } changed := false for outputIndex, effect := range derived { diff --git a/compiler/effects_test.go b/compiler/effects_test.go index ef0b8862..6871cb10 100644 --- a/compiler/effects_test.go +++ b/compiler/effects_test.go @@ -161,48 +161,32 @@ nonempty, empty`) requireTargetEffects(t, ts.ScriptCompiler.Script.Root.StatementEffects[empty], TargetWriteEffect{TargetIndex: 0, Effect: MayWrite}) } -func TestDirectCallSeedEffects(t *testing.T) { +func TestDirectCallEffects(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() cc := NewCodeCompiler(ctx, "callEffects", "", mustParseCode(t, `y = Always(x) - y = x - -y = Maybe(x) - y = x > 0 x`)) + y = x`)) require.Empty(t, cc.Compile()) - ts := solveScriptTypes(t, ctx, cc, t.Name(), `seeded = 7 -seeded = Maybe(1) -fresh = Maybe(1) -always = 4 + ts := solveScriptTypes(t, ctx, cc, t.Name(), `always = 4 always = Always(1) arr = [1] failed = 9 failed = Always(arr[2]) gated = 8 -gated = 1 > 0 Maybe(1) -seeded, always, failed, gated`) +gated = 1 > 0 Always(1) +always, failed, gated`) program := ts.ScriptCompiler.Program - seeded := program.Statements[1].(*ast.LetStatement) - fresh := program.Statements[2].(*ast.LetStatement) - always := program.Statements[4].(*ast.LetStatement) - failed := program.Statements[7].(*ast.LetStatement) - gated := program.Statements[9].(*ast.LetStatement) - - seededEffect := ts.ScriptCompiler.Script.Root.StatementEffects[seeded] - requireTargetEffects(t, seededEffect, TargetWriteEffect{TargetIndex: 0, Effect: MustWrite}) - require.Equal(t, []int{0}, seededEffect.ReadsSeed) - require.Equal(t, []YieldEffect{MayYield}, ts.ExprCache[key(ts.FuncNameMangled, seeded.Value[0])].YieldEffects) - - freshEffect := ts.ScriptCompiler.Script.Root.StatementEffects[fresh] - requireTargetEffects(t, freshEffect, TargetWriteEffect{TargetIndex: 0, Effect: MayWrite}) - require.Empty(t, freshEffect.ReadsSeed) + always := program.Statements[1].(*ast.LetStatement) + failed := program.Statements[4].(*ast.LetStatement) + gated := program.Statements[6].(*ast.LetStatement) alwaysEffect := ts.ScriptCompiler.Script.Root.StatementEffects[always] requireTargetEffects(t, alwaysEffect, TargetWriteEffect{TargetIndex: 0, Effect: MustWrite}) require.Empty(t, alwaysEffect.ReadsSeed) + require.Equal(t, []YieldEffect{MustYield}, ts.ExprCache[key(ts.FuncNameMangled, always.Value[0])].YieldEffects) failedEffect := ts.ScriptCompiler.Script.Root.StatementEffects[failed] requireTargetEffects(t, failedEffect, TargetWriteEffect{TargetIndex: 0, Effect: MayWrite}) @@ -210,62 +194,13 @@ seeded, always, failed, gated`) gatedEffect := ts.ScriptCompiler.Script.Root.StatementEffects[gated] requireTargetEffects(t, gatedEffect, TargetWriteEffect{TargetIndex: 0, Effect: MayWrite}) - require.Equal(t, []int{0}, gatedEffect.ReadsSeed) + require.Empty(t, gatedEffect.ReadsSeed) - maybeFunc := cc.Compiler.FuncCache[Mangle(cc.Compiler.MangledPath, "Maybe", []Type{I64})] alwaysFunc := cc.Compiler.FuncCache[Mangle(cc.Compiler.MangledPath, "Always", []Type{I64})] - - require.Equal(t, []WriteEffect{MayWrite}, maybeFunc.BodyOutputEffects) require.Equal(t, []WriteEffect{MustWrite}, alwaysFunc.BodyOutputEffects) - require.True(t, maybeFunc.Settled) require.True(t, alwaysFunc.Settled) } -func TestFunctionOutputIsNotInitiallyWritten(t *testing.T) { - ctx := llvm.NewContext() - defer ctx.Dispose() - - cc := NewCodeCompiler(ctx, "wrapperEffects", "", mustParseCode(t, `y = Maybe(x) - y = x > 0 x - -y = Wrap(x) - y = Maybe(x) - y = Maybe(x)`)) - require.Empty(t, cc.Compile()) - - ts := solveScriptTypes(t, ctx, cc, t.Name(), `fresh = Wrap(1) -existing = 7 -existing = Wrap(1) -fresh, existing`) - - wrap := cc.Compiler.FuncCache[Mangle(cc.Compiler.MangledPath, "Wrap", []Type{I64})] - - require.NotNil(t, wrap) - require.Equal(t, []WriteEffect{MayWrite}, wrap.BodyOutputEffects) - - template, ok := cc.lookupFuncTemplate("Wrap", 1) - require.True(t, ok) - firstBodyStmt := template.Body.Statements[0].(*ast.LetStatement) - firstBodyEffect := wrap.StatementEffects[firstBodyStmt] - requireTargetEffects(t, firstBodyEffect, TargetWriteEffect{TargetIndex: 0, Effect: MayWrite}) - require.Empty(t, firstBodyEffect.ReadsSeed) - - secondBodyStmt := template.Body.Statements[1].(*ast.LetStatement) - secondBodyEffect := wrap.StatementEffects[secondBodyStmt] - requireTargetEffects(t, secondBodyEffect, TargetWriteEffect{TargetIndex: 0, Effect: MustWrite}) - require.Equal(t, []int{0}, secondBodyEffect.ReadsSeed) - - fresh := ts.ScriptCompiler.Program.Statements[0].(*ast.LetStatement) - freshEffect := ts.ScriptCompiler.Script.Root.StatementEffects[fresh] - requireTargetEffects(t, freshEffect, TargetWriteEffect{TargetIndex: 0, Effect: MayWrite}) - require.Empty(t, freshEffect.ReadsSeed) - - existing := ts.ScriptCompiler.Program.Statements[2].(*ast.LetStatement) - existingEffect := ts.ScriptCompiler.Script.Root.StatementEffects[existing] - requireTargetEffects(t, existingEffect, TargetWriteEffect{TargetIndex: 0, Effect: MustWrite}) - require.Equal(t, []int{0}, existingEffect.ReadsSeed) -} - func TestCallDomainComposesWithBodyOutputEffects(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() @@ -339,60 +274,6 @@ existing`) } } -func TestConditionalBodyRemainsMayWriteAcrossNonemptyDomain(t *testing.T) { - ctx := llvm.NewContext() - defer ctx.Dispose() - - cc := NewCodeCompiler(ctx, "conditionalDomainEffects", "", mustParseCode(t, `y = ConditionalSquare(x) - y = x > 5 x * x`)) - require.Empty(t, cc.Compile()) - - ts := solveScriptTypes(t, ctx, cc, t.Name(), `fresh = ConditionalSquare(0:5) -existing = 9 -existing = ConditionalSquare(0:5) -fresh, existing`) - - mangled := Mangle(cc.Compiler.MangledPath, "ConditionalSquare", []Type{Range{Iter: I64}}) - conditional := cc.Compiler.FuncCache[mangled] - - require.NotNil(t, conditional) - require.Equal(t, []WriteEffect{MayWrite}, conditional.BodyOutputEffects) - - fresh := ts.ScriptCompiler.Program.Statements[0].(*ast.LetStatement) - freshEffect := ts.ScriptCompiler.Script.Root.StatementEffects[fresh] - requireTargetEffects(t, freshEffect, TargetWriteEffect{TargetIndex: 0, Effect: MayWrite}) - require.Empty(t, freshEffect.ReadsSeed) - - existing := ts.ScriptCompiler.Program.Statements[2].(*ast.LetStatement) - existingEffect := ts.ScriptCompiler.Script.Root.StatementEffects[existing] - requireTargetEffects(t, existingEffect, TargetWriteEffect{TargetIndex: 0, Effect: MustWrite}) - require.Equal(t, []int{0}, existingEffect.ReadsSeed) -} - -func TestRecursiveBodyOutputEffectsConvergeAcrossSCC(t *testing.T) { - ctx := llvm.NewContext() - defer ctx.Dispose() - - cc := NewCodeCompiler(ctx, "recursiveEffects", "", mustParseCode(t, `y = A(n) - y = B(n) - -y = B(n) - y = n > 0 A(n - 1) - y = n == 0 "done"`)) - require.Empty(t, cc.Compile()) - - solveScriptTypes(t, ctx, cc, t.Name(), `result = A(2) -result`) - - a := cc.Compiler.FuncCache[Mangle(cc.Compiler.MangledPath, "A", []Type{I64})] - b := cc.Compiler.FuncCache[Mangle(cc.Compiler.MangledPath, "B", []Type{I64})] - - require.Equal(t, []WriteEffect{MayWrite}, a.BodyOutputEffects) - require.Equal(t, []WriteEffect{MayWrite}, b.BodyOutputEffects) - require.True(t, a.Settled) - require.True(t, b.Settled) -} - func TestCallGraphWalkOrderAndEdgeViews(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() @@ -400,7 +281,7 @@ func TestCallGraphWalkOrderAndEdgeViews(t *testing.T) { cc := NewCodeCompiler(ctx, "effectGraphIDs", "", mustParseCode(t, `y = Z(x) y = B(x) C(x) - y = B(x) + y = y + B(x) y = B(x) y = C(x) @@ -435,9 +316,6 @@ result`) require.Equal(t, []specializationNodeID{cID}, graph.nodes[bID].effectCallees) require.Equal(t, []specializationNodeID{zID, bID}, graph.nodes[cID].effectCallers) require.Equal(t, [][]specializationNodeID{{cID}, {bID}, {zID}}, graph.calleeFirstComponents()) - require.Equal(t, []string{walkOrder[1], walkOrder[2]}, graph.nodes[zID].directCallees) - require.Equal(t, []string{walkOrder[2]}, graph.nodes[bID].directCallees) - require.Empty(t, graph.nodes[cID].directCallees) } func TestCallGraphExcludesScalarEffectEdge(t *testing.T) { @@ -474,8 +352,6 @@ result`) require.True(t, primaryInBatch) require.True(t, scalarInBatch) require.Equal(t, []specializationNodeID{primaryID}, graph.nodes[gatherID].effectCallees) - require.Equal(t, []string{primaryMangled, scalarMangled}, graph.nodes[gatherID].directCallees) - require.Equal(t, []string{primaryMangled, scalarMangled}, cc.Compiler.FuncCache[gatherMangled].CFGResult.DirectCallees) } func TestScriptEffectsRejectInvalidExpressionFacts(t *testing.T) { @@ -497,15 +373,15 @@ value`) ) } -func TestMayWriteCallPreservesInvalidInvocationEffect(t *testing.T) { +func TestCallPreservesInvalidInvocationEffect(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() - cc := NewCodeCompiler(ctx, "invalidCallEffects", "", mustParseCode(t, `y = Maybe(x) - y = x > 0 x`)) + cc := NewCodeCompiler(ctx, "invalidCallEffects", "", mustParseCode(t, `y = Always(x) + y = x`)) require.Empty(t, cc.Compile()) - ts := solveScriptTypes(t, ctx, cc, t.Name(), `value = Maybe(1 + 1) + ts := solveScriptTypes(t, ctx, cc, t.Name(), `value = Always(1 + 1) value`) stmt := ts.ScriptCompiler.Program.Statements[0].(*ast.LetStatement) diff --git a/compiler/scriptcompiler.go b/compiler/scriptcompiler.go index dd7960f3..d2a9b7f1 100644 --- a/compiler/scriptcompiler.go +++ b/compiler/scriptcompiler.go @@ -24,13 +24,6 @@ type Script struct { Root *FuncInfo } -type cfgDiagnosticKey struct { - fileName string - line int - column int - message string -} - func NewScriptCompiler(ctx llvm.Context, name string, program *ast.Program, cc *CodeCompiler) *ScriptCompiler { compiler := NewCompiler(ctx, cc.Compiler.MangledPath, cc) script := &Script{ @@ -62,8 +55,6 @@ func (sc *ScriptCompiler) Compile() []*token.CompileError { cfg := NewCFG(sc.Compiler.CodeCompiler) cfg.AnalyzeScript(sc.Program.Statements, sc.Script.Root.StatementEffects) - directCallees, _ := collectSpecializationCallEdges(sc.Compiler, sc.ScriptMangled, sc.Program.Statements) - cfg.Errors = replaySpecializationCFG(sc.Compiler, directCallees, cfg.Errors) if len(cfg.Errors) > 0 { return cfg.Errors } @@ -102,55 +93,3 @@ func (sc *ScriptCompiler) compileStatements() { c.compileStatement(stmt) } } - -// replaySpecializationCFG reports the settled dataflow diagnostics of every -// specialization the script reaches, root-first and depth-first in source -// order, deduplicated by location and message. A body is analyzed once per -// type specialization at settlement, independent of its callers: sharing an -// input with an output only adds reads, so it can never make a body invalid, -// and a body must be valid without it. -func replaySpecializationCFG(compiler *Compiler, roots []string, errors []*token.CompileError) []*token.CompileError { - visited := make(map[string]struct{}) - reported := make(map[cfgDiagnosticKey]struct{}, len(errors)) - for _, compileError := range errors { - reported[cfgDiagnosticKeyFor(compileError)] = struct{}{} - } - - for _, mangled := range roots { - errors = replaySpecializationCFGNode(compiler, mangled, visited, reported, errors) - } - - return errors -} - -func replaySpecializationCFGNode(compiler *Compiler, mangled string, visited map[string]struct{}, reported map[cfgDiagnosticKey]struct{}, errors []*token.CompileError) []*token.CompileError { - if _, seen := visited[mangled]; seen { - return errors - } - visited[mangled] = struct{}{} - - info := compiler.FuncCache[mangled] - for _, compileError := range info.CFGResult.Errors { - key := cfgDiagnosticKeyFor(compileError) - if _, seen := reported[key]; seen { - continue - } - - reported[key] = struct{}{} - errors = append(errors, compileError) - } - for _, callee := range info.CFGResult.DirectCallees { - errors = replaySpecializationCFGNode(compiler, callee, visited, reported, errors) - } - - return errors -} - -func cfgDiagnosticKeyFor(compileError *token.CompileError) cfgDiagnosticKey { - return cfgDiagnosticKey{ - fileName: compileError.Token.FileName, - line: compileError.Token.Line, - column: compileError.Token.Column, - message: compileError.Msg, - } -} diff --git a/compiler/solver.go b/compiler/solver.go index 73285be6..e5e90bbd 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -2665,24 +2665,15 @@ func (ts *TypeSolver) TypeScriptFunc(mangled string, template *ast.FuncStatement } // settleSpecializationBatch publishes reusable analysis facts atomically with -// respect to Settled: every CFG result is staged and installed before any -// specialization in the batch becomes visible as settled. +// respect to Settled: every specialization in the batch is checked against +// its template's text range summary before any becomes visible as settled. func (ts *TypeSolver) settleSpecializationBatch(graph *specializationCallGraph) { ts.settleEffects(graph) - staged := make([]*SpecializationCFGResult, len(graph.nodes)) + cc := ts.ScriptCompiler.Compiler.CodeCompiler - for id, node := range graph.nodes { + for _, node := range graph.nodes { walked := ts.walkedFuncs[node.mangled] - cfg := NewCFG(ts.ScriptCompiler.Compiler.CodeCompiler) - cfg.AnalyzeSpecialization(walked.template, walked.info) - staged[id] = &SpecializationCFGResult{ - DirectCallees: slices.Clone(node.directCallees), - Errors: slices.Clone(cfg.Errors), - } - } - - for id, node := range graph.nodes { - ts.walkedFuncs[node.mangled].info.CFGResult = staged[id] + cc.checkSettledRanges(walked.template, walked.info) } for _, node := range graph.nodes { @@ -2695,10 +2686,6 @@ func (ts *TypeSolver) settleSpecializationBatch(graph *specializationCallGraph) func (ts *TypeSolver) TypeFunc(mangled string, template *ast.FuncStatement) bool { f := ts.ScriptCompiler.Compiler.FuncCache[mangled] if f.Settled { - if f.CFGResult == nil { - panic(fmt.Sprintf("internal: settled specialization %s has no CFG result", mangled)) - } - return true } if _, ok := ts.walkedFuncs[mangled]; ok { diff --git a/compiler/solver_test.go b/compiler/solver_test.go index a2b6d697..545f71e3 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -34,16 +34,20 @@ func numberedNames(prefix string, count int) string { func TestMutualRecursion(t *testing.T) { codeStr := `# define isEven: returns (x, y) = (is-even?, is-odd?) x, y = isEven(n) - # recursive step: if n≠0, flip the pair returned by isOdd(n-1) - x, y = n != 0 isOdd(n - 1) + # recursive step: if n≠0, flip the pair returned by isOdd(n-1); a + # skipped step leaves the locals empty, and every output is still written + odd, even = n != 0 isOdd(n - 1) + x, y = odd, even # base case: 0 is even, not odd x = n == 1 "no" x = n == 0 "yes" # define isOdd: returns (x, y) = (is-odd?, is-even?) +# this function infers x only through isEven x, y = isOdd(n) # recursive step: if n≠0, flip the pair returned by isEven(n-1) - x, y = n != 0 isEven(n - 1) + even, odd = n != 0 isEven(n - 1) + x, y = even, odd # base case: 0 is not odd, but even y = n == 1 "no" y = n == 0 "yes"` @@ -110,8 +114,8 @@ x, y` require.False(t, ts.Converging) require.True(t, isEvenFunc.Settled) require.True(t, isOddFunc.Settled) - require.Equal(t, []WriteEffect{MayWrite, MayWrite}, isEvenFunc.BodyOutputEffects) - require.Equal(t, []WriteEffect{MayWrite, MayWrite}, isOddFunc.BodyOutputEffects) + require.Equal(t, []WriteEffect{MustWrite, MustWrite}, isEvenFunc.BodyOutputEffects) + require.Equal(t, []WriteEffect{MustWrite, MustWrite}, isOddFunc.BodyOutputEffects) ts.Solve() require.Empty(t, ts.Errors) @@ -764,7 +768,7 @@ out, before = Fold(current, item) require.True(t, wrapper.Settled) require.True(t, IsStrH(wrapper.Vars["current"])) require.True(t, IsStrH(wrapper.Sig.OutTypes[1])) - require.Equal(t, []string{foldKey}, wrapper.CFGResult.DirectCallees) + require.Contains(t, cc.Compiler.FuncCache, foldKey) info := ts.ExprCache[key(wrapperKey, call)] require.True(t, IsStrH(info.CallParamTypes[0])) require.True(t, IsStrH(info.OutTypes[1])) @@ -832,7 +836,7 @@ func TestMergeBindingSlotTypeIsMonotonic(t *testing.T) { func TestFunctionOutputBindingRejectsIncompatibleReassignment(t *testing.T) { code := mustParseCode(t, `res = Bad(k) - res = k == 0 1 + res = 1 res = k != 0 "later" `) ctx := llvm.NewContext() @@ -1580,7 +1584,7 @@ func closureLeafWalks(t *testing.T, depth, callSites int) int { for i := range depth { fmt.Fprintf(&b, "res = F%d(k)\n a = F%d(k)\n b = F%d(k + 1)\n res = a + b\n\n", i, i+1, i+1) } - fmt.Fprintf(&b, "res = F%d(k)\n i = 0:2\n res = k + i\n", depth) + fmt.Fprintf(&b, "res = F%d(k)\n res = k + (0:2)\n", depth) l := lexer.New("TestFuncClosureCode", b.String()) cp := parser.NewCodeParser(l) @@ -1976,13 +1980,8 @@ scaled`) primaryMangled := Mangle(cc.Compiler.MangledPath, "Scale", callInfo.CallParamTypes) scalarMangled := Mangle(cc.Compiler.MangledPath, "Scale", []Type{I64}) require.True(t, callInfo.ScalarCallVariantEnsured) - directCallees, _ := collectSpecializationCallEdges(sc.Compiler, sc.ScriptMangled, program.Statements) - require.Equal(t, []string{primaryMangled, scalarMangled}, directCallees) - callInfo.ScalarCallVariantEnsured = false - directCallees, _ = collectSpecializationCallEdges(sc.Compiler, sc.ScriptMangled, program.Statements) - require.Equal(t, []string{primaryMangled}, directCallees, - "a scalar key already present in the shared cache must not create an edge without a call-local ensured fact") - callInfo.ScalarCallVariantEnsured = true + require.Equal(t, []string{primaryMangled}, collectSpecializationCallEdges(sc.Compiler, sc.ScriptMangled, program.Statements), + "a scalar companion is checked but is not an effect dependency") require.Contains(t, cc.Compiler.FuncCache, primaryMangled) require.Contains(t, cc.Compiler.FuncCache, scalarMangled) template, ok := cc.lookupFuncTemplate("Scale", 1) @@ -1993,84 +1992,6 @@ scaled`) "diagnostic frames must retain the actual specialization key when body parameter types collapse") } -func TestCFGDiagnosticsDoNotFailSolver(t *testing.T) { - code := mustParseCode(t, `result = Noisy(x) - unused = x - result = x -`) - - ctx := llvm.NewContext() - defer ctx.Dispose() - cc := NewCodeCompiler(ctx, "specializationCFGDiagnostics", "", code) - require.Empty(t, cc.Compile()) - - ts := solveScriptTypes(t, ctx, cc, t.Name(), "value = Noisy(1)\nvalue") - mangled := Mangle(cc.Compiler.MangledPath, "Noisy", []Type{I64}) - info := cc.Compiler.FuncCache[mangled] - - require.Empty(t, ts.Errors, "function CFG diagnostics must not become type-solver failures") - require.True(t, info.Settled) - require.NotNil(t, info.CFGResult) - require.Len(t, info.CFGResult.Errors, 1) - require.Contains(t, info.CFGResult.Errors[0].Msg, `"unused"`) -} - -func TestCFGRecordsSettledDirectCallee(t *testing.T) { - code := mustParseCode(t, `result = Leaf(x) - result = x - -result = Wrapper(x) - result = Leaf(x) -`) - - ctx := llvm.NewContext() - defer ctx.Dispose() - cc := NewCodeCompiler(ctx, "settledCFGEdge", "", code) - require.Empty(t, cc.Compile()) - - solveScriptTypes(t, ctx, cc, t.Name()+"Leaf", "value = Leaf(1)\nvalue") - leafMangled := Mangle(cc.Compiler.MangledPath, "Leaf", []Type{I64}) - leaf := cc.Compiler.FuncCache[leafMangled] - require.True(t, leaf.Settled) - require.NotNil(t, leaf.CFGResult) - require.Empty(t, leaf.CFGResult.Errors) - require.Empty(t, leaf.CFGResult.DirectCallees) - - solveScriptTypes(t, ctx, cc, t.Name()+"Wrapper", "value = Wrapper(1)\nvalue") - wrapperMangled := Mangle(cc.Compiler.MangledPath, "Wrapper", []Type{I64}) - wrapper := cc.Compiler.FuncCache[wrapperMangled] - - require.True(t, wrapper.Settled) - require.NotNil(t, wrapper.CFGResult) - require.Equal(t, []string{leafMangled}, wrapper.CFGResult.DirectCallees) -} - -func TestSettledSpecializationRequiresCFG(t *testing.T) { - code := mustParseCode(t, `result = Leaf(x) - result = x - -result = Wrapper(x) - result = Leaf(x) -`) - - ctx := llvm.NewContext() - defer ctx.Dispose() - cc := NewCodeCompiler(ctx, "missingSettledCFG", "", code) - require.Empty(t, cc.Compile()) - - solveScriptTypes(t, ctx, cc, t.Name()+"Leaf", "value = Leaf(1)\nvalue") - leafMangled := Mangle(cc.Compiler.MangledPath, "Leaf", []Type{I64}) - cc.Compiler.FuncCache[leafMangled].CFGResult = nil - - program := mustParseScript(t, "value = Wrapper(1)\nvalue") - sc := NewScriptCompiler(ctx, t.Name()+"Wrapper", program, cc) - ts := NewTypeSolver(sc) - require.PanicsWithValue(t, - "internal: settled specialization "+leafMangled+" has no CFG result", - ts.Solve, - ) -} - func TestNonConvergingCalleeIsBlamed(t *testing.T) { codeStr := `y = m(x) y = bad(x) @@ -2209,7 +2130,8 @@ p = Keep(q, n) 1 prior ] - z = tab.a[0]`, + t = tab.a[0] + z = t`, script: "half, first = Mixed(2)\nhalf, first", want: map[string]Type{"half": F64, "first": F64}, }, @@ -2221,7 +2143,8 @@ p = Keep(q, n) : a b prior x ] - y = tab.b[0]`, + t = tab.b[0] + y = t`, script: "v = Pick(2, 7)\nv", want: map[string]Type{"v": I64}, }, @@ -2276,7 +2199,8 @@ p = Keep(q, n) prior ] col = x > 3 && tab.a || [x] - y = col[0] + 1`, + t = col[0] + 1 + y = t`, script: "v = Pad(2, 5)\nv", want: map[string]Type{"v": I64}, }, @@ -2289,7 +2213,8 @@ p = Keep(q, n) prior ] col = x > 3 && [x] || tab.a - y = col[0] + 1`, + t = col[0] + 1 + y = t`, script: "v = PadRight(2, 5)\nv", want: map[string]Type{"v": I64}, }, @@ -2336,7 +2261,8 @@ func TestNonConvergingCalleeIsBlamedThroughCheckedUse(t *testing.T) { defer ctx.Dispose() cc := NewCodeCompiler(ctx, "test", "", mustParseCode(t, `y = m(x) - y = bad(x)[0] + t = bad(x)[0] + y = t y = bad(x) y = bad(x - 1)`)) @@ -2347,7 +2273,7 @@ y = bad(x) require.Len(t, ts.Errors, 1) require.Contains(t, ts.Errors[0].Msg, "Function bad is not converging") - require.Equal(t, 4, ts.Errors[0].Token.Line, "must point at bad's definition, not the index in m") + require.Equal(t, 5, ts.Errors[0].Token.Line, "must point at bad's definition, not the index in m") } // A check that waited for a recursive result still runs once the result is @@ -2384,7 +2310,7 @@ func TestRecursiveResultChecksRunOnceTyped(t *testing.T) { code: `y = R(n) y = n prior = n > 0 R(n - 1) - y = prior.age`, + y = y + prior.age`, script: "v = R(3)\nv", want: "field access expects a struct or table value, got I64", }, diff --git a/compiler/types.go b/compiler/types.go index b902d3a0..21602236 100644 --- a/compiler/types.go +++ b/compiler/types.go @@ -255,18 +255,8 @@ func (f Func) OutputTypesInferred() bool { return true } -// SpecializationCFGResult is the dataflow result and persistent direct-call -// reachability for one settled function specialization. Errors is the -// specialization's diagnostics, produced once at settlement and replayed by -// every script that reaches it. -type SpecializationCFGResult struct { - DirectCallees []string - Errors []*token.CompileError -} - // FuncInfo holds the mutable facts for one function specialization. Settled -// means all reusable type, effect, and CFG facts have been published; a settled -// function specialization always has a non-nil CFG result. +// means its reusable type and effect facts have been published. type FuncInfo struct { Sig Func Vars map[string]Type @@ -274,7 +264,6 @@ type FuncInfo struct { // BodyOutputEffects summarizes the typed scalar body before a call-owned // Range or ArrayRange domain determines whether that body executes. BodyOutputEffects []WriteEffect - CFGResult *SpecializationCFGResult Settled bool } diff --git a/llvm_pipeline_test.go b/llvm_pipeline_test.go index 654fe81a..168d1239 100644 --- a/llvm_pipeline_test.go +++ b/llvm_pipeline_test.go @@ -344,7 +344,7 @@ y = Fib(n) y = FibAux(n, 0, 1) y = FibAux(n, a, b) - y = n == 0 a + y = a y = n != 0 FibAux(n - 1, b, a + b) `, "main.spt": ` diff --git a/tests/math/dependent_range.pt b/tests/math/dependent_range.pt index 8b55db42..dd15339e 100644 --- a/tests/math/dependent_range.pt +++ b/tests/math/dependent_range.pt @@ -2,3 +2,13 @@ res = UpperTriRowTail(a, n, i) j = (i + 1):n t = a[i * n + j] res = t + +# A range that arrives through a call may be empty, so the output starts from +# prev, and an empty range keeps the caller's value. +r = MakeRange(n) + r = 0:n + +out = DoubleLast(prev, n) + r = MakeRange(n) + out = prev + out = r * 2 diff --git a/tests/math/dependent_range_fn.exp b/tests/math/dependent_range_fn.exp index 04b230ab..b28c6029 100644 --- a/tests/math/dependent_range_fn.exp +++ b/tests/math/dependent_range_fn.exp @@ -1 +1,2 @@ UpperTriRows: 4 8 12 +DoubleLast: 7 4 diff --git a/tests/math/dependent_range_fn.spt b/tests/math/dependent_range_fn.spt index 3bf080cb..111217a1 100644 --- a/tests/math/dependent_range_fn.spt +++ b/tests/math/dependent_range_fn.spt @@ -6,3 +6,8 @@ r1 = UpperTriRowTail(a, n, 1) r2 = UpperTriRowTail(a, n, 2) "UpperTriRows: -r0 -r1 -r2" + +kept = 7 +kept = DoubleLast(kept, 0) +doubled = DoubleLast(0, 3) +"DoubleLast: -kept -doubled" diff --git a/tests/math/func.exp b/tests/math/func.exp index cf452f67..235532b5 100644 --- a/tests/math/func.exp +++ b/tests/math/func.exp @@ -39,3 +39,6 @@ SumSquaresRangeBothTrue: 18 SumSquaresRangeAllFalse: 0 SumSquaresRangeKeepPrev: 77 SumSquaresRangeTailFalse: 8 +PosKeep: 9 +PosWrite: 5 +PosMask: [1 0 3] diff --git a/tests/math/func.spt b/tests/math/func.spt index 1ee1403e..d401fca1 100644 --- a/tests/math/func.spt +++ b/tests/math/func.spt @@ -107,3 +107,12 @@ sumRangeTailFalse = SumSquares(m > 1, m < 3) "SumSquaresRangeAllFalse: -sumRangeAllFalse" "SumSquaresRangeKeepPrev: -sumRangeKeepPrev" "SumSquaresRangeTailFalse: -sumRangeTailFalse" + +# The comparison counts as possibly skipping for every argument type, so the +# default before it stays, and a failed scalar comparison keeps it. +posKeep = Pos(9, -1) +posWrite = Pos(9, 5) +posMask = Pos([]0, [1 -2 3]) +"PosKeep: -posKeep" +"PosWrite: -posWrite" +"PosMask: -posMask" diff --git a/tests/math/math.pt b/tests/math/math.pt index 318efbb7..06fb8e57 100644 --- a/tests/math/math.pt +++ b/tests/math/math.pt @@ -30,3 +30,9 @@ x, y = F(i) a, b = G(i) a, b = 2.3 * i, 5.6 * i + +# One body serves scalar and array arguments: a scalar comparison can fail +# and keep prev, while an array comparison is a mask that always writes. +out = Pos(prev, x) + out = prev + out = x > 0 From baf21694b105ea3a59482c664dcf7145e797ae92 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 7 Oct 2026 13:23:41 +0530 Subject: [PATCH 03/34] refactor(compiler): drop the body-effect fixed point Every body that runs now writes every output, so a specialization's BodyOutputEffects summary could only be MustWrite, and the fixed point that settled it across the call graph's strongly connected components had nothing left to weaken. A call's slots now yield exactly when the call is invoked: its arguments' yields, joined with MayYield when a range that may run no iteration drives it. Removed: BodyOutputEffects and its publication, the specialization call graph with its Tarjan components and worklist, per-specialization statement effects, WriteUncomputed, and the walk index the graph keyed on. Statement effects are derived for the script root only, in one pass. Settlement checks each walked specialization against its template's text range summary, then marks it settled. Co-Authored-By: Claude Opus 5.5 --- compiler/effects.go | 423 ++------------------------------------- compiler/effects_test.go | 98 +-------- compiler/solver.go | 36 ++-- compiler/solver_test.go | 6 - compiler/types.go | 10 +- 5 files changed, 35 insertions(+), 538 deletions(-) diff --git a/compiler/effects.go b/compiler/effects.go index 7c1b8815..8c297396 100644 --- a/compiler/effects.go +++ b/compiler/effects.go @@ -8,20 +8,18 @@ import ( ) // WriteEffect describes whether one named target is guaranteed to receive a -// value. Uncomputed and Invalid are publication states, not lattice members. +// value. Invalid marks a target whose facts could not be derived; it is not a +// lattice member. type WriteEffect uint8 const ( - WriteUncomputed WriteEffect = iota - WriteInvalid + WriteInvalid WriteEffect = iota MustWrite MayWrite ) func (effect WriteEffect) String() string { switch effect { - case WriteUncomputed: - return "Uncomputed" case WriteInvalid: return "Invalid" case MustWrite: @@ -76,20 +74,6 @@ type StatementEffect struct { ReadsSeed []int } -func validPublishedEffects(effects []WriteEffect, count int) bool { - if len(effects) != count { - return false - } - - for _, effect := range effects { - if effect != MustWrite && effect != MayWrite { - return false - } - } - - return true -} - func joinYield(left, right YieldEffect) YieldEffect { if left == YieldInvalid || right == YieldInvalid { return YieldInvalid @@ -103,8 +87,8 @@ func joinYield(left, right YieldEffect) YieldEffect { return MustYield } -// classifyWriteEffect keeps analysis states outside the yield lattice invalid -// so function publication cannot turn missing facts into MayWrite. +// classifyWriteEffect keeps analysis states outside the yield lattice invalid, +// so missing facts never pass as MayWrite. func classifyWriteEffect(yield YieldEffect, maySkip bool) WriteEffect { if yield != MustYield && yield != MayYield { return WriteInvalid @@ -120,16 +104,12 @@ func classifyWriteEffect(yield YieldEffect, maySkip bool) WriteEffect { type effectAnalyzer struct { compiler *Compiler funcNameMangled string - graph *specializationCallGraph - working [][]WriteEffect } -func newEffectAnalyzer(compiler *Compiler, mangled string, graph *specializationCallGraph, working [][]WriteEffect) *effectAnalyzer { +func newEffectAnalyzer(compiler *Compiler, mangled string) *effectAnalyzer { return &effectAnalyzer{ compiler: compiler, funcNameMangled: mangled, - graph: graph, - working: working, } } @@ -256,6 +236,9 @@ func yieldSlot(effects []YieldEffect, index int) YieldEffect { return effects[index] } +// deriveCall yields every output of a call that is invoked, since every body +// that runs writes every output. Its arguments can still fail, and a range +// that may run no iteration can keep it from being invoked. func (analyzer *effectAnalyzer) deriveCall(expr *ast.CallExpression) []YieldEffect { info := analyzer.exprInfo(expr) invocation := analyzer.callInvocationEffect(expr) @@ -263,44 +246,10 @@ func (analyzer *effectAnalyzer) deriveCall(expr *ast.CallExpression) []YieldEffe invocation = joinYield(invocation, MayYield) } - callee := analyzer.callBodyOutputEffects(expr) - if len(callee) != len(info.OutTypes) { - panic(fmt.Sprintf("internal: call %s has %d output effects for %d typed outputs", expr.Function.Value, len(callee), len(info.OutTypes))) - } - - info.YieldEffects = make([]YieldEffect, len(info.OutTypes)) - - for i, effect := range callee { - if effect == MustWrite { - info.YieldEffects[i] = invocation - continue - } - info.YieldEffects[i] = YieldInvalid - } - + info.YieldEffects = slices.Repeat([]YieldEffect{invocation}, len(info.OutTypes)) return info.YieldEffects } -func (analyzer *effectAnalyzer) callBodyOutputEffects(expr *ast.CallExpression) []WriteEffect { - info := analyzer.exprInfo(expr) - mangled := Mangle(analyzer.compiler.MangledPath, expr.Function.Value, info.CallParamTypes) - f := analyzer.compiler.FuncCache[mangled] - if f.Settled { - return f.BodyOutputEffects - } - - if analyzer.graph == nil { - panic(fmt.Sprintf("internal: unsettled callee %s outside effect settlement", mangled)) - } - - id, ok := analyzer.graph.byMangled[mangled] - if !ok { - panic(fmt.Sprintf("internal: unsettled callee %s missing from effect graph", mangled)) - } - - return analyzer.working[id] -} - // hasPossiblyEmptyRange ignores named drivers already owned by an enclosing // domain, such as a statement condition around a call. func hasPossiblyEmptyRange(ranges, excluded []*RangeInfo) bool { @@ -394,13 +343,8 @@ func (analyzer *effectAnalyzer) callOwnsPossiblyEmptyDomain(call *ast.CallExpres return false } -func (analyzer *effectAnalyzer) deriveStatements(statements []ast.Statement, initiallyDefined map[string]struct{}) map[*ast.LetStatement]StatementEffect { - defined := make(map[string]struct{}, len(initiallyDefined)) - - for name := range initiallyDefined { - defined[name] = struct{}{} - } - +func (analyzer *effectAnalyzer) deriveStatements(statements []ast.Statement) map[*ast.LetStatement]StatementEffect { + defined := make(map[string]struct{}) results := make(map[*ast.LetStatement]StatementEffect) for _, statement := range statements { @@ -495,349 +439,10 @@ func validStatementEffect(stmt *ast.LetStatement, effect StatementEffect) bool { return true } -func deriveBodyOutputEffects(template *ast.FuncStatement, statements map[*ast.LetStatement]StatementEffect) []WriteEffect { - effects := make([]WriteEffect, len(template.Outputs)) - - for i := range effects { - effects[i] = MayWrite - } - - outputIndex := make(map[string]int, len(template.Outputs)) - - for i, output := range template.Outputs { - outputIndex[output.Value] = i - } - - for _, statement := range template.Body.Statements { - stmt, ok := statement.(*ast.LetStatement) - if !ok { - continue - } - - statementEffect, exists := statements[stmt] - if !exists || !validStatementEffect(stmt, statementEffect) { - return slices.Repeat([]WriteEffect{WriteInvalid}, len(template.Outputs)) - } - - for name := range definiteTargets(stmt, statementEffect) { - if index, isOutput := outputIndex[name]; isOutput { - effects[index] = MustWrite - } - } - } - - return effects -} - -// definiteTargets is the set of targets a statement leaves holding its own -// value on every path: unconditional writes that do not merely preserve the -// target's seed. -func definiteTargets(stmt *ast.LetStatement, effect StatementEffect) map[string]struct{} { - targets := make(map[string]struct{}, len(effect.Writes)) - for _, write := range effect.Writes { - if write.Effect == MustWrite && !slices.Contains(effect.ReadsSeed, write.TargetIndex) { - targets[stmt.Name[write.TargetIndex].Value] = struct{}{} - } - } - return targets -} - -type specializationNodeID int - -type specializationNode struct { - mangled string - effectCallees []specializationNodeID - effectCallers []specializationNodeID - componentIndex int -} - -// specializationCallGraph interns the newly walked batch once. Its dense -// effect edges drive SCC settlement. -type specializationCallGraph struct { - nodes []specializationNode - byMangled map[string]specializationNodeID -} - -func newSpecializationCallGraph(walked map[string]walkedSpecialization) *specializationCallGraph { - graph := &specializationCallGraph{ - nodes: make([]specializationNode, len(walked)), - byMangled: make(map[string]specializationNodeID, len(walked)), - } - - for mangled, walkedFunc := range walked { - id := specializationNodeID(walkedFunc.walkIndex) - graph.byMangled[mangled] = id - graph.nodes[id] = specializationNode{mangled: mangled} - } - - return graph -} - -// collectSpecializationCallEdges returns a body's source-order primary effect -// dependencies, checking that each call's specializations exist, a distinct -// scalar companion included. -func collectSpecializationCallEdges(compiler *Compiler, callerMangled string, statements []ast.Statement) []string { - var effectCallees []string - - for _, call := range collectBodyCalls(statements) { - if _, builtin := Builtins[call.Function.Value]; builtin { - continue - } - - info := compiler.ExprCache[key(callerMangled, call)] - primary := Mangle(compiler.MangledPath, call.Function.Value, info.CallParamTypes) - requireSpecializationCallTarget(compiler, callerMangled, primary) - effectCallees = append(effectCallees, primary) - - if !info.ScalarCallVariantEnsured { - continue - } - - scalar := Mangle(compiler.MangledPath, call.Function.Value, info.ScalarCallParamTypes) - if scalar == primary { - panic(fmt.Sprintf("internal: call %s in %s marks a non-distinct scalar specialization", call.Function.Value, callerMangled)) - } - requireSpecializationCallTarget(compiler, callerMangled, scalar) - } - - return effectCallees -} - -func requireSpecializationCallTarget(compiler *Compiler, callerMangled, calleeMangled string) { - if compiler.FuncCache[calleeMangled] == nil { - panic(fmt.Sprintf("internal: typed call from %s targets missing specialization %s", callerMangled, calleeMangled)) - } -} - -func (ts *TypeSolver) addSpecializationGraphEdges(graph *specializationCallGraph, callerID specializationNodeID) { - caller := &graph.nodes[callerID] - walked := ts.walkedFuncs[caller.mangled] - compiler := ts.ScriptCompiler.Compiler - effectCallees := collectSpecializationCallEdges(compiler, caller.mangled, walked.template.Body.Statements) - - for _, callee := range effectCallees { - if calleeID, inGraph := graph.byMangled[callee]; inGraph { - caller.effectCallees = append(caller.effectCallees, calleeID) - } - } - - slices.Sort(caller.effectCallees) - caller.effectCallees = slices.Compact(caller.effectCallees) - - for _, calleeID := range caller.effectCallees { - graph.nodes[calleeID].effectCallers = append(graph.nodes[calleeID].effectCallers, callerID) - } -} - -func (ts *TypeSolver) buildSpecializationCallGraph() *specializationCallGraph { - graph := newSpecializationCallGraph(ts.walkedFuncs) - - for id := range graph.nodes { - ts.addSpecializationGraphEdges(graph, specializationNodeID(id)) - } - - return graph -} - -func collectBodyCalls(statements []ast.Statement) []*ast.CallExpression { - var calls []*ast.CallExpression - - for _, statement := range statements { - switch stmt := statement.(type) { - case *ast.LetStatement: - for _, condition := range stmt.Condition { - calls = append(calls, collectExprCalls(condition)...) - } - - for _, value := range stmt.Value { - calls = append(calls, collectExprCalls(value)...) - } - case *ast.PrintStatement: - for _, argument := range stmt.Expression.Arguments { - calls = append(calls, collectExprCalls(argument)...) - } - } - } - - return calls -} - -func collectExprCalls(expr ast.Expression) []*ast.CallExpression { - var calls []*ast.CallExpression - if call, ok := expr.(*ast.CallExpression); ok { - calls = append(calls, call) - } - - for _, child := range ast.ExprChildren(expr) { - calls = append(calls, collectExprCalls(child)...) - } - - return calls -} - -type tarjanState struct { - graph *specializationCallGraph - index int - indices []int - lowlink []int - stack []specializationNodeID - onStack []bool - components [][]specializationNodeID -} - -func (graph *specializationCallGraph) calleeFirstComponents() [][]specializationNodeID { - state := &tarjanState{ - graph: graph, - indices: make([]int, len(graph.nodes)), - lowlink: make([]int, len(graph.nodes)), - onStack: make([]bool, len(graph.nodes)), - } - - for id := range graph.nodes { - if state.indices[id] == 0 { - state.visit(specializationNodeID(id)) - } - } - - return state.components -} - -func (state *tarjanState) visit(id specializationNodeID) { - state.index++ - state.indices[id] = state.index - state.lowlink[id] = state.index - state.stack = append(state.stack, id) - state.onStack[id] = true - - for _, calleeID := range state.graph.nodes[id].effectCallees { - if state.indices[calleeID] == 0 { - state.visit(calleeID) - state.lowlink[id] = min(state.lowlink[id], state.lowlink[calleeID]) - } else if state.onStack[calleeID] { - state.lowlink[id] = min(state.lowlink[id], state.indices[calleeID]) - } - } - - if state.lowlink[id] != state.indices[id] { - return - } - - componentIndex := len(state.components) - var component []specializationNodeID - - for { - last := len(state.stack) - 1 - member := state.stack[last] - state.stack = state.stack[:last] - state.onStack[member] = false - state.graph.nodes[member].componentIndex = componentIndex - component = append(component, member) - if member == id { - break - } - } - - slices.Sort(component) - state.components = append(state.components, component) -} - -// deriveEffectNode refreshes one specialization and reports whether any output -// weakened from MustWrite to MayWrite. -func (ts *TypeSolver) deriveEffectNode(graph *specializationCallGraph, working [][]WriteEffect, id specializationNodeID) bool { - node := &graph.nodes[id] - walked := ts.walkedFuncs[node.mangled] - initial := functionInitialBindings(walked.template) - analyzer := newEffectAnalyzer(ts.ScriptCompiler.Compiler, node.mangled, graph, working) - statements := analyzer.deriveStatements(walked.template.Body.Statements, initial) - derived := deriveBodyOutputEffects(walked.template, statements) - - if !validPublishedEffects(derived, len(walked.info.Sig.OutTypes)) { - panic(fmt.Sprintf("internal: invalid effects for specialization %s", node.mangled)) - } - if slices.Contains(derived, MayWrite) { - panic(fmt.Sprintf("internal: specialization %s may leave an output unwritten, which its template check rejects", node.mangled)) - } - - changed := false - for outputIndex, effect := range derived { - if working[id][outputIndex] == MustWrite && effect == MayWrite { - working[id][outputIndex] = MayWrite - changed = true - } - } - - walked.info.StatementEffects = statements - - return changed -} - -func enqueueRecursiveEffectCallers(graph *specializationCallGraph, id specializationNodeID, pending []specializationNodeID, queued []bool) []specializationNodeID { - componentIndex := graph.nodes[id].componentIndex - - for _, callerID := range graph.nodes[id].effectCallers { - if graph.nodes[callerID].componentIndex != componentIndex || queued[callerID] { - continue - } - pending = append(pending, callerID) - queued[callerID] = true - } - - return pending -} - -// settleEffectComponent weakens one SCC to a fixed point and publishes all -// members only after their shared worklist drains. -func (ts *TypeSolver) settleEffectComponent(graph *specializationCallGraph, working [][]WriteEffect, component []specializationNodeID, queued []bool) { - pending := slices.Clone(component) - - for _, id := range pending { - queued[id] = true - } - - for next := 0; next < len(pending); next++ { - id := pending[next] - queued[id] = false - if ts.deriveEffectNode(graph, working, id) { - pending = enqueueRecursiveEffectCallers(graph, id, pending, queued) - } - } - - for _, id := range component { - node := &graph.nodes[id] - ts.walkedFuncs[node.mangled].info.BodyOutputEffects = slices.Clone(working[id]) - } -} - -func (ts *TypeSolver) settleEffects(graph *specializationCallGraph) { - working := make([][]WriteEffect, len(graph.nodes)) - - for id := range graph.nodes { - walked := ts.walkedFuncs[graph.nodes[id].mangled] - working[id] = slices.Repeat([]WriteEffect{MustWrite}, len(walked.info.Sig.OutTypes)) - } - - components := graph.calleeFirstComponents() - queued := make([]bool, len(graph.nodes)) - - for _, component := range components { - ts.settleEffectComponent(graph, working, component, queued) - } -} - -func functionInitialBindings(template *ast.FuncStatement) map[string]struct{} { - defined := make(map[string]struct{}, len(template.Parameters)) - - for _, parameter := range template.Parameters { - defined[parameter.Value] = struct{}{} - } - - return defined -} - func (ts *TypeSolver) deriveScriptEffects() { root := ts.ScriptCompiler.Script.Root - analyzer := newEffectAnalyzer(ts.ScriptCompiler.Compiler, ts.ScriptCompiler.ScriptMangled, nil, nil) - root.StatementEffects = analyzer.deriveStatements(ts.ScriptCompiler.Program.Statements, nil) + analyzer := newEffectAnalyzer(ts.ScriptCompiler.Compiler, ts.ScriptCompiler.ScriptMangled) + root.StatementEffects = analyzer.deriveStatements(ts.ScriptCompiler.Program.Statements) for _, statement := range ts.ScriptCompiler.Program.Statements { stmt, ok := statement.(*ast.LetStatement) diff --git a/compiler/effects_test.go b/compiler/effects_test.go index 6871cb10..391bf6d4 100644 --- a/compiler/effects_test.go +++ b/compiler/effects_test.go @@ -197,11 +197,12 @@ always, failed, gated`) require.Empty(t, gatedEffect.ReadsSeed) alwaysFunc := cc.Compiler.FuncCache[Mangle(cc.Compiler.MangledPath, "Always", []Type{I64})] - require.Equal(t, []WriteEffect{MustWrite}, alwaysFunc.BodyOutputEffects) require.True(t, alwaysFunc.Settled) } -func TestCallDomainComposesWithBodyOutputEffects(t *testing.T) { +// A call that runs writes every output, so only its own domain decides +// whether it writes: an empty range keeps an existing destination. +func TestCallDomainDecidesCallWrites(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() @@ -215,17 +216,6 @@ empty = Increment(0:0) nonempty = Increment(0:2) existing, empty, nonempty`) - mangled := Mangle(cc.Compiler.MangledPath, "Increment", []Type{Range{Iter: I64}}) - increment := cc.Compiler.FuncCache[mangled] - - require.NotNil(t, increment) - require.Equal(t, []WriteEffect{MustWrite}, increment.BodyOutputEffects) - - template, ok := cc.lookupFuncTemplate("Increment", 1) - require.True(t, ok) - bodyStmt := template.Body.Statements[0].(*ast.LetStatement) - requireTargetEffects(t, increment.StatementEffects[bodyStmt], TargetWriteEffect{TargetIndex: 0, Effect: MustWrite}) - existing := ts.ScriptCompiler.Program.Statements[1].(*ast.LetStatement) existingEffect := ts.ScriptCompiler.Script.Root.StatementEffects[existing] requireTargetEffects(t, existingEffect, TargetWriteEffect{TargetIndex: 0, Effect: MustWrite}) @@ -274,86 +264,6 @@ existing`) } } -func TestCallGraphWalkOrderAndEdgeViews(t *testing.T) { - ctx := llvm.NewContext() - defer ctx.Dispose() - - cc := NewCodeCompiler(ctx, "effectGraphIDs", "", mustParseCode(t, `y = Z(x) - y = B(x) - C(x) - y = y + B(x) - -y = B(x) - y = C(x) - -y = C(x) - y = x`)) - require.Empty(t, cc.Compile()) - - ts := solveScriptTypes(t, ctx, cc, t.Name(), `result = Z(1) -result`) - - walkOrder := []string{ - Mangle(cc.Compiler.MangledPath, "Z", []Type{I64}), - Mangle(cc.Compiler.MangledPath, "B", []Type{I64}), - Mangle(cc.Compiler.MangledPath, "C", []Type{I64}), - } - - graph := ts.buildSpecializationCallGraph() - - for index, name := range walkOrder { - id := specializationNodeID(index) - require.Equal(t, id, graph.byMangled[name]) - require.Equal(t, name, graph.nodes[id].mangled) - } - - zID := graph.byMangled[Mangle(cc.Compiler.MangledPath, "Z", []Type{I64})] - bID := graph.byMangled[Mangle(cc.Compiler.MangledPath, "B", []Type{I64})] - cID := graph.byMangled[Mangle(cc.Compiler.MangledPath, "C", []Type{I64})] - - require.Equal(t, []specializationNodeID{bID, cID}, graph.nodes[zID].effectCallees) - require.Equal(t, []specializationNodeID{zID}, graph.nodes[bID].effectCallers) - require.Equal(t, []specializationNodeID{cID}, graph.nodes[bID].effectCallees) - require.Equal(t, []specializationNodeID{zID, bID}, graph.nodes[cID].effectCallers) - require.Equal(t, [][]specializationNodeID{{cID}, {bID}, {zID}}, graph.calleeFirstComponents()) -} - -func TestCallGraphExcludesScalarEffectEdge(t *testing.T) { - ctx := llvm.NewContext() - defer ctx.Dispose() - - cc := NewCodeCompiler(ctx, "scalarCompanionEdges", "", mustParseCode(t, `result = Gather(arr) - i = 0:3 - result = [Scale(arr[i])] - -result = Scale(x) - result = x`)) - require.Empty(t, cc.Compile()) - - ts := solveScriptTypes(t, ctx, cc, t.Name(), `arr = [10 20 30] -result = Gather(arr) -result`) - gatherMangled := Mangle(cc.Compiler.MangledPath, "Gather", []Type{Array{ElemType: I64, Rank: 1}}) - gatherTemplate, ok := cc.lookupFuncTemplate("Gather", 1) - require.True(t, ok) - gatherCall := gatherTemplate.Body.Statements[1].(*ast.LetStatement).Value[0].(*ast.ArrayLiteral).Rows[0][0].(*ast.CallExpression) - callInfo := ts.ExprCache[key(gatherMangled, gatherCall)] - require.True(t, callInfo.ScalarCallVariantEnsured) - - primaryMangled := Mangle(cc.Compiler.MangledPath, "Scale", callInfo.CallParamTypes) - scalarMangled := Mangle(cc.Compiler.MangledPath, "Scale", callInfo.ScalarCallParamTypes) - require.NotEqual(t, primaryMangled, scalarMangled) - - graph := ts.buildSpecializationCallGraph() - gatherID, gatherInBatch := graph.byMangled[gatherMangled] - primaryID, primaryInBatch := graph.byMangled[primaryMangled] - _, scalarInBatch := graph.byMangled[scalarMangled] - require.True(t, gatherInBatch) - require.True(t, primaryInBatch) - require.True(t, scalarInBatch) - require.Equal(t, []specializationNodeID{primaryID}, graph.nodes[gatherID].effectCallees) -} - func TestScriptEffectsRejectInvalidExpressionFacts(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() @@ -389,7 +299,7 @@ value`) argument := call.Arguments[0] ts.ExprCache[key(ts.FuncNameMangled, argument)].OutTypes = []Type{Unresolved{}} - analyzer := newEffectAnalyzer(ts.ScriptCompiler.Compiler, ts.ScriptCompiler.ScriptMangled, nil, nil) + analyzer := newEffectAnalyzer(ts.ScriptCompiler.Compiler, ts.ScriptCompiler.ScriptMangled) require.Equal(t, []YieldEffect{YieldInvalid}, analyzer.deriveExpr(call)) } diff --git a/compiler/solver.go b/compiler/solver.go index e5e90bbd..82ef38e2 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -128,11 +128,8 @@ type pendingAssignment struct { } type walkedSpecialization struct { - // walkIndex is dense within the current solver pass and becomes the - // specialization call graph node ID. - walkIndex int - info *FuncInfo - template *ast.FuncStatement + info *FuncInfo + template *ast.FuncStatement } type TypeSolver struct { @@ -2564,9 +2561,7 @@ func newFunc(name string, bodyArgs []Type, template *ast.FuncStatement) *FuncInf Params: bodyArgs, OutTypes: make([]Type, len(template.Outputs)), }, - Vars: make(map[string]Type), - StatementEffects: make(map[*ast.LetStatement]StatementEffect), - BodyOutputEffects: slices.Repeat([]WriteEffect{WriteUncomputed}, len(template.Outputs)), + Vars: make(map[string]Type), } for i := range f.Sig.OutTypes { f.Sig.OutTypes[i] = Unresolved{} @@ -2645,8 +2640,7 @@ func (ts *TypeSolver) TypeScriptFunc(mangled string, template *ast.FuncStatement panic(fmt.Sprintf("internal: cannot settle incomplete specialization %s", mangled)) } } - graph := ts.buildSpecializationCallGraph() - ts.settleSpecializationBatch(graph) + ts.settleSpecializationBatch() return f.Sig.OutTypes } @@ -2664,20 +2658,17 @@ func (ts *TypeSolver) TypeScriptFunc(mangled string, template *ast.FuncStatement } } -// settleSpecializationBatch publishes reusable analysis facts atomically with -// respect to Settled: every specialization in the batch is checked against -// its template's text range summary before any becomes visible as settled. -func (ts *TypeSolver) settleSpecializationBatch(graph *specializationCallGraph) { - ts.settleEffects(graph) +// settleSpecializationBatch publishes the walked specializations atomically +// with respect to Settled: each is checked against its template's text range +// summary before any becomes visible as settled. +func (ts *TypeSolver) settleSpecializationBatch() { cc := ts.ScriptCompiler.Compiler.CodeCompiler - - for _, node := range graph.nodes { - walked := ts.walkedFuncs[node.mangled] + for _, walked := range ts.walkedFuncs { cc.checkSettledRanges(walked.template, walked.info) } - for _, node := range graph.nodes { - ts.walkedFuncs[node.mangled].info.Settled = true + for _, walked := range ts.walkedFuncs { + walked.info.Settled = true } } @@ -2692,9 +2683,8 @@ func (ts *TypeSolver) TypeFunc(mangled string, template *ast.FuncStatement) bool return f.OutputTypesInferred() } ts.walkedFuncs[mangled] = walkedSpecialization{ - walkIndex: len(ts.walkedFuncs), - info: f, - template: template, + info: f, + template: template, } revision := ts.storageRevision previousSlots := ts.previousSlotTypes diff --git a/compiler/solver_test.go b/compiler/solver_test.go index 545f71e3..e736bf38 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -80,14 +80,12 @@ x, y` require.True(t, ok) isEvenFunc := newFunc(call.Function.Value, args, template) cc.Compiler.FuncCache[isEvenMangled] = isEvenFunc - require.Equal(t, []WriteEffect{WriteUncomputed, WriteUncomputed}, isEvenFunc.BodyOutputEffects) isOddMangled := Mangle(cc.Compiler.MangledPath, "isOdd", args) ts.Converging = false ts.firstUnresolved = nil clear(ts.walkedFuncs) require.True(t, ts.TypeFunc(isEvenMangled, template)) - require.Equal(t, []WriteEffect{WriteUncomputed, WriteUncomputed}, isEvenFunc.BodyOutputEffects) isOddFunc := cc.Compiler.FuncCache[isOddMangled] require.NotNil(t, isOddFunc) require.True(t, isEvenFunc.AllTypesInferred()) @@ -114,8 +112,6 @@ x, y` require.False(t, ts.Converging) require.True(t, isEvenFunc.Settled) require.True(t, isOddFunc.Settled) - require.Equal(t, []WriteEffect{MustWrite, MustWrite}, isEvenFunc.BodyOutputEffects) - require.Equal(t, []WriteEffect{MustWrite, MustWrite}, isOddFunc.BodyOutputEffects) ts.Solve() require.Empty(t, ts.Errors) @@ -1980,8 +1976,6 @@ scaled`) primaryMangled := Mangle(cc.Compiler.MangledPath, "Scale", callInfo.CallParamTypes) scalarMangled := Mangle(cc.Compiler.MangledPath, "Scale", []Type{I64}) require.True(t, callInfo.ScalarCallVariantEnsured) - require.Equal(t, []string{primaryMangled}, collectSpecializationCallEdges(sc.Compiler, sc.ScriptMangled, program.Statements), - "a scalar companion is checked but is not an effect dependency") require.Contains(t, cc.Compiler.FuncCache, primaryMangled) require.Contains(t, cc.Compiler.FuncCache, scalarMangled) template, ok := cc.lookupFuncTemplate("Scale", 1) diff --git a/compiler/types.go b/compiler/types.go index 21602236..0932ffe2 100644 --- a/compiler/types.go +++ b/compiler/types.go @@ -255,16 +255,14 @@ func (f Func) OutputTypesInferred() bool { return true } -// FuncInfo holds the mutable facts for one function specialization. Settled -// means its reusable type and effect facts have been published. +// FuncInfo holds the mutable facts for one function specialization or the +// script root. Settled means a specialization's reusable type facts have been +// published; StatementEffects are derived for the script root only. type FuncInfo struct { Sig Func Vars map[string]Type StatementEffects map[*ast.LetStatement]StatementEffect - // BodyOutputEffects summarizes the typed scalar body before a call-owned - // Range or ArrayRange domain determines whether that body executes. - BodyOutputEffects []WriteEffect - Settled bool + Settled bool } func (f *FuncInfo) AllTypesInferred() bool { From f6e7e4677165c860a1199fc3f2e493518c97b85a Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 7 Oct 2026 13:25:41 +0530 Subject: [PATCH 04/34] docs: describe definite outputs and per-template checks as implemented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #123's rule and its per-template checks are now the current design, so the docs split the decision: definite outputs, the per-template flow pass and its text range rules read as implemented, while stable inputs (#144) and ABI 3.0 (#69) stay planned. The Memory Model's status keeps only the live-reference item, the README adds the rule and the prev idiom but keeps its sharing example, and C ABI Spec §5.3 points at #69. Two range rules are corrected to match the solver: a statement's condition iterates the ranges it names, and under || and && only a call operand passes its range on. Only a range literal with constant, nonempty bounds counts as always running, and the Memory Model no longer claims that an array specialization drops a dead default, which it still copies. The IR plan drops the per-specialization CFG, its replay and the effect fixed point, and records that scripts move to the text classification in a follow-up; the capability matrix cites the migrated fixtures. Co-Authored-By: Claude Opus 5.5 --- README.md | 14 + docs/Pluto ABI Optimization Plan.md | 17 +- docs/Pluto C ABI Spec.md | 32 ++- docs/Pluto Conditional Value Semantics.md | 15 +- docs/Pluto Effects and Follow-up Plan.md | 20 +- docs/Pluto IR Plan.md | 334 +++++++++------------- docs/Pluto Memory Model.md | 48 ++-- docs/Pluto PIR Capability Matrix.md | 19 +- docs/Pluto Range Semantics.md | 16 +- 9 files changed, 226 insertions(+), 289 deletions(-) diff --git a/README.md b/README.md index cbdce151..e83e775d 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,20 @@ y = Square(x) Inputs are read-only — they flow in. Outputs flow out: the template may read one only after assigning it unconditionally, as in `sq = x * x` followed by `cube = sq * x`; before that, use a local. Read-only means the template cannot assign through the input name; it does not freeze a value shared with an output. A caller may reuse a variable as both argument and destination, `a = Square(a)`. +Every body that runs assigns every output on every path. A write under a condition, from a value that can fail, or driven by a range that may be empty can be skipped, so it needs a default first. A function keeps an old value only through an input its caller passes: + +```python +y = Fib(n) + y = n + y = n > 1 Fib(n - 1) + Fib(n - 2) + +out = Maybe(prev, x) + out = prev + out = x > 0 x +``` + +`a = Maybe(a, -1)` keeps `a`. A call therefore either writes all of its outputs or does not run at all: when an argument fails, when the statement's condition fails, or when its range is empty. + ```python out, seen = Fold(current, item) out = current + item diff --git a/docs/Pluto ABI Optimization Plan.md b/docs/Pluto ABI Optimization Plan.md index 9ec0ae26..df1bf08e 100644 --- a/docs/Pluto ABI Optimization Plan.md +++ b/docs/Pluto ABI Optimization Plan.md @@ -29,17 +29,17 @@ Pluto's source-level semantics stay unchanged: - assignments copy - input names are read-only and keep their values for the whole call; today - they can observe writes through a shared output, which #123 (decided) + they can observe writes through a shared output, which #144 (decided) removes -- every body that runs writes every output (#123, decided); output names are - readable only once definitely assigned, and results reach the caller at - assignment commit +- every body that runs writes every output (#123); output names are readable + only once definitely assigned, and results reach the caller at assignment + commit These are **language semantics**. How values physically move across a call boundary is the **lowered calling convention** — a separate concern. An `I64` input can be passed by value provided the call site's alias pattern (the private `_aN` variant) redirects each read to its shared output when required; -after #123 no read needs redirecting. A single `I64` output can be returned in a +once inputs are stable (#144) no read needs redirecting. A single `I64` output can be returned in a register while still behaving like a Pluto output. ## 3. Architecture @@ -115,9 +115,10 @@ wrong argument list. Write-effect information may still eliminate seed use inside Pluto code; a seedless fast path needs a distinctly named private clone behind the stable public entry point. -#123 (decided) keeps that rule while removing the seed: whether a function -keeps it depends on whether it has a range parameter, never on its write -effects. The C ABI Spec §5.3 describes ABI 3.0. +ABI 3.0 (#69) keeps that rule while removing the seed, which #123 left unused +outside callee-owned domains: whether a function keeps it depends on whether +it has a range parameter, never on its write effects. The C ABI Spec §5.3 +describes ABI 3.0. This was the highest-value initial optimization because it benefits all scalar-heavy code, not just specific patterns. It reduces stack traffic, simplifies IR, and materially improved `fib`, `fib_tail`, and `harmonic`. diff --git a/docs/Pluto C ABI Spec.md b/docs/Pluto C ABI Spec.md index 3f2d0da9..5dbf1197 100644 --- a/docs/Pluto C ABI Spec.md +++ b/docs/Pluto C ABI Spec.md @@ -333,15 +333,17 @@ types: - `I64` and `F64` parameters are passed directly. Ranges, internal `ArrayRange` descriptors, and other values are passed indirectly. - A function with exactly one `I64` or `F64` output returns that scalar - directly and receives one hidden seed value. The seed preserves the caller's - staged value when the callee does not write its output, including a failed - conditional assignment or an empty `Range`/internal `ArrayRange`. + directly and receives one hidden seed value. Every body that runs writes + every output (#123), so the seed preserves the caller's staged value only + when a `Range` or internal `ArrayRange` parameter runs no iteration; a + function without one never reads its seed. - All other output lists use an indirect `void` return. Argument zero points to a carrier whose first `N` fields are output pointers and whose next `N` fields are pointers to `i1` write markers. - The caller initializes every write marker to false. A callee sets the marker - only when that output is actually written. This lets an empty range or - skipped conditional preserve an existing caller destination. + when it writes that output, which every body that runs does. A marker left + false therefore means an empty range parameter, and the caller keeps its + existing destination. - Output expressions are staged independently at the call site, so one output cannot mutate a destination before a sibling right-hand side reads its statement-start value. @@ -373,8 +375,8 @@ may-write—cannot alter the prototype of an existing symbol. A future seedless internal fast path must therefore use a distinct private symbol behind this stable boundary. -The planned ABI 3.0 (§5.3) removes the seed and the write markers from every -function without a `Range` or `ArrayRange` parameter. +The planned ABI 3.0 (§5.3, #69) removes the seed and the write markers from +every function without a `Range` or `ArrayRange` parameter. Conceptually, a two-output indirect call uses: @@ -474,14 +476,14 @@ first parameter sharing its first output, which is therefore an `I64`. `Demangle` renders it as `math.Fold(I64 -> 1, StrH)`. The public specialization symbol is unchanged by the variant. C callers -never see a variant and cannot request one. Under the planned ABI 3.0, a +never see a variant and cannot request one. Once inputs are stable (#144), a variant can no longer change what a body reads within one invocation (§5.3). -### 5.3 Planned: ABI 3.0 (#123) +### 5.3 Planned: ABI 3.0 (#69) -Decided in #123 and not yet implemented. Every body that runs will write every -output, so no function needs its destination's old value as an implicit -input. ABI 3.0 removes the hidden seed and the write markers from every +Every body that runs writes every output (#123), so no function needs its +destination's old value as an implicit input. ABI 3.0, tracked in #69 and not +yet implemented, removes the hidden seed and the write markers from every function without a `Range` or `ArrayRange` parameter: a direct return takes only its source parameters, and the indirect carrier holds only output pointers. `Square` becomes `int64_t Pt_Square_I64(int64_t x)`. @@ -497,9 +499,9 @@ function such as `Acc` must still report that no iteration ran, since zero iterations mean no assignment; it keeps its seed until PIR Step 7 chooses between one "did execute" bit and a caller-side emptiness check. -Inputs become stable within each scalar invocation, so within one invocation -a private variant (§5.2) may only let a shared input and output use one slot -where no read can tell. Across iterations, a variant must produce what +Once inputs are stable within each scalar invocation (#144), a private +variant (§5.2) may only let a shared input and output use one slot within one +invocation where no read can tell. Across iterations, a variant must produce what repeated scalar invocations of the bare specialization produce, with each iteration's shared argument rebound to the destination's current value. With `out = a + i`, `sum = Acc(sum, 1:5)` from 0 gives 10, while an unshared diff --git a/docs/Pluto Conditional Value Semantics.md b/docs/Pluto Conditional Value Semantics.md index a4c58760..d34f64d1 100644 --- a/docs/Pluto Conditional Value Semantics.md +++ b/docs/Pluto Conditional Value Semantics.md @@ -126,9 +126,8 @@ An unresolved failure that reaches an **invocation boundary** merges lanes there: a failed call argument suppresses that call's complete output tuple, and a failed print argument suppresses the complete print invocation and its newline (see "Print invocation atomicity" under Status). A call that did run -writes every output (#123, decided and not yet implemented), so a failure -inside a callee never reaches its caller. Until then, each output of a call -that ran resolves independently per flattened output slot. +writes every output (#123), so a failure inside a callee never reaches its +caller. ```pluto y > 2 # yields y when y > 2, else fails (the value is the left operand) @@ -424,12 +423,10 @@ shape — unresolved meaning no closer resolver claimed it first: in failed one. Ordinary calls are currently lazy in exactly the way print is, and PIR Step 6 is the behavior change for both. The out-of-bounds case changes too: today it materializes a zero and prints. A - suppressed invocation still releases its owned temporaries. Today an - unwritten direct-return argument resolves to its seed, which is zero in a - print, and always yields, because the result carries no validity bit. - #123 (decided) removes the case instead of adding a bit: a body that runs - writes every output, so a print argument that is a call either yields or - failed before the call ran. + suppressed invocation still releases its owned temporaries. A + direct-return argument carries no validity bit, and #123 removed the need + for one instead of adding it: a body that runs writes every output, so a + print argument that is a call either yields or failed before the call ran. - **Checked-access fallback (decided, not yet implemented):** the nearest resolver wins, so `x = arr[oob] || -1` assigns `-1`, and in print position `arr[oob] || -1, val1` emits `-1 val1`. The fallback tests the diff --git a/docs/Pluto Effects and Follow-up Plan.md b/docs/Pluto Effects and Follow-up Plan.md index 0b3c91b5..8651ed25 100644 --- a/docs/Pluto Effects and Follow-up Plan.md +++ b/docs/Pluto Effects and Follow-up Plan.md @@ -23,8 +23,10 @@ Resolved by a language rule instead of an analysis ([PR #104](https://github.com/thiremani/pluto/pull/104), superseding the closed [PR #102](https://github.com/thiremani/pluto/pull/102)): a declared output is readable inside its template only after it is definitely assigned, so a body -that writes `y = x > 0 x` and then `y = y + 1` is rejected at the read. The hidden seed and destination-seeded staging slots continue to -preserve outputs that are not written. A caller can explicitly connect an +that writes `y = x > 0 x` and then `y = y + 1` is rejected at the read. Since +#123, every body that runs writes every output, so the hidden seed and +destination-seeded staging slots preserve a destination only when a +callee-owned range runs no iteration. A caller can explicitly connect an input to an output by reusing the same binding: later statements then observe writes through that output, in ordinary and ranged calls alike. Inputs are read-only bindings, not frozen values. No per-iteration input snapshot is @@ -57,11 +59,13 @@ it, are not planned ([issue #105](https://github.com/thiremani/pluto/issues/105) a function that needs its destination's previous value takes it as an input, and the caller shares the destination with it. -**Decided 2026-09-28 ([issue #123](https://github.com/thiremani/pluto/issues/123); -not yet implemented).** Every body that runs writes every output, sharing is -decided only at the call site, and inputs keep their values within an -invocation, which reverses the live references described above. Flow checks -run once per template, the same for every argument type. The Memory +**Decided 2026-09-28 ([issue #123](https://github.com/thiremani/pluto/issues/123)).** +Every body that runs writes every output, sharing is decided only at the call +site, and inputs keep their values within an invocation, which reverses the +live references described above. Definite outputs and the per-template flow +checks are implemented; stable inputs +([#144](https://github.com/thiremani/pluto/issues/144)) and ABI 3.0 +([#69](https://github.com/thiremani/pluto/issues/69)) are not. The Memory Model's "Parameters and Outputs" and "Call Site" state the rules and what master still does differently, and the C ABI Spec §5.3 describes ABI 3.0. @@ -117,7 +121,7 @@ and [ABI stability plan](./Pluto%20ABI%20Optimization%20Plan.md). | Work | Completion criterion / existing reference | | --- | --- | -| Seed/effect correctness | Section 1; resolved by the definite-assignment rule for output reads in [PR #104](https://github.com/thiremani/pluto/pull/104); flow-versus-slot call specialization is [#103](https://github.com/thiremani/pluto/issues/103). Definite outputs, stable inputs and ABI 3.0 are decided in [#123](https://github.com/thiremani/pluto/issues/123) and land before PIR Step 4's call slice | +| Seed/effect correctness | Section 1; resolved by the definite-assignment rule for output reads in [PR #104](https://github.com/thiremani/pluto/pull/104); flow-versus-slot call specialization is [#103](https://github.com/thiremani/pluto/issues/103). Definite outputs and per-template flow checks are done ([#123](https://github.com/thiremani/pluto/issues/123)); stable inputs ([#144](https://github.com/thiremani/pluto/issues/144)) and ABI 3.0 ([#69](https://github.com/thiremani/pluto/issues/69)) land before PIR Step 4's call slice | | `%n` effect contract | Done: `%n` removed (section 2, #109) | | Output path protection | Done ([#80](https://github.com/thiremani/pluto/issues/80)): a script's executable replaces only a missing path or a native executable, never `pt.mod`, a source file, a directory or a symlink, and links through a fresh directory renamed into place | | Numeric edge behavior | Define and guard integer divide/remainder faults and invalid shift counts; audit range/count/allocation arithmetic | diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index c1cd2efa..8bd405ba 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -39,10 +39,10 @@ Pluto source Responsibilities stay decoupled: the solver owns types, ranges, output shapes, and effects; the CFG owns cross-statement dataflow legality; PIR owns how an already-valid statement executes; LLVM owns SSA, storage, optimization, and -ABI. The CFG never reads PIR. Today both consume the solver's effect summary -independently (§15). Under #123 the CFG checks function templates with its -own conservative classification of the source instead, and whether script -checks follow is still open (§15, CFG consumption). +ABI. The CFG never reads PIR. The CFG checks function templates with its own +conservative classification of the source (#123), while the script root's +checks still consume the solver's statement effects until they move to the +same classification (§15, CFG consumption). PIR may refer to solved AST expressions, but LLVM lowering must not reclassify their range, conditional, OOB, collector, affine, or commit behavior. @@ -50,8 +50,8 @@ their range, conditional, OOB, collector, affine, or commit behavior. **ABI note.** Per-slot effects are an internal analysis, never a public ABI classifier: a body-only edit must never change the C prototype of a type-mangled symbol. Today every exported direct `I64`/`F64` return keeps a -hidden seed parameter. #123 (decided) requires every body that runs to write -every output, so ABI 3.0 removes the seed and the write markers from every +hidden seed parameter. #123 requires every body that runs to write every +output, so ABI 3.0 (#69) removes the seed and the write markers from every function without a `Range` or `ArrayRange` parameter. That removal depends only on whether a parameter is a range, and the complete prototype only on the solved parameter and output types, so a prototype still never depends on the @@ -293,7 +293,7 @@ If one owned source feeds multiple targets, at most one consumer takes it; the others require a derived copy. At a call boundary, `a = F(a)` passes `a`'s value as an input that stays -stable for the whole invocation (#123, decided): the output starts unassigned +stable for the whole invocation (#144, decided): the output starts unassigned in its own staging slot, and the lowering may place both in one slot only where no read can tell. The real `a` changes only at the outer assignment's commit, so sibling RHS expressions continue to read the pre-commit binding. @@ -497,9 +497,8 @@ Checked accesses have one canonical representation — `eval` with an explicit `[on-oob=...]` scope — regardless of which legacy path produced them. A **caller-side** failure — a failed invocation or argument — is atomic: the call's whole output tuple keeps its old values. A call that actually ran -writes every output (#123, decided; today it may omit outputs independently, -§15), so per-slot divergence exists only where slots carry independent -conditions. +writes every output (#123), so per-slot divergence exists only where slots +carry independent conditions. ## 10. Collectors @@ -760,9 +759,10 @@ relevant PIR excerpt. ## 15. Solver Effects -Effects are solver-side facts consumed independently by the CFG and by PIR -(under #123 the CFG's function checks use their own classification instead; -see CFG consumption, below). +Effects are solver-side facts derived for the script root, whose CFG checks +consume them; PIR consumes them as its steps migrate. Function templates use +the CFG's own classification of their text instead (#123; see CFG +consumption, below). They are **per LHS slot**, not per statement, because a mixed assignment can skip one target while another always writes: `a, b = arr[i], i + 1` is `[]WriteEffect{MayWrite, MustWrite}`. The target-effect vector stays aligned to @@ -828,10 +828,9 @@ Calls are where the two effects interact. A failure evaluating the invocation or its arguments suppresses the **whole tuple** — the call-merge rule — so every slot of that call becomes `MayYield` together. -**Definite outputs (#123, decided; not yet implemented).** Every body that -runs writes every output. The template-level flow check (CFG consumption, -below) decides this from the function's text, the same for every argument -type. Callee-internal non-writing therefore disappears, and the +**Definite outputs (#123).** Every body that runs writes every output. The +template-level flow check (CFG consumption, below) decides this from the +function's text, the same for every argument type. Callee-internal non-writing therefore disappears, and the transfer rule reduces to `rawYield[i] = invocationYield`. A caller-side failure, such as the failed argument in `Id(arr[oob])`, makes every slot skip: the commit is skipped and the target stays `MayWrite`. Otherwise every slot @@ -841,66 +840,49 @@ zero iterations, which means no assignment. Until Step 7 chooses between one "did execute" bit and a caller-side emptiness check, that case keeps the seed resolution below. -**Today.** Master still lets a body skip a write, so the callee's output slots -keep independent `WriteEffect`s, and two failure sources must not be -conflated: a **caller-side failure** is a tuple fact, and **callee-internal -non-writing**, where the callee ran but did not write an output, is a -per-output fact. The transfer rule is conceptually -`rawYield[i] = invocationYield && didWrite[i]`, but a direct `I64`/`F64` -result carries no validity bit: it is seeded from the destination -(`compileCallInner`), so a callee that wrote nothing returns the seed. After a -successful invocation, seed resolution applies as a **contextual resolver at -the assignment boundary** for a direct `MayWrite` output resolved at an -existing target: the seed *is* the keep-old outcome, so the consumer sees -`MustYield`. A caller-side failure is **never** converted into a seeded -`MustYield`. A nested or targetless call is seeded with zero, so it reads an -unwritten output as zero. +**Seeds until Step 7.** A direct `I64`/`F64` result carries no validity bit: +it is seeded from the destination (`compileCallInner`), so a call-owned domain +that runs no iteration returns the seed. After a successful invocation, seed +resolution applies as a **contextual resolver at the assignment boundary** +for such a call at an existing target: the seed *is* the keep-old outcome, so +the consumer sees `MustYield`. A caller-side failure is **never** converted +into a seeded `MustYield`. A nested or targetless call is seeded with zero. Boundary resolution implies an **implicit read of the destination seed**, and only where the dependency is real: after a successful invocation, at an -*existing* target whose direct callee output is `MayWrite` (today) or whose -call owns a possibly empty domain, resolved at `=`. A fresh destination, a -discard, a nested or targetless call, or an all-`MustWrite` callee introduces -no implicit seed read. A declared output is readable inside its template only -after a statement that definitely assigns it (the structural CFG rejects a -read before any assignment, including a formatting marker, and the typed pass -rejects a read after only conditional or seed-preserving writes), so the body -cannot read the hidden seed through an output name. Step 2A records boundary +*existing* target whose call owns a possibly empty domain, resolved at `=`. A +fresh destination, a discard, a nested or targetless call, or a call without +a call-owned domain introduces no implicit seed read. A declared output is +readable inside its template only after a statement that definitely assigns +it (the template check rejects an earlier read, including a formatting +marker), so the body cannot read the hidden seed through an output name. Step 2A records boundary resolution as a `ReadsSeed` fact on the call site — the CFG is untouched in 2A — and Step 2B converts the fact into an ordinary CFG read event, so a `MustWrite` classification cannot let backward liveness kill the prior value. An earlier revision of this plan added a private `{value, didWrite}` direct-call variant in Step 4, so that print and other failure-propagating -contexts could see a skipped direct return. #123 removes the skipped write +contexts could see a skipped direct return. #123 removed the skipped write instead, so the variant is not built. ### Convergence and publication -**Folding a body into an output summary.** A declared output's body summary is -the sequential fold of the body's per-slot effects for that output, in -statement order: the output starts `MayWrite` (nothing has written it), a -`MustWrite` statement slot sets it to `MustWrite`, and a `MayWrite` statement -slot leaves it unchanged — a later conditional write cannot un-guarantee an -earlier unconditional one. A boundary `MustWrite` obtained by reading the -destination seed (`ReadsSeed`) also leaves the summary unchanged: preserving -an earlier value does not prove that the body wrote one. Under #123 this -precise summary no longer decides whether a template is valid; it stays an -execution fact for routing, ownership and optimization. The published -`BodyOutputEffects` deliberately stop before a call-owned domain: a `Range` or -`ArrayRange` parameter controls whether -the scalar body executes, not what the body does when it executes. Each call -combines that reusable body summary with its solved domain. A provably -non-empty literal can therefore preserve `MustWrite`, while an empty or unknown -domain weakens the call to `MayWrite`. A range created *inside* the body still -weakens only the outputs its statements drive — a possibly empty local range -makes those slots `MayWrite`, so `UpperTriRowTail` publishes `MayWrite` for -`res` — while unrelated outputs keep their effects. - -**Finite specialization closure.** SCC settlement assumes discovery has already -produced a finite graph. It cannot catch +**No body summary.** Every body that runs writes every output (#123), so a +call's slots yield exactly when the call is invoked: its arguments' yields, +joined with `MayYield` when a range that may run no iteration drives it. A +call-owned `Range` or `ArrayRange` domain controls whether the scalar body +executes, not what the body does when it executes, so a provably non-empty +literal keeps the call `MustYield` while an empty or unknown domain weakens it. +Statement effects therefore need no per-specialization summary and no fixed +point across recursive calls: the script root derives them in one pass. An +earlier revision folded each body into a `BodyOutputEffects` summary settled +callee-first over the call graph's strongly connected components; #123 made +every summary `MustWrite`, and both are gone. + +**Finite specialization closure.** Settlement assumes discovery has already +produced a finite set of specializations. It cannot catch `f(T) -> f(Array) -> f(Array>) -> ...`: every specialization is a -new node, so Tarjan never runs. Before Step 2B, discovery caps active +new one, so discovery never closes. Before Step 2B, discovery caps active specialization frames in one **recursive inference region** while attempting to allocate a missing candidate. The region starts at the earliest active frame whose function template repeats in the candidate path; flat sibling calls and @@ -921,117 +903,81 @@ transformation. The fuse guarantees controlled compiler failure for unbounded specialization discovery, not runtime termination; totality remains a separate future analysis. -After discovery closes, effects and CFG share one -`specializationCallGraph` for each stable newly walked batch. Its dense, -batch-local primary-call edges drive effect SCC settlement; its separate -source-ordered mangled direct-call keys include settled callees and distinct -scalar companions actually ensured for lowering. CFG reuses node enumeration -and persistent reachability, not Tarjan state, effect worklists, or lattice -working vectors. - -**Fixed point.** Function-body output effects, and only those, need one: a -recursive callee can refine its outputs after types settle, and -`TypeScriptFunc` today converges on types alone. Condense the typed -specialization call graph into its strongly connected components and process -them **callee-first** in reverse topological order — this is what lets a -component assume every callee outside it has already published. Within one -component: - -1. Seed every member's outputs with a provisional `MustWrite` working vector. A - recursive call reads that provisional value — which is why `Uncomputed` - cannot be a lattice element, as there would be nothing to read. -2. Iterate the component, recomputing outputs from rebuilt statement effects - and callees' current values. Callees outside the component contribute their - published summaries. -3. Weaken monotonically, `MustWrite → MayWrite` only. The working vector - **persists across body walks** within the component; it is not cleared with - `FuncInfo.Vars`. -4. Stop when nothing changes — at most one weakening per slot. -5. Publish one coherent snapshot for the whole component at once. - -An `Invalid` output blocks publication for its entire component; provisional -values are never read outside it. - Only reusable function specializations publish, through `FuncInfo.Settled`, -which requires type convergence, the effect fixed point, complete direct-call -keys, and a non-nil cached specialization CFG result. Every batch CFG result is -staged and installed before any member becomes settled; a diagnostic-bearing -result is complete and settles just like an empty successful result. A script -root owns its current compilation facts and is consumed immediately after -solving, so it needs no settled-publication step. Reading an unpublished or -`Invalid` effect, or a settled specialization with no CFG result, is an ICE, -never a default. +which requires type convergence and agreement with the template's text range +summary (CFG consumption, below); a disagreement is an ICE. A script root owns +its current compilation facts and is consumed immediately after solving, so it +needs no settled-publication step. Snapshot analysis such as `FuncInfo.Vars` is cleared and rebuilt whenever a body is walked and becomes reusable only once settled. `ExprCache` is not a precedent for append-only analysis: stable AST entries are overwritten while generated rewrite nodes may add entries across walks. Issue #71's compile-order-dependent wrong output came from reusing some body facts while -discarding others. Specialization CFG success or diagnostics are likewise -cached on `FuncInfo` and replayed when a later script reuses a settled body. -Under #123, flow checks run once per template (CFG consumption, below), so -settlement no longer requires a CFG result and nothing is replayed. +discarding others. Flow checks run once per template (CFG consumption, below), +so nothing about a specialization's diagnostics is cached or replayed. ### CFG consumption -**Decided (#123; not yet implemented): flow checks per template.** Output -initialization, reads before definite assignment, dead stores and -write-after-write move to one pass per template, which runs whether or not -anything calls it. The pass classifies writes from the text alone and -conservatively: a statement gate, a range that may be empty, or a value that -can fail (a value-position comparison, `&&`, a checked access, a `||` whose -last alternative can fail, a call with such an argument) makes a write -possibly skipped, even where a specialization's types would make it always -write, as an array mask does. This classification stays separate from the -solver's effects, which remain precise per specialization for routing, -ownership and optimization; a specialization may drop a default its precise -effects prove dead without reporting it. +**Flow checks per template (#123).** Output initialization, reads before +definite assignment, dead stores and write-after-write run in one pass per +template, which runs whether or not anything calls it. The pass classifies +writes from the text alone and conservatively: a statement gate, a range that +may be empty, or a value that can fail (a value-position comparison, `&&`, a +checked access, a `||` whose last alternative can fail, a call with such an +argument) makes a write possibly skipped, even where a specialization's types +would make it always write, as an array mask does. Only a range literal +written with constant, nonempty bounds always runs; a range bound to a name +or returned by a call may be empty. A call counts as writing every output, +since its callee passes the same check, so a caller of a rejected template is +not reported again. The pass also reads from the text which values are ranges, and so which statements a range drives. A parameter never holds a range, since a range argument drives the whole body, and struct fields, constants and array -elements cannot hold one (`[r]` collects `r`'s values). A value is therefore -a range only when it is a range literal, a binding whose assigned value is a -range (`x = r`, not `x = r * 2`), a `||` or value-position `&&` that yields -a range (`MakeRange(n > 0) || MakeRange(3)` forwards whichever alternative -succeeds, and `c > 0 && MakeRange(3)` its right operand), or a call output -that its template assigns a range. Before checking any body, the pass -computes that last fact for every template output, iterating to a fixed -point across templates that call each other: an output assigned `t` after +elements cannot hold one (`[r]` collects `r`'s values). A value holds a range +when it is a range literal, a binding assigned a descriptor (`x = r`, not +`x = r * 2`), a call output that its template assigns a range, or a `||` or +value-position `&&` whose operand is such a call +(`MakeRange(n > 0) || MakeRange(3)` forwards whichever alternative succeeds, +and `c > 0 && MakeRange(3)` its right operand). A range literal or binding +operand of `||` and `&&` is iterated instead (`n > 0 && 0:5` gives an +element). A statement's condition iterates the ranges it names, so its value +reads them as elements (`kept = s > 2 s`); a range the condition only +collects, or does not name, stays a descriptor. Before checking any body, the +pass computes which template outputs hold a range, iterating to a fixed point +across templates that call each other: an output assigned `t` after `t = MakeRange(n)` is a range as well. A range that arrives through a call -counts as possibly empty. -Every check then uses the same classification, and none waits for a type: -after `r = MakeRange(n)`, `out = r * 2` does not kill an earlier -`out = prev`, does not definitely assign `out`, and so cannot by itself make -a later read of `out` valid. A settled specialization's range types must -agree with this summary; a disagreement is an ICE rather than a diagnostic. +counts as possibly empty. Every check then uses the same classification, and +none waits for a type: after `r = MakeRange(n)`, `out = r * 2` does not kill +an earlier `out = prev`, does not definitely assign `out`, and so cannot by +itself make a later read of `out` valid. Settlement panics when a +specialization's Range-typed bindings disagree with this summary. + +The pass runs after a template's structural checks, and only when they pass: +explicit use-before-definition, illegal input/global writes, unused inputs, +syntactically unassigned outputs, formatting structure, and discard behavior. +Those collect all reads before declaring a statement's destinations, so a +fresh `x = x + 1` cannot define its own RHS. An unknown main format marker +remains literal text; malformed specifiers and missing dynamic width/precision +variables on a resolved marker remain structural errors. A flow error in any +template fails the code module before any script compiles. This partly reverses Step 2B, which moved dead-store and write-after-write -checks to specializations, and the per-specialization diagnostic cache and -replay go with it. Whether a script's dead-store and write-after-write checks -join this pass or keep the effect-sensitive dataflow described below is still -open. The rest of this section describes the current design. - -`.pt` functions run `AnalyzeFuncs` once before any specialization exists. That -pass is structural only: explicit use-before-definition, illegal input/global -writes, unused inputs, syntactically unassigned outputs, formatting structure, -and discard behavior. It collects all reads before declaring a statement's -destinations, so a fresh `x = x + 1` cannot define its own RHS. An unknown main -format marker remains literal text; malformed specifiers and missing dynamic -width/precision variables on a resolved marker remain structural errors. - -After a stable specialization batch reaches its effect SCC fixed point, each -node runs effect-sensitive CFG dataflow exactly once and caches its diagnostics. -For a let, event order is condition reads, RHS reads, `ReadsSeed` destination -reads, then sparse `StatementEffect.Writes` mapped by `TargetIndex`; all reads +checks to specializations; the per-specialization diagnostic cache and replay +are gone. Scripts move to the same classification in a follow-up (decided +2026-10-07). Until then, after a script solve succeeds, CFG treats the script +as a zero-input, zero-output template for structural validation, then runs +effect-sensitive dataflow over the typed body with a fresh scope. For a let, +event order is condition reads, RHS reads, `ReadsSeed` destination reads, +then sparse `StatementEffect.Writes` mapped by `TargetIndex`; all reads therefore observe the simultaneous assignment's pre-commit snapshot. Print arguments contribute ordinary reads even though prints have no statement -effect entry. An unreachable template gets structural and parser checks only: -effects cannot be derived without types. Consequently, a library-only package -whose templates are never instantiated receives no dead-store or -write-after-write diagnostics in that build. +effect entry. Structural diagnostics do not gate that typed pass: its +already-collected explicit reads are reused so one compile reports all +independent CFG diagnostics without repeating formatting errors. -The two diagnostics consume effects differently: +Both passes consume write kinds the same way: - *Backward (dead store).* A write is dead when its destination is not live afterward, and that holds for `MayWrite` as well as `MustWrite`. What @@ -1045,45 +991,23 @@ The two diagnostics consume effects differently: explicitly when its value is semantically required. Under #123's template-level check, an `out = prev` default before a comparison override is live for every argument type, so it needs no exemption. -- *Shared inputs.* A body is analyzed once per type specialization at - settlement, with every input treated as its own value, and every script - that reaches the specialization replays its diagnostics. Sharing an input - with an output at a call only adds reads today, and none once #123 makes - inputs stable, so it can never make a body invalid, and a body must be - valid without it: `out = current + 1` written - twice is reported for `x = Twice(x)` as well as for `y = Twice(x)`, because - the body never reads `out`. A body that means to build on its own write - says so by naming the output, `out = out + 1`, which is readable once - definitely assigned. Diagnostics are deduplicated by location and message, - and unused-write errors are errors rather than warnings throughout. - -After a script solve succeeds, CFG first treats the script as a zero-input, -zero-output template for structural validation, then runs effect-sensitive -dataflow over the typed body with a fresh scope. Structural diagnostics do not -gate that typed pass: its already-collected explicit reads are reused so one -compile reports all independent CFG diagnostics without repeating formatting -errors. The compiler then traverses -the script's complete -direct-callee keys and each cached `DirectCallees` slice depth-first with a -visited set. Every specialization remains analyzed and cached independently; -the script presents their diagnostics as a first-seen, source-stable set union -keyed by source location and message, so two type variants of one template do -not print the same defect twice. Warm scripts need no body rewalk, and -unreachable cached diagnostics never leak. Persistent edges use -`CallParamTypes` plus a distinct -`ScalarCallParamTypes` key only when the final source-call `ExprInfo` records -that the companion was actually ensured; shared-cache membership alone never -creates reachability. +- *Shared inputs.* A body is analyzed once per template, with every input + treated as its own value. Sharing an input with an output at a call only + adds reads today, and none once #144 makes inputs stable, so it can never + make a body invalid, and a body must be valid without it: + `out = current + 1` written twice is reported for `x = Twice(x)` as well as + for `y = Twice(x)`, because the body never reads `out`. A body that means to + build on its own write says so by naming the output, `out = out + 1`, which + is readable once definitely assigned. Unused-write errors are errors rather + than warnings throughout. Inside a function body, domain ownership decides which statements an empty domain can skip. A `Range` or `ArrayRange` **parameter** establishes a -function-level domain driving the whole body, contributing one shared effect at -the function boundary rather than making every statement that reads it -independently conditional. A locally created range owns only the statements it -drives — `res = a[i * n + j]` under a body-local `j = (i + 1):n` has an +function-level domain driving the whole body: it decides whether the body runs +at all, not what a body that runs writes, so the template check never treats +a parameter as a range. A locally created range owns only the statements it +drives — `t = a[i * n + j]` under a body-local `j = (i + 1):n` has an RHS-local domain, not a function-owned one. -Structural template CFG deliberately makes neither distinction — typed -per-specialization effects resolve it when the function becomes reachable. The CFG pass itself stays: dataflow legality is a different question from ownership checking. This fix does not depend on PIR and lands before it. @@ -1111,9 +1035,9 @@ Pluto has no users yet, so migration optimizes for the clean end state: `compileCondExprStatement` handles ranged logical trees; `compileCondStatement` feeds the general assignment path. Axes: gate (none/scalar/ranged); RHS capabilities as **composable flags** (conditional, checked, ranged, - collector, call — one RHS can be several at once); callee output effect for - call rows (all-`MustWrite` versus any-`MayWrite`, which after #123 only a - possibly empty callee-owned domain produces); value kind + collector, call — one RHS can be several at once); for call rows, whether + a possibly empty callee-owned domain can leave the outputs unwritten (the + only way left after #123); value kind (scalar/heap/multi-output/self-referential/descriptor/struct/table); target kind (local/output/discard); statement form. Each range domain also carries a role: descriptor value, RHS-local, shared @@ -1176,8 +1100,9 @@ Two PRs, implementing §15. `ReadsSeed` facts become ordinary pre-write CFG reads; templates keep only structural checks; each specialization caches immutable diagnostics and complete direct-call keys; scripts replay the reachable closure once; and - the duplicated syntactic effect classifiers are deleted. #123 moves these - diagnostics back to one conservative pass per template (§15). + the duplicated syntactic effect classifiers are deleted. #123 moved these + diagnostics back to one conservative pass per template, and removed the + per-specialization caches, the replay and the effect fixed point (§15). Step 2 fixes the conditional-write false positive without depending on PIR. The Step 2B regression and leak suites pass, so PIR implementation can resume. @@ -1226,8 +1151,8 @@ both); suite output is unchanged. `person.name` and `%t0#1.name` goldens of §17 land here. - The ownership elaboration pass (§8), with generic cleanup lowering and the consuming-operand promotion; its copy-loud goldens are listed in §17. -- **Calls split by argument yield.** #123 (decided) lands before this slice: - every body that runs writes every output, and ABI 3.0 removes the seed and +- **Calls split by argument yield.** #123 landed before this slice: every + body that runs writes every output, and ABI 3.0 (#69) removes the seed and the write markers from functions without a range parameter, so every non-ranged callee is all-`MustWrite`. An argument whose conditional or checked failure remains unresolved still makes the invocation `MayYield`, @@ -1238,7 +1163,7 @@ both); suite output is unchanged. separately. Argument yield is therefore a router axis, recorded in the capability matrix (rows 6, 6b, 6c). The earlier plan also split on callee output effect and built a `{value, didWrite}` variant for print; #123 - removes both for every call without a callee-owned domain. + removed both for every call without a callee-owned domain. **First slice landed — heap values and ownership elaboration.** The router admits heap value kinds (both string flavours, arrays, tables) and struct @@ -1460,8 +1385,9 @@ generic plan lowerer, the existing reusable primitives, and no duplicated statement classification or specialized conditional orchestration. Surviving reorganized rather than deleted: loop and guard emission, generic storage across branches and iterations, the expression compiler for `eval` regions, the -CFG pass (one flow pass per template under #123, whose text-only write -classification deliberately stays separate from the solver's effects, §15), +CFG pass (one flow pass per template, scripts included once they move to it, +whose text-only write classification deliberately stays separate from the +solver's effects, §15), and the runtime. Per-step deletions are targets, not guarantees — each lands only when its step @@ -1526,17 +1452,17 @@ immediate deletion at the last consumer. - #123: flow checks run once per template, including one nothing calls: a body that can leave an output unwritten is rejected at the definition; `out = x > 0` alone is rejected for every argument type; `out = prev` then - `out = x > 0` is accepted for scalar and array `x`, and the array - specialization drops the dead default without a diagnostic; after + `out = x > 0` is accepted for scalar and array `x`, with no diagnostic for + the array specialization's dead default; after `r = MakeRange(n)`, `out = prev` then `out = r * 2` is accepted and `F(y, 0)` keeps `y`, while without the default both the body and a read of `out` after `out = r * 2` are rejected; a range passed on through another template's output, or forwarded by `||` or value-position `&&` as in `r = MakeRange(n > 0) || MakeRange(3)`, counts the same; and `y = Helper(x)` then - `out = y + 1`, where `Helper` returns a scalar, is a definite write -- summaries are rebuilt per specialization walk and published only on - `Settled`; transitive effects reach a fixed point across recursive closures + `out = y + 1`, where `Helper` returns a scalar, is a definite write; each + way a value holds or iterates a range (a gate naming it, a literal or + binding under `&&`, a call under `||`) agrees with the solved types ### Loop-carried tests diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index 0c4fcbfc..19477941 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -22,8 +22,8 @@ This document describes Pluto's semantic model and compares it with other major 7. **Read-Only Function Arguments:** Inputs are read-only and keep their values for the whole call, even when the caller also passes the argument as a destination. Every body that runs writes every output, and results - reach the caller's destinations when the assignment commits. Master does - not enforce these rules yet (#123); see "Status" under Call Site. + reach the caller's destinations when the assignment commits. Stable + inputs are not implemented yet (#144); see "Status" under Call Site. 8. **Function Locking:** Input arguments hold read locks, outputs hold write locks (automatic concurrency safety). 9. **Memory Management:** Automatic scope-based deallocation (no GC pauses). @@ -275,8 +275,9 @@ res = sum(a, b) or a call with such an argument. A comparison counts even where an array argument would make it a mask that always writes. Complementary conditions such as `n <= 1` and `n > 1` are not recognized either; for floats they need - not cover every case, since both fail for a NaN. A range with literal - bounds that is not empty always runs. + not cover every case, since both fail for a NaN. Only a range literal + written with constant, nonempty bounds, such as `0:3`, always runs; a range + bound to a name or returned by a call may be empty. - **Reading outputs**: A body may read an output (as a value, a condition, a call argument, a print, a formatting marker, or an empty array's sample) only after it is definitely assigned, judged from the text as above. A read @@ -315,15 +316,20 @@ out = Pos(prev, x) For a scalar `x`, the value-position comparison `x > 0` yields `x` or fails, so `out` keeps `prev`. For an array `x`, it yields a mask and always writes, -so the default is never read: the compiler drops it in that specialization -and reports nothing. A statement gate such as `Maybe`'s `out = x > 0 x` -serves only a scalar `x`, since a statement condition must be a scalar. +so the default is never read, and the check reports nothing for it; the +array specialization still copies the default before the mask replaces it. +A statement gate such as `Maybe`'s `out = x > 0 x` serves only a scalar `x`, +since a statement condition must be a scalar. The text also decides which values are ranges. A parameter never holds one, since a range argument runs the function once per element: `Wrap(1:5)`, where `Wrap` returns its input, gives `4`. Whether a function returns a range follows from its text, as with `MakeRange` below, and a range that -arrives through a call counts as possibly empty, so `F` needs its default: +arrives through a call counts as possibly empty, so `F` needs its default. +A statement's condition iterates the ranges it names, so its value reads them +as elements: in `kept = s > 2 s`, `kept` gets an element of `s`. Under `||` +and `&&`, only a call passes its range on whole; a range literal or binding +operand is iterated, so `n > 0 && 0:5` gives an element. ```python r = MakeRange(n) @@ -416,24 +422,14 @@ give the input and the output one slot where no read can tell the difference: in `a = Maybe(a, -1)`, the body reads `prev` only in its first write to `out`, so `out = prev` needs no copy. -**Status (#123, decided 2026-09-28; not yet implemented).** Master still -differs in three ways: - -- A body may leave an output unwritten. `x = F()` then keeps `x` through a - hidden seed parameter, and a fresh `x` gets 0, while a nested call such as - `c = F(c) + 0` reads the unwritten output as zero. -- A shared input is a live reference: each read observes earlier writes to - the shared output. `value, seen = FoldAfter(value, 5)` gives `15 15`, - `p, q = Swap(p, q)` with the sequential body gives `2 2`, - `value, seen, old = FoldAfter(value, 5), value` gives `15 15 10`, and - `FoldAfter(value, 1:3)` gives `13 13`. -- Flow checks run per specialization, so their result can depend on the - argument types: `out = prev` followed by `out = x > 0` is rejected as a - dead store for an array `x`. A function nothing calls gets only structural - checks. - -The #123 implementation removes these differences, migrates the fixtures -(`tests/alias_input` among them), and updates the README. +**Status.** Definite outputs and the per-template checks are implemented +(#123). Stable inputs are decided and not yet implemented (#144): a shared +input is still a live reference, so each read observes earlier writes to the +shared output. `value, seen = FoldAfter(value, 5)` gives `15 15`, +`p, q = Swap(p, q)` with the sequential body gives `2 2`, +`value, seen, old = FoldAfter(value, 5), value` gives `15 15 10`, and +`FoldAfter(value, 1:3)` gives `13 13`. #144 removes this difference, migrates +`tests/alias_input`, and updates the README's sharing example. ### Range Parameters diff --git a/docs/Pluto PIR Capability Matrix.md b/docs/Pluto PIR Capability Matrix.md index 8103f2bf..575b101a 100644 --- a/docs/Pluto PIR Capability Matrix.md +++ b/docs/Pluto PIR Capability Matrix.md @@ -35,8 +35,7 @@ combination migratable. A fallback that resolves every failure leaves the argument `MustYield` and outside this rule, though the router may still defer it until its node is supported. -**After #123 (decided; lands before Step 4's call slice).** A body that runs -writes every output, so every non-ranged callee is all-`MustWrite`, and +**After #123.** A body that runs writes every output, so every non-ranged callee is all-`MustWrite`, and any-`MayWrite` remains only where a possibly empty callee-owned domain can leave the outputs unwritten (Step 7). The split rows 5c, 6, 6b, 8b, 35d and 36d then cut over in Step 4 whenever their arguments are `MustYield`, and @@ -163,7 +162,7 @@ two together rather than treating any single row as a deletion trigger. - **4** — `commitAssignments` copy/move marking; **now planned**: `pir.Elaborate` promotes a borrow to transfer when its owner is replaced in the group and copies every later borrow of that owner. *Tests:* `mem/mem.spt:64,78`; goldens `TestPlanGoldenHeapSwap`, `TestPlanGoldenDuplicateSource`, `TestPlanGoldenReplaceAndDiscard`. *Helpers:* `markCopyRequirements`, `freeExprOldValues`, `deepCopyIfNeeded` - **5** — per-slot sink: never bound, never typed (`isDiscard`), CFG-exempt. *Tests:* `discard`; the bare Range-descriptor discard is pinned by `TestPlanGoldenRangeDiscard` in `compiler/pir_test.go` - **5b** — per-slot sink; a discarded temporary is dropped (`drop`), a discarded named value stays borrowed; **now planned** for ordinary heap expressions: the derived `drop %tN` appears in expanded PIR. *Tests:* `discard`; golden `TestPlanGoldenReplaceAndDiscard` -- **5c** — discarded call outputs keep their yield/write validity for cleanup, and the whole-call rule holds even when every output is discarded. The Step 1 inventory sent any-`MayWrite` calls to Step 6; after #123 no non-ranged callee may skip a write, so the only deferral left here is a call whose argument can fail, in Step 6. A possibly empty callee-owned domain is a ranged call, handled in Step 7 (row 5f). *Tests:* `discard`: all-`MustWrite` multi-output, both scalar (`FOuter`) and heap (`twoStr`); any-`MayWrite` heap (`maybeStr`, writing and non-writing paths). *Missing:* all-`MustWrite` direct-scalar (single-output) call; a discarded call whose argument can fail, such as `_ = mkTag(arr[9])` +- **5c** — discarded call outputs keep their yield/write validity for cleanup, and the whole-call rule holds even when every output is discarded. The Step 1 inventory sent any-`MayWrite` calls to Step 6; after #123 no non-ranged callee may skip a write, so the only deferral left here is a call whose argument can fail, in Step 6. A possibly empty callee-owned domain is a ranged call, handled in Step 7 (row 5f). *Tests:* `discard`: all-`MustWrite` multi-output, both scalar (`FOuter`) and heap (`twoStr`); a heap callee that starts from a default and overrides it under a gate (`maybeStr`, both paths). *Missing:* all-`MustWrite` direct-scalar (single-output) call; a discarded call whose argument can fail, such as `_ = mkTag(arr[9])` - **5d** — a blank needs no seed on the skip path, so `ensureSeededDest` leaves it unbound. *Tests:* `discard` (failed and admitted, scalar and heap element). *Helpers:* `commitAssignmentsPerExpr` - **5e** — `commitConditionalOutputs` frees the blank's temp instead of binding it, on both the admitted and skipped paths. *Tests:* `discard`: all-`MustWrite` heap multi-output call (`twoStr`), gate admitting and rejecting. *Missing:* scalar-valued and ordinary-RHS gated blanks; a gated call to a callee that starts from a default, the #123 form of a conditional callee. *Helpers:* `compileCondStatement` - **5f** — `bindRangedTempOutputs` skips blanks, so ranged staging leaves no transient binding; the discarded value is released per iteration. *Tests:* `discard`: `_ = mkTag(i)` (single output), `_, _ = twoStr(mkTag(i), "z")` (multi-output). *Missing:* both fixtures are heap-valued, so the all-`MustWrite` scalar ranged call is uncovered, as is a call over a possibly empty range with its outputs discarded. *Helpers:* `compileAssignments` @@ -187,11 +186,11 @@ two together rather than treating any single row as a deletion trigger. - **13** — callee body iterates: `compileCallInner` for direct returns, destination-seeded staged outputs for indirect and multi-output. *Tests:* `math/acc.spt`, `math/acc_desc`, `array/array_range.spt:89-96` (heap via indirect ABI) - **14** — ordinary function-body statement; a direct `I64`/`F64` output is an SSA value with **no** runtime write flag. *Tests:* `math/acc.pt` - **14i** — ordinary function-body statement; an indirect output has a runtime write flag set on commit. *Tests:* `mem/mem_alias_refine.pt`, `mem/mem.pt` -- **14a** — gated function-body statement: the output is conditionally written, which is what makes the callee `MayWrite` at its boundary (`IsEven`/`IsOdd` conditionally write their indirect output pair). Under #123 such a body writes a default first, so the gate no longer weakens the boundary. *Tests:* `math/math.pt`, `math/rec.pt`, `mem/mem_cmp_lhs.pt`. *Helpers:* `compileCondStatement` +- **14a** — gated function-body statement after a default: under #123 a body writes its output first and overrides it under the gate (`IsEven`/`IsOdd` start from their base values), so the gate no longer weakens the boundary. *Tests:* `math/math.pt`, `math/rec.pt`, `mem/mem_cmp_lhs.pt`. *Helpers:* `compileCondStatement` - **14c** — a body-local collector domain, i.e. a collector driven by a range created inside the body rather than by a parameter. *Missing:* **uncovered**: `cache_reuse.pt` uses fixed literals, `array_scalar_assign.pt` is parameter-driven (row 14b). *Helpers:* collector rewrite - **14b** — body driven by a `Range`/`ArrayRange` **parameter**, whose domain wraps the whole body and may execute zero times — this is what weakens the output to `MayWrite` at the boundary. *Tests:* `array/array_range.pt` - **14e** — as 14b with an inner collector: `ArraySetAdd(1:4)` runs a function-owned outer domain around a collector-local `[0:i]`, so full cutover needs collectors. *Tests:* `array/array_scalar_assign.pt` (`ArraySetAdd`), `array/array_func.pt`. *Helpers:* collector rewrite -- **14d** — a range created **inside** the body drives one statement; the output is still `MayWrite` at the boundary because that local range can be empty — only the blanket function-owned weakening is absent. Under #123 the output needs a default before that statement. *Tests:* `math/dependent_range.pt` (`j = (i + 1):n`) +- **14d** — a range created **inside** the body drives one statement, which may then run no iteration. Under #123 the output needs a default before that statement, or the statement writes a local first: `UpperTriRowTail` assigns `t = a[i * n + j]` under `j = (i + 1):n`, then `res = t`, and `DoubleLast` starts from `prev` before a range from a call. *Tests:* `math/dependent_range.pt` - **15** — `compileCondStatement`. *Tests:* `assign`, `initialize`, `zero_val`, `partial_returns`. *Helpers:* `compileCondStatement` - **16** — `compileCondStatement` + `prePromoteConditionalCallArgs`. *Tests:* `cond_copy`, `mem/mem_str.spt`, `math/math.pt`. *Helpers:* staging family - **17** — `compileCondStatement` + `aliasCondDests`. *Tests:* `tests/cond/expr_forms`. *Helpers:* `aliasCondDests` @@ -241,7 +240,7 @@ two together rather than treating any single row as a deletion trigger. - **7d** — print-position fallback: `arr[oob] \|\| -1, val1` emits `-1 val1` — the fallback resolves before the invocation boundary, letting the whole line print. *Missing:* regressions when implemented, incl. a heap-valued print fallback. *Helpers:* condLHS spine - **29** — `compilePrintStatement` direct arm. *Tests:* `helloworld`, `str`, `1.2-report`. *Helpers:* `printAllExpressions`, `compilePrintStatement` -- **29b** — non-ranged direct call argument in print. Today an unwritten result prints its seed; after #123 a call that runs writes every output, so no `{value, didWrite}` variant is needed. *Tests:* `math/print_func.spt:4` (`Square(3)`). *Missing:* a call argument whose own argument fails +- **29b** — non-ranged direct call argument in print. A call that runs writes every output (#123), so no `{value, didWrite}` variant is needed. *Tests:* `math/print_func.spt:4` (`Square(3)`). *Missing:* a call argument whose own argument fails - **29c** — non-ranged indirect call argument in print. *Missing:* **uncovered**: multi-output and heap call results printed directly - **30** — `compileCondOperands` ANDs every argument's conditions and gates the one `printAllExpressions` call. *Tests:* `mem/mem_cmp_lhs.spt`, `array/oob_print`. *Missing:* side-effecting or owned-heap sibling of a failed conditional (add with Step 6). *Helpers:* `compileCondOperands` - **31** — no active bounds guard: prints the materialized zero today; Step 6 makes an unresolved OOB suppress the complete invocation and newline. *Tests:* `array/oob_print`. *Missing:* a suppressed invocation whose arguments own heap temporaries (add with Step 6). *Helpers:* §5 @@ -375,10 +374,10 @@ to a **gated print** (`arr[oob] val1, val2`) — scheduled syntax that does not parse today (`PrintStatement` has no gate field) and lands in its own required PR before Step 6, with semantics-doc entry and capability rows. -An unwritten direct-return argument cannot suppress the invocation today, -since a direct `I64`/`F64` result carries no validity bit, so it prints its -seed. #123 (decided) removes the case instead of adding a -`{value, didWrite}` variant: a call that runs writes every output (plan §15). +A direct `I64`/`F64` result carries no validity bit, and none is needed: +#123 removed the unwritten direct return instead of adding a +`{value, didWrite}` variant, since a call that runs writes every output +(plan §15). ## 6. Correction to the plan's deletion order diff --git a/docs/Pluto Range Semantics.md b/docs/Pluto Range Semantics.md index d7e81992..3b48888f 100644 --- a/docs/Pluto Range Semantics.md +++ b/docs/Pluto Range Semantics.md @@ -145,15 +145,13 @@ as in `F(i, j)`, still form their normal cartesian domain. These choices do not change the results visible to source code. Function outputs are staged independently before the call. A body that runs -writes every output (#123, decided and not yet implemented), so a destination -keeps its value only when no invocation is admitted: the driver is empty, an -argument fails at every point, or the statement's gate is false. Zero -iterations always mean no assignment, wherever the loop is placed. Today a -skipped condition inside the body can also leave an output unwritten, and its -staged value is preserved. At -representation boundaries, such as a static string result being assigned -into an owned-string destination, the callee receives its declared zero value -and the caller commits the adapted result only when the callee writes it. +writes every output (#123), so a destination keeps its value only when no +invocation is admitted: the driver is empty, an argument fails at every +point, or the statement's gate is false. Zero iterations always mean no +assignment, wherever the loop is placed. At representation boundaries, such as +a static string result being assigned into an owned-string destination, the +callee receives its declared zero value and the caller commits the adapted +result once the callee writes it. Examples: From 00f459497e01411b2561120eef8916e588c4a9d6 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 7 Oct 2026 15:02:04 +0530 Subject: [PATCH 05/34] fix(compiler): report template text the flow checks cannot classify The per-template pass classifies each write from a template's text and then checks flow over those classes. Where the text could not be read, the classification guessed: an assignment whose values did not fill its targets marked every target as possibly skipped, and a name that once held a range counted as one in every later statement. A template the solver rejects then got "may be left unassigned" errors, and since those stop the code module before any script compiles, the real arity or type error never appeared. The pass now reports what it cannot classify, as the solver does in any specialization: - a call that names no template: undefined function: Missing - an assignment whose values do not fill its targets: assignment mismatch: 3 targets but 4 values - a name assigned both a range and a non-range value: cannot reassign "r" from a range to a non-range value These run alongside the structural checks, for templates nothing calls as well, so such a template no longer compiles while unused. The flow checks run only when both kinds of check pass. The solver's arity error now uses the same message, replacing "Statement lhs identifiers are not equal to rhs values!!!". Refs #123 Co-Authored-By: Claude Opus 5.5 --- compiler/cfg.go | 154 +++++++++++++++++++++++++--------- compiler/cfg_test.go | 178 +++++++++++++++++++++++++++++++++++++--- compiler/solver.go | 4 +- compiler/solver_test.go | 29 +++++++ docs/Pluto IR Plan.md | 18 ++-- 5 files changed, 324 insertions(+), 59 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index cc6517be..e1d7f46d 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -187,8 +187,8 @@ func (cfg *CFG) collectSpecifierReads(value string, tok token.Token, runes []run } // AnalyzeFuncs validates every function template once, whether or not -// anything calls it: structural checks, then dataflow checks that classify -// writes from the template's text alone. +// anything calls it: structural checks, then a classification of each write +// from the template's text alone, then dataflow checks over those writes. func (cfg *CFG) AnalyzeFuncs() { cfg.CodeCompiler.settleRangeOutputs() @@ -235,22 +235,93 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { cfg.addError(output.Tok(), fmt.Sprintf("output parameter %q is never assigned", output.Value)) } + writes := cfg.classifyTemplateWrites(fn) if len(cfg.Errors) == errorsBefore { - cfg.checkTemplateFlow(fn, body.statementReads) + cfg.checkTemplateFlow(fn, body.statementReads, writes) } } -// checkTemplateFlow runs the dataflow checks once per template, classifying -// each write from the text: an output is read only after a definite +// classifyTemplateWrites classifies every assignment's writes from the +// template's text, indexed by statement, and reports the text it cannot +// classify: a call that names no template, an assignment whose values do not +// fill its targets, and a name reassigned between a range and a non-range +// value. The solver rejects each of these in any specialization; the flow +// checks, which would misread them, do not run over a template that has one. +func (cfg *CFG) classifyTemplateWrites(fn *ast.FuncStatement) [][]textWrite { + flow := newRangeFlow(cfg.CodeCompiler, fn) + writes := make([][]textWrite, len(fn.Body.Statements)) + for i, stmt := range fn.Body.Statements { + cfg.rejectUndefinedCalls(statementExpressions(stmt)) + let, ok := stmt.(*ast.LetStatement) + if !ok { + continue + } + + letWrites, ok := flow.letWrites(let) + if !ok { + cfg.addError(let.Token, assignmentMismatch(len(let.Name), cfg.CodeCompiler.valueCount(let.Value))) + continue + } + cfg.rejectRangeKindChanges(flow, let, letWrites) + flow.record(let, letWrites) + writes[i] = letWrites + } + return writes +} + +func (cfg *CFG) rejectUndefinedCalls(exprs []ast.Expression) { + for _, expr := range exprs { + if call, ok := expr.(*ast.CallExpression); ok { + if _, found := cfg.CodeCompiler.callTemplate(call); !found { + cfg.addError(call.Token, undefinedFunction(call.Function.Value)) + } + } + cfg.rejectUndefinedCalls(ast.ExprChildren(expr)) + } +} + +func (cfg *CFG) rejectRangeKindChanges(flow *rangeFlow, let *ast.LetStatement, writes []textWrite) { + for i, target := range let.Name { + heldRange, assigned := flow.assigned[target.Value] + if !assigned || heldRange == writes[i].holdsRange { + continue + } + + if heldRange { + cfg.addError(target.Tok(), fmt.Sprintf("cannot reassign %q from a range to a non-range value", target.Value)) + continue + } + cfg.addError(target.Tok(), fmt.Sprintf("cannot reassign %q from a non-range value to a range", target.Value)) + } +} + +func undefinedFunction(name string) string { + return fmt.Sprintf("undefined function: %s", name) +} + +func assignmentMismatch(targets, values int) string { + return fmt.Sprintf("assignment mismatch: %s but %s", countOf(targets, "target"), countOf(values, "value")) +} + +// countOf writes n with its noun, in the plural unless n is one. +func countOf(n int, noun string) string { + if n == 1 { + return "1 " + noun + } + return fmt.Sprintf("%d %ss", n, noun) +} + +// checkTemplateFlow runs the dataflow checks once per template over the writes +// classified from its text: an output is read only after a definite // assignment and is definitely assigned by the end of the body, and no write // is dead or overwrites a definite write that nothing read. It runs only on a -// structurally valid body, so every output read follows some write. -func (cfg *CFG) checkTemplateFlow(fn *ast.FuncStatement, statementReads [][]VarEvent) { +// structurally valid body whose text classifies, so every output read follows +// some write and every assignment has its writes. +func (cfg *CFG) checkTemplateFlow(fn *ast.FuncStatement, statementReads [][]VarEvent, writes [][]textWrite) { cfg.PushBlock() defer cfg.PopBlock() outputs := identSet(fn.Outputs) - flow := newRangeFlow(cfg.CodeCompiler, fn) assigned := make(map[string]struct{}, len(outputs)) lastWrites := make(map[string]VarEvent) @@ -258,9 +329,7 @@ func (cfg *CFG) checkTemplateFlow(fn *ast.FuncStatement, statementReads [][]VarE cfg.rejectUnassignedOutputReads(statementReads[i], outputs, assigned) events := append([]VarEvent(nil), statementReads[i]...) if let, ok := stmt.(*ast.LetStatement); ok { - writes := flow.letWrites(let) - flow.record(let, writes) - events = append(events, textWriteEvents(let, writes)...) + events = append(events, textWriteEvents(let, writes[i])...) } cfg.processDataflowEvents(stmt, events, lastWrites) @@ -323,11 +392,13 @@ type textWrite struct { // descriptor and which values a range drives, as the solver types them. A // parameter never holds a range, since a range argument runs the body once // per element; a binding holds one when it is assigned a descriptor; a call's -// output holds one when its template's output does. +// output holds one when its template's output does. assigned keeps whether +// each assigned name's first assignment gave it a range. type rangeFlow struct { cc *CodeCompiler bindings map[string]struct{} defined map[string]struct{} + assigned map[string]bool } func newRangeFlow(cc *CodeCompiler, fn *ast.FuncStatement) *rangeFlow { @@ -335,39 +406,27 @@ func newRangeFlow(cc *CodeCompiler, fn *ast.FuncStatement) *rangeFlow { for _, output := range fn.Outputs { defined[output.Value] = struct{}{} } - return &rangeFlow{cc: cc, bindings: make(map[string]struct{}), defined: defined} + return &rangeFlow{cc: cc, bindings: make(map[string]struct{}), defined: defined, assigned: make(map[string]bool)} } // letWrites classifies every target of an assignment, discards included, so // the result lines up with its names. Values take targets by their output -// counts; when the counts do not cover the targets, which the solver reports, -// every target gets the statement's weakest class. -func (rf *rangeFlow) letWrites(let *ast.LetStatement) []textWrite { - gateNames := rf.gateRanges(let.Condition) - gated := len(let.Condition) > 0 - slots := make([]int, len(let.Value)) - total := 0 - for i, value := range let.Value { - slots[i] = rf.cc.valueSlots(value) - total += slots[i] +// counts; it reports false when those do not fill the targets. +func (rf *rangeFlow) letWrites(let *ast.LetStatement) ([]textWrite, bool) { + if rf.cc.valueCount(let.Value) != len(let.Name) { + return nil, false } + gateNames := rf.gateRanges(let.Condition) + gated := len(let.Condition) > 0 writes := make([]textWrite, 0, len(let.Name)) - if total != len(let.Name) { - definite := !gated && !slices.ContainsFunc(let.Value, rf.maySkip) - for range let.Name { - writes = append(writes, textWrite{definite: definite}) - } - return writes - } - - for i, value := range let.Value { + for _, value := range let.Value { definite := !gated && !rf.maySkip(value) - for _, holdsRange := range rf.slotRanges(value, slots[i], gateNames) { + for _, holdsRange := range rf.slotRanges(value, rf.cc.valueSlots(value), gateNames) { writes = append(writes, textWrite{definite: definite, holdsRange: holdsRange}) } } - return writes + return writes, true } // maySkip reports whether a value can leave its targets unwritten: it can @@ -409,6 +468,9 @@ func (rf *rangeFlow) record(let *ast.LetStatement, writes []textWrite) { } rf.defined[target.Value] = struct{}{} + if _, ok := rf.assigned[target.Value]; !ok { + rf.assigned[target.Value] = writes[i].holdsRange + } if writes[i].holdsRange { rf.bindings[target.Value] = struct{}{} } @@ -580,8 +642,12 @@ func (cc *CodeCompiler) settleRangeOutputs() { func (cc *CodeCompiler) templateRangeBindings(fn *ast.FuncStatement) map[string]struct{} { flow := newRangeFlow(cc, fn) for _, stmt := range fn.Body.Statements { - if let, ok := stmt.(*ast.LetStatement); ok { - flow.record(let, flow.letWrites(let)) + let, ok := stmt.(*ast.LetStatement) + if !ok { + continue + } + if writes, ok := flow.letWrites(let); ok { + flow.record(let, writes) } } return flow.bindings @@ -611,14 +677,20 @@ func (cc *CodeCompiler) valueSlots(value ast.Expression) int { return 1 } +// valueCount counts the values a list of expressions yields, as valueSlots +// counts each. +func (cc *CodeCompiler) valueCount(values []ast.Expression) int { + count := 0 + for _, value := range values { + count += cc.valueSlots(value) + } + return count +} + // callTemplate finds a call's template by its name and arity, where an // argument counts once for each value it yields. func (cc *CodeCompiler) callTemplate(call *ast.CallExpression) (*ast.FuncStatement, bool) { - arity := 0 - for _, argument := range call.Arguments { - arity += cc.valueSlots(argument) - } - return cc.lookupFuncTemplate(call.Function.Value, arity) + return cc.lookupFuncTemplate(call.Function.Value, cc.valueCount(call.Arguments)) } // callRangeOutputs reports which outputs of a call's template hold a Range, diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index 95f609e7..073aad60 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -1326,17 +1326,6 @@ r = Forward(n) out = "-n%(-w)d"`, unassigned: []string{"out"}, }, - { - // When the values do not cover the targets, which the solver - // reports, every target gets the statement's weakest class. - name: "MismatchedTargetsAllMaySkip", - code: `a, b = Pair(x) - a, b = x, x - -a, b, c = F(arr, x) - a, b, c = Pair(x), arr[0], x`, - unassigned: []string{"a", "b", "c"}, - }, { // A && fills its right operand's targets. name: "AndTakesRightOperandSlots", @@ -1394,6 +1383,173 @@ func TestUnassignedOutputIsReportedAtTheHeader(t *testing.T) { require.Equal(t, 1, errs[0].Token.Column) } +// The template checks classify each write from the text, so they first report +// text the classification cannot read, which the solver rejects in any +// specialization, and check no flow over it. +func TestUnclassifiableTemplateTextIsReported(t *testing.T) { + tests := []struct { + name string + code string + errors []string + }{ + { + // The value that can fail does not mark the targets as possibly + // skipped. + name: "ValueCountMismatch", + code: `a, b = Pair(x) + a, b = x, x + +a, b, c = F(arr, x) + a, b, c = Pair(x), arr[0], x`, + errors: []string{"assignment mismatch: 3 targets but 4 values"}, + }, + { + name: "OneValueForTwoTargets", + code: `a, b = F(x) + a, b = x`, + errors: []string{"assignment mismatch: 2 targets but 1 value"}, + }, + { + name: "UndefinedFunction", + code: `y = F(x) + y = Missing(x)`, + errors: []string{"undefined function: Missing"}, + }, + { + // A call names a template by the number of values its arguments + // yield. + name: "WrongArgumentCount", + code: `a, b = Pair(x) + a, b = x, x + +y = F(x) + y = Pair(x, x)`, + errors: []string{"undefined function: Pair"}, + }, + { + name: "UndefinedFunctionInConditionAndArgument", + code: `y = F(x) + y = x + y = Check(x) > 0 Inc(Next(x)) + +out = Inc(n) + out = n + 1`, + errors: []string{"undefined function: Check", "undefined function: Next"}, + }, + { + name: "UndefinedFunctionInPrint", + code: `y = F(x) + y = x + Missing(x)`, + errors: []string{"undefined function: Missing"}, + }, + { + // The solver counts an unknown call as one value and reports both. + name: "UndefinedFunctionInTuple", + code: `a, b = F(x) + a, b = Missing(x)`, + errors: []string{"undefined function: Missing", "assignment mismatch: 2 targets but 1 value"}, + }, + { + name: "RangeReassignedNonRange", + code: `y = G(x) + r = 1:3 + s = [r] + r = x + y = r + s`, + errors: []string{`cannot reassign "r" from a range to a non-range value`}, + }, + { + name: "NonRangeReassignedRange", + code: `y = G(x) + r = x + s = r + 1 + r = 1:3 + y = r + s`, + errors: []string{`cannot reassign "r" from a non-range value to a range`}, + }, + { + // As in the solver, the first assignment fixes the kind. + name: "KindFollowsFirstAssignment", + code: `y = G(n) + r = 0:n + r = n + r = 1:n + y = [r]`, + errors: []string{`cannot reassign "r" from a range to a non-range value`}, + }, + { + // A range the statement's condition iterates reads as an element. + name: "GateIteratedRangeReassigned", + code: `y = G(n) + r = 0:n + s = [r] + r = r > 1 r + y = s`, + errors: []string{`cannot reassign "r" from a range to a non-range value`}, + }, + { + // y's gated write waits until the template's text classifies. + name: "NoFlowChecksOverUnclassifiableText", + code: `y, z = F(x) + y = x > 0 x + z = Missing(x)`, + errors: []string{"undefined function: Missing"}, + }, + { + name: "OtherTemplatesStillChecked", + code: `y = F(x) + y = Missing(x) + +out = Maybe(x) + out = x > 0 x`, + errors: []string{"undefined function: Missing", unassignedOutputMessage("out")}, + }, + { + // The text is read even when the structure is invalid; the flow + // is not. + name: "ReportedWithStructuralErrors", + code: `y = F(x, unused) + y = Missing(x)`, + errors: []string{`input parameter "unused" is never read`, "undefined function: Missing"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + cc := NewCodeCompiler(ctx, test.name, "", mustParseCode(t, test.code)) + require.Equal(t, test.errors, extractErrorMessages(cc.Compile())) + }) + } +} + +// Unclassifiable text is reported where the solver reports it: an assignment +// at its =, a call at its parenthesis, and a reassignment at its target. +func TestUnclassifiableTemplateTextPositions(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + cc := NewCodeCompiler(ctx, t.Name(), "", mustParseCode(t, `a, b = F(x) + a, b = Missing(x) + +y = G(x) + r = 1:3 + s = [r] + r = x + y = r + s`)) + errs := cc.Compile() + + require.Len(t, errs, 3) + positions := make([][2]int, len(errs)) + for i, err := range errs { + positions[i] = [2]int{err.Token.Line, err.Token.Column} + } + require.Equal(t, [][2]int{{2, 19}, {2, 10}, {7, 5}}, positions) +} + // The template checks read from the text which bindings hold a Range, and // settlement checks that the solved types agree, so each way a value holds // or iterates a range is pinned here against the solver. diff --git a/compiler/solver.go b/compiler/solver.go index 82ef38e2..a2182ab7 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -981,7 +981,7 @@ func (ts *TypeSolver) TypeLetStatement(stmt *ast.LetStatement) { if len(stmt.Name) != len(types) { ce := &token.CompileError{ Token: stmt.Token, - Msg: fmt.Sprintf("Statement lhs identifiers are not equal to rhs values!!! lhs identifiers: %d. rhs values: %d. Stmt %q", len(stmt.Name), len(types), stmt), + Msg: assignmentMismatch(len(stmt.Name), len(types)), } ts.Errors = append(ts.Errors, ce) return @@ -2543,7 +2543,7 @@ func (ts *TypeSolver) lookupCallTemplate(ce *ast.CallExpression, args []Type) (* if !ok { cerr := &token.CompileError{ Token: ce.Token, - Msg: fmt.Sprintf("undefined function: %s", ce.Function.Value), + Msg: undefinedFunction(ce.Function.Value), } ts.Errors = append(ts.Errors, cerr) return nil, "", false diff --git a/compiler/solver_test.go b/compiler/solver_test.go index e736bf38..3042ddfc 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -632,6 +632,35 @@ func TestArrayConcatTypeErrors(t *testing.T) { } } +// A script reports a count mismatch or an unknown call in the words a +// template's text check uses. +func TestScriptSharesTemplateTextMessages(t *testing.T) { + tests := []struct { + name string + script string + err string + }{ + {name: "AssignmentMismatch", script: "a, b = 1\na", err: "assignment mismatch: 2 targets but 1 value"}, + {name: "UndefinedFunction", script: "a = Missing(1)\na", err: "undefined function: Missing"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + cc := NewCodeCompiler(ctx, test.name, "", ast.NewCode()) + require.Empty(t, cc.Compile()) + sc := NewScriptCompiler(ctx, test.name, mustParseScript(t, test.script), cc) + ts := NewTypeSolver(sc) + ts.Solve() + + require.NotEmpty(t, ts.Errors) + require.Equal(t, test.err, ts.Errors[0].Msg) + }) + } +} + func TestArrayToScalarAssignmentError(t *testing.T) { ctx := llvm.NewContext() cc := NewCodeCompiler(ctx, "arrayToScalar", "", ast.NewCode()) diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index 8bd405ba..10bef221 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -954,14 +954,19 @@ an earlier `out = prev`, does not definitely assign `out`, and so cannot by itself make a later read of `out` valid. Settlement panics when a specialization's Range-typed bindings disagree with this summary. -The pass runs after a template's structural checks, and only when they pass: -explicit use-before-definition, illegal input/global writes, unused inputs, +The flow checks run after a template's structural checks: explicit +use-before-definition, illegal input/global writes, unused inputs, syntactically unassigned outputs, formatting structure, and discard behavior. Those collect all reads before declaring a statement's destinations, so a fresh `x = x + 1` cannot define its own RHS. An unknown main format marker remains literal text; malformed specifiers and missing dynamic width/precision -variables on a resolved marker remain structural errors. A flow error in any -template fails the code module before any script compiles. +variables on a resolved marker remain structural errors. Alongside them, the +pass reports text it cannot classify, each an error the solver reports in any +specialization: a call that names no template, an assignment whose values do +not fill its targets, and a name assigned both a range and a non-range value. +The flow checks run only when both kinds of check pass, so they never misread +such a template, and a template nothing calls gets these errors too. An error +in any template fails the code module before any script compiles. This partly reverses Step 2B, which moved dead-store and write-after-write checks to specializations; the per-specialization diagnostic cache and replay @@ -1462,7 +1467,10 @@ immediate deletion at the last consumer. `y = Helper(x)` then `out = y + 1`, where `Helper` returns a scalar, is a definite write; each way a value holds or iterates a range (a gate naming it, a literal or - binding under `&&`, a call under `||`) agrees with the solved types + binding under `&&`, a call under `||`) agrees with the solved types; and a + call that names no template, an assignment whose values do not fill its + targets, or a name assigned both a range and a non-range value is reported + where the solver reports it, with no flow error over that template ### Loop-carried tests From 7b56252ca362ef0ed86ca48881774dbc1150777e Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 7 Oct 2026 17:49:06 +0530 Subject: [PATCH 06/34] fix(compiler): keep each range a call passes through || and && A call under || or a value-position && passes each of its outputs on whole, so a Range output stays a descriptor. The template's text range summary applied that only to calls with one output. With Two(n) returning 0:n and n: - kept, k = n > 0 && Two(n) and kept, k = Two(n > 0) || Two(3) gave kept no range, so settlement stopped with an internal error once a script called the function; - the same || written to outputs counted as iterating Two's range, so the body was rejected as possibly leaving its outputs unassigned. A multi-output || or && now takes its right operand's ranges, slot by slot, since a ||'s alternatives must match, and a call passes its ranges on whole when any of its outputs holds one. A || whose alternatives yield different numbers of values gets no ranges and stays the solver's error. Refs #123 Co-Authored-By: Claude Opus 5.5 --- compiler/cfg.go | 19 +++++++---- compiler/cfg_test.go | 54 +++++++++++++++++++++++++++++++ tests/math/dependent_range.pt | 13 ++++++++ tests/math/dependent_range_fn.exp | 1 + tests/math/dependent_range_fn.spt | 5 +++ 5 files changed, 86 insertions(+), 6 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index e1d7f46d..b9f86e69 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -448,13 +448,21 @@ func textNodeFails(expr ast.Expression) bool { } // slotRanges reports, per target a value fills, whether it receives a Range -// descriptor. +// descriptor. A || or value-position && yields its right operand's values, +// since the solver requires a ||'s alternatives to match; one whose right +// operand fills a different number of targets is the solver's error. func (rf *rangeFlow) slotRanges(value ast.Expression, slots int, gateNames map[string]struct{}) []bool { if slots == 1 { return []bool{rf.holdsRange(value, gateNames)} } - if call, ok := value.(*ast.CallExpression); ok { - return rf.cc.callRangeOutputs(call) + + switch v := value.(type) { + case *ast.CallExpression: + return rf.cc.callRangeOutputs(v) + case *ast.InfixExpression: + if (v.IsLogicalOr() || v.IsLogicalAnd()) && rf.cc.valueSlots(v.Right) == slots { + return rf.slotRanges(v.Right, slots, gateNames) + } } return make([]bool, slots) } @@ -493,13 +501,12 @@ func (rf *rangeFlow) holdsRange(value ast.Expression, gateNames map[string]struc } // forwardsRange reports whether expr passes a Range descriptor on whole: a -// call whose template output holds one, or a || or value-position && that +// call with an output that holds one, or a || or value-position && that // yields such a call. A literal or binding operand of || and && is iterated. func (rf *rangeFlow) forwardsRange(expr ast.Expression) bool { switch e := expr.(type) { case *ast.CallExpression: - outputs := rf.cc.callRangeOutputs(e) - return len(outputs) == 1 && outputs[0] + return slices.Contains(rf.cc.callRangeOutputs(e), true) case *ast.InfixExpression: if e.IsLogicalOr() || e.IsLogicalAnd() { return rf.forwardsRange(e.Right) diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index 073aad60..10929b4c 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -1326,6 +1326,16 @@ r = Forward(n) out = "-n%(-w)d"`, unassigned: []string{"out"}, }, + { + // A call under || passes on each output whole, so no range + // drives the write. + name: "OrForwardsMultiOutputCall", + code: `r, k = Two(n) + r, k = 0:n, n + +a, b = F(n) + a, b = Two(n > 0) || Two(3)`, + }, { // A && fills its right operand's targets. name: "AndTakesRightOperandSlots", @@ -1550,6 +1560,28 @@ y = G(x) require.Equal(t, [][2]int{{2, 19}, {2, 10}, {7, 5}}, positions) } +// A || whose alternatives yield different numbers of values gets no ranges +// from the text, and the solver reports it where a script calls it. +func TestMismatchedOrAlternativesLeftToSolver(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + cc := NewCodeCompiler(ctx, t.Name(), "", mustParseCode(t, `r, k = Two(n) + r, k = 0:n, n + +r = One(n) + r = 0:n + +a, b = F(n) + a, b = Two(n > 0) || One(3)`)) + require.Empty(t, cc.Compile()) + + sc := NewScriptCompiler(ctx, t.Name(), mustParseScript(t, "x, y = F(5)\nx"), cc) + messages := extractErrorMessages(sc.Compile()) + require.NotEmpty(t, messages) + require.Contains(t, messages[0], "left expression and right expression have unequal lengths") +} + // The template checks read from the text which bindings hold a Range, and // settlement checks that the solved types agree, so each way a value holds // or iterates a range is pinned here against the solver. @@ -1670,6 +1702,28 @@ out = F(n) out = F(n) kept, k = Two(n) + out = [kept] ⊕ [k]`, + ranges: []string{"kept"}, + }, + { + // A value-position && passes on each output of the call it yields. + name: "AndPassesMultiOutputCall", + code: `r, k = Two(n) + r, k = 0:n, n + +out = F(n) + kept, k = n > 0 && Two(n) + out = [kept] ⊕ [k]`, + ranges: []string{"kept"}, + }, + { + // So does a ||, slot by slot. + name: "OrPassesMultiOutputCalls", + code: `k, r = Flip(n) + k, r = n, 0:n + +out = F(n) + k, kept = Flip(n > 0) || Flip(3) out = [kept] ⊕ [k]`, ranges: []string{"kept"}, }, diff --git a/tests/math/dependent_range.pt b/tests/math/dependent_range.pt index dd15339e..ad2bf244 100644 --- a/tests/math/dependent_range.pt +++ b/tests/math/dependent_range.pt @@ -12,3 +12,16 @@ out = DoubleLast(prev, n) r = MakeRange(n) out = prev out = r * 2 + +# A call under && or || passes on each of its outputs whole, so a range in one +# of several outputs stays a range. +r, k = Two(n) + r, k = 0:n, n + +out = AndTwo(n) + kept, k = n > 0 && Two(n) + out = [kept] ⊕ [k] + +out = OrTwo(n) + kept, k = Two(n > 0) || Two(3) + out = [kept] ⊕ [k] diff --git a/tests/math/dependent_range_fn.exp b/tests/math/dependent_range_fn.exp index b28c6029..c0c0fcf3 100644 --- a/tests/math/dependent_range_fn.exp +++ b/tests/math/dependent_range_fn.exp @@ -1,2 +1,3 @@ UpperTriRows: 4 8 12 DoubleLast: 7 4 +MultiOutputRanges: [0 1 2 3] [0 1 2] [0 1 2 3] diff --git a/tests/math/dependent_range_fn.spt b/tests/math/dependent_range_fn.spt index 111217a1..048b5f04 100644 --- a/tests/math/dependent_range_fn.spt +++ b/tests/math/dependent_range_fn.spt @@ -11,3 +11,8 @@ kept = 7 kept = DoubleLast(kept, 0) doubled = DoubleLast(0, 3) "DoubleLast: -kept -doubled" + +andTwo = AndTwo(3) +orTwo = OrTwo(2) +orFallback = OrTwo(-1) +"MultiOutputRanges: -andTwo -orTwo -orFallback" From 2e67e0d2d069bea0f2c2f12485c43aac40b3f312 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 7 Oct 2026 18:14:57 +0530 Subject: [PATCH 07/34] fix(compiler): report operators whose sides do not line up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-template check counted an operator's values as its wider side's and read a ||'s ranges from its last alternative alone. The solver rejects an operator whose sides yield different numbers of values (a && may also fold a condition onto one value or broadcast it to several), and a || whose alternatives do not match, as when only one of them yields a range. A template with such an operator got "may be left unassigned" before the solver's error; a generated sweep found 233 value shapes like `a, b = Two(n) * 2` and `out = MakeRange(n > 0) || 0:n`. The check now reports these from the text, as the solver does: - operand mismatch: "*" has 2 values on its left but 1 value on its right - logical AND condition arity must match the value's, fold to one, or broadcast from one — got 2 and 3 - logical OR value operands must have matching output types, but only the left one yields a range (a range literal or name under || yields its elements) An operator whose sides do not line up counts as one value and passes no range, since the solver leaves its value unresolved, and the || check skips an alternative that is unresolved. The solver's operand error now uses the same message, replacing "left expression and right expression have unequal lengths!". One function, passedRanges, now says which values pass a range on whole, for the range summary, the flow checks and the || check. Refs #123 Co-Authored-By: Claude Opus 5.5 --- compiler/cfg.go | 159 +++++++++++++++++++++++++++++----------- compiler/cfg_test.go | 122 ++++++++++++++++++++++++------ compiler/solver.go | 4 +- compiler/solver_test.go | 9 ++- docs/Pluto IR Plan.md | 13 ++-- 5 files changed, 234 insertions(+), 73 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index b9f86e69..13344b01 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -243,15 +243,16 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { // classifyTemplateWrites classifies every assignment's writes from the // template's text, indexed by statement, and reports the text it cannot -// classify: a call that names no template, an assignment whose values do not -// fill its targets, and a name reassigned between a range and a non-range -// value. The solver rejects each of these in any specialization; the flow -// checks, which would misread them, do not run over a template that has one. +// classify: a call that names no template, an operator whose sides do not line +// up, an assignment whose values do not fill its targets, and a name +// reassigned between a range and a non-range value. The solver rejects each of +// these in any specialization; the flow checks, which would misread them, do +// not run over a template that has one. func (cfg *CFG) classifyTemplateWrites(fn *ast.FuncStatement) [][]textWrite { flow := newRangeFlow(cfg.CodeCompiler, fn) writes := make([][]textWrite, len(fn.Body.Statements)) for i, stmt := range fn.Body.Statements { - cfg.rejectUndefinedCalls(statementExpressions(stmt)) + cfg.rejectUnclassifiableExprs(statementExpressions(stmt)) let, ok := stmt.(*ast.LetStatement) if !ok { continue @@ -269,17 +270,81 @@ func (cfg *CFG) classifyTemplateWrites(fn *ast.FuncStatement) [][]textWrite { return writes } -func (cfg *CFG) rejectUndefinedCalls(exprs []ast.Expression) { +func (cfg *CFG) rejectUnclassifiableExprs(exprs []ast.Expression) { for _, expr := range exprs { - if call, ok := expr.(*ast.CallExpression); ok { - if _, found := cfg.CodeCompiler.callTemplate(call); !found { - cfg.addError(call.Token, undefinedFunction(call.Function.Value)) + switch e := expr.(type) { + case *ast.CallExpression: + if _, found := cfg.CodeCompiler.callTemplate(e); !found { + cfg.addError(e.Token, undefinedFunction(e.Function.Value)) } + case *ast.InfixExpression: + cfg.rejectMisalignedOperands(e) + } + cfg.rejectUnclassifiableExprs(ast.ExprChildren(expr)) + } +} + +// rejectMisalignedOperands reports an operator whose sides yield counts the +// solver rejects, or a || of which only one alternative yields a range. An +// alternative that is itself unresolved matches anything, as in the solver. +func (cfg *CFG) rejectMisalignedOperands(infix *ast.InfixExpression) { + cc := cfg.CodeCompiler + left, right := cc.valueSlots(infix.Left), cc.valueSlots(infix.Right) + switch { + case !operandsLineUp(infix, left, right) && infix.IsLogicalAnd(): + cfg.addError(infix.Token, logicalAndArityMismatch(left, right)) + case !operandsLineUp(infix, left, right): + cfg.addError(infix.Token, operandMismatch(infix.Token.Literal, left, right)) + case infix.IsLogicalOr() && cc.textResolved(infix.Left) && cc.textResolved(infix.Right): + leftRanges, rightRanges := cc.passedRanges(infix.Left), cc.passedRanges(infix.Right) + for i := range leftRanges { + if leftRanges[i] == rightRanges[i] { + continue + } + + side := "right" + if leftRanges[i] { + side = "left" + } + cfg.addError(infix.Token, fmt.Sprintf("logical OR value operands must have matching output types, but only the %s one yields a range (a range literal or name under || yields its elements)", side)) + return } - cfg.rejectUndefinedCalls(ast.ExprChildren(expr)) } } +// textResolved reports whether the solver can resolve expr's values: every +// call in it names a template and every operator in it lines up. The solver +// leaves anything else unresolved. +func (cc *CodeCompiler) textResolved(expr ast.Expression) bool { + switch e := expr.(type) { + case *ast.CallExpression: + if _, found := cc.callTemplate(e); !found { + return false + } + case *ast.InfixExpression: + if !operandsLineUp(e, cc.valueSlots(e.Left), cc.valueSlots(e.Right)) { + return false + } + } + + for _, child := range ast.ExprChildren(expr) { + if !cc.textResolved(child) { + return false + } + } + return true +} + +// operandsLineUp reports whether an operator's sides yield counts the solver +// accepts: equal counts, or for a && also a condition that folds onto one +// value or broadcasts to several. +func operandsLineUp(infix *ast.InfixExpression, left, right int) bool { + if infix.IsLogicalAnd() { + return left == right || left == 1 || right == 1 + } + return left == right +} + func (cfg *CFG) rejectRangeKindChanges(flow *rangeFlow, let *ast.LetStatement, writes []textWrite) { for i, target := range let.Name { heldRange, assigned := flow.assigned[target.Value] @@ -303,6 +368,14 @@ func assignmentMismatch(targets, values int) string { return fmt.Sprintf("assignment mismatch: %s but %s", countOf(targets, "target"), countOf(values, "value")) } +func operandMismatch(operator string, left, right int) string { + return fmt.Sprintf("operand mismatch: %q has %s on its left but %s on its right", operator, countOf(left, "value"), countOf(right, "value")) +} + +func logicalAndArityMismatch(left, right int) string { + return fmt.Sprintf("logical AND condition arity must match the value's, fold to one, or broadcast from one — got %d and %d", left, right) +} + // countOf writes n with its noun, in the plural unless n is one. func countOf(n int, noun string) string { if n == 1 { @@ -422,7 +495,7 @@ func (rf *rangeFlow) letWrites(let *ast.LetStatement) ([]textWrite, bool) { writes := make([]textWrite, 0, len(let.Name)) for _, value := range let.Value { definite := !gated && !rf.maySkip(value) - for _, holdsRange := range rf.slotRanges(value, rf.cc.valueSlots(value), gateNames) { + for _, holdsRange := range rf.slotRanges(value, gateNames) { writes = append(writes, textWrite{definite: definite, holdsRange: holdsRange}) } } @@ -448,23 +521,12 @@ func textNodeFails(expr ast.Expression) bool { } // slotRanges reports, per target a value fills, whether it receives a Range -// descriptor. A || or value-position && yields its right operand's values, -// since the solver requires a ||'s alternatives to match; one whose right -// operand fills a different number of targets is the solver's error. -func (rf *rangeFlow) slotRanges(value ast.Expression, slots int, gateNames map[string]struct{}) []bool { - if slots == 1 { +// descriptor. +func (rf *rangeFlow) slotRanges(value ast.Expression, gateNames map[string]struct{}) []bool { + if rf.cc.valueSlots(value) == 1 { return []bool{rf.holdsRange(value, gateNames)} } - - switch v := value.(type) { - case *ast.CallExpression: - return rf.cc.callRangeOutputs(v) - case *ast.InfixExpression: - if (v.IsLogicalOr() || v.IsLogicalAnd()) && rf.cc.valueSlots(v.Right) == slots { - return rf.slotRanges(v.Right, slots, gateNames) - } - } - return make([]bool, slots) + return rf.cc.passedRanges(value) } // record publishes an assignment's targets once its reads are classified: @@ -500,19 +562,9 @@ func (rf *rangeFlow) holdsRange(value ast.Expression, gateNames map[string]struc return rf.forwardsRange(value) } -// forwardsRange reports whether expr passes a Range descriptor on whole: a -// call with an output that holds one, or a || or value-position && that -// yields such a call. A literal or binding operand of || and && is iterated. +// forwardsRange reports whether expr passes a Range descriptor on whole. func (rf *rangeFlow) forwardsRange(expr ast.Expression) bool { - switch e := expr.(type) { - case *ast.CallExpression: - return slices.Contains(rf.cc.callRangeOutputs(e), true) - case *ast.InfixExpression: - if e.IsLogicalOr() || e.IsLogicalAnd() { - return rf.forwardsRange(e.Right) - } - } - return false + return slices.Contains(rf.cc.passedRanges(expr), true) } // drivesValue reports whether a range that may be empty drives an @@ -665,8 +717,9 @@ func templateKey(fn *ast.FuncStatement) funcKey { } // valueSlots counts the targets a value fills, read from the text the way the -// solver counts a tuple: a call fills its template's outputs, a && its right -// operand's, and any other operator its wider operand's. +// solver counts a tuple: a call fills its template's outputs, and an operator +// its right operand's. An operator whose sides do not line up fills one, as +// the solver counts it after reporting the mismatch. func (cc *CodeCompiler) valueSlots(value ast.Expression) int { switch v := value.(type) { case *ast.CallExpression: @@ -674,16 +727,36 @@ func (cc *CodeCompiler) valueSlots(value ast.Expression) int { return len(template.Outputs) } case *ast.InfixExpression: - if v.IsLogicalAnd() { - return cc.valueSlots(v.Right) + left, right := cc.valueSlots(v.Left), cc.valueSlots(v.Right) + if operandsLineUp(v, left, right) { + return right } - return max(cc.valueSlots(v.Left), cc.valueSlots(v.Right)) case *ast.PrefixExpression: return cc.valueSlots(v.Right) } return 1 } +// passedRanges reports, per value expr yields, whether it passes a Range +// descriptor on whole: a call's range outputs, or those of the call that a || +// or value-position && yields. Any other value passes none, so a range +// literal or binding under an operator yields its elements, and an operator +// whose sides do not line up passes none, its value being unresolved. +func (cc *CodeCompiler) passedRanges(expr ast.Expression) []bool { + slots := cc.valueSlots(expr) + switch e := expr.(type) { + case *ast.CallExpression: + if outputs := cc.callRangeOutputs(e); len(outputs) == slots { + return outputs + } + case *ast.InfixExpression: + if (e.IsLogicalOr() || e.IsLogicalAnd()) && operandsLineUp(e, cc.valueSlots(e.Left), cc.valueSlots(e.Right)) { + return cc.passedRanges(e.Right) + } + } + return make([]bool, slots) +} + // valueCount counts the values a list of expressions yields, as valueSlots // counts each. func (cc *CodeCompiler) valueCount(values []ast.Expression) int { diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index 10929b4c..441b3496 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -1460,6 +1460,106 @@ out = Inc(n) a, b = Missing(x)`, errors: []string{"undefined function: Missing", "assignment mismatch: 2 targets but 1 value"}, }, + { + // An operator whose sides do not line up yields one value, as the + // solver counts it, so the assignment does not line up either. + name: "OperandCountMismatch", + code: `r, k = Two(n) + r, k = 0:n, n + +a, b = F(n) + a, b = Two(n) * 2`, + errors: []string{`operand mismatch: "*" has 2 values on its left but 1 value on its right`, "assignment mismatch: 2 targets but 1 value"}, + }, + { + name: "OrAlternativesCountMismatch", + code: `r, k = Two(n) + r, k = 0:n, n + +r = One(n) + r = 0:n + +a, b = F(n) + a, b = Two(n > 0) || One(3)`, + errors: []string{`operand mismatch: "||" has 2 values on its left but 1 value on its right`, "assignment mismatch: 2 targets but 1 value"}, + }, + { + name: "NestedMismatchCountsOneValue", + code: `p, q = Pair(n) + p, q = n, n + 1 + +y = F(n) + y = (Pair(n) * 2) + 1`, + errors: []string{`operand mismatch: "*" has 2 values on its left but 1 value on its right`}, + }, + { + // A && condition folds onto one value or broadcasts to several, + // but two conditions cannot gate three values. + name: "AndArityMismatch", + code: `p, q = Pair(n) + p, q = n, n + 1 + +a, b, c = Three(n) + a, b, c = n, n, n + +a, b, c = F(n) + a, b, c = Pair(n > 0) && Three(n)`, + errors: []string{"logical AND condition arity must match the value's, fold to one, or broadcast from one — got 2 and 3", "assignment mismatch: 3 targets but 1 value"}, + }, + { + // Under ||, a range literal yields its elements, so it cannot back + // up a call that returns a range. + name: "OrAlternativesDisagreeOnRange", + code: makeRangeTemplate + `out = F(n) + out = MakeRange(n > 0) || 0:n`, + errors: []string{"logical OR value operands must have matching output types, but only the left one yields a range (a range literal or name under || yields its elements)"}, + }, + { + // The solver leaves an unknown call unresolved, and anything + // computed from it, which matches any alternative. + name: "UnresolvedAlternativeIsNotCompared", + code: makeRangeTemplate + `out = F(n) + out = (Missing(n) + 1) || MakeRange(3)`, + errors: []string{"undefined function: Missing"}, + }, + { + name: "MisalignedAlternativeIsNotCompared", + code: makeRangeTemplate + `p, q = Pair(n) + p, q = n, n + 1 + +out = F(n) + out = (Pair(n) * 2) || MakeRange(3)`, + errors: []string{`operand mismatch: "*" has 2 values on its left but 1 value on its right`}, + }, + { + // A || whose sides do not line up passes no range, so its caller + // is not reported again. + name: "CallerOfMisalignedTemplate", + code: `r, k = Two(n) + r, k = 0:n, n + +r = One(n) + r = 0:n + +r = G(n) + r = Two(n > 0) || One(3) + +out = F(n) + out = G(n) * 2`, + errors: []string{`operand mismatch: "||" has 2 values on its left but 1 value on its right`}, + }, + { + name: "OrAlternativesDisagreeOnRangeSlotBySlot", + code: `r, k = Two(n) + r, k = 0:n, n + +k, r = Flip(n) + k, r = n, 0:n + +a, b = F(n) + a, b = Flip(n > 0) || Two(3)`, + errors: []string{"logical OR value operands must have matching output types, but only the right one yields a range (a range literal or name under || yields its elements)"}, + }, { name: "RangeReassignedNonRange", code: `y = G(x) @@ -1560,28 +1660,6 @@ y = G(x) require.Equal(t, [][2]int{{2, 19}, {2, 10}, {7, 5}}, positions) } -// A || whose alternatives yield different numbers of values gets no ranges -// from the text, and the solver reports it where a script calls it. -func TestMismatchedOrAlternativesLeftToSolver(t *testing.T) { - ctx := llvm.NewContext() - defer ctx.Dispose() - - cc := NewCodeCompiler(ctx, t.Name(), "", mustParseCode(t, `r, k = Two(n) - r, k = 0:n, n - -r = One(n) - r = 0:n - -a, b = F(n) - a, b = Two(n > 0) || One(3)`)) - require.Empty(t, cc.Compile()) - - sc := NewScriptCompiler(ctx, t.Name(), mustParseScript(t, "x, y = F(5)\nx"), cc) - messages := extractErrorMessages(sc.Compile()) - require.NotEmpty(t, messages) - require.Contains(t, messages[0], "left expression and right expression have unequal lengths") -} - // The template checks read from the text which bindings hold a Range, and // settlement checks that the solved types agree, so each way a value holds // or iterates a range is pinned here against the solver. diff --git a/compiler/solver.go b/compiler/solver.go index a2182ab7..9dbb079c 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -1983,7 +1983,7 @@ func (ts *TypeSolver) typeLogicalAndExpression(expr *ast.InfixExpression, left, if len(left) != len(right) && len(left) != 1 && len(right) != 1 { ts.Errors = append(ts.Errors, &token.CompileError{ Token: expr.Token, - Msg: fmt.Sprintf("logical AND condition arity must match the value's, fold to one, or broadcast from one — got %d and %d", len(left), len(right)), + Msg: logicalAndArityMismatch(len(left), len(right)), }) types := []Type{Unresolved{}} ts.ExprCache[key(ts.FuncNameMangled, expr)] = &ExprInfo{OutTypes: types, ExprLen: 1} @@ -2050,7 +2050,7 @@ func (ts *TypeSolver) TypeInfixExpression(expr *ast.InfixExpression) (types []Ty if len(left) != len(right) { ce := &token.CompileError{ Token: expr.Token, - Msg: fmt.Sprintf("left expression and right expression have unequal lengths! Left expr: %s, length: %d. Right expr: %s, length: %d. Operator: %q", expr.Left, len(left), expr.Right, len(right), expr.Token.Literal), + Msg: operandMismatch(expr.Token.Literal, len(left), len(right)), } ts.Errors = append(ts.Errors, ce) types = []Type{Unresolved{}} diff --git a/compiler/solver_test.go b/compiler/solver_test.go index 3042ddfc..58c7c0cf 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -635,6 +635,11 @@ func TestArrayConcatTypeErrors(t *testing.T) { // A script reports a count mismatch or an unknown call in the words a // template's text check uses. func TestScriptSharesTemplateTextMessages(t *testing.T) { + const pairs = `p, q = Pair(n) + p, q = n, n + 1 + +a, b, c = Three(n) + a, b, c = n, n, n` tests := []struct { name string script string @@ -642,6 +647,8 @@ func TestScriptSharesTemplateTextMessages(t *testing.T) { }{ {name: "AssignmentMismatch", script: "a, b = 1\na", err: "assignment mismatch: 2 targets but 1 value"}, {name: "UndefinedFunction", script: "a = Missing(1)\na", err: "undefined function: Missing"}, + {name: "OperandMismatch", script: "a = Pair(1) * 2\na", err: `operand mismatch: "*" has 2 values on its left but 1 value on its right`}, + {name: "AndArityMismatch", script: "a = Pair(1 > 0) && Three(1)\na", err: "logical AND condition arity must match the value's, fold to one, or broadcast from one — got 2 and 3"}, } for _, test := range tests { @@ -649,7 +656,7 @@ func TestScriptSharesTemplateTextMessages(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() - cc := NewCodeCompiler(ctx, test.name, "", ast.NewCode()) + cc := NewCodeCompiler(ctx, test.name, "", mustParseCode(t, pairs)) require.Empty(t, cc.Compile()) sc := NewScriptCompiler(ctx, test.name, mustParseScript(t, test.script), cc) ts := NewTypeSolver(sc) diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index 10bef221..e7f4ddb7 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -962,8 +962,10 @@ fresh `x = x + 1` cannot define its own RHS. An unknown main format marker remains literal text; malformed specifiers and missing dynamic width/precision variables on a resolved marker remain structural errors. Alongside them, the pass reports text it cannot classify, each an error the solver reports in any -specialization: a call that names no template, an assignment whose values do -not fill its targets, and a name assigned both a range and a non-range value. +specialization: a call that names no template, an operator whose sides do not +line up (value counts the solver rejects, or a `||` of which only one +alternative yields a range), an assignment whose values do not fill its +targets, and a name assigned both a range and a non-range value. The flow checks run only when both kinds of check pass, so they never misread such a template, and a template nothing calls gets these errors too. An error in any template fails the code module before any script compiles. @@ -1468,9 +1470,10 @@ immediate deletion at the last consumer. `out = y + 1`, where `Helper` returns a scalar, is a definite write; each way a value holds or iterates a range (a gate naming it, a literal or binding under `&&`, a call under `||`) agrees with the solved types; and a - call that names no template, an assignment whose values do not fill its - targets, or a name assigned both a range and a non-range value is reported - where the solver reports it, with no flow error over that template + call that names no template, an operator whose sides do not line up, an + assignment whose values do not fill its targets, or a name assigned both a + range and a non-range value is reported where the solver reports it, with + no flow error over that template ### Loop-carried tests From 3bec6c54901ca60e7e589b2ada9b867c31db8529 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 7 Oct 2026 20:03:00 +0530 Subject: [PATCH 08/34] feat(compiler)!: reject ranges as function outputs A range argument already runs a function once per element, so a function never receives a range itself. Outputs now match: an output assigned a range is rejected at the function's definition, including one output of several, and the function returns the bounds for its caller to build the range from (#146): lo, hi = Bounds(n) lo, hi = 0, n lo, hi = Bounds(5) r = lo:hi Since no call yields a range, each template's range summary is its own: the cross-template fixed point, range forwarding through || and && for call operands, and the check that || alternatives agree on yielding a range all go. #143's reproducers, a returned range never iterated, now fail at the definition instead of crashing. The fixtures that returned ranges return bounds (range_finalize, gate_heap), use a local range (DoubleLast), or go where the case can no longer arise (self_alias's NextRange, the multi-output range cases). BREAKING CHANGE: a function whose output holds a range no longer compiles. Return the bounds and build the range where it is used, keeping any condition the original assignment had. Refs #143, #146 Co-Authored-By: Claude Opus 5.5 --- compiler/cfg.go | 186 ++++------------ compiler/cfg_test.go | 318 +++++++++------------------- compiler/codecompiler.go | 7 +- docs/Pluto IR Plan.md | 47 ++-- docs/Pluto Memory Model.md | 20 +- docs/Pluto PIR Capability Matrix.md | 4 +- docs/Pluto Range Semantics.md | 8 +- tests/alias_input/self_alias.exp | 1 - tests/alias_input/self_alias.pt | 5 - tests/alias_input/self_alias.spt | 4 - tests/math/dependent_range.pt | 22 +- tests/math/dependent_range_fn.exp | 1 - tests/math/dependent_range_fn.spt | 5 - tests/mem/gate_heap/gate_heap.pt | 4 +- tests/mem/gate_heap/gate_heap.spt | 7 +- tests/range_finalize.exp | 2 +- tests/range_finalize.spt | 9 +- tests/range_shadow.pt | 4 +- 18 files changed, 198 insertions(+), 456 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index 13344b01..cca5595b 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -190,7 +190,7 @@ func (cfg *CFG) collectSpecifierReads(value string, tok token.Token, runes []run // anything calls it: structural checks, then a classification of each write // from the template's text alone, then dataflow checks over those writes. func (cfg *CFG) AnalyzeFuncs() { - cfg.CodeCompiler.settleRangeOutputs() + cfg.CodeCompiler.rangeBindings = make(map[funcKey]map[string]struct{}) for _, stmt := range cfg.CodeCompiler.Code.Statements { fn, ok := stmt.(*ast.FuncStatement) @@ -242,14 +242,17 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { } // classifyTemplateWrites classifies every assignment's writes from the -// template's text, indexed by statement, and reports the text it cannot -// classify: a call that names no template, an operator whose sides do not line -// up, an assignment whose values do not fill its targets, and a name -// reassigned between a range and a non-range value. The solver rejects each of -// these in any specialization; the flow checks, which would misread them, do -// not run over a template that has one. +// template's text, indexed by statement, and records its range bindings for +// settlement to check. It reports the text it cannot classify: a call that +// names no template, an operator whose sides do not line up, an assignment +// whose values do not fill its targets, and a name reassigned between a range +// and a non-range value. The solver rejects each of these in any +// specialization; the flow checks, which would misread them, do not run over a +// template that has one. It also reports an output that holds a range, which +// a function cannot return. func (cfg *CFG) classifyTemplateWrites(fn *ast.FuncStatement) [][]textWrite { flow := newRangeFlow(cfg.CodeCompiler, fn) + outputs := identSet(fn.Outputs) writes := make([][]textWrite, len(fn.Body.Statements)) for i, stmt := range fn.Body.Statements { cfg.rejectUnclassifiableExprs(statementExpressions(stmt)) @@ -264,9 +267,12 @@ func (cfg *CFG) classifyTemplateWrites(fn *ast.FuncStatement) [][]textWrite { continue } cfg.rejectRangeKindChanges(flow, let, letWrites) + cfg.rejectRangeOutputs(outputs, let, letWrites) flow.record(let, letWrites) writes[i] = letWrites } + + cfg.CodeCompiler.rangeBindings[templateKey(fn)] = flow.bindings return writes } @@ -285,8 +291,7 @@ func (cfg *CFG) rejectUnclassifiableExprs(exprs []ast.Expression) { } // rejectMisalignedOperands reports an operator whose sides yield counts the -// solver rejects, or a || of which only one alternative yields a range. An -// alternative that is itself unresolved matches anything, as in the solver. +// solver rejects. func (cfg *CFG) rejectMisalignedOperands(infix *ast.InfixExpression) { cc := cfg.CodeCompiler left, right := cc.valueSlots(infix.Left), cc.valueSlots(infix.Right) @@ -295,44 +300,7 @@ func (cfg *CFG) rejectMisalignedOperands(infix *ast.InfixExpression) { cfg.addError(infix.Token, logicalAndArityMismatch(left, right)) case !operandsLineUp(infix, left, right): cfg.addError(infix.Token, operandMismatch(infix.Token.Literal, left, right)) - case infix.IsLogicalOr() && cc.textResolved(infix.Left) && cc.textResolved(infix.Right): - leftRanges, rightRanges := cc.passedRanges(infix.Left), cc.passedRanges(infix.Right) - for i := range leftRanges { - if leftRanges[i] == rightRanges[i] { - continue - } - - side := "right" - if leftRanges[i] { - side = "left" - } - cfg.addError(infix.Token, fmt.Sprintf("logical OR value operands must have matching output types, but only the %s one yields a range (a range literal or name under || yields its elements)", side)) - return - } - } -} - -// textResolved reports whether the solver can resolve expr's values: every -// call in it names a template and every operator in it lines up. The solver -// leaves anything else unresolved. -func (cc *CodeCompiler) textResolved(expr ast.Expression) bool { - switch e := expr.(type) { - case *ast.CallExpression: - if _, found := cc.callTemplate(e); !found { - return false - } - case *ast.InfixExpression: - if !operandsLineUp(e, cc.valueSlots(e.Left), cc.valueSlots(e.Right)) { - return false - } - } - - for _, child := range ast.ExprChildren(expr) { - if !cc.textResolved(child) { - return false - } } - return true } // operandsLineUp reports whether an operator's sides yield counts the solver @@ -360,6 +328,18 @@ func (cfg *CFG) rejectRangeKindChanges(flow *rangeFlow, let *ast.LetStatement, w } } +// rejectRangeOutputs reports an output assigned a range. A function returns a +// range's bounds instead, and its caller builds the range. +func (cfg *CFG) rejectRangeOutputs(outputs map[string]struct{}, let *ast.LetStatement, writes []textWrite) { + for i, target := range let.Name { + if _, isOutput := outputs[target.Value]; !isOutput || !writes[i].holdsRange { + continue + } + + cfg.addError(target.Tok(), fmt.Sprintf("output %q cannot hold a range; return its bounds and build the range where it is used", target.Value)) + } +} + func undefinedFunction(name string) string { return fmt.Sprintf("undefined function: %s", name) } @@ -464,9 +444,9 @@ type textWrite struct { // rangeFlow reads from a template's text which bindings hold a Range // descriptor and which values a range drives, as the solver types them. A // parameter never holds a range, since a range argument runs the body once -// per element; a binding holds one when it is assigned a descriptor; a call's -// output holds one when its template's output does. assigned keeps whether -// each assigned name's first assignment gave it a range. +// per element, and no call yields one, since a function cannot return a +// range; a binding holds one when it is assigned a descriptor. assigned keeps +// whether each assigned name's first assignment gave it a range. type rangeFlow struct { cc *CodeCompiler bindings map[string]struct{} @@ -521,12 +501,14 @@ func textNodeFails(expr ast.Expression) bool { } // slotRanges reports, per target a value fills, whether it receives a Range -// descriptor. +// descriptor. A value that fills several targets comes from calls, which +// yield no range. func (rf *rangeFlow) slotRanges(value ast.Expression, gateNames map[string]struct{}) []bool { - if rf.cc.valueSlots(value) == 1 { + slots := rf.cc.valueSlots(value) + if slots == 1 { return []bool{rf.holdsRange(value, gateNames)} } - return rf.cc.passedRanges(value) + return make([]bool, slots) } // record publishes an assignment's targets once its reads are classified: @@ -559,18 +541,13 @@ func (rf *rangeFlow) holdsRange(value ast.Expression, gateNames map[string]struc _, iterated := gateNames[v.Value] return isRange && !iterated } - return rf.forwardsRange(value) -} - -// forwardsRange reports whether expr passes a Range descriptor on whole. -func (rf *rangeFlow) forwardsRange(expr ast.Expression) bool { - return slices.Contains(rf.cc.passedRanges(expr), true) + return false } // drivesValue reports whether a range that may be empty drives an // assignment's value: any range the value iterates, apart from a descriptor -// it assigns or passes on whole. A range the statement's condition names is -// already driven by the gate, which can skip the write by itself. +// it assigns. A range the statement's condition names is already driven by +// the gate, which can skip the write by itself. func (rf *rangeFlow) drivesValue(value ast.Expression) bool { switch v := value.(type) { case *ast.RangeLiteral: @@ -579,21 +556,10 @@ func (rf *rangeFlow) drivesValue(value ast.Expression) bool { return false case *ast.CallExpression: return rf.drivesAny(v.Arguments) - case *ast.InfixExpression: - if v.IsLogicalOr() || v.IsLogicalAnd() { - return rf.drivesOperand(v.Left) || rf.drivesOperand(v.Right) - } } return rf.drives(value) } -func (rf *rangeFlow) drivesOperand(operand ast.Expression) bool { - if rf.forwardsRange(operand) { - return rf.drivesValue(operand) - } - return rf.drives(operand) -} - // drives reports whether expr iterates a range that may be empty. Every range // in it is iterated, except inside an array literal, which settles its cells // itself. Only a range literal with constant, nonempty bounds always runs. @@ -608,10 +574,6 @@ func (rf *rangeFlow) drives(expr ast.Expression) bool { return isRange case *ast.StringLiteral: return len(rf.specifierRanges(e)) > 0 - case *ast.CallExpression: - if slices.Contains(rf.cc.callRangeOutputs(e), true) { - return true - } } return rf.drivesAny(ast.ExprChildren(expr)) } @@ -668,50 +630,6 @@ func (rf *rangeFlow) isDefined(name string) bool { return ok || rf.cc.isGlobalBinding(name) } -// settleRangeOutputs reads from the text which template outputs hold a Range -// descriptor, to a fixed point across templates that call one another, and -// records each template's range bindings for settlement to check. -func (cc *CodeCompiler) settleRangeOutputs() { - var templates []*ast.FuncStatement - cc.rangeOutputs = make(map[funcKey][]bool) - for _, stmt := range cc.Code.Statements { - if fn, ok := stmt.(*ast.FuncStatement); ok { - templates = append(templates, fn) - cc.rangeOutputs[templateKey(fn)] = make([]bool, len(fn.Outputs)) - } - } - - cc.rangeBindings = make(map[funcKey]map[string]struct{}, len(templates)) - for changed := true; changed; { - changed = false - for _, fn := range templates { - bindings := cc.templateRangeBindings(fn) - outputs := cc.rangeOutputs[templateKey(fn)] - for i, output := range fn.Outputs { - if _, isRange := bindings[output.Value]; isRange && !outputs[i] { - outputs[i] = true - changed = true - } - } - cc.rangeBindings[templateKey(fn)] = bindings - } - } -} - -func (cc *CodeCompiler) templateRangeBindings(fn *ast.FuncStatement) map[string]struct{} { - flow := newRangeFlow(cc, fn) - for _, stmt := range fn.Body.Statements { - let, ok := stmt.(*ast.LetStatement) - if !ok { - continue - } - if writes, ok := flow.letWrites(let); ok { - flow.record(let, writes) - } - } - return flow.bindings -} - func templateKey(fn *ast.FuncStatement) funcKey { return funcKey{name: fn.Token.Literal, arity: len(fn.Parameters)} } @@ -737,26 +655,6 @@ func (cc *CodeCompiler) valueSlots(value ast.Expression) int { return 1 } -// passedRanges reports, per value expr yields, whether it passes a Range -// descriptor on whole: a call's range outputs, or those of the call that a || -// or value-position && yields. Any other value passes none, so a range -// literal or binding under an operator yields its elements, and an operator -// whose sides do not line up passes none, its value being unresolved. -func (cc *CodeCompiler) passedRanges(expr ast.Expression) []bool { - slots := cc.valueSlots(expr) - switch e := expr.(type) { - case *ast.CallExpression: - if outputs := cc.callRangeOutputs(e); len(outputs) == slots { - return outputs - } - case *ast.InfixExpression: - if (e.IsLogicalOr() || e.IsLogicalAnd()) && operandsLineUp(e, cc.valueSlots(e.Left), cc.valueSlots(e.Right)) { - return cc.passedRanges(e.Right) - } - } - return make([]bool, slots) -} - // valueCount counts the values a list of expressions yields, as valueSlots // counts each. func (cc *CodeCompiler) valueCount(values []ast.Expression) int { @@ -773,16 +671,6 @@ func (cc *CodeCompiler) callTemplate(call *ast.CallExpression) (*ast.FuncStateme return cc.lookupFuncTemplate(call.Function.Value, cc.valueCount(call.Arguments)) } -// callRangeOutputs reports which outputs of a call's template hold a Range, -// or nil when the call names no template. -func (cc *CodeCompiler) callRangeOutputs(call *ast.CallExpression) []bool { - template, ok := cc.callTemplate(call) - if !ok { - return nil - } - return cc.rangeOutputs[templateKey(template)] -} - // checkSettledRanges panics when a settled specialization types a binding as // a Range where its template's text summary does not, or the reverse: the // template checks classified the body's writes from that summary. diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index 441b3496..2ebe15eb 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -1090,11 +1090,6 @@ func unassignedOutputMessage(name string) string { return fmt.Sprintf("output %q may be left unassigned; assign it unconditionally first, or pass the previous value as an input and initialize from it (%s = prev), with each caller passing its destination as that input", name, name) } -const makeRangeTemplate = `r = MakeRange(n) - r = 0:n - -` - // Every body that runs writes every output. The template check reads the // text: a statement gate, a value that can fail, or a range that may be empty // can skip a write, for every argument type. @@ -1193,57 +1188,12 @@ x, y = IsOdd(n) out = x + (0:3)`, }, { - // A range that arrives through a call may be empty. - name: "RangeFromCall", - code: makeRangeTemplate + `out = F(n) - r = MakeRange(n) - out = r * 2`, - unassigned: []string{"out"}, - }, - { - // A range a call returns runs the function it is passed to once - // per element, so it may run it not at all (#143 tracks lowering - // this form). - name: "RangeFromCallAsArgument", - code: makeRangeTemplate + `y = Sq(x) - y = x * x - -out = F(n) - out = Sq(MakeRange(n))`, - unassigned: []string{"out"}, - }, - { - name: "RangeFromCallWithDefault", - code: makeRangeTemplate + `out = F(prev, n) - r = MakeRange(n) + name: "LocalRangeWithDefault", + code: `out = F(prev, n) + r = 0:n out = prev out = r * 2`, }, - { - name: "RangeThroughAnotherTemplate", - code: makeRangeTemplate + `r = Forward(n) - t = MakeRange(n) - r = t - -out = F(n) - r = Forward(n) - out = r * 2`, - unassigned: []string{"out"}, - }, - { - name: "RangeForwardedByOr", - code: makeRangeTemplate + `out = F(n) - r = MakeRange(n > 0) || MakeRange(3) - out = r * 2`, - unassigned: []string{"out"}, - }, - { - name: "RangeForwardedByAnd", - code: makeRangeTemplate + `out = F(n) - r = n > 0 && MakeRange(3) - out = r * 2`, - unassigned: []string{"out"}, - }, { name: "ScalarCallOutput", code: `y = Helper(x) @@ -1272,8 +1222,7 @@ out = F(x) unassigned: []string{"out"}, }, { - // A literal or binding under && is iterated; only a call operand - // passes its descriptor on. + // A literal or binding under && is iterated. name: "AndIteratesLiteral", code: `out = F(n) last = n > 0 && 0:5 @@ -1285,38 +1234,6 @@ out = F(x) stream = 0:n last = n > 0 && stream out = last + 1`, - }, - { - // Templates are summarized to a fixed point, so a caller defined - // before the template that returns a range still sees it. - name: "RangeThroughTemplatesDefinedLater", - code: `out = F(n) - r = Forward(n) - out = r * 2 - -r = Forward(n) - t = MakeRange(n) - r = t - -` + makeRangeTemplate, - unassigned: []string{"out"}, - }, - { - // A descriptor assigned or passed on whole is not iterated. - name: "OutputCopiesRangeDescriptor", - code: `out = F(n) - stream = 0:n - out = stream`, - }, - { - name: "OutputFromRangeCall", - code: makeRangeTemplate + `out = F(n) - out = MakeRange(n)`, - }, - { - name: "OutputFromForwardedRange", - code: makeRangeTemplate + `out = F(n) - out = MakeRange(n > 0) || MakeRange(3)`, }, { // A width or precision consumes a range as numbers. @@ -1326,16 +1243,6 @@ r = Forward(n) out = "-n%(-w)d"`, unassigned: []string{"out"}, }, - { - // A call under || passes on each output whole, so no range - // drives the write. - name: "OrForwardsMultiOutputCall", - code: `r, k = Two(n) - r, k = 0:n, n - -a, b = F(n) - a, b = Two(n > 0) || Two(3)`, - }, { // A && fills its right operand's targets. name: "AndTakesRightOperandSlots", @@ -1464,23 +1371,23 @@ out = Inc(n) // An operator whose sides do not line up yields one value, as the // solver counts it, so the assignment does not line up either. name: "OperandCountMismatch", - code: `r, k = Two(n) - r, k = 0:n, n + code: `p, q = Pair(n) + p, q = n, n + 1 a, b = F(n) - a, b = Two(n) * 2`, + a, b = Pair(n) * 2`, errors: []string{`operand mismatch: "*" has 2 values on its left but 1 value on its right`, "assignment mismatch: 2 targets but 1 value"}, }, { name: "OrAlternativesCountMismatch", - code: `r, k = Two(n) - r, k = 0:n, n + code: `p, q = Pair(n) + p, q = n, n + 1 -r = One(n) - r = 0:n +y = Inc(n) + y = n + 1 a, b = F(n) - a, b = Two(n > 0) || One(3)`, + a, b = Pair(n > 0) || Inc(3)`, errors: []string{`operand mismatch: "||" has 2 values on its left but 1 value on its right`, "assignment mismatch: 2 targets but 1 value"}, }, { @@ -1506,60 +1413,6 @@ a, b, c = F(n) a, b, c = Pair(n > 0) && Three(n)`, errors: []string{"logical AND condition arity must match the value's, fold to one, or broadcast from one — got 2 and 3", "assignment mismatch: 3 targets but 1 value"}, }, - { - // Under ||, a range literal yields its elements, so it cannot back - // up a call that returns a range. - name: "OrAlternativesDisagreeOnRange", - code: makeRangeTemplate + `out = F(n) - out = MakeRange(n > 0) || 0:n`, - errors: []string{"logical OR value operands must have matching output types, but only the left one yields a range (a range literal or name under || yields its elements)"}, - }, - { - // The solver leaves an unknown call unresolved, and anything - // computed from it, which matches any alternative. - name: "UnresolvedAlternativeIsNotCompared", - code: makeRangeTemplate + `out = F(n) - out = (Missing(n) + 1) || MakeRange(3)`, - errors: []string{"undefined function: Missing"}, - }, - { - name: "MisalignedAlternativeIsNotCompared", - code: makeRangeTemplate + `p, q = Pair(n) - p, q = n, n + 1 - -out = F(n) - out = (Pair(n) * 2) || MakeRange(3)`, - errors: []string{`operand mismatch: "*" has 2 values on its left but 1 value on its right`}, - }, - { - // A || whose sides do not line up passes no range, so its caller - // is not reported again. - name: "CallerOfMisalignedTemplate", - code: `r, k = Two(n) - r, k = 0:n, n - -r = One(n) - r = 0:n - -r = G(n) - r = Two(n > 0) || One(3) - -out = F(n) - out = G(n) * 2`, - errors: []string{`operand mismatch: "||" has 2 values on its left but 1 value on its right`}, - }, - { - name: "OrAlternativesDisagreeOnRangeSlotBySlot", - code: `r, k = Two(n) - r, k = 0:n, n - -k, r = Flip(n) - k, r = n, 0:n - -a, b = F(n) - a, b = Flip(n > 0) || Two(3)`, - errors: []string{"logical OR value operands must have matching output types, but only the right one yields a range (a range literal or name under || yields its elements)"}, - }, { name: "RangeReassignedNonRange", code: `y = G(x) @@ -1660,6 +1513,96 @@ y = G(x) require.Equal(t, [][2]int{{2, 19}, {2, 10}, {7, 5}}, positions) } +func rangeOutputMessage(name string) string { + return fmt.Sprintf("output %q cannot hold a range; return its bounds and build the range where it is used", name) +} + +// A function cannot return a range: it returns the bounds, and its caller +// builds the range. +func TestFunctionsCannotReturnRanges(t *testing.T) { + tests := []struct { + name string + code string + errors []string + }{ + { + name: "RangeLiteralOutput", + code: `r = MakeRange(n) + r = 0:n`, + errors: []string{rangeOutputMessage("r")}, + }, + { + name: "OneOutputOfSeveral", + code: `r, k = Two(n) + r, k = 0:n, n`, + errors: []string{rangeOutputMessage("r")}, + }, + { + name: "GatedRangeOutput", + code: `out = F(n) + out = n > 0 0:n`, + errors: []string{rangeOutputMessage("out")}, + }, + { + name: "CopiedRangeOutput", + code: `out = F(n) + r = 0:n + out = r`, + errors: []string{rangeOutputMessage("out")}, + }, + { + name: "IteratedRangeOutput", + code: `out = F(prev, n) + r = 0:n + out = prev + out = r * 2`, + }, + { + name: "CollectedRangeOutput", + code: `out = F(n) + out = [0:n]`, + }, + { + name: "BoundsOutputs", + code: `lo, hi = Bounds(n) + lo, hi = 0, n + +out = F(n) + lo, hi = Bounds(n) + r = lo:hi + out = [r]`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + cc := NewCodeCompiler(ctx, test.name, "", mustParseCode(t, test.code)) + want := test.errors + if want == nil { + want = []string{} + } + require.Equal(t, want, extractErrorMessages(cc.Compile())) + }) + } +} + +func TestRangeOutputIsReportedAtItsAssignment(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + cc := NewCodeCompiler(ctx, t.Name(), "", mustParseCode(t, `r, k = Two(n) + k = n + r = 0:n`)) + errs := cc.Compile() + + require.Len(t, errs, 1) + require.Equal(t, 3, errs[0].Token.Line) + require.Equal(t, 5, errs[0].Token.Column) +} + // The template checks read from the text which bindings hold a Range, and // settlement checks that the solved types agree, so each way a value holds // or iterates a range is pinned here against the solver. @@ -1747,61 +1690,10 @@ out = F(n) ranges: []string{"stream"}, }, { - name: "AndPassesCall", - code: makeRangeTemplate + `out = F(n) - kept = n > 0 && MakeRange(3) - out = [kept]`, - ranges: []string{"kept"}, - }, - { - name: "OrPassesCalls", - code: makeRangeTemplate + `out = F(n) - kept = MakeRange(n > 0) || MakeRange(3) - out = [kept]`, - ranges: []string{"kept"}, - }, - { - name: "CallOutput", - code: makeRangeTemplate + `out = F(n) - kept = MakeRange(n) - out = [kept]`, - ranges: []string{"kept"}, - }, - { - name: "RangeOutput", + // A range in one value of a tuple holds only that target. + name: "RangeInOneSlotOfATuple", code: `out = F(n) - out = 0:n`, - ranges: []string{"out"}, - }, - { - name: "RangeInOneSlotOfACall", - code: `r, k = Two(n) - r, k = 0:n, n - -out = F(n) - kept, k = Two(n) - out = [kept] ⊕ [k]`, - ranges: []string{"kept"}, - }, - { - // A value-position && passes on each output of the call it yields. - name: "AndPassesMultiOutputCall", - code: `r, k = Two(n) - r, k = 0:n, n - -out = F(n) - kept, k = n > 0 && Two(n) - out = [kept] ⊕ [k]`, - ranges: []string{"kept"}, - }, - { - // So does a ||, slot by slot. - name: "OrPassesMultiOutputCalls", - code: `k, r = Flip(n) - k, r = n, 0:n - -out = F(n) - k, kept = Flip(n > 0) || Flip(3) + kept, k = 0:n, n out = [kept] ⊕ [k]`, ranges: []string{"kept"}, }, diff --git a/compiler/codecompiler.go b/compiler/codecompiler.go index 3f22b172..de8c7bf4 100644 --- a/compiler/codecompiler.go +++ b/compiler/codecompiler.go @@ -18,10 +18,9 @@ type CodeCompiler struct { // at owned storage, so every read and every nested call it feeds see // the representation lowering stores. outputReads map[funcKey]map[string]struct{} - // rangeOutputs and rangeBindings record, per template, which outputs and - // which bindings hold a Range descriptor, as the template checks read - // them from the text. Settlement checks the solved types agree. - rangeOutputs map[funcKey][]bool + // rangeBindings records, per template, which bindings hold a Range + // descriptor, as the template checks read them from the text. Settlement + // checks the solved types agree. rangeBindings map[funcKey]map[string]struct{} } diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index e7f4ddb7..f67f1a31 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -934,25 +934,21 @@ not reported again. The pass also reads from the text which values are ranges, and so which statements a range drives. A parameter never holds a range, since a range -argument drives the whole body, and struct fields, constants and array -elements cannot hold one (`[r]` collects `r`'s values). A value holds a range -when it is a range literal, a binding assigned a descriptor (`x = r`, not -`x = r * 2`), a call output that its template assigns a range, or a `||` or -value-position `&&` whose operand is such a call -(`MakeRange(n > 0) || MakeRange(3)` forwards whichever alternative succeeds, -and `c > 0 && MakeRange(3)` its right operand). A range literal or binding -operand of `||` and `&&` is iterated instead (`n > 0 && 0:5` gives an +argument drives the whole body; no call yields one, since a function cannot +return a range (an output assigned one is rejected at the definition, #146); +and struct fields, constants and array elements cannot hold one (`[r]` +collects `r`'s values). A value holds a range when it is a range literal or a +binding assigned a descriptor (`x = r`, not `x = r * 2`). A range literal or +binding operand of `||` and `&&` is iterated (`n > 0 && 0:5` gives an element). A statement's condition iterates the ranges it names, so its value reads them as elements (`kept = s > 2 s`); a range the condition only -collects, or does not name, stays a descriptor. Before checking any body, the -pass computes which template outputs hold a range, iterating to a fixed point -across templates that call each other: an output assigned `t` after -`t = MakeRange(n)` is a range as well. A range that arrives through a call -counts as possibly empty. Every check then uses the same classification, and -none waits for a type: after `r = MakeRange(n)`, `out = r * 2` does not kill -an earlier `out = prev`, does not definitely assign `out`, and so cannot by -itself make a later read of `out` valid. Settlement panics when a -specialization's Range-typed bindings disagree with this summary. +collects, or does not name, stays a descriptor. A template's own text +therefore decides all of this, without looking at another template. Every +check then uses the same classification, and none waits for a type: after +`r = 0:n`, `out = r * 2` does not kill an earlier `out = prev`, does not +definitely assign `out`, and so cannot by itself make a later read of `out` +valid. Settlement panics when a specialization's Range-typed bindings +disagree with this summary. The flow checks run after a template's structural checks: explicit use-before-definition, illegal input/global writes, unused inputs, @@ -1460,16 +1456,13 @@ immediate deletion at the last consumer. body that can leave an output unwritten is rejected at the definition; `out = x > 0` alone is rejected for every argument type; `out = prev` then `out = x > 0` is accepted for scalar and array `x`, with no diagnostic for - the array specialization's dead default; after - `r = MakeRange(n)`, `out = prev` then `out = r * 2` is accepted and - `F(y, 0)` keeps `y`, while without the default both the body and a read - of `out` after `out = r * 2` are rejected; a range passed on through - another template's output, or forwarded by `||` or value-position `&&` as - in `r = MakeRange(n > 0) || MakeRange(3)`, counts the same; and - `y = Helper(x)` then - `out = y + 1`, where `Helper` returns a scalar, is a definite write; each - way a value holds or iterates a range (a gate naming it, a literal or - binding under `&&`, a call under `||`) agrees with the solved types; and a + the array specialization's dead default; after `r = 0:n`, `out = prev` + then `out = r * 2` is accepted and `F(y, 0)` keeps `y`, while without the + default both the body and a read of `out` after `out = r * 2` are + rejected; `y = Helper(x)` then `out = y + 1`, where `Helper` returns a + scalar, is a definite write; each way a value holds or iterates a range (a + gate naming it, a literal or binding under `&&`) agrees with the solved + types; an output assigned a range is rejected (#146); and a call that names no template, an operator whose sides do not line up, an assignment whose values do not fill its targets, or a name assigned both a range and a non-range value is reported where the solver reports it, with diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index 19477941..8ebf278e 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -323,20 +323,18 @@ since a statement condition must be a scalar. The text also decides which values are ranges. A parameter never holds one, since a range argument runs the function once per element: `Wrap(1:5)`, -where `Wrap` returns its input, gives `4`. Whether a function returns a -range follows from its text, as with `MakeRange` below, and a range that -arrives through a call counts as possibly empty, so `F` needs its default. -A statement's condition iterates the ranges it names, so its value reads them -as elements: in `kept = s > 2 s`, `kept` gets an element of `s`. Under `||` -and `&&`, only a call passes its range on whole; a range literal or binding -operand is iterated, so `n > 0 && 0:5` gives an element. +where `Wrap` returns its input, gives `4`. A function cannot return a range +either: an output assigned one is rejected, and the function returns the +bounds for its caller to build the range from (#146). A range bound to a +name counts as possibly empty, so `F` below needs its default. A statement's +condition iterates the ranges it names, so its value reads them as elements: +in `kept = s > 2 s`, `kept` gets an element of `s`. Under `||` and `&&`, a +range literal or binding operand is iterated, so `n > 0 && 0:5` gives an +element. ```python -r = MakeRange(n) - r = 0:n - out = F(prev, n) - r = MakeRange(n) + r = 0:n out = prev out = r * 2 ``` diff --git a/docs/Pluto PIR Capability Matrix.md b/docs/Pluto PIR Capability Matrix.md index 575b101a..b2590a88 100644 --- a/docs/Pluto PIR Capability Matrix.md +++ b/docs/Pluto PIR Capability Matrix.md @@ -115,7 +115,7 @@ two together rather than treating any single row as a deletion trigger. | 7e | none | checked, conditional (fallback), ranged | scalar | local | RHS-local | — | **S (decided; semantics doc)** | 7 | | 7f | none | checked, conditional (fallback), collector | scalar | local | collector-local | — | **S (decided; semantics doc)** | 8 | | 8 | none | ordinary | Range descriptor | local | — (no domain) | — | R | 3 | -| 8b | none | call | Range descriptor | local | — (no domain) | split: all-`MustWrite` outputs and `MustYield` arguments → 4, otherwise → 6 | R | 4, 6 | +| 8b | none | call | Range descriptor | local | — | rejected at the definition: a function cannot return a range (#146) | — | — | | 9 | none | ranged | scalar, self-ref | local | RHS-local | — | R | 7 | | 10 | none | ranged, checked | scalar | local | RHS-local | — | S | 7, fast path 10 | | 11 | none | collector, ranged | scalar, heap | local | collector-local | — | S | 8 | @@ -177,7 +177,7 @@ two together rather than treating any single row as a deletion trigger. - **7e** — ranged checked fallback: the fallback resolves per iteration inside the loop nest. *Missing:* regressions when implemented. *Helpers:* condLHS spine, ranged staging - **7f** — collector-cell fallback: in `[arr[oob] \|\| -1]` the `\|\|` resolves before the cell's zero-fill. *Missing:* regressions when implemented. *Helpers:* collector rewrite, condLHS spine - **8** — plain value copy; the solver clears `Ranges`/`HasRanges`, so this is not an active ranged RHS. *Tests:* `range_finalize:2-21` (literal, identifier copy, empty, reassign), `compiler/solver_test.go`. *Helpers:* `compileAssignments` -- **8b** — call lowering + indirect-return ABI, not descriptor copying. *Tests:* `range_finalize:38` (`makeRange`), `mem/gate_heap`. *Missing:* a Range return that starts from a default and is overwritten conditionally, the form #123 requires of a conditional output +- **8b** — no longer arises: a function cannot return a range (#146), so an output assigned one is rejected at its definition. A function returns the bounds instead (`range_finalize`'s `rangeBounds`, `mem/gate_heap`'s `mkBounds`), and the caller's range literal is row 8. *Tests:* `TestFunctionsCannotReturnRanges` - **9** — `compileAssignments` → expression loop nest (passes nil conditions). *Tests:* `math/range_expr`, `math/range.spt`, `range_shadow.spt`, `cond/domain_activation`. *Helpers:* `compileAssignments`, `withCollectorPreparedLoopNest`, `compileCondOperands` - **10** — as #9 + `withLoopNestVersioned` affine probe. *Tests:* `array/affine_bounds_stmt`, `math/affine_bounds_expr`. *Helpers:* affine decision helpers - **11** — `compileArrayExpression` → `compileArray` → `withCollectorDomain`. *Tests:* `range`, `array/array_capture`, `mem/gate_heap`. *Helpers:* collector rewrite diff --git a/docs/Pluto Range Semantics.md b/docs/Pluto Range Semantics.md index 3b48888f..1b8ac860 100644 --- a/docs/Pluto Range Semantics.md +++ b/docs/Pluto Range Semantics.md @@ -65,9 +65,11 @@ Range is nameable and copyable, but is not yet a fully first-class container element. Arrays and tables contain scalar/string elements rather than Range descriptors, so `[i]` consumes `i` and collects its yields. Passing a Range to a function likewise consumes it as a driver rather than passing inert metadata. -A function may return a Range descriptor to a binding, but anonymous -Range-returning expressions are not yet accepted uniformly by every consuming -context; bind the result before consuming it. +A function cannot return a Range either: an output assigned one is rejected +at the function's definition (#146). The function returns the bounds instead, +and the caller builds the range: `lo, hi = Bounds(n)`, then `r = lo:hi`. If +the range was assigned conditionally, the rebuild keeps the condition: +`r = n > 0 lo:hi`. Range-indexed arrays follow the same rule: diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index 11902057..f2451b5a 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -38,7 +38,6 @@ Powers: 9 27 PowersShared: 9 81 ReadTwice: second first second SharedPair: 105 10 -NextRange: 2:3 CondCount: 11 11 lr re:storage: (0x[0-9a-f]+) (?!\1$)0x[0-9a-f]+ re:storage: (0x[0-9a-f]+) \1 diff --git a/tests/alias_input/self_alias.pt b/tests/alias_input/self_alias.pt index 857d30ce..130489e5 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -118,11 +118,6 @@ out, before = SharedPair(a, b) before = b out = AddBoth(a, b, (1:3) + 0) -# A shared range parameter is iterated by a scalar bound to the same name; -# that scalar must not inherit the range's alias. -out = NextRange(i) - out = i:(i+1) - # A conditional call promotes its indirect arguments before branching. The # scalar after a two-valued argument binds a later parameter and must keep # its live alias. diff --git a/tests/alias_input/self_alias.spt b/tests/alias_input/self_alias.spt index b6c64324..c07b44a9 100644 --- a/tests/alias_input/self_alias.spt +++ b/tests/alias_input/self_alias.spt @@ -147,10 +147,6 @@ pair = 10 pair, before = SharedPair(pair, pair) "SharedPair:", pair, before -nextRange = 1:3 -nextRange = NextRange(nextRange) -"NextRange:", nextRange - condCount = 10 condCount, condCountSeen, condTag = CondCount(condCount) "CondCount:", condCount, condCountSeen, condTag diff --git a/tests/math/dependent_range.pt b/tests/math/dependent_range.pt index ad2bf244..6c9ed3a0 100644 --- a/tests/math/dependent_range.pt +++ b/tests/math/dependent_range.pt @@ -3,25 +3,9 @@ res = UpperTriRowTail(a, n, i) t = a[i * n + j] res = t -# A range that arrives through a call may be empty, so the output starts from -# prev, and an empty range keeps the caller's value. -r = MakeRange(n) - r = 0:n - +# A named range may be empty, so the output starts from prev, and an empty +# range keeps the caller's value. out = DoubleLast(prev, n) - r = MakeRange(n) + r = 0:n out = prev out = r * 2 - -# A call under && or || passes on each of its outputs whole, so a range in one -# of several outputs stays a range. -r, k = Two(n) - r, k = 0:n, n - -out = AndTwo(n) - kept, k = n > 0 && Two(n) - out = [kept] ⊕ [k] - -out = OrTwo(n) - kept, k = Two(n > 0) || Two(3) - out = [kept] ⊕ [k] diff --git a/tests/math/dependent_range_fn.exp b/tests/math/dependent_range_fn.exp index c0c0fcf3..b28c6029 100644 --- a/tests/math/dependent_range_fn.exp +++ b/tests/math/dependent_range_fn.exp @@ -1,3 +1,2 @@ UpperTriRows: 4 8 12 DoubleLast: 7 4 -MultiOutputRanges: [0 1 2 3] [0 1 2] [0 1 2 3] diff --git a/tests/math/dependent_range_fn.spt b/tests/math/dependent_range_fn.spt index 048b5f04..111217a1 100644 --- a/tests/math/dependent_range_fn.spt +++ b/tests/math/dependent_range_fn.spt @@ -11,8 +11,3 @@ kept = 7 kept = DoubleLast(kept, 0) doubled = DoubleLast(0, 3) "DoubleLast: -kept -doubled" - -andTwo = AndTwo(3) -orTwo = OrTwo(2) -orFallback = OrTwo(-1) -"MultiOutputRanges: -andTwo -orTwo -orFallback" diff --git a/tests/mem/gate_heap/gate_heap.pt b/tests/mem/gate_heap/gate_heap.pt index a93b45b6..090851a8 100644 --- a/tests/mem/gate_heap/gate_heap.pt +++ b/tests/mem/gate_heap/gate_heap.pt @@ -1,5 +1,5 @@ tag = mkTag(v) tag = "tag-v" -r = mkRange(n) - r = 0:n +start, stop = mkBounds(n) + start, stop = 0, n diff --git a/tests/mem/gate_heap/gate_heap.spt b/tests/mem/gate_heap/gate_heap.spt index 8b5b3809..14a08f4b 100644 --- a/tests/mem/gate_heap/gate_heap.spt +++ b/tests/mem/gate_heap/gate_heap.spt @@ -12,9 +12,10 @@ z = "keepme" z = q > 9 mkTag(q) "RangedGateHeapCallNoAdmit: -z" -# A Range returned by a function is an ordinary descriptor: it drives a -# collector domain and a statement gate like any locally created range. -fr = mkRange(3) +# A range built from a function's bounds drives a collector domain and a +# statement gate like any other range. +frStart, frStop = mkBounds(3) +fr = frStart:frStop fc = [fr] "FnRangeCollector: -fc" diff --git a/tests/range_finalize.exp b/tests/range_finalize.exp index 49591e22..ac07bf36 100644 --- a/tests/range_finalize.exp +++ b/tests/range_finalize.exp @@ -5,7 +5,7 @@ EmptyRangeExisting: [0 1] -> [] EmptyComputeFresh: 0 EmptyComputeExisting: 91 CallFinal: 20 -ReturnedRange: [2 3 4] +BuiltRange: [2 3 4] MarkerFinal: Marker 0:3 MarkerDescriptor 0:3 MarkerWidthFinal: | 7| diff --git a/tests/range_finalize.spt b/tests/range_finalize.spt index aff7855d..e8eb669d 100644 --- a/tests/range_finalize.spt +++ b/tests/range_finalize.spt @@ -34,10 +34,11 @@ existing = emptyRange + 1 callFinal = useShadow(ascending) "CallFinal: -callFinal" -# A Range returned by a function is a descriptor once bound. -returnedRange = makeRange() -returnedValues = [returnedRange] -"ReturnedRange: -returnedValues" +# A function returns a range's bounds, and the caller builds the range. +boundsStart, boundsStop = rangeBounds() +builtRange = boundsStart:boundsStop +builtValues = [builtRange] +"BuiltRange: -builtValues" # A bare Range in a main marker formats the descriptor: the string is one # value with no drivers, in interpolation exactly as in assignment. diff --git a/tests/range_shadow.pt b/tests/range_shadow.pt index 6248129f..a26ec3f3 100644 --- a/tests/range_shadow.pt +++ b/tests/range_shadow.pt @@ -6,5 +6,5 @@ res = AddMul(x, y) i = 10 res = i * x + y -res = makeRange() - res = 2:5 +start, stop = rangeBounds() + start, stop = 2, 5 From 3e5d2b963d316b64d6e17eefff5c760d8a92da85 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 7 Oct 2026 20:52:56 +0530 Subject: [PATCH 09/34] feat(compiler)!: iterate a bare range name wherever it is used A range literal is now the only Range value (#146). Every other use of a range name iterates it, a bare one included: - An assignment keeps the final yield: `j = i` is `j = i + 0`. Over an empty range it assigns nothing, so a fresh name gets zero and an existing one keeps its value. - A print statement runs once per yield, and two different names print their cartesian product. A bare range literal still prints as written. - A string marker naming a range runs once per yield, as width and precision markers already did, so `-i%d` applies %d to each yield. This replaces three rules that depended on context: a copy kept the descriptor unless the statement's condition iterated the name, and a print argument or a main marker showed the descriptor unless a sibling iterated it. The per-template range summary follows: a binding holds a range exactly when it is assigned a range literal, so gate and copy tracking go. The type checker no longer keeps bare names as descriptors in assignments and prints, and lowering closes a bare name at an assignment's root as it closes a ranged string. PIR no longer plans `j = i`, which is now ranged. BREAKING CHANGE: `copy = i` holds i's final value instead of the range. For a second driver over the same domain, construct another literal. Printing a range name prints its values; print a range literal, or the values a range was built from, to show bounds. These changes are silent where a program still compiles. Refs #146 Co-Authored-By: Claude Opus 5.5 --- README.md | 25 ++--- compiler/cfg.go | 102 +++++------------- compiler/cfg_test.go | 56 ++++++++-- compiler/compiler.go | 30 +++++- compiler/pir_test.go | 27 ++--- compiler/solver.go | 80 +++++--------- compiler/solver_test.go | 52 +++++---- docs/Pluto IR Plan.md | 17 ++- docs/Pluto Memory Model.md | 44 ++++---- docs/Pluto PIR Capability Matrix.md | 2 +- docs/Pluto Range Semantics.md | 86 ++++++++------- docs/Pluto String and Formatting Semantics.md | 23 ++-- tests/array/cond_accum.exp | 2 +- tests/array/cond_accum.spt | 3 +- tests/math/print_func.exp | 29 ++++- tests/math/print_func.spt | 8 +- tests/range.exp | 4 + tests/range.spt | 7 +- tests/range_finalize.exp | 20 ++-- tests/range_finalize.spt | 64 +++++------ 20 files changed, 350 insertions(+), 331 deletions(-) diff --git a/README.md b/README.md index e83e775d..a287b332 100644 --- a/README.md +++ b/README.md @@ -226,21 +226,22 @@ A range literal binds an execution domain: ```python i = 0:5 -copy = i # same bounds, independent named driver -values = [copy] # [0 1 2 3 4] -last = i + 0 # 4 +values = [i] # [0 1 2 3 4] +last = i # 4 lastSquare = Square(i) # 16 -i # prints the descriptor: 0:5 +i # prints 0, 1, 2, 3 and 4, one per line +0:5 # a literal prints as written: 0:5 ``` -A bare range is a value: assignment copies it and print shows its descriptor. -Using it in an operation creates a ranged computation: the assignment keeps -the final yield, brackets materialize all yields into an array, and passing it -to a template executes the call once for each value. The compiler can map -these operations to SIMD instructions — this is range-driven -auto-vectorization. Each named Range binding is a distinct driver: different -names form a cartesian domain when consumed together, even when their bounds -are equal. +Only a range literal makes a range. Using a range name anywhere creates a +ranged computation: an assignment keeps the final yield, brackets materialize +all yields into an array, passing it to a template executes the call once for +each value, and printing it prints once per value. The compiler can map these +operations to SIMD instructions — this is range-driven auto-vectorization. +Each named Range binding is a distinct driver: different names form a +cartesian domain when consumed together, even when their bounds are equal, so +a second driver over the same domain is another literal (`j = 0:5`). A function +cannot return a range; it returns the bounds, and the caller builds the range. Range-indexed array access follows the same rule: diff --git a/compiler/cfg.go b/compiler/cfg.go index cca5595b..61e569c7 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -442,11 +442,13 @@ type textWrite struct { } // rangeFlow reads from a template's text which bindings hold a Range -// descriptor and which values a range drives, as the solver types them. A -// parameter never holds a range, since a range argument runs the body once -// per element, and no call yields one, since a function cannot return a -// range; a binding holds one when it is assigned a descriptor. assigned keeps -// whether each assigned name's first assignment gave it a range. +// descriptor and which values a range drives, as the solver types them. Only +// a range literal assigned on its own constructs a descriptor: a parameter +// never holds one, since a range argument runs the body once per element; no +// call yields one, since a function cannot return a range; and a range name +// iterates wherever it is used, so a binding assigned from one holds an +// element. assigned keeps whether each assigned name's first assignment gave +// it a range. type rangeFlow struct { cc *CodeCompiler bindings map[string]struct{} @@ -470,12 +472,11 @@ func (rf *rangeFlow) letWrites(let *ast.LetStatement) ([]textWrite, bool) { return nil, false } - gateNames := rf.gateRanges(let.Condition) gated := len(let.Condition) > 0 writes := make([]textWrite, 0, len(let.Name)) for _, value := range let.Value { definite := !gated && !rf.maySkip(value) - for _, holdsRange := range rf.slotRanges(value, gateNames) { + for _, holdsRange := range rf.slotRanges(value) { writes = append(writes, textWrite{definite: definite, holdsRange: holdsRange}) } } @@ -501,14 +502,13 @@ func textNodeFails(expr ast.Expression) bool { } // slotRanges reports, per target a value fills, whether it receives a Range -// descriptor. A value that fills several targets comes from calls, which -// yield no range. -func (rf *rangeFlow) slotRanges(value ast.Expression, gateNames map[string]struct{}) []bool { - slots := rf.cc.valueSlots(value) - if slots == 1 { - return []bool{rf.holdsRange(value, gateNames)} +// descriptor: only a bare range literal constructs one. +func (rf *rangeFlow) slotRanges(value ast.Expression) []bool { + ranges := make([]bool, rf.cc.valueSlots(value)) + if _, isLiteral := value.(*ast.RangeLiteral); isLiteral { + ranges[0] = true } - return make([]bool, slots) + return ranges } // record publishes an assignment's targets once its reads are classified: @@ -529,31 +529,13 @@ func (rf *rangeFlow) record(let *ast.LetStatement, writes []textWrite) { } } -// holdsRange reports whether a single-slot value assigns a Range descriptor. -// The value reads a range that its statement's condition iterates as that -// range's current element. -func (rf *rangeFlow) holdsRange(value ast.Expression, gateNames map[string]struct{}) bool { - switch v := value.(type) { - case *ast.RangeLiteral: - return true - case *ast.Identifier: - _, isRange := rf.bindings[v.Value] - _, iterated := gateNames[v.Value] - return isRange && !iterated - } - return false -} - // drivesValue reports whether a range that may be empty drives an -// assignment's value: any range the value iterates, apart from a descriptor -// it assigns. A range the statement's condition names is already driven by -// the gate, which can skip the write by itself. +// assignment's value: any range the value iterates, apart from a range +// literal it assigns whole. func (rf *rangeFlow) drivesValue(value ast.Expression) bool { switch v := value.(type) { case *ast.RangeLiteral: return rf.drivesAny(ast.ExprChildren(v)) - case *ast.Identifier: - return false case *ast.CallExpression: return rf.drivesAny(v.Arguments) } @@ -570,10 +552,12 @@ func (rf *rangeFlow) drives(expr ast.Expression) bool { case *ast.RangeLiteral: return !rangeLiteralGuaranteedNonEmpty(e) case *ast.Identifier: - _, isRange := rf.bindings[e.Value] - return isRange + return rf.isRangeBinding(e.Value) case *ast.StringLiteral: - return len(rf.specifierRanges(e)) > 0 + // A marker iterates the range it names, whether it formats the range + // or uses it as a width or precision. + mains, specs := formatMarkerIdentifiers(e.Token.Literal, rf.isDefined) + return slices.ContainsFunc(slices.Concat(mains, specs), rf.isRangeBinding) } return rf.drivesAny(ast.ExprChildren(expr)) } @@ -582,47 +566,9 @@ func (rf *rangeFlow) drivesAny(exprs []ast.Expression) bool { return slices.ContainsFunc(exprs, rf.drives) } -// gateRanges returns the range bindings that a statement's conditions iterate. -func (rf *rangeFlow) gateRanges(conditions []ast.Expression) map[string]struct{} { - names := make(map[string]struct{}) - for _, condition := range conditions { - rf.collectRangeNames(condition, names) - } - return names -} - -func (rf *rangeFlow) collectRangeNames(expr ast.Expression, names map[string]struct{}) { - switch e := expr.(type) { - case *ast.ArrayLiteral: - return - case *ast.Identifier: - if _, isRange := rf.bindings[e.Value]; isRange { - names[e.Value] = struct{}{} - } - return - case *ast.StringLiteral: - for _, name := range rf.specifierRanges(e) { - names[name] = struct{}{} - } - return - } - - for _, child := range ast.ExprChildren(expr) { - rf.collectRangeNames(child, names) - } -} - -// specifierRanges returns the range bindings a string's width or precision -// consumes as numbers; a main marker formats a range as its descriptor. -func (rf *rangeFlow) specifierRanges(lit *ast.StringLiteral) []string { - _, specs := formatMarkerIdentifiers(lit.Token.Literal, rf.isDefined) - var ranges []string - for _, name := range specs { - if _, isRange := rf.bindings[name]; isRange { - ranges = append(ranges, name) - } - } - return ranges +func (rf *rangeFlow) isRangeBinding(name string) bool { + _, ok := rf.bindings[name] + return ok } func (rf *rangeFlow) isDefined(name string) bool { diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index 2ebe15eb..b1cedc09 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -1213,12 +1213,19 @@ out = F(x) out = last + 1`, }, { - name: "GateOnAnotherRangeKeepsDescriptor", + name: "GateOnAnotherRangeIteratesBoth", code: `out = F(n) other = 0:3 stream = 0:n kept = other > 1 stream out = kept * 2`, + }, + { + // A bare range name iterates, and a named range may be empty. + name: "BareRangeNameIterates", + code: `out = F(n) + stream = 0:n + out = stream`, unassigned: []string{"out"}, }, { @@ -1235,6 +1242,14 @@ out = F(x) last = n > 0 && stream out = last + 1`, }, + { + // A main marker iterates the range it names, too. + name: "MarkerRangeIterates", + code: `out = F(n) + r = 0:n + out = "v -r"`, + unassigned: []string{"out"}, + }, { // A width or precision consumes a range as numbers. name: "SpecifierRangeIterates", @@ -1544,11 +1559,12 @@ func TestFunctionsCannotReturnRanges(t *testing.T) { errors: []string{rangeOutputMessage("out")}, }, { - name: "CopiedRangeOutput", + // A range name iterates, so the output receives an element. + name: "RangeNameOutputIterates", code: `out = F(n) r = 0:n out = r`, - errors: []string{rangeOutputMessage("out")}, + errors: []string{unassignedOutputMessage("out")}, }, { name: "IteratedRangeOutput", @@ -1589,6 +1605,22 @@ out = F(n) } } +// A range literal assigned whole always writes, even an empty one, so +// overwriting it unread is reported as for any definite write. +func TestRangeLiteralWriteIsDefinite(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + cc := NewCodeCompiler(ctx, t.Name(), "", mustParseCode(t, `out = F(n) + r = 0:n + r = 1:n + out = [r]`)) + require.Equal(t, []string{ + `unconditional assignment to "r" overwrites a previous value that was never used. It was previously written at line 2:5`, + `value assigned to "r" is never used`, + }, extractErrorMessages(cc.Compile())) +} + func TestRangeOutputIsReportedAtItsAssignment(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() @@ -1613,12 +1645,14 @@ func TestTextRangeSummaryAgreesWithSolver(t *testing.T) { ranges []string }{ { - name: "DescriptorCopy", + // A range name iterates wherever it is used, so a copy holds an + // element. + name: "CopyIterates", code: `out = F(n) stream = 0:n kept = stream out = [kept]`, - ranges: []string{"kept", "stream"}, + ranges: []string{"stream"}, }, { name: "OperationIterates", @@ -1637,13 +1671,13 @@ func TestTextRangeSummaryAgreesWithSolver(t *testing.T) { ranges: []string{"stream"}, }, { - name: "GateOnAnotherRangeKeepsDescriptor", + name: "GateOnAnotherRangeIteratesBoth", code: `out = F(n) other = 0:3 stream = 0:n kept = other > 1 stream out = [kept]`, - ranges: []string{"kept", "other", "stream"}, + ranges: []string{"other", "stream"}, }, { name: "WholeLiteralUnderGateKeepsDescriptor", @@ -1654,9 +1688,9 @@ func TestTextRangeSummaryAgreesWithSolver(t *testing.T) { ranges: []string{"kept", "stream"}, }, { - // A range inside a collector in the condition is collected, so - // the gate does not iterate it. - name: "CollectorInGateKeepsDescriptor", + // The collector in the condition settles its own iteration; the + // value iterates the range by name. + name: "CollectorInGate", code: `n = Size(xs) t = xs[0] n = t + 2 @@ -1665,7 +1699,7 @@ out = F(n) stream = 0:n kept = Size([stream]) > 1 stream out = [kept]`, - ranges: []string{"kept", "stream"}, + ranges: []string{"stream"}, }, { name: "SpecifierInGateIterates", diff --git a/compiler/compiler.go b/compiler/compiler.go index 30012878..bcd34742 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -1171,7 +1171,7 @@ func (c *Compiler) compileExprAssigns(writeIdents []*ast.Identifier, ownershipId i := 0 for _, expr := range exprs { guardPtr := c.pushBoundsGuard("assign_bounds_guard") - res := c.compileExpression(expr, writeIdents[i:]) + res := c.compileAssignedValue(expr, writeIdents[i:]) var bit llvm.Value if c.stmtBoundsUsed() { bit = c.createLoad(guardPtr, Int{Width: 1}, "assign_bounds_ok") @@ -1184,6 +1184,34 @@ func (c *Compiler) compileExprAssigns(writeIdents []*ast.Identifier, ownershipId return assigns } +// compileAssignedValue compiles one assignment value. A bare range name there +// iterates its range and keeps the final yield, as an operation over it would; +// inside a loop that already binds the range, the name reads that iteration's +// element. +func (c *Compiler) compileAssignedValue(expr ast.Expression, dest []*ast.Identifier) []*Symbol { + ident, isIdent := expr.(*ast.Identifier) + if !isIdent { + return c.compileExpression(expr, dest) + } + info := c.ExprCache[key(c.FuncNameMangled, ident)] + if len(c.pendingLoopRanges(info.Ranges)) == 0 { + return c.compileExpression(expr, dest) + } + + PushScope(&c.Scopes, BlockScope) + defer c.popScope() + + outputs := c.makeSeededTempOutputs(dest, info.OutTypes) + c.bindRangedTempOutputs(dest, outputs) + output := outputs[0] + c.withLoopNest(info.Ranges, func() { + value := c.compileIdentifier(ident) + c.storeRangedOutput(output, value.Val, value.Type) + }) + + return c.loadOutputValues(outputs, "range_final") +} + // 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 { diff --git a/compiler/pir_test.go b/compiler/pir_test.go index 4f84a4ae..4890b26e 100644 --- a/compiler/pir_test.go +++ b/compiler/pir_test.go @@ -42,11 +42,10 @@ b = a + 2 * 3 a, b = b, a _ = 7 r = 0:10:2 -s = r a, b -s`) +r`) - require.Equal(t, []string{"assign a", "assign b", "assign a, b", "assign _", "assign r", "assign s"}, planLabels(plans)) + require.Equal(t, []string{"assign a", "assign b", "assign a, b", "assign _", "assign r"}, planLabels(plans)) require.Equal(t, `statement assign a, b source "a, b = b, a" @@ -89,23 +88,14 @@ s`) commit r <- %t0 `, plans[4].Render(false)) - - require.Equal(t, `statement assign s - source "s = r" - - execute - %t0 = eval I64:I64:I64 r - - commit - s <- %t0 -`, plans[5].Render(false)) } // TestPlanRouterRejections pins the capability boundary: statements with -// gates, conditional values, checked accesses, ranged RHS, calls, and -// block-layout literals keep their legacy lowering, while ordinary heap -// values — both string flavours, concatenations, inline array literals — -// plan alongside scalars and Range descriptors. +// gates, conditional values, checked accesses, ranged RHS (a bare range name +// included, since it iterates), calls, and block-layout literals keep their +// legacy lowering, while ordinary heap values — both string flavours, +// concatenations, inline array literals — plan alongside scalars and range +// literals. func TestPlanRouterRejections(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() @@ -124,6 +114,7 @@ arr = [1 2] z = arr[0] q = 0:3 w = q + 1 +qc = q _ = 0:3 d = Twice(x) m = [ @@ -136,7 +127,7 @@ tbl = [ ] rc = [q] cc = [x > 2] -g, y, sg, shc, z, w, d, m, tbl, rc, cc`) +g, y, sg, shc, z, w, qc, d, m, tbl, rc, cc`) require.Equal(t, []string{"assign x", "assign y", "assign s", "assign sg", "assign sh", "assign shc", "assign arr", "assign q", "assign _"}, planLabels(plans)) } diff --git a/compiler/solver.go b/compiler/solver.go index 9dbb079c..03b7e156 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -550,18 +550,21 @@ func (ts *TypeSolver) HandleCallRanges(call *ast.CallExpression) (ranges []*Rang return } -// collectPrintArgRanges keeps bare Range descriptors out of the ordinary -// argument pass until sibling drivers are known. Every other argument uses -// collectExprRanges exactly as it does for an ordinary call. A bare descriptor -// whose name a sibling binds then joins that driver; any other bare descriptor -// keeps its original expression and prints as a value. +// collectPrintArgRanges keeps a bare range literal out of the ordinary +// argument pass: it constructs a Range, which prints as written. Every other +// argument, a range name included, uses collectExprRanges exactly as it does +// for an ordinary call, so the print runs once per yield. func (ts *TypeSolver) collectPrintArgRanges(exprs []ast.Expression) (ranges []*RangeInfo, args []ast.Expression, changed bool) { args = append([]ast.Expression(nil), exprs...) var ordinaryArgs []ast.Expression var ordinaryIndexes []int for i, arg := range exprs { - if ts.bareRangeDescriptorArg(arg) { + if _, isLiteral := arg.(*ast.RangeLiteral); isLiteral { + info := ts.ExprCache[key(ts.FuncNameMangled, arg)] + info.Ranges = nil + info.HasRanges = false + info.Rewrite = nil continue } ordinaryArgs = append(ordinaryArgs, arg) @@ -572,39 +575,9 @@ func (ts *TypeSolver) collectPrintArgRanges(exprs []ast.Expression) (ranges []*R for i, argIndex := range ordinaryIndexes { args[argIndex] = rewrites[i] } - - for i, arg := range exprs { - if !ts.bareRangeDescriptorArg(arg) { - continue - } - if ident, ok := arg.(*ast.Identifier); ok && rangeDriverNamed(ranges, ident.Value) { - argRanges, rew := ts.HandleRanges(arg) - args[i] = rew - changed = changed || rew != arg - ranges = mergeUses(ranges, argRanges) - continue - } - info := ts.ExprCache[key(ts.FuncNameMangled, arg)] - info.Ranges = nil - info.HasRanges = false - info.Rewrite = nil - } return ranges, args, changed } -// bareRangeDescriptorArg reports whether a print argument is a complete Range -// descriptor: a range literal or a name bound to a Range. -func (ts *TypeSolver) bareRangeDescriptorArg(arg ast.Expression) bool { - switch a := arg.(type) { - case *ast.RangeLiteral: - return true - case *ast.Identifier: - typ, ok := ts.GetIdentifier(a.Value) - return ok && typ.Kind() == RangeKind - } - return false -} - // isBareRangeExpr reports whether expr is a driver that a function can consume // through a range-specialized variant. A ranged array access is eligible only // while it is an immediate call argument; collectCallArgs gives that call site @@ -622,9 +595,8 @@ func (ts *TypeSolver) isBareRangeExpr(expr ast.Expression) bool { } // HandleIdentifierRanges processes identifier expressions, detecting if they -// refer to range-typed variables and including them in range tracking. The -// enclosing context decides whether that occurrence consumes the driver or a -// complete assignment copies the descriptor. +// refer to range-typed variables and including them in range tracking. Every +// use of a range name consumes it as a driver. func (ts *TypeSolver) HandleIdentifierRanges(ident *ast.Identifier) (ranges []*RangeInfo, rew ast.Expression) { typ, ok := ts.GetIdentifier(ident.Value) if ok && typ.Kind() == RangeKind { @@ -645,11 +617,10 @@ func (ts *TypeSolver) HandleIdentifierRanges(ident *ast.Identifier) (ranges []*R // formatting markers so interpolation follows the same driver semantics as an // ordinary identifier expression. func (ts *TypeSolver) HandleStringLiteralRanges(lit *ast.StringLiteral) (ranges []*RangeInfo, rew ast.Expression) { - // A main marker formats its value, so a bare Range there stays a - // descriptor. Width and precision operands are consumed as numbers, which - // makes a named Range in a specifier an iteration driver. - _, specs := formatMarkerIdentifiers(lit.Token.Literal, ts.isDefined) - for _, name := range specs { + // A range name iterates wherever it is used, in a main marker as much as + // in a width or precision operand. + mains, specs := formatMarkerIdentifiers(lit.Token.Literal, ts.isDefined) + for _, name := range slices.Concat(mains, specs) { typ, ok := ts.GetIdentifier(name) if !ok || typ.Kind() != RangeKind { continue @@ -673,11 +644,11 @@ func rangeDriverNamed(ranges []*RangeInfo, name string) bool { return false } -// resolveBareRangeAssignment distinguishes Range descriptor copies from uses of -// a Range that an enclosing statement condition has already bound as an -// iterator. `copy = source` and `copy = 0:n` preserve the descriptor; in -// `filtered = source > 2 source`, the RHS reads the current scalar yield. -func (ts *TypeSolver) resolveBareRangeAssignment(expr ast.Expression, types []Type, condRanges []*RangeInfo) { +// resolveBareRangeAssignment settles a bare Range value at an assignment root. +// A range literal constructs the descriptor (`r = 0:n`); a range name +// iterates, so the assignment keeps the final yield (`last = r`), as it would +// for any operation over `r`. +func (ts *TypeSolver) resolveBareRangeAssignment(expr ast.Expression, types []Type) { if len(types) != 1 { return } @@ -690,12 +661,9 @@ func (ts *TypeSolver) resolveBareRangeAssignment(expr ast.Expression, types []Ty info := ts.ExprCache[key(ts.FuncNameMangled, expr)] switch e := expr.(type) { case *ast.Identifier: - if !rangeDriverNamed(condRanges, e.Value) { - info.Ranges = nil - info.HasRanges = false - info.Rewrite = nil - return - } + info.Ranges = mergeUses(info.Ranges, []*RangeInfo{{Name: e.Value}}) + info.HasRanges = true + info.Rewrite = e types[0] = rangeType.Iter info.OutTypes[0] = rangeType.Iter case *ast.RangeLiteral: @@ -969,7 +937,7 @@ func (ts *TypeSolver) TypeLetStatement(stmt *ast.LetStatement) { exprIdxs := make([]int, 0, len(stmt.Name)) for _, expr := range stmt.Value { exprTypes := ts.TypeExpression(expr, true) - ts.resolveBareRangeAssignment(expr, exprTypes, condRanges) + ts.resolveBareRangeAssignment(expr, exprTypes) ts.mergeCondRangesIntoValue(expr, condRanges) for idx := range exprTypes { types = append(types, exprTypes[idx]) diff --git a/compiler/solver_test.go b/compiler/solver_test.go index 58c7c0cf..578a44c5 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -1381,49 +1381,63 @@ res = [idx]` require.IsType(t, &ast.ArrayLiteral{}, info.Rewrite) } -func TestBareRangeAssignmentsCopyDescriptors(t *testing.T) { +// Only a range literal constructs a Range; a range name iterates wherever it +// is used, so assigning one keeps its final element. +func TestBareRangeNamesIterate(t *testing.T) { ctx := llvm.NewContext() - cc := NewCodeCompiler(ctx, "bareRangeCopies", "", ast.NewCode()) + cc := NewCodeCompiler(ctx, "bareRangeNames", "", ast.NewCode()) program := mustParseScript(t, `source = 0:5 -copy = (source) -last = source + 0 +last = (source) +sum = source + 0 outer = 0:2 -gatedCopy = outer < 2 source -filtered = source > 2 source`) +gated = outer < 2 source +filtered = source > 2 source +marked = "v -source"`) sc := NewScriptCompiler(ctx, t.Name(), program, cc) ts := NewTypeSolver(sc) ts.Solve() require.Emptyf(t, ts.Errors, "unexpected type errors: %v", ts.Errors) - for _, name := range []string{"source", "copy", "gatedCopy"} { + for _, name := range []string{"source", "outer"} { typ, ok := ts.GetIdentifier(name) require.Truef(t, ok, "expected %s binding", name) require.Equal(t, Range{Iter: I64}, typ) } - for _, name := range []string{"last", "filtered"} { + for _, name := range []string{"last", "sum", "gated", "filtered"} { typ, ok := ts.GetIdentifier(name) require.Truef(t, ok, "expected %s binding", name) require.Equal(t, I64, typ) } - copyExpr := program.Statements[1].(*ast.LetStatement).Value[0] - copyInfo := ts.ExprCache[key(ts.FuncNameMangled, copyExpr)] - require.False(t, copyInfo.HasRanges) - require.Empty(t, copyInfo.Ranges) - require.Nil(t, copyInfo.Rewrite) - - gatedCopyExpr := program.Statements[4].(*ast.LetStatement).Value[0] - gatedCopyInfo := ts.ExprCache[key(ts.FuncNameMangled, gatedCopyExpr)] - require.Equal(t, []Type{Range{Iter: I64}}, gatedCopyInfo.OutTypes) - require.Len(t, gatedCopyInfo.Ranges, 1) - require.Equal(t, "outer", gatedCopyInfo.Ranges[0].Name) + lastExpr := program.Statements[1].(*ast.LetStatement).Value[0] + lastInfo := ts.ExprCache[key(ts.FuncNameMangled, lastExpr)] + require.Equal(t, []Type{I64}, lastInfo.OutTypes) + require.True(t, lastInfo.HasRanges) + require.Len(t, lastInfo.Ranges, 1) + require.Equal(t, "source", lastInfo.Ranges[0].Name) + + gatedExpr := program.Statements[4].(*ast.LetStatement).Value[0] + gatedInfo := ts.ExprCache[key(ts.FuncNameMangled, gatedExpr)] + require.Equal(t, []Type{I64}, gatedInfo.OutTypes) + gatedDrivers := make([]string, len(gatedInfo.Ranges)) + for i, driver := range gatedInfo.Ranges { + gatedDrivers[i] = driver.Name + } + require.ElementsMatch(t, []string{"outer", "source"}, gatedDrivers) filteredExpr := program.Statements[5].(*ast.LetStatement).Value[0] filteredInfo := ts.ExprCache[key(ts.FuncNameMangled, filteredExpr)] require.Equal(t, []Type{I64}, filteredInfo.OutTypes) require.Len(t, filteredInfo.Ranges, 1) require.Equal(t, "source", filteredInfo.Ranges[0].Name) + + // A main marker naming a range drives the string. + markedExpr := program.Statements[6].(*ast.LetStatement).Value[0] + markedInfo := ts.ExprCache[key(ts.FuncNameMangled, markedExpr)] + require.True(t, markedInfo.HasRanges) + require.Len(t, markedInfo.Ranges, 1) + require.Equal(t, "source", markedInfo.Ranges[0].Name) } func TestRangedArrayAccessTypesAsElementStream(t *testing.T) { diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index f67f1a31..66fd1d08 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -937,13 +937,12 @@ statements a range drives. A parameter never holds a range, since a range argument drives the whole body; no call yields one, since a function cannot return a range (an output assigned one is rejected at the definition, #146); and struct fields, constants and array elements cannot hold one (`[r]` -collects `r`'s values). A value holds a range when it is a range literal or a -binding assigned a descriptor (`x = r`, not `x = r * 2`). A range literal or -binding operand of `||` and `&&` is iterated (`n > 0 && 0:5` gives an -element). A statement's condition iterates the ranges it names, so its value -reads them as elements (`kept = s > 2 s`); a range the condition only -collects, or does not name, stays a descriptor. A template's own text -therefore decides all of this, without looking at another template. Every +collects `r`'s values). Only a range literal assigned on its own constructs +one: a range name iterates wherever it is used, so `x = r` keeps `r`'s final +element just as `x = r * 2` iterates `r` (#146), and a range literal operand +of `||` and `&&` is iterated too (`n > 0 && 0:5` gives an element). A +binding therefore holds a range exactly when it is assigned a range literal, +which a template's own text decides without looking at another template. Every check then uses the same classification, and none waits for a type: after `r = 0:n`, `out = r * 2` does not kill an earlier `out = prev`, does not definitely assign `out`, and so cannot by itself make a later read of `out` @@ -1461,8 +1460,8 @@ immediate deletion at the last consumer. default both the body and a read of `out` after `out = r * 2` are rejected; `y = Helper(x)` then `out = y + 1`, where `Helper` returns a scalar, is a definite write; each way a value holds or iterates a range (a - gate naming it, a literal or binding under `&&`) agrees with the solved - types; an output assigned a range is rejected (#146); and a + gate naming it, a bare name, a literal or binding under `&&`) agrees with + the solved types; an output assigned a range is rejected (#146); and a call that names no template, an operator whose sides do not line up, an assignment whose values do not fill its targets, or a name assigned both a range and a non-range value is reported where the solver reports it, with diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index 8ebf278e..3297e8cd 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -4,18 +4,21 @@ This document describes Pluto's semantic model and compares it with other major ## The Pluto Model (Summary) -1. **Materialized Assignment is Copy:** assigning a scalar, Range descriptor, +1. **Materialized Assignment is Copy:** assigning a scalar, range literal, array, table, string, or struct creates an independent value. 2. **Arrays are Values:** `arr2 = arr1` copies data (COW). 3. **Range Selections are Streams:** `s = arr[i]` keeps the final selected value (an element or owned subarray); `s = [arr[i]]` materializes every selected value. -4. **Ranges are Descriptor Values:** `j = i` copies a Range; consuming it in - `x = i + 1`, `arr[i]`, a call, or `[]` drives a loop. Print and main - interpolation markers format the descriptor itself. -5. **Empty-Domain Initialization:** An empty Range descriptor still assigns. - An empty ranged computation leaves an existing destination unchanged and a - fresh destination at its type's zero value. +4. **Only a Range Literal Makes a Range:** `i = 0:5` binds a Range + descriptor. Every use of the name drives a loop: `j = i` and `x = i + 1` + keep the last yield, and `arr[i]`, a call, `[]`, a print, and an + interpolation marker run once per yield. A range literal printed on its + own shows as written (`0:5`). A function cannot return a Range (#146). +5. **Empty-Domain Initialization:** A range literal still assigns when it is + empty. An empty ranged computation, including `j = i` over an empty `i`, + leaves an existing destination unchanged and a fresh destination at its + type's zero value. 6. **Driver Identity Determines Looping:** Repeated use of one Range binding shares a loop; distinct bindings form a cartesian domain even when their descriptors have equal bounds. @@ -37,7 +40,7 @@ This document describes Pluto's semantic model and compares it with other major | **Array Assign** | **Copy** (COW) | Reference | Move | Reference (Slice) | Reference | Copy | | **Function Args** | **Read-only binding** (scalars lowered by value) | Reference | Move / Borrow | Copy (Slice Ref) | Reference | Copy | | **Range selection (`a[range]`)** | **Value stream** (final value or explicit collection) | Copy (List) / View (NumPy) | View (Slice) | View (Slice) | Copy (default) / View (`@view`) | View (Slice) | -| **Range Usage** | **Copyable descriptor; operations iterate** | Reference (Generator) | Reference (Iterator) | N/A | Reference (Iterator) | N/A | +| **Range Usage** | **Literal-built descriptor; every use iterates** | Reference (Generator) | Reference (Iterator) | N/A | Reference (Iterator) | N/A | | **Mutability** | **In-Place Only** | Mutable Objects | Mutable (if `mut`) | Mutable | Mutable | Mutable | | **Memory Mgmt** | **Auto (Scope)** | Auto (GC) | Auto (Owner) | Auto (GC) | Auto (GC) | Manual | @@ -60,16 +63,15 @@ x = (i+1 for i in iter) # Lazy generator a = [1]; b = a; a[0] = 2 # b sees 1 (independent copy) i = 0:5 -j = i # Descriptor copy; no loop -x = i + 0 # Loop executes, x = 4 (last yield) +j = i # Loop executes, j = 4 (last yield) x = i + 1 # Loop executes, x = 5 (last value) i = 0:10 # Bind a new reusable Range domain y = i + 1 # Consuming statement runs the loop; y = 10 ``` **Difference:** Pluto is safer and more predictable. A range literal binds a -reusable descriptor. A bare assignment copies it; a consuming expression runs -it as a loop rather than creating a lazy generator. +reusable descriptor, and every use of its name runs it as a loop rather than +creating a lazy generator. --- @@ -167,16 +169,16 @@ an owned subarray: ```python i = 0:5 -j = i # Same bounds, independent named driver -x = i + 0 # Loop at statement: x = 4 (last yielded iterator) +x = i # Loop at statement: x = 4 (last yielded iterator) x = i + 1 # Loop at statement: x = 5 (last scalar value) y = i * 2 # Loop at statement: y = 8 (last scalar value) z = (i + 1) / (i + 2) # Single loop: z = 5/6 (last value) ``` -Complete Range expressions construct or copy descriptors. Operations and -range-indexed array accesses consume descriptors as loop drivers. An -assignment root keeps the last computation yield; `[]` collects every yield. +A bare range literal constructs a descriptor. Every use of a range name, +bare or in an operation or a range-indexed array access, consumes it as a +loop driver. An assignment root keeps the last computation yield; `[]` +collects every yield. ### Driver Identity Determines Loop Structure @@ -195,8 +197,8 @@ product = (i + 1) * (j + 1) | Mode | Syntax | Behavior | |------|--------|----------| -| **Descriptor Copy** | `j = i` | No loop; j receives the Range value | -| **Last Value** | `x = i + 0` or `x = arr[i]` | Loop runs, x = last yielded value | +| **Construct** | `i = 0:5` | No loop; i receives the Range value | +| **Last Value** | `x = i`, `x = i + 0` or `x = arr[i]` | Loop runs, x = last yielded value | | **Accumulate** | `x = x + i` | Loop runs, x accumulates | | **Collect** | `arr = [i * 2]` | Loop runs, collects to array | @@ -238,8 +240,8 @@ Pluto sits in a "Sweet Spot" for parallel computing: 1. **Value Semantics (like R/Matlab)** make reasoning about concurrent code easy. "If I have `x`, I own `x`." 2. **Explicit Collection** makes every allocation and materialization boundary visible. -3. **Range-Driven Execution (Unique)** separates copyable descriptors from - operations that iterate without lazy-generator complexity. +3. **Range-Driven Execution (Unique)** builds a range only from a literal, + and every use of its name iterates without lazy-generator complexity. 4. **Named Driver Reuse (Unique)** makes user intent explicit — repeated use of one range name shares one loop. 5. **Defined Empty Domains (Unique)** give fresh and existing destinations diff --git a/docs/Pluto PIR Capability Matrix.md b/docs/Pluto PIR Capability Matrix.md index b2590a88..72183e75 100644 --- a/docs/Pluto PIR Capability Matrix.md +++ b/docs/Pluto PIR Capability Matrix.md @@ -176,7 +176,7 @@ two together rather than treating any single row as a deletion trigger. - **7c** — comparison- and call-propagated fallback: `arr[oob] > 0 \|\| -1`, `Id(arr[oob]) \|\| -1` — the failure travels through a propagator before the resolver. *Missing:* regressions when implemented. *Helpers:* condLHS spine - **7e** — ranged checked fallback: the fallback resolves per iteration inside the loop nest. *Missing:* regressions when implemented. *Helpers:* condLHS spine, ranged staging - **7f** — collector-cell fallback: in `[arr[oob] \|\| -1]` the `\|\|` resolves before the cell's zero-fill. *Missing:* regressions when implemented. *Helpers:* collector rewrite, condLHS spine -- **8** — plain value copy; the solver clears `Ranges`/`HasRanges`, so this is not an active ranged RHS. *Tests:* `range_finalize:2-21` (literal, identifier copy, empty, reassign), `compiler/solver_test.go`. *Helpers:* `compileAssignments` +- **8** — a range literal assigned on its own constructs the descriptor; the solver clears `Ranges`/`HasRanges`, so this is not an active ranged RHS. A bare range name iterates instead (#146), so `j = i` is a ranged RHS (row 9), finalized by `compileAssignedValue`. *Tests:* `range_finalize` (stepped and empty literals), golden `TestPlanGolden` (`r = 0:10:2`), `TestBareRangeNamesIterate`. *Helpers:* `compileAssignments` - **8b** — no longer arises: a function cannot return a range (#146), so an output assigned one is rejected at its definition. A function returns the bounds instead (`range_finalize`'s `rangeBounds`, `mem/gate_heap`'s `mkBounds`), and the caller's range literal is row 8. *Tests:* `TestFunctionsCannotReturnRanges` - **9** — `compileAssignments` → expression loop nest (passes nil conditions). *Tests:* `math/range_expr`, `math/range.spt`, `range_shadow.spt`, `cond/domain_activation`. *Helpers:* `compileAssignments`, `withCollectorPreparedLoopNest`, `compileCondOperands` - **10** — as #9 + `withLoopNestVersioned` affine probe. *Tests:* `array/affine_bounds_stmt`, `math/affine_bounds_expr`. *Helpers:* affine decision helpers diff --git a/docs/Pluto Range Semantics.md b/docs/Pluto Range Semantics.md index 1b8ac860..fb311c76 100644 --- a/docs/Pluto Range Semantics.md +++ b/docs/Pluto Range Semantics.md @@ -2,24 +2,24 @@ ## Core Model -A `Range` is a descriptor value. A range literal constructs one, and a -complete Range-valued assignment copies it: +A `Range` is a descriptor value, and a range literal is the only way to +construct one (#146): ```pluto i = 0:5 -j = i -k = (i) +k = (0:5) ``` -`i`, `j`, and `k` contain equal descriptor values with the same captured -bounds. Parentheses are transparent. Each binding is nevertheless a distinct -driver identity: consuming `i` and `j` together forms a cartesian domain, -while repeated uses of `i` share one loop. +`i` and `k` hold equal descriptor values with the same captured bounds; +parentheses are transparent. Each binding is a distinct driver identity: +consuming `i` and `k` together forms a cartesian domain, while repeated uses +of `i` share one loop. -Using a Range in an operation creates a ranged computation. `Ranged` is a -useful description of that expression effect, not a storable source type. An -operation produces ordered per-iteration values; an individual yield may be a -scalar or an owned subarray. +Every use of a range name creates a ranged computation, a bare one included: +`j = i` iterates `i` and keeps its final yield, exactly as `j = i + 0` does. +`Ranged` is a useful description of that expression effect, not a storable +source type. An operation produces ordered per-iteration values; an individual +yield may be a scalar or an owned subarray. There are two explicit closing steps for a ranged computation: @@ -27,22 +27,28 @@ There are two explicit closing steps for a ranged computation: 2. The root expression of an assignment closes any remaining outer iteration by taking the final yielded value in iteration order. -A Range descriptor assignment is not a closing step and does not iterate. -This keeps descriptor copying, collection, and final-value selection separate. +A range literal assigned on its own is not a closing step and does not +iterate: it constructs the descriptor. -## Migration From Bare-Range Finalization +## Migration From Descriptor Copies -Previously, assigning a bare named Range kept its final yield. Code that relied -on that behavior should use an operation such as `last = i + 0`; for -`i = 0:5`, `last` becomes `4`, while `copy = i` now copies the descriptor. +Before #146, assigning a bare named Range (`copy = i`) copied the descriptor, +and printing a range name or naming it in a main interpolation marker showed +the descriptor. A range name now iterates wherever it is used, which restores +the earlier bare-range finalization: -This change can be silent for a fresh destination. A later call, index, or -collector consumes the copied Range and runs its whole domain, while print and -interpolation format the descriptor itself. Assigning a Range -to an existing scalar is instead rejected as a type-changing reassignment. -Descriptor copies are unconditional writes and participate in the ordinary -dead-store checks. Range-indexed expressions such as `last = data[i]` are -unchanged because indexing is already a ranged computation. +- `copy = i` keeps `i`'s final yield. For a second driver over the same + domain, construct another literal (`j = 0:5`), keeping the bounds in names + if they can change in between. Assigning a range name to a binding that + holds a Range is a type-changing reassignment. +- Printing a range name prints once per yield, two distinct names print their + cartesian product, and an empty range prints nothing. `[i]` lists the values + on one line, and a range literal printed on its own shows as written. +- A main marker naming a range, as in `"v=-i"`, runs once per yield. + +These changes are silent where a program still compiles. Range-indexed +expressions such as `last = data[i]` are unchanged because indexing is already +a ranged computation. ## Ranges And Drivers @@ -87,28 +93,28 @@ surrounding expression, finalized at an assignment root, or materialized by final value can itself be an array. This ownership is semantic: copy-on-write is permitted, but an escaping view into the source is not. -Assigning an empty Range descriptor still performs a normal write. Consuming +Assigning an empty range literal still performs a normal write. Consuming an empty Range produces no yields: a fresh ranged-computation destination retains its type's zero value (an empty array for a subarray result), while an existing destination is unchanged. Outside `[]`, an out-of-bounds selection point yields nothing, so the last valid selected value wins. Inside `[]`, failed cells are zero-filled to preserve collection shape, as described below. -Print is a sink, not an operation: a bare Range argument or main marker -formats the descriptor as `start:stop` (or `start:stop:step`), exactly as the -value it is. Computations still drive the print loop, and a bare name bound as -a driver by a sibling computation prints its per-iteration scalar. A width or -precision marker consumes its Range operand as a number, so that operand still -drives; see +Print is a sink: a bare range literal argument formats its descriptor as +`start:stop` (or `start:stop:step`), exactly as written. Any other argument +naming a range, bare or in a computation, drives the print loop, and so does a +marker naming one, whether it formats the value or uses it as a width or +precision; see [Pluto String and Formatting Semantics](Pluto%20String%20and%20Formatting%20Semantics.md#interpolation-markers) for the formatting rules and examples. For ordinary print arguments: ```pluto i = 0:2 j = 2:4 -i, j # one line: 0:2 2:4 -i + 0, j + 0 # cartesian: 0 2 / 0 3 / 1 2 / 1 3 -i, Square(i) # i is driven: 0 0 / 1 1 +0:2, 2:4 # one line: 0:2 2:4 +i, j # cartesian: 0 2 / 0 3 / 1 2 / 1 3 +i + 0, j + 0 # the same +i, Square(i) # one shared loop: 0 0 / 1 1 ``` Distinct drivers nest in source order, so collecting over two ranges walks @@ -481,11 +487,11 @@ the statement condition opens that outer loop first. Inside the RHS, the same name refers to the current scalar iterator value, not to a fresh nested loop. -Consequently, `filtered = i > 2 i` keeps the final admitted scalar, while -`copy = outer > 2 i` copies `i` when only `outer` belongs to the statement -domain. If the statement domain is empty, it performs no write: an existing -Range destination stays unchanged and a fresh one keeps the zero Range -descriptor. +Consequently, `filtered = i > 2 i` keeps the final admitted scalar, and +`cross = outer > 2 i` iterates `i` inside each admitted `outer` point and +keeps the final yield. If the statement domain is empty, it performs no +write: an existing destination stays unchanged and a fresh one keeps its zero +value. For non-collector tuple outputs, one admitted statement iteration is still one shared scalar update step, but each RHS has its own local yield outcome. If one diff --git a/docs/Pluto String and Formatting Semantics.md b/docs/Pluto String and Formatting Semantics.md index 5c623686..b30020d7 100644 --- a/docs/Pluto String and Formatting Semantics.md +++ b/docs/Pluto String and Formatting Semantics.md @@ -63,27 +63,24 @@ width = 5 "-missing%(-width)d" # -missing%(5)d ``` -A main marker formats its value whatever the type, so a bare `Range` formats -its descriptor and contributes no iteration: the string is a single value in -assignment and in print alike. A Range identifier used for dynamic width or -precision is consumed as a number, which makes it an iteration driver. An -explicit numeric conversion such as `-i%d` is a compile error while `i` is an -undriven Range descriptor — a descriptor is not a number. +A marker naming a `Range` iterates it, like any other use of a range name +(#146): a main marker formats each yield, and a width or precision marker +consumes each yield as a number. The string is then a ranged computation: +assigned, it keeps the final yield; printed, it prints once per yield. An +explicit numeric conversion such as `-i%d` applies to each yield. ```pluto i = 0:3 -s = "item -i" # "item 0:3" -"item -i" # prints item 0:3 on one line +s = "item -i" # "item 2" +"item -i" # prints item 0, item 1 and item 2, one per line +"v=-i%d" # prints v=0, v=1 and v=2 n = 7 w = 1:3 "|-n%(-w)d|" # one line per width: |7| then | 7| ``` -Within a print statement, another argument that consumes the same named `Range` -makes it a driver for that print loop. During each iteration, the main marker -reads the current `I64` yield, so an explicit numeric conversion applies to the -yield. This sibling binding is specific to print arguments; it does not apply -across sibling values in an assignment. +Within a print statement, a marker and another argument naming the same +`Range` share one driver, so the line prints once per yield. ```pluto i = 0:3 diff --git a/tests/array/cond_accum.exp b/tests/array/cond_accum.exp index 3bce1d8f..25cb2c92 100644 --- a/tests/array/cond_accum.exp +++ b/tests/array/cond_accum.exp @@ -37,7 +37,7 @@ MultiCell: [0 10 1 11] ScalarArith: 3 4 ScalarElem: 20 -CrossRange: [0 1 2 3 4] +CrossRange: [4] LitCross: [0 1 2 3 4] 30 SeedLit: [8 9] diff --git a/tests/array/cond_accum.spt b/tests/array/cond_accum.spt index adea5774..0911c4f4 100644 --- a/tests/array/cond_accum.spt +++ b/tests/array/cond_accum.spt @@ -205,7 +205,8 @@ q = 0:3 elem = q < 2 arr4[q] "ScalarElem: -elem" -# A statement driver gates a distinct bare Range descriptor without consuming it. +# A bare range name under a statement driver iterates too, so the statement +# runs over both ranges and keeps the final value. r = 0:3 i = 0:5 cross = r < 4 i diff --git a/tests/math/print_func.exp b/tests/math/print_func.exp index 7aa03591..a894f638 100644 --- a/tests/math/print_func.exp +++ b/tests/math/print_func.exp @@ -13,11 +13,30 @@ 1 1 2 4 3 9 -0:4 1:3 -0:4 2 -0:4 3 -0:4 4 -0:4 9 +0 1 +0 2 +1 1 +1 2 +2 1 +2 2 +3 1 +3 2 +0 2 +0 3 +1 2 +1 3 +2 2 +2 3 +3 2 +3 3 +0 4 +0 9 +1 4 +1 9 +2 4 +2 9 +3 4 +3 9 1 4 2 9 1 0 diff --git a/tests/math/print_func.spt b/tests/math/print_func.spt index 9fc9fea1..1f95efe4 100644 --- a/tests/math/print_func.spt +++ b/tests/math/print_func.spt @@ -17,15 +17,15 @@ Square(i) # Named range both direct and in function call i, Square(i) -# Two bare ranges print as descriptor values on one line. +# Two range names iterate as nested loops, the first outermost: one line per +# pair. j = 1:3 i, j -# A computation drives only its own name; the undriven bare range stays a -# descriptor, printed once per driver yield. +# A name beside a computation over another range iterates too. i, j + 1 -# The undriven bare range prints its descriptor beside each computed value. +# So does a name beside a call over another range. i, Square(j + 1) # Same driver bare and in a computation - the computation binds it, single loop diff --git a/tests/range.exp b/tests/range.exp index d4e337d7..99260527 100644 --- a/tests/range.exp +++ b/tests/range.exp @@ -1,6 +1,10 @@ [0 1 2 3 4] [1 2 3 4 5 6 7 8 9] [0 2] [-3 -2 -1 0 1 2 3 4] [0 1 2 3 4 5] [0 1 2 3 4 5] [1 2 3 4 5 6 7] [6] [0 3 6] Descriptors 0:2 2:4 +Names 0 2 +Names 0 3 +Names 1 2 +Names 1 3 Cartesian 0 2 Cartesian 0 3 Cartesian 1 2 diff --git a/tests/range.spt b/tests/range.spt index a1453771..bd89637f 100644 --- a/tests/range.spt +++ b/tests/range.spt @@ -17,9 +17,10 @@ u = 0:b:d [i], [j], [k], [u] -# Bare descriptors print as values on one line; computations over distinct -# drivers still form a cartesian iteration domain. +# Range literals print as written, on one line. Range names iterate, bare or +# in a computation, and distinct names form a cartesian iteration domain. p = 0:2 q = 2:4 -"Descriptors", p, q +"Descriptors", 0:2, 2:4 +"Names", p, q "Cartesian", p + 0, q + 0 diff --git a/tests/range_finalize.exp b/tests/range_finalize.exp index ac07bf36..9e79034e 100644 --- a/tests/range_finalize.exp +++ b/tests/range_finalize.exp @@ -1,13 +1,15 @@ -AscendingCopy: [0 1 2 3 4] -DescendingCopy: [6 4 2] -EmptyRangeFresh: [] -EmptyRangeExisting: [0 1] -> [] +AscendingLast: 4 [0 1 2 3 4] +DescendingLast: 2 +EmptyNameFresh: 0 +EmptyNameExisting: 91 EmptyComputeFresh: 0 EmptyComputeExisting: 91 CallFinal: 20 BuiltRange: [2 3 4] -MarkerFinal: Marker 0:3 -MarkerDescriptor 0:3 +MarkerFinal: Marker 2 +MarkerEach 0 +MarkerEach 1 +MarkerEach 2 MarkerWidthFinal: | 7| MarkerWidthEach: |7| MarkerWidthEach: | 7| @@ -18,6 +20,10 @@ DrivenMarker i=1 1 DrivenMarker i=2 2 EmptyDescriptor: 3:3 SteppedDescriptor: 6:0:-2 +SteppedName: 6 +SteppedName: 4 +SteppedName: 2 +AfterEmptyName SimultaneousRange: 2 77 SimultaneousArray: 30 88 -SimultaneousString: Marker 0:3 old +SimultaneousString: Marker 2 old diff --git a/tests/range_finalize.spt b/tests/range_finalize.spt index e8eb669d..270812e1 100644 --- a/tests/range_finalize.spt +++ b/tests/range_finalize.spt @@ -1,25 +1,23 @@ -# Bare range assignments copy descriptors. Brackets observe the copied stream. +# A bare range name iterates, so assigning one keeps the final value, as an +# operation over it does. Brackets collect every value. ascendingRange = 0:5 ascending = ascendingRange -ascendingValues = [ascending] -"AscendingCopy: -ascendingValues" +ascendingValues = [ascendingRange] +"AscendingLast: -ascending -ascendingValues" descendingRange = 6:0:-2 descending = descendingRange -descendingValues = [descending] -"DescendingCopy: -descendingValues" +"DescendingLast: -descending" -# Empty descriptors still assign: both destinations become empty ranges. +# An empty range runs no iteration: a fresh destination gets zero, and an +# existing one keeps its value. emptyRange = 3:3 -freshRange = emptyRange -freshRangeValues = [freshRange] -"EmptyRangeFresh: -freshRangeValues" +freshEmpty = emptyRange +"EmptyNameFresh: -freshEmpty" -existingRange = 0:2 -existingRangeBefore = [existingRange] -existingRange = emptyRange -existingRangeValues = [existingRange] -"EmptyRangeExisting: -existingRangeBefore -> -existingRangeValues" +existingEmpty = 91 +existingEmpty = emptyRange +"EmptyNameExisting: -existingEmpty" # Empty ranged computations yield no value: fresh scalar destinations use zero, # while existing scalar destinations retain their seed. @@ -30,8 +28,8 @@ existing = 91 existing = emptyRange + 1 "EmptyComputeExisting: -existing" -# A copied descriptor remains consumable by functions. -callFinal = useShadow(ascending) +# A range passed to a function runs it once per value. +callFinal = useShadow(ascendingRange) "CallFinal: -callFinal" # A function returns a range's bounds, and the caller builds the range. @@ -40,33 +38,37 @@ builtRange = boundsStart:boundsStop builtValues = [builtRange] "BuiltRange: -builtValues" -# A bare Range in a main marker formats the descriptor: the string is one -# value with no drivers, in interpolation exactly as in assignment. +# A marker naming a range iterates it: assigned, the string keeps the final +# value; printed, it prints once per value. markerRange = 0:3 formattedFinal = "Marker -markerRange" "MarkerFinal: -formattedFinal" -"MarkerDescriptor -markerRange" +"MarkerEach -markerRange" -# A width or precision operand is consumed as a number, so a named Range there -# is still an iteration driver. +# A width or precision operand iterates a named range the same way. widthRange = 1:4 widthValue = 7 formattedWidth = "|-widthValue%(-widthRange)d|" "MarkerWidthFinal: -formattedWidth" "MarkerWidthEach: |-widthValue%(-widthRange)d|" -# A range literal in print position is a descriptor value too. +# A bare range literal prints as written. "LiteralDescriptor", 0:3 -# A sibling computation binds the marker's name, so the marker formats that -# iteration's I64 yield and an explicit %d applies to it. +# The marker and a sibling computation share one driver, so the line prints +# once per value and an explicit %d applies to the element. "DrivenMarker i=-markerRange%d", markerRange + 0 -# Descriptors print their exact bounds, including empty and stepped forms. -emptyDescriptor = 3:3 -"EmptyDescriptor:", emptyDescriptor -steppedDescriptor = 6:0:-2 -"SteppedDescriptor:", steppedDescriptor +# A range literal prints its exact bounds, including empty and stepped forms. +"EmptyDescriptor:", 3:3 +"SteppedDescriptor:", 6:0:-2 + +# A range name prints once per value, and an empty one prints nothing. +steppedName = 6:0:-2 +"SteppedName:", steppedName +emptyName = 3:3 +"EmptyName:", emptyName +"AfterEmptyName" # Ranged computations preserve simultaneous-assignment reads. simRange = 0:3 @@ -79,8 +81,8 @@ first = 88 first, second = simArray[simRange], first "SimultaneousArray: -first -second" -# A bare-marker string is a single value, so this write is unconditional; the -# sibling still reads text's pre-statement value. +# A marker naming a range iterates it, and the sibling still reads text's +# pre-statement value. text = "old" text, otherText = "Marker -markerRange", text "SimultaneousString: -text -otherText" From dc032877fd055d0aeb549172d9d49f7f9a710823 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 7 Oct 2026 21:19:47 +0530 Subject: [PATCH 10/34] fix(compiler): drive string markers in the order they appear The previous commit made every string marker naming a range a loop driver, but the type checker took the names that format a value before the width and precision names. `"|-n%(-w)d| -j"` therefore made `j` the outer loop although `w` comes first, and printed or collected values came out in the wrong order. The marker scan now returns one list of names in the order they appear, each marker's value and then its width and precision operands, and the type checker takes the string's drivers in that order. The per-template check and hasValidMarkers read the same list. Range Semantics no longer calls a Range copyable or says a bare name under an unrelated gate copies its descriptor, and the formatting doc states the order rule. Refs #146 Co-Authored-By: Claude Opus 5.5 --- compiler/cfg.go | 3 +-- compiler/format.go | 24 ++++++++--------- compiler/solver.go | 6 ++--- compiler/solver_test.go | 27 +++++++++++++++++++ docs/Pluto Range Semantics.md | 9 ++++--- docs/Pluto String and Formatting Semantics.md | 6 ++++- tests/range_finalize.exp | 5 ++++ tests/range_finalize.spt | 9 +++++++ 8 files changed, 67 insertions(+), 22 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index 61e569c7..47570e85 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -556,8 +556,7 @@ func (rf *rangeFlow) drives(expr ast.Expression) bool { case *ast.StringLiteral: // A marker iterates the range it names, whether it formats the range // or uses it as a width or precision. - mains, specs := formatMarkerIdentifiers(e.Token.Literal, rf.isDefined) - return slices.ContainsFunc(slices.Concat(mains, specs), rf.isRangeBinding) + return slices.ContainsFunc(formatMarkerNames(e.Token.Literal, rf.isDefined), rf.isRangeBinding) } return rf.drivesAny(ast.ExprChildren(expr)) } diff --git a/compiler/format.go b/compiler/format.go index ed2f663c..6aeb5527 100644 --- a/compiler/format.go +++ b/compiler/format.go @@ -942,12 +942,12 @@ func (c *Compiler) structFormatArgs(s *Symbol) (fmtStr string, args []llvm.Value return } -// formatMarkerIdentifiers returns the identifiers read by resolved markers in -// source order, separating main-marker identifiers from dynamic -// width/precision identifiers. A main marker formats its value whatever the -// type, while a specifier operand is consumed as a number, so only specifier -// identifiers can turn a named Range into an iteration driver. -func formatMarkerIdentifiers(value string, isDefined func(string) bool) (mains, specs []string) { +// formatMarkerNames returns the identifiers read by resolved markers in the +// order they appear: each marker's value, then its dynamic width and +// precision operands. Every one of them iterates a named Range, so this order +// is the order of the string's drivers. +func formatMarkerNames(value string, isDefined func(string) bool) []string { + var names []string runes := []rune(value) for i := 0; i < len(runes); i++ { if runes[i] == '\\' { @@ -962,7 +962,7 @@ func formatMarkerIdentifiers(value string, isDefined func(string) bool) (mains, if !isDefined(mainID) { continue } - mains = append(mains, mainID) + names = append(names, mainID) if end >= len(runes) || runes[end] != '%' { i = end - 1 @@ -971,16 +971,16 @@ func formatMarkerIdentifiers(value string, isDefined func(string) bool) (mains, spec, _ := parseSpecifierSyntax(token.Token{}, value, runes, end) for _, specID := range spec.ids { if isDefined(specID.name) { - specs = append(specs, specID.name) + names = append(names, specID.name) } } i = spec.end - 1 } - return mains, specs + return names } -// hasValidMarkers checks if a format string contains a resolved marker. +// hasValidMarkers checks if a format string contains a resolved marker. A +// resolved marker names its value first, so any name means one resolved. func hasValidMarkers(value string, isDefined func(string) bool) bool { - mains, _ := formatMarkerIdentifiers(value, isDefined) - return len(mains) > 0 + return len(formatMarkerNames(value, isDefined)) > 0 } diff --git a/compiler/solver.go b/compiler/solver.go index 03b7e156..745836d1 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -618,9 +618,9 @@ func (ts *TypeSolver) HandleIdentifierRanges(ident *ast.Identifier) (ranges []*R // ordinary identifier expression. func (ts *TypeSolver) HandleStringLiteralRanges(lit *ast.StringLiteral) (ranges []*RangeInfo, rew ast.Expression) { // A range name iterates wherever it is used, in a main marker as much as - // in a width or precision operand. - mains, specs := formatMarkerIdentifiers(lit.Token.Literal, ts.isDefined) - for _, name := range slices.Concat(mains, specs) { + // in a width or precision operand, and the drivers nest in the order the + // string names them. + for _, name := range formatMarkerNames(lit.Token.Literal, ts.isDefined) { typ, ok := ts.GetIdentifier(name) if !ok || typ.Kind() != RangeKind { continue diff --git a/compiler/solver_test.go b/compiler/solver_test.go index 578a44c5..67b50603 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -1440,6 +1440,33 @@ marked = "v -source"`) require.Equal(t, "source", markedInfo.Ranges[0].Name) } +// A string's markers drive in the order they appear, a width or precision +// operand included, so the first range the string names is the outer loop. +func TestMarkerDriversFollowTextOrder(t *testing.T) { + ctx := llvm.NewContext() + cc := NewCodeCompiler(ctx, "markerOrder", "", ast.NewCode()) + program := mustParseScript(t, `n = 1 +w = 1:3 +j = 7:9 +s = "|-n%(-w)d| -j" +t = "-j |-n%(-w)d|"`) + + sc := NewScriptCompiler(ctx, t.Name(), program, cc) + ts := NewTypeSolver(sc) + ts.Solve() + require.Emptyf(t, ts.Errors, "unexpected type errors: %v", ts.Errors) + + for i, want := range [][]string{{"w", "j"}, {"j", "w"}} { + lit := program.Statements[3+i].(*ast.LetStatement).Value[0] + info := ts.ExprCache[key(ts.FuncNameMangled, lit)] + drivers := make([]string, len(info.Ranges)) + for k, driver := range info.Ranges { + drivers[k] = driver.Name + } + require.Equal(t, want, drivers) + } +} + func TestRangedArrayAccessTypesAsElementStream(t *testing.T) { ctx := llvm.NewContext() code := ast.NewCode() diff --git a/docs/Pluto Range Semantics.md b/docs/Pluto Range Semantics.md index fb311c76..ecf034b0 100644 --- a/docs/Pluto Range Semantics.md +++ b/docs/Pluto Range Semantics.md @@ -67,8 +67,9 @@ The bound values are captured when the range is constructed, so later changes to the source variables do not mutate the existing range. Functions that need those bounds as data should currently receive the scalar values explicitly. -Range is nameable and copyable, but is not yet a fully first-class container -element. Arrays and tables contain scalar/string elements rather than Range +A Range can be named but not copied, since assigning a range name iterates +it, and it is not yet a fully first-class container element. Arrays and +tables contain scalar/string elements rather than Range descriptors, so `[i]` consumes `i` and collects its yields. Passing a Range to a function likewise consumes it as a driver rather than passing inert metadata. A function cannot return a Range either: an output assigned one is rejected @@ -464,8 +465,8 @@ x, y = i < 2 [1], j + 0 The statement condition `i < 2` is shared. `x` collects once for each admitted `i`, producing `[1 1]`. `y`'s operation uses its own local `j` driver inside that shared gate and ends -with the final result `1`. A bare `j` in this position would instead copy the -Range descriptor because only `i` belongs to the active statement domain. +with the final result `1`. A bare `j` in this position iterates the same way +and also ends with `1`. Likewise: diff --git a/docs/Pluto String and Formatting Semantics.md b/docs/Pluto String and Formatting Semantics.md index b30020d7..20f538bd 100644 --- a/docs/Pluto String and Formatting Semantics.md +++ b/docs/Pluto String and Formatting Semantics.md @@ -67,7 +67,9 @@ A marker naming a `Range` iterates it, like any other use of a range name (#146): a main marker formats each yield, and a width or precision marker consumes each yield as a number. The string is then a ranged computation: assigned, it keeps the final yield; printed, it prints once per yield. An -explicit numeric conversion such as `-i%d` applies to each yield. +explicit numeric conversion such as `-i%d` applies to each yield. Several +ranges nest in the order the string names them, the first outermost, whether +a marker formats a range or uses it as a width or precision. ```pluto i = 0:3 @@ -77,6 +79,8 @@ s = "item -i" # "item 2" n = 7 w = 1:3 "|-n%(-w)d|" # one line per width: |7| then | 7| +j = 8:10 +"|-n%(-w)d| -j" # w is the outer loop: |7| 8, |7| 9, | 7| 8, | 7| 9 ``` Within a print statement, a marker and another argument naming the same diff --git a/tests/range_finalize.exp b/tests/range_finalize.exp index 9e79034e..8b06d14c 100644 --- a/tests/range_finalize.exp +++ b/tests/range_finalize.exp @@ -14,6 +14,11 @@ MarkerWidthFinal: | 7| MarkerWidthEach: |7| MarkerWidthEach: | 7| MarkerWidthEach: | 7| +MarkerOrder: |1| 7 +MarkerOrder: |1| 8 +MarkerOrder: | 1| 7 +MarkerOrder: | 1| 8 +MarkerOrderCollected: ["|1| 7" "|1| 8" "| 1| 7" "| 1| 8"] LiteralDescriptor 0:3 DrivenMarker i=0 0 DrivenMarker i=1 1 diff --git a/tests/range_finalize.spt b/tests/range_finalize.spt index 270812e1..f2f2df57 100644 --- a/tests/range_finalize.spt +++ b/tests/range_finalize.spt @@ -52,6 +52,15 @@ formattedWidth = "|-widthValue%(-widthRange)d|" "MarkerWidthFinal: -formattedWidth" "MarkerWidthEach: |-widthValue%(-widthRange)d|" +# Markers drive in the order they appear, a width included, so the width +# range here is the outer loop. +orderValue = 1 +orderWidth = 1:3 +orderInner = 7:9 +"MarkerOrder: |-orderValue%(-orderWidth)d| -orderInner" +orderCollected = ["|-orderValue%(-orderWidth)d| -orderInner"] +"MarkerOrderCollected: -orderCollected" + # A bare range literal prints as written. "LiteralDescriptor", 0:3 From f51a5905039afcc7f9dce241ab637f2f703a3a18 Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 9 Oct 2026 23:02:59 +0530 Subject: [PATCH 11/34] refactor(compiler): leave range retypes to the type checker The per-function check rejected a name reassigned between a range and a non-range value, a copy of the type checker's rule that a variable keeps its type. It kept the copy so its flow checks would not misread such a name. They now read each name by its latest assignment instead: a name holds a range from an assignment of a range literal until it is assigned anything else. The type checker reports the retype, at the same position, when it types the function. A function nothing calls is no longer checked for a range retype, as for any other type error. The IR plan says so, and no longer describes the `||` range check that 3bec6c5 removed. Refs #123 Co-Authored-By: Claude Opus 5.5 --- compiler/cfg.go | 43 ++++++++++----------------------- compiler/cfg_test.go | 52 ++++------------------------------------ compiler/solver_test.go | 53 +++++++++++++++++++++++++++++++++++++++++ docs/Pluto IR Plan.md | 25 ++++++++++--------- 4 files changed, 84 insertions(+), 89 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index 47570e85..db0d085b 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -244,12 +244,11 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { // classifyTemplateWrites classifies every assignment's writes from the // template's text, indexed by statement, and records its range bindings for // settlement to check. It reports the text it cannot classify: a call that -// names no template, an operator whose sides do not line up, an assignment -// whose values do not fill its targets, and a name reassigned between a range -// and a non-range value. The solver rejects each of these in any -// specialization; the flow checks, which would misread them, do not run over a -// template that has one. It also reports an output that holds a range, which -// a function cannot return. +// names no template, an operator whose sides do not line up, and an +// assignment whose values do not fill its targets. The solver rejects each of +// these in any specialization; the flow checks, which would misread them, do +// not run over a template that has one. It also reports an output that holds +// a range, which a function cannot return. func (cfg *CFG) classifyTemplateWrites(fn *ast.FuncStatement) [][]textWrite { flow := newRangeFlow(cfg.CodeCompiler, fn) outputs := identSet(fn.Outputs) @@ -266,7 +265,6 @@ func (cfg *CFG) classifyTemplateWrites(fn *ast.FuncStatement) [][]textWrite { cfg.addError(let.Token, assignmentMismatch(len(let.Name), cfg.CodeCompiler.valueCount(let.Value))) continue } - cfg.rejectRangeKindChanges(flow, let, letWrites) cfg.rejectRangeOutputs(outputs, let, letWrites) flow.record(let, letWrites) writes[i] = letWrites @@ -313,21 +311,6 @@ func operandsLineUp(infix *ast.InfixExpression, left, right int) bool { return left == right } -func (cfg *CFG) rejectRangeKindChanges(flow *rangeFlow, let *ast.LetStatement, writes []textWrite) { - for i, target := range let.Name { - heldRange, assigned := flow.assigned[target.Value] - if !assigned || heldRange == writes[i].holdsRange { - continue - } - - if heldRange { - cfg.addError(target.Tok(), fmt.Sprintf("cannot reassign %q from a range to a non-range value", target.Value)) - continue - } - cfg.addError(target.Tok(), fmt.Sprintf("cannot reassign %q from a non-range value to a range", target.Value)) - } -} - // rejectRangeOutputs reports an output assigned a range. A function returns a // range's bounds instead, and its caller builds the range. func (cfg *CFG) rejectRangeOutputs(outputs map[string]struct{}, let *ast.LetStatement, writes []textWrite) { @@ -447,13 +430,13 @@ type textWrite struct { // never holds one, since a range argument runs the body once per element; no // call yields one, since a function cannot return a range; and a range name // iterates wherever it is used, so a binding assigned from one holds an -// element. assigned keeps whether each assigned name's first assignment gave -// it a range. +// element. A binding holds a range as its latest assignment left it; a +// reassignment between a range and a non-range value is the solver's to +// reject, like any other change of type. type rangeFlow struct { cc *CodeCompiler bindings map[string]struct{} defined map[string]struct{} - assigned map[string]bool } func newRangeFlow(cc *CodeCompiler, fn *ast.FuncStatement) *rangeFlow { @@ -461,7 +444,7 @@ func newRangeFlow(cc *CodeCompiler, fn *ast.FuncStatement) *rangeFlow { for _, output := range fn.Outputs { defined[output.Value] = struct{}{} } - return &rangeFlow{cc: cc, bindings: make(map[string]struct{}), defined: defined, assigned: make(map[string]bool)} + return &rangeFlow{cc: cc, bindings: make(map[string]struct{}), defined: defined} } // letWrites classifies every target of an assignment, discards included, so @@ -512,7 +495,8 @@ func (rf *rangeFlow) slotRanges(value ast.Expression) []bool { } // record publishes an assignment's targets once its reads are classified: -// they become defined, and those that receive a descriptor hold a range. +// they become defined, and each holds a range exactly when this assignment +// gives it a descriptor. func (rf *rangeFlow) record(let *ast.LetStatement, writes []textWrite) { for i, target := range let.Name { if isDiscard(target) { @@ -520,12 +504,11 @@ func (rf *rangeFlow) record(let *ast.LetStatement, writes []textWrite) { } rf.defined[target.Value] = struct{}{} - if _, ok := rf.assigned[target.Value]; !ok { - rf.assigned[target.Value] = writes[i].holdsRange - } if writes[i].holdsRange { rf.bindings[target.Value] = struct{}{} + continue } + delete(rf.bindings, target.Value) } } diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index b1cedc09..0001ea31 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -1428,44 +1428,6 @@ a, b, c = F(n) a, b, c = Pair(n > 0) && Three(n)`, errors: []string{"logical AND condition arity must match the value's, fold to one, or broadcast from one — got 2 and 3", "assignment mismatch: 3 targets but 1 value"}, }, - { - name: "RangeReassignedNonRange", - code: `y = G(x) - r = 1:3 - s = [r] - r = x - y = r + s`, - errors: []string{`cannot reassign "r" from a range to a non-range value`}, - }, - { - name: "NonRangeReassignedRange", - code: `y = G(x) - r = x - s = r + 1 - r = 1:3 - y = r + s`, - errors: []string{`cannot reassign "r" from a non-range value to a range`}, - }, - { - // As in the solver, the first assignment fixes the kind. - name: "KindFollowsFirstAssignment", - code: `y = G(n) - r = 0:n - r = n - r = 1:n - y = [r]`, - errors: []string{`cannot reassign "r" from a range to a non-range value`}, - }, - { - // A range the statement's condition iterates reads as an element. - name: "GateIteratedRangeReassigned", - code: `y = G(n) - r = 0:n - s = [r] - r = r > 1 r - y = s`, - errors: []string{`cannot reassign "r" from a range to a non-range value`}, - }, { // y's gated write waits until the template's text classifies. name: "NoFlowChecksOverUnclassifiableText", @@ -1505,27 +1467,21 @@ out = Maybe(x) } // Unclassifiable text is reported where the solver reports it: an assignment -// at its =, a call at its parenthesis, and a reassignment at its target. +// at its = and a call at its parenthesis. func TestUnclassifiableTemplateTextPositions(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() cc := NewCodeCompiler(ctx, t.Name(), "", mustParseCode(t, `a, b = F(x) - a, b = Missing(x) - -y = G(x) - r = 1:3 - s = [r] - r = x - y = r + s`)) + a, b = Missing(x)`)) errs := cc.Compile() - require.Len(t, errs, 3) + require.Len(t, errs, 2) positions := make([][2]int, len(errs)) for i, err := range errs { positions[i] = [2]int{err.Token.Line, err.Token.Column} } - require.Equal(t, [][2]int{{2, 19}, {2, 10}, {7, 5}}, positions) + require.Equal(t, [][2]int{{2, 19}, {2, 10}}, positions) } func rangeOutputMessage(name string) string { diff --git a/compiler/solver_test.go b/compiler/solver_test.go index 67b50603..3c07baa6 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -893,6 +893,59 @@ func TestFunctionOutputBindingRejectsIncompatibleReassignment(t *testing.T) { require.Equal(t, "test.pt:3:5", err.Token.Location()) } +// A body that reassigns a name between a range and a non-range value gets the +// solver's retype error, as for any other type. The per-template pass reads +// each name by its latest assignment: read as a range, r would leave res +// possibly unassigned in RangeToNonRange. +func TestFunctionRangeRetypeIsReportedBySolver(t *testing.T) { + tests := []struct { + name string + code string + msg string + location string + }{ + { + name: "RangeToNonRange", + code: `res = Retype(n) + r = 0:n + t = 0 + t = t + r + r = t + 1 + res = r`, + msg: `cannot reassign type to identifier. Old Type: I64:I64:I64. New Type: I64. Identifier "r"`, + location: "test.pt:5:5", + }, + { + name: "NonRangeToRange", + code: `res = Retype(n) + r = n + res = r + 1 + r = 1:n + res = res + r`, + msg: `cannot reassign type to identifier. Old Type: I64. New Type: I64:I64:I64. Identifier "r"`, + location: "test.pt:4:5", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + cc := NewCodeCompiler(ctx, test.name, "", mustParseCode(t, test.code)) + require.Empty(t, cc.Compile()) + + sc := NewScriptCompiler(ctx, test.name, mustParseScript(t, "value = Retype(4)\nvalue"), cc) + ts := NewTypeSolver(sc) + ts.Solve() + + require.Len(t, ts.Errors, 1) + require.Equal(t, test.msg, ts.Errors[0].Msg) + require.Equal(t, test.location, ts.Errors[0].Token.Location()) + }) + } +} + func TestRangeBoundsCannotDependOnRangeValues(t *testing.T) { ctx := llvm.NewContext() cc := NewCodeCompiler(ctx, "rangeBoundsDepend", "", ast.NewCode()) diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index 66fd1d08..2123dafd 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -958,12 +958,14 @@ remains literal text; malformed specifiers and missing dynamic width/precision variables on a resolved marker remain structural errors. Alongside them, the pass reports text it cannot classify, each an error the solver reports in any specialization: a call that names no template, an operator whose sides do not -line up (value counts the solver rejects, or a `||` of which only one -alternative yields a range), an assignment whose values do not fill its -targets, and a name assigned both a range and a non-range value. -The flow checks run only when both kinds of check pass, so they never misread -such a template, and a template nothing calls gets these errors too. An error -in any template fails the code module before any script compiles. +line up (value counts the solver rejects), and an assignment whose values do +not fill its targets. The flow checks run only when both kinds of check pass, +so they never misread such a template, and a template nothing calls gets +these errors too. A name reassigned between a range and a non-range value is +left to the solver, like any other change of type: the pass reads each name +by its latest assignment, so its flow checks follow the text as written, and +a template nothing calls is not checked for it. An error in any template +fails the code module before any script compiles. This partly reverses Step 2B, which moved dead-store and write-after-write checks to specializations; the per-specialization diagnostic cache and replay @@ -1461,11 +1463,12 @@ immediate deletion at the last consumer. rejected; `y = Helper(x)` then `out = y + 1`, where `Helper` returns a scalar, is a definite write; each way a value holds or iterates a range (a gate naming it, a bare name, a literal or binding under `&&`) agrees with - the solved types; an output assigned a range is rejected (#146); and a - call that names no template, an operator whose sides do not line up, an - assignment whose values do not fill its targets, or a name assigned both a - range and a non-range value is reported where the solver reports it, with - no flow error over that template + the solved types; an output assigned a range is rejected (#146); a call + that names no template, an operator whose sides do not line up, or an + assignment whose values do not fill its targets is reported where the + solver reports it, with no flow error over that template; and a body that + reassigns a name between a range and a non-range value gets only the + solver's retype error ### Loop-carried tests From 51ed236ece5964e9b5c9d8966977a2742aa74132 Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 9 Oct 2026 23:41:20 +0530 Subject: [PATCH 12/34] docs: say when the type checker reports a range retype The previous commit left a range retype to the type checker, and the IR plan and the new test's comment said a body with one gets only the type checker's error. That holds only once the flow checks pass: they read each name by its latest assignment, and their errors stop compilation before the type checker runs. A body with `r = n`, a print of `r`, then `r = 0:n` and `res = r` is reported only for leaving `res` possibly unassigned. Both now say so. Refs #123 Co-Authored-By: Claude Opus 5.5 --- compiler/solver_test.go | 9 +++++---- docs/Pluto IR Plan.md | 14 ++++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/compiler/solver_test.go b/compiler/solver_test.go index 3c07baa6..a7ade797 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -893,10 +893,11 @@ func TestFunctionOutputBindingRejectsIncompatibleReassignment(t *testing.T) { require.Equal(t, "test.pt:3:5", err.Token.Location()) } -// A body that reassigns a name between a range and a non-range value gets the -// solver's retype error, as for any other type. The per-template pass reads -// each name by its latest assignment: read as a range, r would leave res -// possibly unassigned in RangeToNonRange. +// A body that reassigns a name between a range and a non-range value, and +// passes the flow checks as written, gets the solver's retype error, as for +// any other type. The per-template pass reads each name by its latest +// assignment: read as a range, r would leave res possibly unassigned in +// RangeToNonRange. func TestFunctionRangeRetypeIsReportedBySolver(t *testing.T) { tests := []struct { name string diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index 2123dafd..11b115ea 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -962,10 +962,12 @@ line up (value counts the solver rejects), and an assignment whose values do not fill its targets. The flow checks run only when both kinds of check pass, so they never misread such a template, and a template nothing calls gets these errors too. A name reassigned between a range and a non-range value is -left to the solver, like any other change of type: the pass reads each name -by its latest assignment, so its flow checks follow the text as written, and -a template nothing calls is not checked for it. An error in any template -fails the code module before any script compiles. +left to the solver, like any other change of type. The pass reads each name +by its latest assignment, so its flow checks follow the text as written and +the solver reports the retype only once they pass: a body with `r = n`, a +print of `r`, then `r = 0:n` and `res = r` is reported only for leaving +`res` possibly unassigned. A template nothing calls is not checked for it. +An error in any template fails the code module before any script compiles. This partly reverses Step 2B, which moved dead-store and write-after-write checks to specializations; the per-specialization diagnostic cache and replay @@ -1467,8 +1469,8 @@ immediate deletion at the last consumer. that names no template, an operator whose sides do not line up, or an assignment whose values do not fill its targets is reported where the solver reports it, with no flow error over that template; and a body that - reassigns a name between a range and a non-range value gets only the - solver's retype error + reassigns a name between a range and a non-range value, and passes the + flow checks as written, gets only the solver's retype error ### Loop-carried tests From cbae63899afccc47c4253a85a22925d3f6e62408 Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 9 Oct 2026 23:49:48 +0530 Subject: [PATCH 13/34] fix(compiler): wait for every template before the flow checks A function rejected for holding a range in an output also misled its callers' checks. A caller reads each call as returning values, so `out = 0` followed by `out = MakeRange(n) + 1` was reported as a dead store next to the real error, whichever function came first, and so was a caller of a function that forwards the call. The flow checks ran per template, gated only on that template's own errors. AnalyzeFuncs now validates and classifies every template first and runs the flow checks only when that reports nothing. While any template has a structural or text error, no template gets flow errors; they appear once it is fixed. Refs #123 Co-Authored-By: Claude Opus 5.5 --- compiler/cfg.go | 37 +++++++++++++++++++++++++--------- compiler/cfg_test.go | 47 ++++++++++++++++++++++++++++++++++++++++--- docs/Pluto IR Plan.md | 30 +++++++++++++++------------ 3 files changed, 88 insertions(+), 26 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index db0d085b..efaa92f0 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -187,23 +187,42 @@ func (cfg *CFG) collectSpecifierReads(value string, tok token.Token, runes []run } // AnalyzeFuncs validates every function template once, whether or not -// anything calls it: structural checks, then a classification of each write +// anything calls it: structural checks and a classification of each write // from the template's text alone, then dataflow checks over those writes. +// The dataflow checks wait until every template passes the first two, since a +// caller reads each call as returning values: a function rejected for holding +// a range in an output would mislead its callers' checks. func (cfg *CFG) AnalyzeFuncs() { cfg.CodeCompiler.rangeBindings = make(map[funcKey]map[string]struct{}) + errorsBefore := len(cfg.Errors) + var templates []classifiedTemplate for _, stmt := range cfg.CodeCompiler.Code.Statements { fn, ok := stmt.(*ast.FuncStatement) if !ok { continue } - cfg.validateFuncTemplate(fn) + templates = append(templates, cfg.validateFuncTemplate(fn)) + } + if len(cfg.Errors) > errorsBefore { + return + } + + for _, template := range templates { + cfg.checkTemplateFlow(template.fn, template.statementReads, template.writes) } } -func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { - errorsBefore := len(cfg.Errors) +// classifiedTemplate keeps what the dataflow checks need from a validated +// and classified template. +type classifiedTemplate struct { + fn *ast.FuncStatement + statementReads [][]VarEvent + writes [][]textWrite +} + +func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) classifiedTemplate { PushScope(&cfg.Scopes, FuncScope) defer PopScope(&cfg.Scopes) @@ -236,9 +255,7 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { } writes := cfg.classifyTemplateWrites(fn) - if len(cfg.Errors) == errorsBefore { - cfg.checkTemplateFlow(fn, body.statementReads, writes) - } + return classifiedTemplate{fn: fn, statementReads: body.statementReads, writes: writes} } // classifyTemplateWrites classifies every assignment's writes from the @@ -246,9 +263,9 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { // settlement to check. It reports the text it cannot classify: a call that // names no template, an operator whose sides do not line up, and an // assignment whose values do not fill its targets. The solver rejects each of -// these in any specialization; the flow checks, which would misread them, do -// not run over a template that has one. It also reports an output that holds -// a range, which a function cannot return. +// these in any specialization; the flow checks, which would misread them, wait +// until no template has one. It also reports an output that holds a range, +// which a function cannot return. func (cfg *CFG) classifyTemplateWrites(fn *ast.FuncStatement) [][]textWrite { flow := newRangeFlow(cfg.CodeCompiler, fn) outputs := identSet(fn.Outputs) diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index 0001ea31..8e742d66 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -1437,13 +1437,18 @@ a, b, c = F(n) errors: []string{"undefined function: Missing"}, }, { - name: "OtherTemplatesStillChecked", + // Every template's text is checked, and Maybe's flow error waits + // until all of it classifies. + name: "FlowChecksWaitForEveryTemplate", code: `y = F(x) y = Missing(x) out = Maybe(x) - out = x > 0 x`, - errors: []string{"undefined function: Missing", unassignedOutputMessage("out")}, + out = x > 0 x + +z = G(x) + z = Gone(x)`, + errors: []string{"undefined function: Missing", "undefined function: Gone"}, }, { // The text is read even when the structure is invalid; the flow @@ -1544,6 +1549,42 @@ out = F(n) r = lo:hi out = [r]`, }, + { + // G's checks read the call as a value, which would make its + // default a dead store, so they wait for MakeRange's error. + name: "CallerAfterRangeOutput", + code: `res = MakeRange(n) + res = 0:n + +out = G(n) + out = 0 + out = MakeRange(n) + 1`, + errors: []string{rangeOutputMessage("res")}, + }, + { + name: "CallerBeforeRangeOutput", + code: `out = G(n) + out = 0 + out = MakeRange(n) + 1 + +res = MakeRange(n) + res = 0:n`, + errors: []string{rangeOutputMessage("res")}, + }, + { + // Fwd takes the call's value, so H's call to Fwd misleads it too. + name: "CallerThroughForwardingCall", + code: `res = MakeRange(n) + res = 0:n + +v = Fwd(n) + v = MakeRange(n) + +out = H(n) + out = 0 + out = Fwd(n) + 1`, + errors: []string{rangeOutputMessage("res")}, + }, } for _, test := range tests { diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index 11b115ea..9a0f7e99 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -949,7 +949,7 @@ definitely assign `out`, and so cannot by itself make a later read of `out` valid. Settlement panics when a specialization's Range-typed bindings disagree with this summary. -The flow checks run after a template's structural checks: explicit +The flow checks run after every template's structural checks: explicit use-before-definition, illegal input/global writes, unused inputs, syntactically unassigned outputs, formatting structure, and discard behavior. Those collect all reads before declaring a statement's destinations, so a @@ -959,15 +959,17 @@ variables on a resolved marker remain structural errors. Alongside them, the pass reports text it cannot classify, each an error the solver reports in any specialization: a call that names no template, an operator whose sides do not line up (value counts the solver rejects), and an assignment whose values do -not fill its targets. The flow checks run only when both kinds of check pass, -so they never misread such a template, and a template nothing calls gets -these errors too. A name reassigned between a range and a non-range value is -left to the solver, like any other change of type. The pass reads each name -by its latest assignment, so its flow checks follow the text as written and -the solver reports the retype only once they pass: a body with `r = n`, a -print of `r`, then `r = 0:n` and `res = r` is reported only for leaving -`res` possibly unassigned. A template nothing calls is not checked for it. -An error in any template fails the code module before any script compiles. +not fill its targets. The flow checks run only once every template passes +both kinds of check, so they misread neither such a template nor a call to a +function rejected for holding a range in an output, which its callers read +as returning values. A template nothing calls gets these errors too. A name +reassigned between a range and a non-range value is left to the solver, +like any other change of type. The pass reads each name by its latest +assignment, so its flow checks follow the text as written and the solver +reports the retype only once they pass: a body with `r = n`, a print of `r`, +then `r = 0:n` and `res = r` is reported only for leaving `res` possibly +unassigned. A template nothing calls is not checked for it. An error in any +template fails the code module before any script compiles. This partly reverses Step 2B, which moved dead-store and write-after-write checks to specializations; the per-specialization diagnostic cache and replay @@ -1465,10 +1467,12 @@ immediate deletion at the last consumer. rejected; `y = Helper(x)` then `out = y + 1`, where `Helper` returns a scalar, is a definite write; each way a value holds or iterates a range (a gate naming it, a bare name, a literal or binding under `&&`) agrees with - the solved types; an output assigned a range is rejected (#146); a call - that names no template, an operator whose sides do not line up, or an + the solved types; an output assigned a range is rejected (#146), with no + flow error in any template, so none in a caller declared before or after + it or calling it through a function that forwards the call; a call that + names no template, an operator whose sides do not line up, or an assignment whose values do not fill its targets is reported where the - solver reports it, with no flow error over that template; and a body that + solver reports it, with no flow error in any template; and a body that reassigns a name between a range and a non-range value, and passes the flow checks as written, gets only the solver's retype error From 566dbb7055beda84bc0e164f3efc0f53e8748a14 Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 9 Oct 2026 23:59:02 +0530 Subject: [PATCH 14/34] docs: drop the last mentions of ranges returned by calls Two passages still said a range returned by a call may be empty, and the PIR capability matrix said gate_heap covers a function-returned Range. A function cannot return a range (#146): gate_heap builds its range from a function's returned bounds, and the matrix row now says so. Refs #146 Co-Authored-By: Claude Opus 5.5 --- docs/Pluto IR Plan.md | 5 ++--- docs/Pluto Memory Model.md | 2 +- docs/Pluto PIR Capability Matrix.md | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index 9a0f7e99..32d8943a 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -928,9 +928,8 @@ checked access, a `||` whose last alternative can fail, a call with such an argument) makes a write possibly skipped, even where a specialization's types would make it always write, as an array mask does. Only a range literal written with constant, nonempty bounds always runs; a range bound to a name -or returned by a call may be empty. A call counts as writing every output, -since its callee passes the same check, so a caller of a rejected template is -not reported again. +may be empty. A call counts as writing every output, since its callee passes +the same check, so a caller of a rejected template is not reported again. The pass also reads from the text which values are ranges, and so which statements a range drives. A parameter never holds a range, since a range diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index 3297e8cd..321f343e 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -279,7 +279,7 @@ res = sum(a, b) such as `n <= 1` and `n > 1` are not recognized either; for floats they need not cover every case, since both fail for a NaN. Only a range literal written with constant, nonempty bounds, such as `0:3`, always runs; a range - bound to a name or returned by a call may be empty. + bound to a name may be empty. - **Reading outputs**: A body may read an output (as a value, a condition, a call argument, a print, a formatting marker, or an empty array's sample) only after it is definitely assigned, judged from the text as above. A read diff --git a/docs/Pluto PIR Capability Matrix.md b/docs/Pluto PIR Capability Matrix.md index 72183e75..1793444c 100644 --- a/docs/Pluto PIR Capability Matrix.md +++ b/docs/Pluto PIR Capability Matrix.md @@ -283,7 +283,7 @@ clock. | 27 | Ranged gate whose value is a heap-returning call — the per-iteration free-before-overwrite path had no test | `tests/mem/gate_heap/` (leak-checked) | | 11, 23, 24 | Empty and fully-rejected domains: what a collector and a carry commit | `tests/cond/domain_activation.spt` | | 5 | Blank targets: **zero** coverage in the entire corpus before this step | `tests/discard.spt` | -| 11, 23 | A function-returned `Range` driving a collector domain and a shared statement gate | `tests/mem/gate_heap/` | +| 11, 23 | A range built from a function's returned bounds, driving a collector domain and a shared statement gate | `tests/mem/gate_heap/` | | 30, 31 | Out-of-bounds and failed-conditional print arguments | `tests/array/oob_print.spt` | Confirmed by these fixtures and now normative in the plan (plan §7, plan §10): From ce70aed8f83780541a74048c94fc271326ef2be6 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 10 Oct 2026 23:33:50 +0530 Subject: [PATCH 15/34] refactor(compiler): pass the classified template to the flow checks checkTemplateFlow took a template, its statements' reads and its assignments' write classification as three parameters, which AnalyzeFuncs unpacked from the classifiedTemplate it had just built. It now takes that struct. Co-Authored-By: Claude Opus 5.5 --- compiler/cfg.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index efaa92f0..87713051 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -210,7 +210,7 @@ func (cfg *CFG) AnalyzeFuncs() { } for _, template := range templates { - cfg.checkTemplateFlow(template.fn, template.statementReads, template.writes) + cfg.checkTemplateFlow(template) } } @@ -370,19 +370,19 @@ func countOf(n int, noun string) string { // is dead or overwrites a definite write that nothing read. It runs only on a // structurally valid body whose text classifies, so every output read follows // some write and every assignment has its writes. -func (cfg *CFG) checkTemplateFlow(fn *ast.FuncStatement, statementReads [][]VarEvent, writes [][]textWrite) { +func (cfg *CFG) checkTemplateFlow(template classifiedTemplate) { cfg.PushBlock() defer cfg.PopBlock() - outputs := identSet(fn.Outputs) + outputs := identSet(template.fn.Outputs) assigned := make(map[string]struct{}, len(outputs)) lastWrites := make(map[string]VarEvent) - for i, stmt := range fn.Body.Statements { - cfg.rejectUnassignedOutputReads(statementReads[i], outputs, assigned) - events := append([]VarEvent(nil), statementReads[i]...) + for i, stmt := range template.fn.Body.Statements { + cfg.rejectUnassignedOutputReads(template.statementReads[i], outputs, assigned) + events := append([]VarEvent(nil), template.statementReads[i]...) if let, ok := stmt.(*ast.LetStatement); ok { - events = append(events, textWriteEvents(let, writes[i])...) + events = append(events, textWriteEvents(let, template.writes[i])...) } cfg.processDataflowEvents(stmt, events, lastWrites) @@ -393,7 +393,7 @@ func (cfg *CFG) checkTemplateFlow(fn *ast.FuncStatement, statementReads [][]VarE } } - for _, output := range fn.Outputs { + for _, output := range template.fn.Outputs { if _, ok := assigned[output.Value]; ok { continue } From 206c84a78f88fe82005ca6b19f49cb4dbdb40d85 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sun, 11 Oct 2026 16:36:54 +0530 Subject: [PATCH 16/34] refactor(compiler): rename rangeFlow to writeClassifier rangeFlow dates from when a call's output could hold a range, so range facts flowed between functions. Since functions cannot return ranges, it only classifies one template's writes from its text: whether each write always happens, and which bindings hold a range. The name and its comment now say so. Co-Authored-By: Claude Opus 5.5 --- compiler/cfg.go | 86 ++++++++++++++++++++++++------------------------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index 87713051..74368be5 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -267,7 +267,7 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) classifiedTemplate { // until no template has one. It also reports an output that holds a range, // which a function cannot return. func (cfg *CFG) classifyTemplateWrites(fn *ast.FuncStatement) [][]textWrite { - flow := newRangeFlow(cfg.CodeCompiler, fn) + classifier := newWriteClassifier(cfg.CodeCompiler, fn) outputs := identSet(fn.Outputs) writes := make([][]textWrite, len(fn.Body.Statements)) for i, stmt := range fn.Body.Statements { @@ -277,17 +277,17 @@ func (cfg *CFG) classifyTemplateWrites(fn *ast.FuncStatement) [][]textWrite { continue } - letWrites, ok := flow.letWrites(let) + letWrites, ok := classifier.letWrites(let) if !ok { cfg.addError(let.Token, assignmentMismatch(len(let.Name), cfg.CodeCompiler.valueCount(let.Value))) continue } cfg.rejectRangeOutputs(outputs, let, letWrites) - flow.record(let, letWrites) + classifier.record(let, letWrites) writes[i] = letWrites } - cfg.CodeCompiler.rangeBindings[templateKey(fn)] = flow.bindings + cfg.CodeCompiler.rangeBindings[templateKey(fn)] = classifier.bindings return writes } @@ -441,42 +441,42 @@ type textWrite struct { holdsRange bool } -// rangeFlow reads from a template's text which bindings hold a Range -// descriptor and which values a range drives, as the solver types them. Only -// a range literal assigned on its own constructs a descriptor: a parameter -// never holds one, since a range argument runs the body once per element; no -// call yields one, since a function cannot return a range; and a range name -// iterates wherever it is used, so a binding assigned from one holds an -// element. A binding holds a range as its latest assignment left it; a -// reassignment between a range and a non-range value is the solver's to -// reject, like any other change of type. -type rangeFlow struct { +// writeClassifier classifies a template's writes from its text, statement by +// statement: whether each always happens, and which bindings hold a Range +// descriptor, as the solver types them. Only a range literal assigned on its +// own constructs a descriptor: a parameter never holds one, since a range +// argument runs the body once per element; no call yields one, since a +// function cannot return a range; and a range name iterates wherever it is +// used, so a binding assigned from one holds an element. A binding holds a +// range as its latest assignment left it; a reassignment between a range and +// a non-range value is the solver's to reject, like any other change of type. +type writeClassifier struct { cc *CodeCompiler bindings map[string]struct{} defined map[string]struct{} } -func newRangeFlow(cc *CodeCompiler, fn *ast.FuncStatement) *rangeFlow { +func newWriteClassifier(cc *CodeCompiler, fn *ast.FuncStatement) *writeClassifier { defined := identSet(fn.Parameters) for _, output := range fn.Outputs { defined[output.Value] = struct{}{} } - return &rangeFlow{cc: cc, bindings: make(map[string]struct{}), defined: defined} + return &writeClassifier{cc: cc, bindings: make(map[string]struct{}), defined: defined} } // letWrites classifies every target of an assignment, discards included, so // the result lines up with its names. Values take targets by their output // counts; it reports false when those do not fill the targets. -func (rf *rangeFlow) letWrites(let *ast.LetStatement) ([]textWrite, bool) { - if rf.cc.valueCount(let.Value) != len(let.Name) { +func (wc *writeClassifier) letWrites(let *ast.LetStatement) ([]textWrite, bool) { + if wc.cc.valueCount(let.Value) != len(let.Name) { return nil, false } gated := len(let.Condition) > 0 writes := make([]textWrite, 0, len(let.Name)) for _, value := range let.Value { - definite := !gated && !rf.maySkip(value) - for _, holdsRange := range rf.slotRanges(value) { + definite := !gated && !wc.maySkip(value) + for _, holdsRange := range wc.slotRanges(value) { writes = append(writes, textWrite{definite: definite, holdsRange: holdsRange}) } } @@ -487,8 +487,8 @@ func (rf *rangeFlow) letWrites(let *ast.LetStatement) ([]textWrite, bool) { // fail (a value-position comparison, &&, a checked access, a || whose last // alternative can fail, or a call with such an argument), or a range that may // be empty drives it. -func (rf *rangeFlow) maySkip(value ast.Expression) bool { - return treeCanFail(value, textNodeFails) || rf.drivesValue(value) +func (wc *writeClassifier) maySkip(value ast.Expression) bool { + return treeCanFail(value, textNodeFails) || wc.drivesValue(value) } func textNodeFails(expr ast.Expression) bool { @@ -503,8 +503,8 @@ func textNodeFails(expr ast.Expression) bool { // slotRanges reports, per target a value fills, whether it receives a Range // descriptor: only a bare range literal constructs one. -func (rf *rangeFlow) slotRanges(value ast.Expression) []bool { - ranges := make([]bool, rf.cc.valueSlots(value)) +func (wc *writeClassifier) slotRanges(value ast.Expression) []bool { + ranges := make([]bool, wc.cc.valueSlots(value)) if _, isLiteral := value.(*ast.RangeLiteral); isLiteral { ranges[0] = true } @@ -514,65 +514,65 @@ func (rf *rangeFlow) slotRanges(value ast.Expression) []bool { // record publishes an assignment's targets once its reads are classified: // they become defined, and each holds a range exactly when this assignment // gives it a descriptor. -func (rf *rangeFlow) record(let *ast.LetStatement, writes []textWrite) { +func (wc *writeClassifier) record(let *ast.LetStatement, writes []textWrite) { for i, target := range let.Name { if isDiscard(target) { continue } - rf.defined[target.Value] = struct{}{} + wc.defined[target.Value] = struct{}{} if writes[i].holdsRange { - rf.bindings[target.Value] = struct{}{} + wc.bindings[target.Value] = struct{}{} continue } - delete(rf.bindings, target.Value) + delete(wc.bindings, target.Value) } } // drivesValue reports whether a range that may be empty drives an // assignment's value: any range the value iterates, apart from a range // literal it assigns whole. -func (rf *rangeFlow) drivesValue(value ast.Expression) bool { +func (wc *writeClassifier) drivesValue(value ast.Expression) bool { switch v := value.(type) { case *ast.RangeLiteral: - return rf.drivesAny(ast.ExprChildren(v)) + return wc.drivesAny(ast.ExprChildren(v)) case *ast.CallExpression: - return rf.drivesAny(v.Arguments) + return wc.drivesAny(v.Arguments) } - return rf.drives(value) + return wc.drives(value) } // drives reports whether expr iterates a range that may be empty. Every range // in it is iterated, except inside an array literal, which settles its cells // itself. Only a range literal with constant, nonempty bounds always runs. -func (rf *rangeFlow) drives(expr ast.Expression) bool { +func (wc *writeClassifier) drives(expr ast.Expression) bool { switch e := expr.(type) { case *ast.ArrayLiteral: return false case *ast.RangeLiteral: return !rangeLiteralGuaranteedNonEmpty(e) case *ast.Identifier: - return rf.isRangeBinding(e.Value) + return wc.isRangeBinding(e.Value) case *ast.StringLiteral: // A marker iterates the range it names, whether it formats the range // or uses it as a width or precision. - return slices.ContainsFunc(formatMarkerNames(e.Token.Literal, rf.isDefined), rf.isRangeBinding) + return slices.ContainsFunc(formatMarkerNames(e.Token.Literal, wc.isDefined), wc.isRangeBinding) } - return rf.drivesAny(ast.ExprChildren(expr)) + return wc.drivesAny(ast.ExprChildren(expr)) } -func (rf *rangeFlow) drivesAny(exprs []ast.Expression) bool { - return slices.ContainsFunc(exprs, rf.drives) +func (wc *writeClassifier) drivesAny(exprs []ast.Expression) bool { + return slices.ContainsFunc(exprs, wc.drives) } -func (rf *rangeFlow) isRangeBinding(name string) bool { - _, ok := rf.bindings[name] +func (wc *writeClassifier) isRangeBinding(name string) bool { + _, ok := wc.bindings[name] return ok } -func (rf *rangeFlow) isDefined(name string) bool { - _, ok := rf.defined[name] - return ok || rf.cc.isGlobalBinding(name) +func (wc *writeClassifier) isDefined(name string) bool { + _, ok := wc.defined[name] + return ok || wc.cc.isGlobalBinding(name) } func templateKey(fn *ast.FuncStatement) funcKey { From fd6214a3b581644386be36bf6a919c5415003662 Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 12 Oct 2026 11:15:08 +0530 Subject: [PATCH 17/34] refactor(compiler): classify template writes with plain functions writeClassifier kept the classification walk's state behind a constructor and accessor methods. The walk now keeps one local map of the bindings that hold a range and passes it to verb-named functions: classifyWrites, maySkip, drivesValue, drives, drivesAny and updateRanges. Its private set of names defined so far goes too. The walk opens its own function scope and declares names as it goes, so cfg.isDefined resolves a string's markers against the names defined before it, as the type checker does. Two new cases pin that resolution: an input's marker takes the specifier after it, and a name defined further down is plain text. Both pass on the previous commit as well. No behavior changes. Co-Authored-By: Claude Opus 5.5 --- compiler/cfg.go | 131 +++++++++++++++++++------------------------ compiler/cfg_test.go | 21 +++++++ 2 files changed, 78 insertions(+), 74 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index 74368be5..56e5e9dd 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -267,7 +267,19 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) classifiedTemplate { // until no template has one. It also reports an output that holds a range, // which a function cannot return. func (cfg *CFG) classifyTemplateWrites(fn *ast.FuncStatement) [][]textWrite { - classifier := newWriteClassifier(cfg.CodeCompiler, fn) + // A scope of its own resolves each string's markers against the names + // defined before it, as the solver resolves them. + PushScope(&cfg.Scopes, FuncScope) + defer PopScope(&cfg.Scopes) + cfg.declareTargets(fn.Parameters) + cfg.declareTargets(fn.Outputs) + + // ranges holds the bindings whose latest assignment gave them a range. + // Only a range literal assigned on its own does: a parameter holds one + // element per run, no call returns a range, and a range name iterates + // wherever it is used. A change between a range and a non-range value is + // the solver's to reject, like any other change of type. + ranges := make(map[string]struct{}) outputs := identSet(fn.Outputs) writes := make([][]textWrite, len(fn.Body.Statements)) for i, stmt := range fn.Body.Statements { @@ -277,17 +289,18 @@ func (cfg *CFG) classifyTemplateWrites(fn *ast.FuncStatement) [][]textWrite { continue } - letWrites, ok := classifier.letWrites(let) + letWrites, ok := cfg.classifyWrites(let, ranges) if !ok { cfg.addError(let.Token, assignmentMismatch(len(let.Name), cfg.CodeCompiler.valueCount(let.Value))) continue } cfg.rejectRangeOutputs(outputs, let, letWrites) - classifier.record(let, letWrites) + cfg.declareTargets(let.Name) + updateRanges(ranges, let.Name, letWrites) writes[i] = letWrites } - cfg.CodeCompiler.rangeBindings[templateKey(fn)] = classifier.bindings + cfg.CodeCompiler.rangeBindings[templateKey(fn)] = ranges return writes } @@ -441,43 +454,24 @@ type textWrite struct { holdsRange bool } -// writeClassifier classifies a template's writes from its text, statement by -// statement: whether each always happens, and which bindings hold a Range -// descriptor, as the solver types them. Only a range literal assigned on its -// own constructs a descriptor: a parameter never holds one, since a range -// argument runs the body once per element; no call yields one, since a -// function cannot return a range; and a range name iterates wherever it is -// used, so a binding assigned from one holds an element. A binding holds a -// range as its latest assignment left it; a reassignment between a range and -// a non-range value is the solver's to reject, like any other change of type. -type writeClassifier struct { - cc *CodeCompiler - bindings map[string]struct{} - defined map[string]struct{} -} - -func newWriteClassifier(cc *CodeCompiler, fn *ast.FuncStatement) *writeClassifier { - defined := identSet(fn.Parameters) - for _, output := range fn.Outputs { - defined[output.Value] = struct{}{} - } - return &writeClassifier{cc: cc, bindings: make(map[string]struct{}), defined: defined} -} - -// letWrites classifies every target of an assignment, discards included, so -// the result lines up with its names. Values take targets by their output +// classifyWrites classifies every target of an assignment, discards included, +// so the result lines up with its names. Values take targets by their output // counts; it reports false when those do not fill the targets. -func (wc *writeClassifier) letWrites(let *ast.LetStatement) ([]textWrite, bool) { - if wc.cc.valueCount(let.Value) != len(let.Name) { +func (cfg *CFG) classifyWrites(let *ast.LetStatement, ranges map[string]struct{}) ([]textWrite, bool) { + cc := cfg.CodeCompiler + if cc.valueCount(let.Value) != len(let.Name) { return nil, false } gated := len(let.Condition) > 0 writes := make([]textWrite, 0, len(let.Name)) for _, value := range let.Value { - definite := !gated && !wc.maySkip(value) - for _, holdsRange := range wc.slotRanges(value) { - writes = append(writes, textWrite{definite: definite, holdsRange: holdsRange}) + definite := !gated && !cfg.maySkip(value, ranges) + // Only a range literal assigned whole holds a range, and it fills one + // target. + _, isRange := value.(*ast.RangeLiteral) + for slot := 0; slot < cc.valueSlots(value); slot++ { + writes = append(writes, textWrite{definite: definite, holdsRange: isRange}) } } return writes, true @@ -487,8 +481,8 @@ func (wc *writeClassifier) letWrites(let *ast.LetStatement) ([]textWrite, bool) // fail (a value-position comparison, &&, a checked access, a || whose last // alternative can fail, or a call with such an argument), or a range that may // be empty drives it. -func (wc *writeClassifier) maySkip(value ast.Expression) bool { - return treeCanFail(value, textNodeFails) || wc.drivesValue(value) +func (cfg *CFG) maySkip(value ast.Expression, ranges map[string]struct{}) bool { + return treeCanFail(value, textNodeFails) || cfg.drivesValue(value, ranges) } func textNodeFails(expr ast.Expression) bool { @@ -501,78 +495,67 @@ func textNodeFails(expr ast.Expression) bool { return false } -// slotRanges reports, per target a value fills, whether it receives a Range -// descriptor: only a bare range literal constructs one. -func (wc *writeClassifier) slotRanges(value ast.Expression) []bool { - ranges := make([]bool, wc.cc.valueSlots(value)) - if _, isLiteral := value.(*ast.RangeLiteral); isLiteral { - ranges[0] = true - } - return ranges -} - -// record publishes an assignment's targets once its reads are classified: -// they become defined, and each holds a range exactly when this assignment -// gives it a descriptor. -func (wc *writeClassifier) record(let *ast.LetStatement, writes []textWrite) { - for i, target := range let.Name { +// updateRanges records which of an assignment's targets now hold a range: +// exactly those it gives one. +func updateRanges(ranges map[string]struct{}, targets []*ast.Identifier, writes []textWrite) { + for i, target := range targets { if isDiscard(target) { continue } - wc.defined[target.Value] = struct{}{} if writes[i].holdsRange { - wc.bindings[target.Value] = struct{}{} + ranges[target.Value] = struct{}{} continue } - delete(wc.bindings, target.Value) + delete(ranges, target.Value) } } // drivesValue reports whether a range that may be empty drives an // assignment's value: any range the value iterates, apart from a range // literal it assigns whole. -func (wc *writeClassifier) drivesValue(value ast.Expression) bool { +func (cfg *CFG) drivesValue(value ast.Expression, ranges map[string]struct{}) bool { switch v := value.(type) { case *ast.RangeLiteral: - return wc.drivesAny(ast.ExprChildren(v)) + return cfg.drivesAny(ast.ExprChildren(v), ranges) case *ast.CallExpression: - return wc.drivesAny(v.Arguments) + return cfg.drivesAny(v.Arguments, ranges) } - return wc.drives(value) + return cfg.drives(value, ranges) } // drives reports whether expr iterates a range that may be empty. Every range // in it is iterated, except inside an array literal, which settles its cells // itself. Only a range literal with constant, nonempty bounds always runs. -func (wc *writeClassifier) drives(expr ast.Expression) bool { +func (cfg *CFG) drives(expr ast.Expression, ranges map[string]struct{}) bool { switch e := expr.(type) { case *ast.ArrayLiteral: return false case *ast.RangeLiteral: return !rangeLiteralGuaranteedNonEmpty(e) case *ast.Identifier: - return wc.isRangeBinding(e.Value) + _, isRange := ranges[e.Value] + return isRange case *ast.StringLiteral: // A marker iterates the range it names, whether it formats the range // or uses it as a width or precision. - return slices.ContainsFunc(formatMarkerNames(e.Token.Literal, wc.isDefined), wc.isRangeBinding) + for _, name := range formatMarkerNames(e.Token.Literal, cfg.isDefined) { + if _, isRange := ranges[name]; isRange { + return true + } + } + return false } - return wc.drivesAny(ast.ExprChildren(expr)) -} - -func (wc *writeClassifier) drivesAny(exprs []ast.Expression) bool { - return slices.ContainsFunc(exprs, wc.drives) + return cfg.drivesAny(ast.ExprChildren(expr), ranges) } -func (wc *writeClassifier) isRangeBinding(name string) bool { - _, ok := wc.bindings[name] - return ok -} - -func (wc *writeClassifier) isDefined(name string) bool { - _, ok := wc.defined[name] - return ok || wc.cc.isGlobalBinding(name) +func (cfg *CFG) drivesAny(exprs []ast.Expression, ranges map[string]struct{}) bool { + for _, expr := range exprs { + if cfg.drives(expr, ranges) { + return true + } + } + return false } func templateKey(fn *ast.FuncStatement) funcKey { diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index 8e742d66..af80dc7f 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -1258,6 +1258,27 @@ out = F(x) out = "-n%(-w)d"`, unassigned: []string{"out"}, }, + { + // An input's marker takes the specifier after it: %-d left-aligns + // p and reads no range. + name: "InputMarkerTakesItsSpecifier", + code: `out, all = Show(p) + d = 0:p + all = [d] + out = "-p%-d"`, + }, + { + // A name defined further down is plain text, so the -d after it is + // a marker for the range d. + name: "LaterNameIsText", + code: `out, all, later = Late(p) + d = 0:p + all = [d] + out = "-q%-d" + q = p + 1 + later = q`, + unassigned: []string{"out"}, + }, { // A && fills its right operand's targets. name: "AndTakesRightOperandSlots", From f2fceed2c539811d969f8413e70e73dbc0e010ea Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 12 Oct 2026 23:33:11 +0530 Subject: [PATCH 18/34] style(compiler): separate loops from the returns after them From review: classifyWrites, drives and drivesAny, which the previous commit reshaped, now put a blank line between each loop and the return after it. Co-Authored-By: Claude Opus 5.5 --- compiler/cfg.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/compiler/cfg.go b/compiler/cfg.go index 56e5e9dd..e2766c7e 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -474,6 +474,7 @@ func (cfg *CFG) classifyWrites(let *ast.LetStatement, ranges map[string]struct{} writes = append(writes, textWrite{definite: definite, holdsRange: isRange}) } } + return writes, true } @@ -544,6 +545,7 @@ func (cfg *CFG) drives(expr ast.Expression, ranges map[string]struct{}) bool { return true } } + return false } return cfg.drivesAny(ast.ExprChildren(expr), ranges) @@ -555,6 +557,7 @@ func (cfg *CFG) drivesAny(exprs []ast.Expression, ranges map[string]struct{}) bo return true } } + return false } From bf22b8cc7586d379f19f929e3c16c62368ea0dc3 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 21 Oct 2026 21:06:01 +0530 Subject: [PATCH 19/34] refactor(compiler): move helpers next to their only callers valueSlots, valueCount and callTemplate become CFG methods. The template checks are their only callers; they were CodeCompiler methods only because they sat beside the cross-function range summary that #146 removed. valueCount also gets a blank line before its return, like the functions the previous commit spaced. checkSettledRanges moves to solver.go as a plain function beside settleSpecializationBatch, its only caller, which now passes it the template's summary. The solver reads the CFG's other map, outputReads, the same way. cfg.go no longer defines CodeCompiler methods. No behavior changes. Co-Authored-By: Claude Opus 5.5 --- compiler/cfg.go | 42 ++++++++++++++---------------------------- compiler/solver.go | 16 ++++++++++++++-- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index e2766c7e..1df300f8 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -291,7 +291,7 @@ func (cfg *CFG) classifyTemplateWrites(fn *ast.FuncStatement) [][]textWrite { letWrites, ok := cfg.classifyWrites(let, ranges) if !ok { - cfg.addError(let.Token, assignmentMismatch(len(let.Name), cfg.CodeCompiler.valueCount(let.Value))) + cfg.addError(let.Token, assignmentMismatch(len(let.Name), cfg.valueCount(let.Value))) continue } cfg.rejectRangeOutputs(outputs, let, letWrites) @@ -308,7 +308,7 @@ func (cfg *CFG) rejectUnclassifiableExprs(exprs []ast.Expression) { for _, expr := range exprs { switch e := expr.(type) { case *ast.CallExpression: - if _, found := cfg.CodeCompiler.callTemplate(e); !found { + if _, found := cfg.callTemplate(e); !found { cfg.addError(e.Token, undefinedFunction(e.Function.Value)) } case *ast.InfixExpression: @@ -321,8 +321,7 @@ func (cfg *CFG) rejectUnclassifiableExprs(exprs []ast.Expression) { // rejectMisalignedOperands reports an operator whose sides yield counts the // solver rejects. func (cfg *CFG) rejectMisalignedOperands(infix *ast.InfixExpression) { - cc := cfg.CodeCompiler - left, right := cc.valueSlots(infix.Left), cc.valueSlots(infix.Right) + left, right := cfg.valueSlots(infix.Left), cfg.valueSlots(infix.Right) switch { case !operandsLineUp(infix, left, right) && infix.IsLogicalAnd(): cfg.addError(infix.Token, logicalAndArityMismatch(left, right)) @@ -458,8 +457,7 @@ type textWrite struct { // so the result lines up with its names. Values take targets by their output // counts; it reports false when those do not fill the targets. func (cfg *CFG) classifyWrites(let *ast.LetStatement, ranges map[string]struct{}) ([]textWrite, bool) { - cc := cfg.CodeCompiler - if cc.valueCount(let.Value) != len(let.Name) { + if cfg.valueCount(let.Value) != len(let.Name) { return nil, false } @@ -470,7 +468,7 @@ func (cfg *CFG) classifyWrites(let *ast.LetStatement, ranges map[string]struct{} // Only a range literal assigned whole holds a range, and it fills one // target. _, isRange := value.(*ast.RangeLiteral) - for slot := 0; slot < cc.valueSlots(value); slot++ { + for slot := 0; slot < cfg.valueSlots(value); slot++ { writes = append(writes, textWrite{definite: definite, holdsRange: isRange}) } } @@ -569,50 +567,38 @@ func templateKey(fn *ast.FuncStatement) funcKey { // solver counts a tuple: a call fills its template's outputs, and an operator // its right operand's. An operator whose sides do not line up fills one, as // the solver counts it after reporting the mismatch. -func (cc *CodeCompiler) valueSlots(value ast.Expression) int { +func (cfg *CFG) valueSlots(value ast.Expression) int { switch v := value.(type) { case *ast.CallExpression: - if template, ok := cc.callTemplate(v); ok { + if template, ok := cfg.callTemplate(v); ok { return len(template.Outputs) } case *ast.InfixExpression: - left, right := cc.valueSlots(v.Left), cc.valueSlots(v.Right) + left, right := cfg.valueSlots(v.Left), cfg.valueSlots(v.Right) if operandsLineUp(v, left, right) { return right } case *ast.PrefixExpression: - return cc.valueSlots(v.Right) + return cfg.valueSlots(v.Right) } return 1 } // valueCount counts the values a list of expressions yields, as valueSlots // counts each. -func (cc *CodeCompiler) valueCount(values []ast.Expression) int { +func (cfg *CFG) valueCount(values []ast.Expression) int { count := 0 for _, value := range values { - count += cc.valueSlots(value) + count += cfg.valueSlots(value) } + return count } // callTemplate finds a call's template by its name and arity, where an // argument counts once for each value it yields. -func (cc *CodeCompiler) callTemplate(call *ast.CallExpression) (*ast.FuncStatement, bool) { - return cc.lookupFuncTemplate(call.Function.Value, cc.valueCount(call.Arguments)) -} - -// checkSettledRanges panics when a settled specialization types a binding as -// a Range where its template's text summary does not, or the reverse: the -// template checks classified the body's writes from that summary. -func (cc *CodeCompiler) checkSettledRanges(template *ast.FuncStatement, info *FuncInfo) { - bindings := cc.rangeBindings[templateKey(template)] - for name, typ := range info.Vars { - _, textRange := bindings[name] - if textRange != (typ.Kind() == RangeKind) { - panic(fmt.Sprintf("internal: %s solves %q as %s, which disagrees with its template's text range summary", info.Sig.Name, name, typ)) - } - } +func (cfg *CFG) callTemplate(call *ast.CallExpression) (*ast.FuncStatement, bool) { + return cfg.CodeCompiler.lookupFuncTemplate(call.Function.Value, cfg.valueCount(call.Arguments)) } // templateBody is the structural summary of one template body. diff --git a/compiler/solver.go b/compiler/solver.go index 745836d1..346c3d4a 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -2630,9 +2630,9 @@ func (ts *TypeSolver) TypeScriptFunc(mangled string, template *ast.FuncStatement // with respect to Settled: each is checked against its template's text range // summary before any becomes visible as settled. func (ts *TypeSolver) settleSpecializationBatch() { - cc := ts.ScriptCompiler.Compiler.CodeCompiler + rangeBindings := ts.ScriptCompiler.Compiler.CodeCompiler.rangeBindings for _, walked := range ts.walkedFuncs { - cc.checkSettledRanges(walked.template, walked.info) + checkSettledRanges(rangeBindings[templateKey(walked.template)], walked.info) } for _, walked := range ts.walkedFuncs { @@ -2640,6 +2640,18 @@ func (ts *TypeSolver) settleSpecializationBatch() { } } +// checkSettledRanges panics when a settled specialization types a binding as +// a Range where its template's text summary does not, or the reverse: the +// template checks classified the body's writes from that summary. +func checkSettledRanges(textRanges map[string]struct{}, info *FuncInfo) { + for name, typ := range info.Vars { + _, textRange := textRanges[name] + if textRange != (typ.Kind() == RangeKind) { + panic(fmt.Sprintf("internal: %s solves %q as %s, which disagrees with its template's text range summary", info.Sig.Name, name, typ)) + } + } +} + // TypeFunc reports whether a specialization is resolved, walking it at most // once per pass and skipping specializations already settled in the shared cache. func (ts *TypeSolver) TypeFunc(mangled string, template *ast.FuncStatement) bool { From f9612e9da78a74b9133f31b63a21dc56ed02c63d Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 21 Oct 2026 21:16:59 +0530 Subject: [PATCH 20/34] refactor(compiler): define a template's key beside funcKey templateKey moves from cfg.go to codecompiler.go, next to the funcKey type and indexDeclarations, which registers each template under that key. indexDeclarations and the solver's outputReads lookup built the same key inline and now use it, so a template's key is derived one way. lookupFuncTemplate keeps its literal, since it keys a call by its name and argument count. No behavior changes. Co-Authored-By: Claude Opus 5.5 --- compiler/cfg.go | 4 ---- compiler/codecompiler.go | 6 +++++- compiler/solver.go | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index 1df300f8..8858df4d 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -559,10 +559,6 @@ func (cfg *CFG) drivesAny(exprs []ast.Expression, ranges map[string]struct{}) bo return false } -func templateKey(fn *ast.FuncStatement) funcKey { - return funcKey{name: fn.Token.Literal, arity: len(fn.Parameters)} -} - // valueSlots counts the targets a value fills, read from the text the way the // solver counts a tuple: a call fills its template's outputs, and an operator // its right operand's. An operator whose sides do not line up fills one, as diff --git a/compiler/codecompiler.go b/compiler/codecompiler.go index de8c7bf4..58ed05a5 100644 --- a/compiler/codecompiler.go +++ b/compiler/codecompiler.go @@ -29,6 +29,10 @@ type funcKey struct { arity int } +func templateKey(fn *ast.FuncStatement) funcKey { + return funcKey{name: fn.Token.Literal, arity: len(fn.Parameters)} +} + func NewCodeCompiler(ctx llvm.Context, modName, relPath string, code *ast.Code) *CodeCompiler { mangledPath := MangleDirPath(modName, relPath) cc := &CodeCompiler{ @@ -67,7 +71,7 @@ func (cc *CodeCompiler) indexDeclarations() []*token.CompileError { } } case *ast.FuncStatement: - key := funcKey{name: s.Token.Literal, arity: len(s.Parameters)} + key := templateKey(s) previous, exists := cc.funcTemplates[key] if !exists { cc.funcTemplates[key] = s diff --git a/compiler/solver.go b/compiler/solver.go index 346c3d4a..22437911 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -2715,7 +2715,7 @@ func (ts *TypeSolver) TypeBlock(template *ast.FuncStatement, f *FuncInfo) { } } - readOutputs := ts.ScriptCompiler.Compiler.CodeCompiler.outputReads[funcKey{name: f.Sig.Name, arity: len(template.Parameters)}] + readOutputs := ts.ScriptCompiler.Compiler.CodeCompiler.outputReads[templateKey(template)] for i, id := range template.Outputs { outArg, ok := Get(ts.Scopes, id.Value) if !ok { From 4df6c58dc84a49400857fc4df78a02a01dcc1bf8 Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 23 Oct 2026 00:22:40 +0530 Subject: [PATCH 21/34] feat(compiler): copy a bare range name unless its loop is running A bare range name behaves like the literal it holds again, under the amended #146: assigned whole, it copies the range, and printed or named in a main marker, it shows the range, unless the statement already loops over that name. Each value of an assignment is evaluated on its own with the statement's condition, so only the condition counts for a value: `x = m > 2 m` keeps the last element above 2, `x = a > 2 m` copies m, and `a, b = i, i * 2` copies i into a. A print's arguments share one line, so `i, i + 1` prints each element while `i, j` prints both ranges. Width and precision markers still take numbers and iterate. This restores master's behavior, which 3e5d2b9 replaced with a rule that iterated every bare name, and drops dc03287's marker ordering, which only that rule needed; width and precision operands still drive in text order. The per-template check tracks copies again: a binding holds a range when assigned a range literal, or a range name its statement's condition does not loop over, and a copy is a definite write. `out = r` with a local range r is rejected as a range output. The marker tests from fd6214a now use width operands, since a main marker no longer drives. Refs #146 Co-Authored-By: Claude Opus 5.5 --- compiler/cfg.go | 84 +++++++++++++++++++++++++++++++------- compiler/cfg_test.go | 79 ++++++++++++++++++----------------- compiler/compiler.go | 30 +------------- compiler/format.go | 24 +++++------ compiler/pir_test.go | 27 ++++++++---- compiler/solver.go | 80 +++++++++++++++++++++++++----------- compiler/solver_test.go | 75 ++++++++++++++++------------------ tests/array/cond_accum.exp | 2 +- tests/array/cond_accum.spt | 3 +- tests/math/print_func.exp | 29 +++---------- tests/math/print_func.spt | 8 ++-- tests/range.exp | 5 +-- tests/range.spt | 4 +- tests/range_finalize.exp | 25 ++++-------- tests/range_finalize.spt | 73 ++++++++++++++------------------- 15 files changed, 287 insertions(+), 261 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index 8858df4d..95dfda82 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -274,11 +274,11 @@ func (cfg *CFG) classifyTemplateWrites(fn *ast.FuncStatement) [][]textWrite { cfg.declareTargets(fn.Parameters) cfg.declareTargets(fn.Outputs) - // ranges holds the bindings whose latest assignment gave them a range. - // Only a range literal assigned on its own does: a parameter holds one - // element per run, no call returns a range, and a range name iterates - // wherever it is used. A change between a range and a non-range value is - // the solver's to reject, like any other change of type. + // ranges holds the bindings whose latest assignment gave them a range: a + // bare range literal, or a bare range name the statement's condition does + // not loop over. A parameter holds one element per run, and no call + // returns a range. A change between a range and a non-range value is the + // solver's to reject, like any other change of type. ranges := make(map[string]struct{}) outputs := identSet(fn.Outputs) writes := make([][]textWrite, len(fn.Body.Statements)) @@ -462,12 +462,11 @@ func (cfg *CFG) classifyWrites(let *ast.LetStatement, ranges map[string]struct{} } gated := len(let.Condition) > 0 + looped := cfg.conditionRanges(let.Condition, ranges) writes := make([]textWrite, 0, len(let.Name)) for _, value := range let.Value { definite := !gated && !cfg.maySkip(value, ranges) - // Only a range literal assigned whole holds a range, and it fills one - // target. - _, isRange := value.(*ast.RangeLiteral) + isRange := assignsRange(value, ranges, looped) for slot := 0; slot < cfg.valueSlots(value); slot++ { writes = append(writes, textWrite{definite: definite, holdsRange: isRange}) } @@ -494,6 +493,58 @@ func textNodeFails(expr ast.Expression) bool { return false } +// assignsRange reports whether a value assigns a range, which fills one +// target: a bare range literal, or a bare range name that the statement's +// condition does not loop over, whose range it copies. Under a condition that +// loops over the name, the value is that loop's element. +func assignsRange(value ast.Expression, ranges, looped map[string]struct{}) bool { + switch v := value.(type) { + case *ast.RangeLiteral: + return true + case *ast.Identifier: + _, isRange := ranges[v.Value] + _, isLooped := looped[v.Value] + return isRange && !isLooped + } + return false +} + +// conditionRanges returns the range bindings a statement's condition loops +// over: every range name in it, apart from those inside an array literal, +// which settles its cells itself, and a string's main markers, which format +// their ranges. +func (cfg *CFG) conditionRanges(conditions []ast.Expression, ranges map[string]struct{}) map[string]struct{} { + looped := make(map[string]struct{}) + for _, condition := range conditions { + cfg.collectLoopedRanges(condition, ranges, looped) + } + return looped +} + +func (cfg *CFG) collectLoopedRanges(expr ast.Expression, ranges, looped map[string]struct{}) { + switch e := expr.(type) { + case *ast.ArrayLiteral: + return + case *ast.Identifier: + if _, isRange := ranges[e.Value]; isRange { + looped[e.Value] = struct{}{} + } + return + case *ast.StringLiteral: + _, specs := formatMarkerIdentifiers(e.Token.Literal, cfg.isDefined) + for _, name := range specs { + if _, isRange := ranges[name]; isRange { + looped[name] = struct{}{} + } + } + return + } + + for _, child := range ast.ExprChildren(expr) { + cfg.collectLoopedRanges(child, ranges, looped) + } +} + // updateRanges records which of an assignment's targets now hold a range: // exactly those it gives one. func updateRanges(ranges map[string]struct{}, targets []*ast.Identifier, writes []textWrite) { @@ -511,12 +562,16 @@ func updateRanges(ranges map[string]struct{}, targets []*ast.Identifier, writes } // drivesValue reports whether a range that may be empty drives an -// assignment's value: any range the value iterates, apart from a range -// literal it assigns whole. +// assignment's value: any range the value iterates, apart from a range it +// assigns whole. A bare range name either copies its range or reads the +// element of a loop its statement's condition runs, and that condition can +// skip the write by itself. func (cfg *CFG) drivesValue(value ast.Expression, ranges map[string]struct{}) bool { switch v := value.(type) { case *ast.RangeLiteral: return cfg.drivesAny(ast.ExprChildren(v), ranges) + case *ast.Identifier: + return false case *ast.CallExpression: return cfg.drivesAny(v.Arguments, ranges) } @@ -525,7 +580,8 @@ func (cfg *CFG) drivesValue(value ast.Expression, ranges map[string]struct{}) bo // drives reports whether expr iterates a range that may be empty. Every range // in it is iterated, except inside an array literal, which settles its cells -// itself. Only a range literal with constant, nonempty bounds always runs. +// itself, and in a string's main marker, which formats its range. Only a +// range literal with constant, nonempty bounds always runs. func (cfg *CFG) drives(expr ast.Expression, ranges map[string]struct{}) bool { switch e := expr.(type) { case *ast.ArrayLiteral: @@ -536,9 +592,9 @@ func (cfg *CFG) drives(expr ast.Expression, ranges map[string]struct{}) bool { _, isRange := ranges[e.Value] return isRange case *ast.StringLiteral: - // A marker iterates the range it names, whether it formats the range - // or uses it as a width or precision. - for _, name := range formatMarkerNames(e.Token.Literal, cfg.isDefined) { + // A width or precision operand consumes a range as numbers. + _, specs := formatMarkerIdentifiers(e.Token.Literal, cfg.isDefined) + for _, name := range specs { if _, isRange := ranges[name]; isRange { return true } diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index af80dc7f..907d0140 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -1213,19 +1213,12 @@ out = F(x) out = last + 1`, }, { - name: "GateOnAnotherRangeIteratesBoth", + name: "GateOnAnotherRangeKeepsDescriptor", code: `out = F(n) other = 0:3 stream = 0:n kept = other > 1 stream out = kept * 2`, - }, - { - // A bare range name iterates, and a named range may be empty. - name: "BareRangeNameIterates", - code: `out = F(n) - stream = 0:n - out = stream`, unassigned: []string{"out"}, }, { @@ -1243,12 +1236,12 @@ out = F(x) out = last + 1`, }, { - // A main marker iterates the range it names, too. - name: "MarkerRangeIterates", + // A main marker formats the range it names, so the write always + // happens. + name: "MainMarkerFormatsItsRange", code: `out = F(n) r = 0:n out = "v -r"`, - unassigned: []string{"out"}, }, { // A width or precision consumes a range as numbers. @@ -1259,25 +1252,23 @@ out = F(x) unassigned: []string{"out"}, }, { - // An input's marker takes the specifier after it: %-d left-aligns - // p and reads no range. + // An input's marker takes the specifier after it, so w is a width + // and iterates. name: "InputMarkerTakesItsSpecifier", - code: `out, all = Show(p) - d = 0:p - all = [d] - out = "-p%-d"`, + code: `out = Show(p) + w = 1:p + out = "-p%(-w)d"`, + unassigned: []string{"out"}, }, { - // A name defined further down is plain text, so the -d after it is - // a marker for the range d. + // A name defined further down is plain text, so the -w after it is + // a main marker and formats its range. name: "LaterNameIsText", - code: `out, all, later = Late(p) - d = 0:p - all = [d] - out = "-q%-d" + code: `out, later = Late(p) + w = 1:p + out = "-q%(-w)d" q = p + 1 later = q`, - unassigned: []string{"out"}, }, { // A && fills its right operand's targets. @@ -1541,12 +1532,11 @@ func TestFunctionsCannotReturnRanges(t *testing.T) { errors: []string{rangeOutputMessage("out")}, }, { - // A range name iterates, so the output receives an element. - name: "RangeNameOutputIterates", + name: "CopiedRangeOutput", code: `out = F(n) r = 0:n out = r`, - errors: []string{unassignedOutputMessage("out")}, + errors: []string{rangeOutputMessage("out")}, }, { name: "IteratedRangeOutput", @@ -1639,6 +1629,23 @@ func TestRangeLiteralWriteIsDefinite(t *testing.T) { }, extractErrorMessages(cc.Compile())) } +// Copying a range always writes, even an empty one, so overwriting a copy +// unread is reported as for any definite write. +func TestRangeCopyWriteIsDefinite(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + cc := NewCodeCompiler(ctx, t.Name(), "", mustParseCode(t, `out = F(n) + r = 0:n + c = r + c = r + out = [c]`)) + require.Equal(t, []string{ + `unconditional assignment to "c" overwrites a previous value that was never used. It was previously written at line 3:5`, + `value assigned to "c" is never used`, + }, extractErrorMessages(cc.Compile())) +} + func TestRangeOutputIsReportedAtItsAssignment(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() @@ -1663,14 +1670,12 @@ func TestTextRangeSummaryAgreesWithSolver(t *testing.T) { ranges []string }{ { - // A range name iterates wherever it is used, so a copy holds an - // element. - name: "CopyIterates", + name: "DescriptorCopy", code: `out = F(n) stream = 0:n kept = stream out = [kept]`, - ranges: []string{"stream"}, + ranges: []string{"kept", "stream"}, }, { name: "OperationIterates", @@ -1689,13 +1694,13 @@ func TestTextRangeSummaryAgreesWithSolver(t *testing.T) { ranges: []string{"stream"}, }, { - name: "GateOnAnotherRangeIteratesBoth", + name: "GateOnAnotherRangeKeepsDescriptor", code: `out = F(n) other = 0:3 stream = 0:n kept = other > 1 stream out = [kept]`, - ranges: []string{"other", "stream"}, + ranges: []string{"kept", "other", "stream"}, }, { name: "WholeLiteralUnderGateKeepsDescriptor", @@ -1706,9 +1711,9 @@ func TestTextRangeSummaryAgreesWithSolver(t *testing.T) { ranges: []string{"kept", "stream"}, }, { - // The collector in the condition settles its own iteration; the - // value iterates the range by name. - name: "CollectorInGate", + // A range inside a collector in the condition is collected, so + // the gate does not iterate it. + name: "CollectorInGateKeepsDescriptor", code: `n = Size(xs) t = xs[0] n = t + 2 @@ -1717,7 +1722,7 @@ out = F(n) stream = 0:n kept = Size([stream]) > 1 stream out = [kept]`, - ranges: []string{"stream"}, + ranges: []string{"kept", "stream"}, }, { name: "SpecifierInGateIterates", diff --git a/compiler/compiler.go b/compiler/compiler.go index bcd34742..30012878 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -1171,7 +1171,7 @@ func (c *Compiler) compileExprAssigns(writeIdents []*ast.Identifier, ownershipId i := 0 for _, expr := range exprs { guardPtr := c.pushBoundsGuard("assign_bounds_guard") - res := c.compileAssignedValue(expr, writeIdents[i:]) + res := c.compileExpression(expr, writeIdents[i:]) var bit llvm.Value if c.stmtBoundsUsed() { bit = c.createLoad(guardPtr, Int{Width: 1}, "assign_bounds_ok") @@ -1184,34 +1184,6 @@ func (c *Compiler) compileExprAssigns(writeIdents []*ast.Identifier, ownershipId return assigns } -// compileAssignedValue compiles one assignment value. A bare range name there -// iterates its range and keeps the final yield, as an operation over it would; -// inside a loop that already binds the range, the name reads that iteration's -// element. -func (c *Compiler) compileAssignedValue(expr ast.Expression, dest []*ast.Identifier) []*Symbol { - ident, isIdent := expr.(*ast.Identifier) - if !isIdent { - return c.compileExpression(expr, dest) - } - info := c.ExprCache[key(c.FuncNameMangled, ident)] - if len(c.pendingLoopRanges(info.Ranges)) == 0 { - return c.compileExpression(expr, dest) - } - - PushScope(&c.Scopes, BlockScope) - defer c.popScope() - - outputs := c.makeSeededTempOutputs(dest, info.OutTypes) - c.bindRangedTempOutputs(dest, outputs) - output := outputs[0] - c.withLoopNest(info.Ranges, func() { - value := c.compileIdentifier(ident) - c.storeRangedOutput(output, value.Val, value.Type) - }) - - return c.loadOutputValues(outputs, "range_final") -} - // 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 { diff --git a/compiler/format.go b/compiler/format.go index 6aeb5527..ed2f663c 100644 --- a/compiler/format.go +++ b/compiler/format.go @@ -942,12 +942,12 @@ func (c *Compiler) structFormatArgs(s *Symbol) (fmtStr string, args []llvm.Value return } -// formatMarkerNames returns the identifiers read by resolved markers in the -// order they appear: each marker's value, then its dynamic width and -// precision operands. Every one of them iterates a named Range, so this order -// is the order of the string's drivers. -func formatMarkerNames(value string, isDefined func(string) bool) []string { - var names []string +// formatMarkerIdentifiers returns the identifiers read by resolved markers in +// source order, separating main-marker identifiers from dynamic +// width/precision identifiers. A main marker formats its value whatever the +// type, while a specifier operand is consumed as a number, so only specifier +// identifiers can turn a named Range into an iteration driver. +func formatMarkerIdentifiers(value string, isDefined func(string) bool) (mains, specs []string) { runes := []rune(value) for i := 0; i < len(runes); i++ { if runes[i] == '\\' { @@ -962,7 +962,7 @@ func formatMarkerNames(value string, isDefined func(string) bool) []string { if !isDefined(mainID) { continue } - names = append(names, mainID) + mains = append(mains, mainID) if end >= len(runes) || runes[end] != '%' { i = end - 1 @@ -971,16 +971,16 @@ func formatMarkerNames(value string, isDefined func(string) bool) []string { spec, _ := parseSpecifierSyntax(token.Token{}, value, runes, end) for _, specID := range spec.ids { if isDefined(specID.name) { - names = append(names, specID.name) + specs = append(specs, specID.name) } } i = spec.end - 1 } - return names + return mains, specs } -// hasValidMarkers checks if a format string contains a resolved marker. A -// resolved marker names its value first, so any name means one resolved. +// hasValidMarkers checks if a format string contains a resolved marker. func hasValidMarkers(value string, isDefined func(string) bool) bool { - return len(formatMarkerNames(value, isDefined)) > 0 + mains, _ := formatMarkerIdentifiers(value, isDefined) + return len(mains) > 0 } diff --git a/compiler/pir_test.go b/compiler/pir_test.go index 4890b26e..4f84a4ae 100644 --- a/compiler/pir_test.go +++ b/compiler/pir_test.go @@ -42,10 +42,11 @@ b = a + 2 * 3 a, b = b, a _ = 7 r = 0:10:2 +s = r a, b -r`) +s`) - require.Equal(t, []string{"assign a", "assign b", "assign a, b", "assign _", "assign r"}, planLabels(plans)) + require.Equal(t, []string{"assign a", "assign b", "assign a, b", "assign _", "assign r", "assign s"}, planLabels(plans)) require.Equal(t, `statement assign a, b source "a, b = b, a" @@ -88,14 +89,23 @@ r`) commit r <- %t0 `, plans[4].Render(false)) + + require.Equal(t, `statement assign s + source "s = r" + + execute + %t0 = eval I64:I64:I64 r + + commit + s <- %t0 +`, plans[5].Render(false)) } // TestPlanRouterRejections pins the capability boundary: statements with -// gates, conditional values, checked accesses, ranged RHS (a bare range name -// included, since it iterates), calls, and block-layout literals keep their -// legacy lowering, while ordinary heap values — both string flavours, -// concatenations, inline array literals — plan alongside scalars and range -// literals. +// gates, conditional values, checked accesses, ranged RHS, calls, and +// block-layout literals keep their legacy lowering, while ordinary heap +// values — both string flavours, concatenations, inline array literals — +// plan alongside scalars and Range descriptors. func TestPlanRouterRejections(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() @@ -114,7 +124,6 @@ arr = [1 2] z = arr[0] q = 0:3 w = q + 1 -qc = q _ = 0:3 d = Twice(x) m = [ @@ -127,7 +136,7 @@ tbl = [ ] rc = [q] cc = [x > 2] -g, y, sg, shc, z, w, qc, d, m, tbl, rc, cc`) +g, y, sg, shc, z, w, d, m, tbl, rc, cc`) require.Equal(t, []string{"assign x", "assign y", "assign s", "assign sg", "assign sh", "assign shc", "assign arr", "assign q", "assign _"}, planLabels(plans)) } diff --git a/compiler/solver.go b/compiler/solver.go index 22437911..bc1d6731 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -550,21 +550,18 @@ func (ts *TypeSolver) HandleCallRanges(call *ast.CallExpression) (ranges []*Rang return } -// collectPrintArgRanges keeps a bare range literal out of the ordinary -// argument pass: it constructs a Range, which prints as written. Every other -// argument, a range name included, uses collectExprRanges exactly as it does -// for an ordinary call, so the print runs once per yield. +// collectPrintArgRanges keeps bare Range descriptors out of the ordinary +// argument pass until sibling drivers are known. Every other argument uses +// collectExprRanges exactly as it does for an ordinary call. A bare descriptor +// whose name a sibling binds then joins that driver; any other bare descriptor +// keeps its original expression and prints as a value. func (ts *TypeSolver) collectPrintArgRanges(exprs []ast.Expression) (ranges []*RangeInfo, args []ast.Expression, changed bool) { args = append([]ast.Expression(nil), exprs...) var ordinaryArgs []ast.Expression var ordinaryIndexes []int for i, arg := range exprs { - if _, isLiteral := arg.(*ast.RangeLiteral); isLiteral { - info := ts.ExprCache[key(ts.FuncNameMangled, arg)] - info.Ranges = nil - info.HasRanges = false - info.Rewrite = nil + if ts.bareRangeDescriptorArg(arg) { continue } ordinaryArgs = append(ordinaryArgs, arg) @@ -575,9 +572,39 @@ func (ts *TypeSolver) collectPrintArgRanges(exprs []ast.Expression) (ranges []*R for i, argIndex := range ordinaryIndexes { args[argIndex] = rewrites[i] } + + for i, arg := range exprs { + if !ts.bareRangeDescriptorArg(arg) { + continue + } + if ident, ok := arg.(*ast.Identifier); ok && rangeDriverNamed(ranges, ident.Value) { + argRanges, rew := ts.HandleRanges(arg) + args[i] = rew + changed = changed || rew != arg + ranges = mergeUses(ranges, argRanges) + continue + } + info := ts.ExprCache[key(ts.FuncNameMangled, arg)] + info.Ranges = nil + info.HasRanges = false + info.Rewrite = nil + } return ranges, args, changed } +// bareRangeDescriptorArg reports whether a print argument is a complete Range +// descriptor: a range literal or a name bound to a Range. +func (ts *TypeSolver) bareRangeDescriptorArg(arg ast.Expression) bool { + switch a := arg.(type) { + case *ast.RangeLiteral: + return true + case *ast.Identifier: + typ, ok := ts.GetIdentifier(a.Value) + return ok && typ.Kind() == RangeKind + } + return false +} + // isBareRangeExpr reports whether expr is a driver that a function can consume // through a range-specialized variant. A ranged array access is eligible only // while it is an immediate call argument; collectCallArgs gives that call site @@ -595,8 +622,9 @@ func (ts *TypeSolver) isBareRangeExpr(expr ast.Expression) bool { } // HandleIdentifierRanges processes identifier expressions, detecting if they -// refer to range-typed variables and including them in range tracking. Every -// use of a range name consumes it as a driver. +// refer to range-typed variables and including them in range tracking. The +// enclosing context decides whether that occurrence consumes the driver or a +// complete assignment copies the descriptor. func (ts *TypeSolver) HandleIdentifierRanges(ident *ast.Identifier) (ranges []*RangeInfo, rew ast.Expression) { typ, ok := ts.GetIdentifier(ident.Value) if ok && typ.Kind() == RangeKind { @@ -617,10 +645,11 @@ func (ts *TypeSolver) HandleIdentifierRanges(ident *ast.Identifier) (ranges []*R // formatting markers so interpolation follows the same driver semantics as an // ordinary identifier expression. func (ts *TypeSolver) HandleStringLiteralRanges(lit *ast.StringLiteral) (ranges []*RangeInfo, rew ast.Expression) { - // A range name iterates wherever it is used, in a main marker as much as - // in a width or precision operand, and the drivers nest in the order the - // string names them. - for _, name := range formatMarkerNames(lit.Token.Literal, ts.isDefined) { + // A main marker formats its value, so a bare Range there stays a + // descriptor. Width and precision operands are consumed as numbers, which + // makes a named Range in a specifier an iteration driver. + _, specs := formatMarkerIdentifiers(lit.Token.Literal, ts.isDefined) + for _, name := range specs { typ, ok := ts.GetIdentifier(name) if !ok || typ.Kind() != RangeKind { continue @@ -644,11 +673,11 @@ func rangeDriverNamed(ranges []*RangeInfo, name string) bool { return false } -// resolveBareRangeAssignment settles a bare Range value at an assignment root. -// A range literal constructs the descriptor (`r = 0:n`); a range name -// iterates, so the assignment keeps the final yield (`last = r`), as it would -// for any operation over `r`. -func (ts *TypeSolver) resolveBareRangeAssignment(expr ast.Expression, types []Type) { +// resolveBareRangeAssignment distinguishes Range descriptor copies from uses of +// a Range that an enclosing statement condition has already bound as an +// iterator. `copy = source` and `copy = 0:n` preserve the descriptor; in +// `filtered = source > 2 source`, the RHS reads the current scalar yield. +func (ts *TypeSolver) resolveBareRangeAssignment(expr ast.Expression, types []Type, condRanges []*RangeInfo) { if len(types) != 1 { return } @@ -661,9 +690,12 @@ func (ts *TypeSolver) resolveBareRangeAssignment(expr ast.Expression, types []Ty info := ts.ExprCache[key(ts.FuncNameMangled, expr)] switch e := expr.(type) { case *ast.Identifier: - info.Ranges = mergeUses(info.Ranges, []*RangeInfo{{Name: e.Value}}) - info.HasRanges = true - info.Rewrite = e + if !rangeDriverNamed(condRanges, e.Value) { + info.Ranges = nil + info.HasRanges = false + info.Rewrite = nil + return + } types[0] = rangeType.Iter info.OutTypes[0] = rangeType.Iter case *ast.RangeLiteral: @@ -937,7 +969,7 @@ func (ts *TypeSolver) TypeLetStatement(stmt *ast.LetStatement) { exprIdxs := make([]int, 0, len(stmt.Name)) for _, expr := range stmt.Value { exprTypes := ts.TypeExpression(expr, true) - ts.resolveBareRangeAssignment(expr, exprTypes) + ts.resolveBareRangeAssignment(expr, exprTypes, condRanges) ts.mergeCondRangesIntoValue(expr, condRanges) for idx := range exprTypes { types = append(types, exprTypes[idx]) diff --git a/compiler/solver_test.go b/compiler/solver_test.go index a7ade797..5dd16b9a 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -896,7 +896,7 @@ func TestFunctionOutputBindingRejectsIncompatibleReassignment(t *testing.T) { // A body that reassigns a name between a range and a non-range value, and // passes the flow checks as written, gets the solver's retype error, as for // any other type. The per-template pass reads each name by its latest -// assignment: read as a range, r would leave res possibly unassigned in +// assignment: read as a range, r would make res a range output in // RangeToNonRange. func TestFunctionRangeRetypeIsReportedBySolver(t *testing.T) { tests := []struct { @@ -1435,17 +1435,19 @@ res = [idx]` require.IsType(t, &ast.ArrayLiteral{}, info.Rewrite) } -// Only a range literal constructs a Range; a range name iterates wherever it -// is used, so assigning one keeps its final element. -func TestBareRangeNamesIterate(t *testing.T) { +// A bare range name assigned whole copies its range, beside other values and +// under a condition that does not loop over it; under one that does, it is +// the loop's element. A main marker formats the range it names. +func TestBareRangeAssignmentsCopyDescriptors(t *testing.T) { ctx := llvm.NewContext() - cc := NewCodeCompiler(ctx, "bareRangeNames", "", ast.NewCode()) + cc := NewCodeCompiler(ctx, "bareRangeCopies", "", ast.NewCode()) program := mustParseScript(t, `source = 0:5 -last = (source) -sum = source + 0 +copy = (source) +last = source + 0 outer = 0:2 -gated = outer < 2 source +gatedCopy = outer < 2 source filtered = source > 2 source +pairCopy, pairSum = source, source + 0 marked = "v -source"`) sc := NewScriptCompiler(ctx, t.Name(), program, cc) @@ -1453,32 +1455,28 @@ marked = "v -source"`) ts.Solve() require.Emptyf(t, ts.Errors, "unexpected type errors: %v", ts.Errors) - for _, name := range []string{"source", "outer"} { + for _, name := range []string{"source", "copy", "gatedCopy", "pairCopy"} { typ, ok := ts.GetIdentifier(name) require.Truef(t, ok, "expected %s binding", name) require.Equal(t, Range{Iter: I64}, typ) } - for _, name := range []string{"last", "sum", "gated", "filtered"} { + for _, name := range []string{"last", "filtered", "pairSum"} { typ, ok := ts.GetIdentifier(name) require.Truef(t, ok, "expected %s binding", name) require.Equal(t, I64, typ) } - lastExpr := program.Statements[1].(*ast.LetStatement).Value[0] - lastInfo := ts.ExprCache[key(ts.FuncNameMangled, lastExpr)] - require.Equal(t, []Type{I64}, lastInfo.OutTypes) - require.True(t, lastInfo.HasRanges) - require.Len(t, lastInfo.Ranges, 1) - require.Equal(t, "source", lastInfo.Ranges[0].Name) - - gatedExpr := program.Statements[4].(*ast.LetStatement).Value[0] - gatedInfo := ts.ExprCache[key(ts.FuncNameMangled, gatedExpr)] - require.Equal(t, []Type{I64}, gatedInfo.OutTypes) - gatedDrivers := make([]string, len(gatedInfo.Ranges)) - for i, driver := range gatedInfo.Ranges { - gatedDrivers[i] = driver.Name - } - require.ElementsMatch(t, []string{"outer", "source"}, gatedDrivers) + copyExpr := program.Statements[1].(*ast.LetStatement).Value[0] + copyInfo := ts.ExprCache[key(ts.FuncNameMangled, copyExpr)] + require.False(t, copyInfo.HasRanges) + require.Empty(t, copyInfo.Ranges) + require.Nil(t, copyInfo.Rewrite) + + gatedCopyExpr := program.Statements[4].(*ast.LetStatement).Value[0] + gatedCopyInfo := ts.ExprCache[key(ts.FuncNameMangled, gatedCopyExpr)] + require.Equal(t, []Type{Range{Iter: I64}}, gatedCopyInfo.OutTypes) + require.Len(t, gatedCopyInfo.Ranges, 1) + require.Equal(t, "outer", gatedCopyInfo.Ranges[0].Name) filteredExpr := program.Statements[5].(*ast.LetStatement).Value[0] filteredInfo := ts.ExprCache[key(ts.FuncNameMangled, filteredExpr)] @@ -1486,32 +1484,31 @@ marked = "v -source"`) require.Len(t, filteredInfo.Ranges, 1) require.Equal(t, "source", filteredInfo.Ranges[0].Name) - // A main marker naming a range drives the string. - markedExpr := program.Statements[6].(*ast.LetStatement).Value[0] + markedExpr := program.Statements[7].(*ast.LetStatement).Value[0] markedInfo := ts.ExprCache[key(ts.FuncNameMangled, markedExpr)] - require.True(t, markedInfo.HasRanges) - require.Len(t, markedInfo.Ranges, 1) - require.Equal(t, "source", markedInfo.Ranges[0].Name) + require.False(t, markedInfo.HasRanges) + require.Empty(t, markedInfo.Ranges) } -// A string's markers drive in the order they appear, a width or precision -// operand included, so the first range the string names is the outer loop. -func TestMarkerDriversFollowTextOrder(t *testing.T) { +// A string's width and precision operands drive in the order they appear, +// and a main marker formats its range without driving. +func TestSpecifierDriversFollowTextOrder(t *testing.T) { ctx := llvm.NewContext() - cc := NewCodeCompiler(ctx, "markerOrder", "", ast.NewCode()) - program := mustParseScript(t, `n = 1 + cc := NewCodeCompiler(ctx, "specifierOrder", "", ast.NewCode()) + program := mustParseScript(t, `n = 1.5 w = 1:3 +p = 2:4 j = 7:9 -s = "|-n%(-w)d| -j" -t = "-j |-n%(-w)d|"`) +s = "|-n%(-w).(-p)f| -j" +t = "-j |-n%(-p).(-w)f|"`) sc := NewScriptCompiler(ctx, t.Name(), program, cc) ts := NewTypeSolver(sc) ts.Solve() require.Emptyf(t, ts.Errors, "unexpected type errors: %v", ts.Errors) - for i, want := range [][]string{{"w", "j"}, {"j", "w"}} { - lit := program.Statements[3+i].(*ast.LetStatement).Value[0] + for i, want := range [][]string{{"w", "p"}, {"p", "w"}} { + lit := program.Statements[4+i].(*ast.LetStatement).Value[0] info := ts.ExprCache[key(ts.FuncNameMangled, lit)] drivers := make([]string, len(info.Ranges)) for k, driver := range info.Ranges { diff --git a/tests/array/cond_accum.exp b/tests/array/cond_accum.exp index 25cb2c92..3bce1d8f 100644 --- a/tests/array/cond_accum.exp +++ b/tests/array/cond_accum.exp @@ -37,7 +37,7 @@ MultiCell: [0 10 1 11] ScalarArith: 3 4 ScalarElem: 20 -CrossRange: [4] +CrossRange: [0 1 2 3 4] LitCross: [0 1 2 3 4] 30 SeedLit: [8 9] diff --git a/tests/array/cond_accum.spt b/tests/array/cond_accum.spt index 0911c4f4..adea5774 100644 --- a/tests/array/cond_accum.spt +++ b/tests/array/cond_accum.spt @@ -205,8 +205,7 @@ q = 0:3 elem = q < 2 arr4[q] "ScalarElem: -elem" -# A bare range name under a statement driver iterates too, so the statement -# runs over both ranges and keeps the final value. +# A statement driver gates a distinct bare Range descriptor without consuming it. r = 0:3 i = 0:5 cross = r < 4 i diff --git a/tests/math/print_func.exp b/tests/math/print_func.exp index a894f638..7aa03591 100644 --- a/tests/math/print_func.exp +++ b/tests/math/print_func.exp @@ -13,30 +13,11 @@ 1 1 2 4 3 9 -0 1 -0 2 -1 1 -1 2 -2 1 -2 2 -3 1 -3 2 -0 2 -0 3 -1 2 -1 3 -2 2 -2 3 -3 2 -3 3 -0 4 -0 9 -1 4 -1 9 -2 4 -2 9 -3 4 -3 9 +0:4 1:3 +0:4 2 +0:4 3 +0:4 4 +0:4 9 1 4 2 9 1 0 diff --git a/tests/math/print_func.spt b/tests/math/print_func.spt index 1f95efe4..9fc9fea1 100644 --- a/tests/math/print_func.spt +++ b/tests/math/print_func.spt @@ -17,15 +17,15 @@ Square(i) # Named range both direct and in function call i, Square(i) -# Two range names iterate as nested loops, the first outermost: one line per -# pair. +# Two bare ranges print as descriptor values on one line. j = 1:3 i, j -# A name beside a computation over another range iterates too. +# A computation drives only its own name; the undriven bare range stays a +# descriptor, printed once per driver yield. i, j + 1 -# So does a name beside a call over another range. +# The undriven bare range prints its descriptor beside each computed value. i, Square(j + 1) # Same driver bare and in a computation - the computation binds it, single loop diff --git a/tests/range.exp b/tests/range.exp index 99260527..ccb48867 100644 --- a/tests/range.exp +++ b/tests/range.exp @@ -1,10 +1,7 @@ [0 1 2 3 4] [1 2 3 4 5 6 7 8 9] [0 2] [-3 -2 -1 0 1 2 3 4] [0 1 2 3 4 5] [0 1 2 3 4 5] [1 2 3 4 5 6 7] [6] [0 3 6] Descriptors 0:2 2:4 -Names 0 2 -Names 0 3 -Names 1 2 -Names 1 3 +Names 0:2 2:4 Cartesian 0 2 Cartesian 0 3 Cartesian 1 2 diff --git a/tests/range.spt b/tests/range.spt index bd89637f..63d6382d 100644 --- a/tests/range.spt +++ b/tests/range.spt @@ -17,8 +17,8 @@ u = 0:b:d [i], [j], [k], [u] -# Range literals print as written, on one line. Range names iterate, bare or -# in a computation, and distinct names form a cartesian iteration domain. +# Bare range literals and range names print as written, on one line. +# Computations over distinct names form a cartesian iteration domain. p = 0:2 q = 2:4 "Descriptors", 0:2, 2:4 diff --git a/tests/range_finalize.exp b/tests/range_finalize.exp index 8b06d14c..ac07bf36 100644 --- a/tests/range_finalize.exp +++ b/tests/range_finalize.exp @@ -1,34 +1,23 @@ -AscendingLast: 4 [0 1 2 3 4] -DescendingLast: 2 -EmptyNameFresh: 0 -EmptyNameExisting: 91 +AscendingCopy: [0 1 2 3 4] +DescendingCopy: [6 4 2] +EmptyRangeFresh: [] +EmptyRangeExisting: [0 1] -> [] EmptyComputeFresh: 0 EmptyComputeExisting: 91 CallFinal: 20 BuiltRange: [2 3 4] -MarkerFinal: Marker 2 -MarkerEach 0 -MarkerEach 1 -MarkerEach 2 +MarkerFinal: Marker 0:3 +MarkerDescriptor 0:3 MarkerWidthFinal: | 7| MarkerWidthEach: |7| MarkerWidthEach: | 7| MarkerWidthEach: | 7| -MarkerOrder: |1| 7 -MarkerOrder: |1| 8 -MarkerOrder: | 1| 7 -MarkerOrder: | 1| 8 -MarkerOrderCollected: ["|1| 7" "|1| 8" "| 1| 7" "| 1| 8"] LiteralDescriptor 0:3 DrivenMarker i=0 0 DrivenMarker i=1 1 DrivenMarker i=2 2 EmptyDescriptor: 3:3 SteppedDescriptor: 6:0:-2 -SteppedName: 6 -SteppedName: 4 -SteppedName: 2 -AfterEmptyName SimultaneousRange: 2 77 SimultaneousArray: 30 88 -SimultaneousString: Marker 2 old +SimultaneousString: Marker 0:3 old diff --git a/tests/range_finalize.spt b/tests/range_finalize.spt index f2f2df57..e8eb669d 100644 --- a/tests/range_finalize.spt +++ b/tests/range_finalize.spt @@ -1,23 +1,25 @@ -# A bare range name iterates, so assigning one keeps the final value, as an -# operation over it does. Brackets collect every value. +# Bare range assignments copy descriptors. Brackets observe the copied stream. ascendingRange = 0:5 ascending = ascendingRange -ascendingValues = [ascendingRange] -"AscendingLast: -ascending -ascendingValues" +ascendingValues = [ascending] +"AscendingCopy: -ascendingValues" descendingRange = 6:0:-2 descending = descendingRange -"DescendingLast: -descending" +descendingValues = [descending] +"DescendingCopy: -descendingValues" -# An empty range runs no iteration: a fresh destination gets zero, and an -# existing one keeps its value. +# Empty descriptors still assign: both destinations become empty ranges. emptyRange = 3:3 -freshEmpty = emptyRange -"EmptyNameFresh: -freshEmpty" +freshRange = emptyRange +freshRangeValues = [freshRange] +"EmptyRangeFresh: -freshRangeValues" -existingEmpty = 91 -existingEmpty = emptyRange -"EmptyNameExisting: -existingEmpty" +existingRange = 0:2 +existingRangeBefore = [existingRange] +existingRange = emptyRange +existingRangeValues = [existingRange] +"EmptyRangeExisting: -existingRangeBefore -> -existingRangeValues" # Empty ranged computations yield no value: fresh scalar destinations use zero, # while existing scalar destinations retain their seed. @@ -28,8 +30,8 @@ existing = 91 existing = emptyRange + 1 "EmptyComputeExisting: -existing" -# A range passed to a function runs it once per value. -callFinal = useShadow(ascendingRange) +# A copied descriptor remains consumable by functions. +callFinal = useShadow(ascending) "CallFinal: -callFinal" # A function returns a range's bounds, and the caller builds the range. @@ -38,46 +40,33 @@ builtRange = boundsStart:boundsStop builtValues = [builtRange] "BuiltRange: -builtValues" -# A marker naming a range iterates it: assigned, the string keeps the final -# value; printed, it prints once per value. +# A bare Range in a main marker formats the descriptor: the string is one +# value with no drivers, in interpolation exactly as in assignment. markerRange = 0:3 formattedFinal = "Marker -markerRange" "MarkerFinal: -formattedFinal" -"MarkerEach -markerRange" +"MarkerDescriptor -markerRange" -# A width or precision operand iterates a named range the same way. +# A width or precision operand is consumed as a number, so a named Range there +# is still an iteration driver. widthRange = 1:4 widthValue = 7 formattedWidth = "|-widthValue%(-widthRange)d|" "MarkerWidthFinal: -formattedWidth" "MarkerWidthEach: |-widthValue%(-widthRange)d|" -# Markers drive in the order they appear, a width included, so the width -# range here is the outer loop. -orderValue = 1 -orderWidth = 1:3 -orderInner = 7:9 -"MarkerOrder: |-orderValue%(-orderWidth)d| -orderInner" -orderCollected = ["|-orderValue%(-orderWidth)d| -orderInner"] -"MarkerOrderCollected: -orderCollected" - -# A bare range literal prints as written. +# A range literal in print position is a descriptor value too. "LiteralDescriptor", 0:3 -# The marker and a sibling computation share one driver, so the line prints -# once per value and an explicit %d applies to the element. +# A sibling computation binds the marker's name, so the marker formats that +# iteration's I64 yield and an explicit %d applies to it. "DrivenMarker i=-markerRange%d", markerRange + 0 -# A range literal prints its exact bounds, including empty and stepped forms. -"EmptyDescriptor:", 3:3 -"SteppedDescriptor:", 6:0:-2 - -# A range name prints once per value, and an empty one prints nothing. -steppedName = 6:0:-2 -"SteppedName:", steppedName -emptyName = 3:3 -"EmptyName:", emptyName -"AfterEmptyName" +# Descriptors print their exact bounds, including empty and stepped forms. +emptyDescriptor = 3:3 +"EmptyDescriptor:", emptyDescriptor +steppedDescriptor = 6:0:-2 +"SteppedDescriptor:", steppedDescriptor # Ranged computations preserve simultaneous-assignment reads. simRange = 0:3 @@ -90,8 +79,8 @@ first = 88 first, second = simArray[simRange], first "SimultaneousArray: -first -second" -# A marker naming a range iterates it, and the sibling still reads text's -# pre-statement value. +# A bare-marker string is a single value, so this write is unconditional; the +# sibling still reads text's pre-statement value. text = "old" text, otherText = "Marker -markerRange", text "SimultaneousString: -text -otherText" From a5e139a5bdc19e221b2471b38b9d9f35184c603b Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 23 Oct 2026 00:22:40 +0530 Subject: [PATCH 22/34] docs: describe bare range names as copies of their range The README and the range, memory, formatting and PIR docs describe the amended #146 rule again: a bare range name behaves like the literal it holds, unless the statement already loops over that name. Range Semantics states the rule next to descriptor assignment, and the formatting doc keeps the order rule for width and precision operands. The IR plan's retype example uses `res = r + 1`, since `res = r` now copies the range and is rejected as a range output. Refs #146 Co-Authored-By: Claude Opus 5.5 --- README.md | 27 +++--- docs/Pluto IR Plan.md | 19 ++-- docs/Pluto Memory Model.md | 46 ++++----- docs/Pluto PIR Capability Matrix.md | 2 +- docs/Pluto Range Semantics.md | 97 +++++++++---------- docs/Pluto String and Formatting Semantics.md | 28 +++--- 6 files changed, 108 insertions(+), 111 deletions(-) diff --git a/README.md b/README.md index a287b332..6b948170 100644 --- a/README.md +++ b/README.md @@ -226,22 +226,23 @@ A range literal binds an execution domain: ```python i = 0:5 -values = [i] # [0 1 2 3 4] -last = i # 4 +copy = i # same bounds, independent named driver +values = [copy] # [0 1 2 3 4] +last = i + 0 # 4 lastSquare = Square(i) # 16 -i # prints 0, 1, 2, 3 and 4, one per line -0:5 # a literal prints as written: 0:5 +i # prints the descriptor: 0:5 ``` -Only a range literal makes a range. Using a range name anywhere creates a -ranged computation: an assignment keeps the final yield, brackets materialize -all yields into an array, passing it to a template executes the call once for -each value, and printing it prints once per value. The compiler can map these -operations to SIMD instructions — this is range-driven auto-vectorization. -Each named Range binding is a distinct driver: different names form a -cartesian domain when consumed together, even when their bounds are equal, so -a second driver over the same domain is another literal (`j = 0:5`). A function -cannot return a range; it returns the bounds, and the caller builds the range. +A bare range is a value, written as a literal or held by a name: assignment +copies it and print shows its descriptor, unless the statement already loops +over that name. Using it in an operation creates a ranged computation: the +assignment keeps the final yield, brackets materialize all yields into an +array, and passing it to a template executes the call once for each value. The +compiler can map these operations to SIMD instructions — this is range-driven +auto-vectorization. Each named Range binding is a distinct driver: different +names form a cartesian domain when consumed together, even when their bounds +are equal. A function cannot return a range; it returns the bounds, and the +caller builds the range. Range-indexed array access follows the same rule: diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index 32d8943a..c76e8b03 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -936,12 +936,13 @@ statements a range drives. A parameter never holds a range, since a range argument drives the whole body; no call yields one, since a function cannot return a range (an output assigned one is rejected at the definition, #146); and struct fields, constants and array elements cannot hold one (`[r]` -collects `r`'s values). Only a range literal assigned on its own constructs -one: a range name iterates wherever it is used, so `x = r` keeps `r`'s final -element just as `x = r * 2` iterates `r` (#146), and a range literal operand -of `||` and `&&` is iterated too (`n > 0 && 0:5` gives an element). A -binding therefore holds a range exactly when it is assigned a range literal, -which a template's own text decides without looking at another template. Every +collects `r`'s values). A value holds a range when it is a range literal or a +binding assigned a descriptor (`x = r`, not `x = r * 2`). A range literal or +binding operand of `||` and `&&` is iterated (`n > 0 && 0:5` gives an +element). A statement's condition iterates the ranges it names, so its value +reads them as elements (`kept = s > 2 s`); a range the condition only +collects, or does not name, stays a descriptor (#146). A template's own text +therefore decides all of this, without looking at another template. Every check then uses the same classification, and none waits for a type: after `r = 0:n`, `out = r * 2` does not kill an earlier `out = prev`, does not definitely assign `out`, and so cannot by itself make a later read of `out` @@ -966,7 +967,7 @@ reassigned between a range and a non-range value is left to the solver, like any other change of type. The pass reads each name by its latest assignment, so its flow checks follow the text as written and the solver reports the retype only once they pass: a body with `r = n`, a print of `r`, -then `r = 0:n` and `res = r` is reported only for leaving `res` possibly +then `r = 0:n` and `res = r + 1` is reported only for leaving `res` possibly unassigned. A template nothing calls is not checked for it. An error in any template fails the code module before any script compiles. @@ -1465,8 +1466,8 @@ immediate deletion at the last consumer. default both the body and a read of `out` after `out = r * 2` are rejected; `y = Helper(x)` then `out = y + 1`, where `Helper` returns a scalar, is a definite write; each way a value holds or iterates a range (a - gate naming it, a bare name, a literal or binding under `&&`) agrees with - the solved types; an output assigned a range is rejected (#146), with no + copy, a gate naming it, a literal or binding under `&&`) agrees with the + solved types; an output assigned a range is rejected (#146), with no flow error in any template, so none in a caller declared before or after it or calling it through a function that forwards the call; a call that names no template, an operator whose sides do not line up, or an diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index 321f343e..2d32b869 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -4,21 +4,20 @@ This document describes Pluto's semantic model and compares it with other major ## The Pluto Model (Summary) -1. **Materialized Assignment is Copy:** assigning a scalar, range literal, +1. **Materialized Assignment is Copy:** assigning a scalar, Range descriptor, array, table, string, or struct creates an independent value. 2. **Arrays are Values:** `arr2 = arr1` copies data (COW). 3. **Range Selections are Streams:** `s = arr[i]` keeps the final selected value (an element or owned subarray); `s = [arr[i]]` materializes every selected value. -4. **Only a Range Literal Makes a Range:** `i = 0:5` binds a Range - descriptor. Every use of the name drives a loop: `j = i` and `x = i + 1` - keep the last yield, and `arr[i]`, a call, `[]`, a print, and an - interpolation marker run once per yield. A range literal printed on its - own shows as written (`0:5`). A function cannot return a Range (#146). -5. **Empty-Domain Initialization:** A range literal still assigns when it is - empty. An empty ranged computation, including `j = i` over an empty `i`, - leaves an existing destination unchanged and a fresh destination at its - type's zero value. +4. **Ranges are Descriptor Values:** `j = i` copies a Range; consuming it in + `x = i + 1`, `arr[i]`, a call, or `[]` drives a loop. Print and main + interpolation markers format the descriptor itself. Where the statement + already loops over the name, a bare use is that loop's element. A function + cannot return a Range (#146). +5. **Empty-Domain Initialization:** An empty Range descriptor still assigns. + An empty ranged computation leaves an existing destination unchanged and a + fresh destination at its type's zero value. 6. **Driver Identity Determines Looping:** Repeated use of one Range binding shares a loop; distinct bindings form a cartesian domain even when their descriptors have equal bounds. @@ -40,7 +39,7 @@ This document describes Pluto's semantic model and compares it with other major | **Array Assign** | **Copy** (COW) | Reference | Move | Reference (Slice) | Reference | Copy | | **Function Args** | **Read-only binding** (scalars lowered by value) | Reference | Move / Borrow | Copy (Slice Ref) | Reference | Copy | | **Range selection (`a[range]`)** | **Value stream** (final value or explicit collection) | Copy (List) / View (NumPy) | View (Slice) | View (Slice) | Copy (default) / View (`@view`) | View (Slice) | -| **Range Usage** | **Literal-built descriptor; every use iterates** | Reference (Generator) | Reference (Iterator) | N/A | Reference (Iterator) | N/A | +| **Range Usage** | **Copyable descriptor; operations iterate** | Reference (Generator) | Reference (Iterator) | N/A | Reference (Iterator) | N/A | | **Mutability** | **In-Place Only** | Mutable Objects | Mutable (if `mut`) | Mutable | Mutable | Mutable | | **Memory Mgmt** | **Auto (Scope)** | Auto (GC) | Auto (Owner) | Auto (GC) | Auto (GC) | Manual | @@ -63,15 +62,16 @@ x = (i+1 for i in iter) # Lazy generator a = [1]; b = a; a[0] = 2 # b sees 1 (independent copy) i = 0:5 -j = i # Loop executes, j = 4 (last yield) +j = i # Descriptor copy; no loop +x = i + 0 # Loop executes, x = 4 (last yield) x = i + 1 # Loop executes, x = 5 (last value) i = 0:10 # Bind a new reusable Range domain y = i + 1 # Consuming statement runs the loop; y = 10 ``` **Difference:** Pluto is safer and more predictable. A range literal binds a -reusable descriptor, and every use of its name runs it as a loop rather than -creating a lazy generator. +reusable descriptor. A bare assignment copies it; a consuming expression runs +it as a loop rather than creating a lazy generator. --- @@ -169,16 +169,16 @@ an owned subarray: ```python i = 0:5 -x = i # Loop at statement: x = 4 (last yielded iterator) +j = i # Same bounds, independent named driver +x = i + 0 # Loop at statement: x = 4 (last yielded iterator) x = i + 1 # Loop at statement: x = 5 (last scalar value) y = i * 2 # Loop at statement: y = 8 (last scalar value) z = (i + 1) / (i + 2) # Single loop: z = 5/6 (last value) ``` -A bare range literal constructs a descriptor. Every use of a range name, -bare or in an operation or a range-indexed array access, consumes it as a -loop driver. An assignment root keeps the last computation yield; `[]` -collects every yield. +Complete Range expressions construct or copy descriptors. Operations and +range-indexed array accesses consume descriptors as loop drivers. An +assignment root keeps the last computation yield; `[]` collects every yield. ### Driver Identity Determines Loop Structure @@ -197,8 +197,8 @@ product = (i + 1) * (j + 1) | Mode | Syntax | Behavior | |------|--------|----------| -| **Construct** | `i = 0:5` | No loop; i receives the Range value | -| **Last Value** | `x = i`, `x = i + 0` or `x = arr[i]` | Loop runs, x = last yielded value | +| **Descriptor Copy** | `j = i` | No loop; j receives the Range value | +| **Last Value** | `x = i + 0` or `x = arr[i]` | Loop runs, x = last yielded value | | **Accumulate** | `x = x + i` | Loop runs, x accumulates | | **Collect** | `arr = [i * 2]` | Loop runs, collects to array | @@ -240,8 +240,8 @@ Pluto sits in a "Sweet Spot" for parallel computing: 1. **Value Semantics (like R/Matlab)** make reasoning about concurrent code easy. "If I have `x`, I own `x`." 2. **Explicit Collection** makes every allocation and materialization boundary visible. -3. **Range-Driven Execution (Unique)** builds a range only from a literal, - and every use of its name iterates without lazy-generator complexity. +3. **Range-Driven Execution (Unique)** separates copyable descriptors from + operations that iterate without lazy-generator complexity. 4. **Named Driver Reuse (Unique)** makes user intent explicit — repeated use of one range name shares one loop. 5. **Defined Empty Domains (Unique)** give fresh and existing destinations diff --git a/docs/Pluto PIR Capability Matrix.md b/docs/Pluto PIR Capability Matrix.md index 1793444c..15e2cf70 100644 --- a/docs/Pluto PIR Capability Matrix.md +++ b/docs/Pluto PIR Capability Matrix.md @@ -176,7 +176,7 @@ two together rather than treating any single row as a deletion trigger. - **7c** — comparison- and call-propagated fallback: `arr[oob] > 0 \|\| -1`, `Id(arr[oob]) \|\| -1` — the failure travels through a propagator before the resolver. *Missing:* regressions when implemented. *Helpers:* condLHS spine - **7e** — ranged checked fallback: the fallback resolves per iteration inside the loop nest. *Missing:* regressions when implemented. *Helpers:* condLHS spine, ranged staging - **7f** — collector-cell fallback: in `[arr[oob] \|\| -1]` the `\|\|` resolves before the cell's zero-fill. *Missing:* regressions when implemented. *Helpers:* collector rewrite, condLHS spine -- **8** — a range literal assigned on its own constructs the descriptor; the solver clears `Ranges`/`HasRanges`, so this is not an active ranged RHS. A bare range name iterates instead (#146), so `j = i` is a ranged RHS (row 9), finalized by `compileAssignedValue`. *Tests:* `range_finalize` (stepped and empty literals), golden `TestPlanGolden` (`r = 0:10:2`), `TestBareRangeNamesIterate`. *Helpers:* `compileAssignments` +- **8** — plain value copy; the solver clears `Ranges`/`HasRanges`, so this is not an active ranged RHS. *Tests:* `range_finalize:2-21` (literal, identifier copy, empty, reassign), `compiler/solver_test.go`. *Helpers:* `compileAssignments` - **8b** — no longer arises: a function cannot return a range (#146), so an output assigned one is rejected at its definition. A function returns the bounds instead (`range_finalize`'s `rangeBounds`, `mem/gate_heap`'s `mkBounds`), and the caller's range literal is row 8. *Tests:* `TestFunctionsCannotReturnRanges` - **9** — `compileAssignments` → expression loop nest (passes nil conditions). *Tests:* `math/range_expr`, `math/range.spt`, `range_shadow.spt`, `cond/domain_activation`. *Helpers:* `compileAssignments`, `withCollectorPreparedLoopNest`, `compileCondOperands` - **10** — as #9 + `withLoopNestVersioned` affine probe. *Tests:* `array/affine_bounds_stmt`, `math/affine_bounds_expr`. *Helpers:* affine decision helpers diff --git a/docs/Pluto Range Semantics.md b/docs/Pluto Range Semantics.md index ecf034b0..4014a5fe 100644 --- a/docs/Pluto Range Semantics.md +++ b/docs/Pluto Range Semantics.md @@ -2,24 +2,24 @@ ## Core Model -A `Range` is a descriptor value, and a range literal is the only way to -construct one (#146): +A `Range` is a descriptor value. A range literal constructs one, and a +complete Range-valued assignment copies it: ```pluto i = 0:5 -k = (0:5) +j = i +k = (i) ``` -`i` and `k` hold equal descriptor values with the same captured bounds; -parentheses are transparent. Each binding is a distinct driver identity: -consuming `i` and `k` together forms a cartesian domain, while repeated uses -of `i` share one loop. +`i`, `j`, and `k` contain equal descriptor values with the same captured +bounds. Parentheses are transparent. Each binding is nevertheless a distinct +driver identity: consuming `i` and `j` together forms a cartesian domain, +while repeated uses of `i` share one loop. -Every use of a range name creates a ranged computation, a bare one included: -`j = i` iterates `i` and keeps its final yield, exactly as `j = i + 0` does. -`Ranged` is a useful description of that expression effect, not a storable -source type. An operation produces ordered per-iteration values; an individual -yield may be a scalar or an owned subarray. +Using a Range in an operation creates a ranged computation. `Ranged` is a +useful description of that expression effect, not a storable source type. An +operation produces ordered per-iteration values; an individual yield may be a +scalar or an owned subarray. There are two explicit closing steps for a ranged computation: @@ -27,28 +27,24 @@ There are two explicit closing steps for a ranged computation: 2. The root expression of an assignment closes any remaining outer iteration by taking the final yielded value in iteration order. -A range literal assigned on its own is not a closing step and does not -iterate: it constructs the descriptor. +A Range descriptor assignment is not a closing step and does not iterate. +This keeps descriptor copying, collection, and final-value selection separate. +A bare range name behaves like the literal it holds, unless the statement +already loops over that name: then it is that loop's current element (#146). -## Migration From Descriptor Copies +## Migration From Bare-Range Finalization -Before #146, assigning a bare named Range (`copy = i`) copied the descriptor, -and printing a range name or naming it in a main interpolation marker showed -the descriptor. A range name now iterates wherever it is used, which restores -the earlier bare-range finalization: +Previously, assigning a bare named Range kept its final yield. Code that relied +on that behavior should use an operation such as `last = i + 0`; for +`i = 0:5`, `last` becomes `4`, while `copy = i` now copies the descriptor. -- `copy = i` keeps `i`'s final yield. For a second driver over the same - domain, construct another literal (`j = 0:5`), keeping the bounds in names - if they can change in between. Assigning a range name to a binding that - holds a Range is a type-changing reassignment. -- Printing a range name prints once per yield, two distinct names print their - cartesian product, and an empty range prints nothing. `[i]` lists the values - on one line, and a range literal printed on its own shows as written. -- A main marker naming a range, as in `"v=-i"`, runs once per yield. - -These changes are silent where a program still compiles. Range-indexed -expressions such as `last = data[i]` are unchanged because indexing is already -a ranged computation. +This change can be silent for a fresh destination. A later call, index, or +collector consumes the copied Range and runs its whole domain, while print and +interpolation format the descriptor itself. Assigning a Range +to an existing scalar is instead rejected as a type-changing reassignment. +Descriptor copies are unconditional writes and participate in the ordinary +dead-store checks. Range-indexed expressions such as `last = data[i]` are +unchanged because indexing is already a ranged computation. ## Ranges And Drivers @@ -67,9 +63,8 @@ The bound values are captured when the range is constructed, so later changes to the source variables do not mutate the existing range. Functions that need those bounds as data should currently receive the scalar values explicitly. -A Range can be named but not copied, since assigning a range name iterates -it, and it is not yet a fully first-class container element. Arrays and -tables contain scalar/string elements rather than Range +Range is nameable and copyable, but is not yet a fully first-class container +element. Arrays and tables contain scalar/string elements rather than Range descriptors, so `[i]` consumes `i` and collects its yields. Passing a Range to a function likewise consumes it as a driver rather than passing inert metadata. A function cannot return a Range either: an output assigned one is rejected @@ -94,28 +89,28 @@ surrounding expression, finalized at an assignment root, or materialized by final value can itself be an array. This ownership is semantic: copy-on-write is permitted, but an escaping view into the source is not. -Assigning an empty range literal still performs a normal write. Consuming +Assigning an empty Range descriptor still performs a normal write. Consuming an empty Range produces no yields: a fresh ranged-computation destination retains its type's zero value (an empty array for a subarray result), while an existing destination is unchanged. Outside `[]`, an out-of-bounds selection point yields nothing, so the last valid selected value wins. Inside `[]`, failed cells are zero-filled to preserve collection shape, as described below. -Print is a sink: a bare range literal argument formats its descriptor as -`start:stop` (or `start:stop:step`), exactly as written. Any other argument -naming a range, bare or in a computation, drives the print loop, and so does a -marker naming one, whether it formats the value or uses it as a width or -precision; see +Print is a sink, not an operation: a bare Range argument or main marker +formats the descriptor as `start:stop` (or `start:stop:step`), exactly as the +value it is. Computations still drive the print loop, and a bare name bound as +a driver by a sibling computation prints its per-iteration scalar. A width or +precision marker consumes its Range operand as a number, so that operand still +drives; see [Pluto String and Formatting Semantics](Pluto%20String%20and%20Formatting%20Semantics.md#interpolation-markers) for the formatting rules and examples. For ordinary print arguments: ```pluto i = 0:2 j = 2:4 -0:2, 2:4 # one line: 0:2 2:4 -i, j # cartesian: 0 2 / 0 3 / 1 2 / 1 3 -i + 0, j + 0 # the same -i, Square(i) # one shared loop: 0 0 / 1 1 +i, j # one line: 0:2 2:4 +i + 0, j + 0 # cartesian: 0 2 / 0 3 / 1 2 / 1 3 +i, Square(i) # i is driven: 0 0 / 1 1 ``` Distinct drivers nest in source order, so collecting over two ranges walks @@ -465,8 +460,8 @@ x, y = i < 2 [1], j + 0 The statement condition `i < 2` is shared. `x` collects once for each admitted `i`, producing `[1 1]`. `y`'s operation uses its own local `j` driver inside that shared gate and ends -with the final result `1`. A bare `j` in this position iterates the same way -and also ends with `1`. +with the final result `1`. A bare `j` in this position would instead copy the +Range descriptor because only `i` belongs to the active statement domain. Likewise: @@ -488,11 +483,11 @@ the statement condition opens that outer loop first. Inside the RHS, the same name refers to the current scalar iterator value, not to a fresh nested loop. -Consequently, `filtered = i > 2 i` keeps the final admitted scalar, and -`cross = outer > 2 i` iterates `i` inside each admitted `outer` point and -keeps the final yield. If the statement domain is empty, it performs no -write: an existing destination stays unchanged and a fresh one keeps its zero -value. +Consequently, `filtered = i > 2 i` keeps the final admitted scalar, while +`copy = outer > 2 i` copies `i` when only `outer` belongs to the statement +domain. If the statement domain is empty, it performs no write: an existing +Range destination stays unchanged and a fresh one keeps the zero Range +descriptor. For non-collector tuple outputs, one admitted statement iteration is still one shared scalar update step, but each RHS has its own local yield outcome. If one diff --git a/docs/Pluto String and Formatting Semantics.md b/docs/Pluto String and Formatting Semantics.md index 20f538bd..a1568a5b 100644 --- a/docs/Pluto String and Formatting Semantics.md +++ b/docs/Pluto String and Formatting Semantics.md @@ -63,28 +63,28 @@ width = 5 "-missing%(-width)d" # -missing%(5)d ``` -A marker naming a `Range` iterates it, like any other use of a range name -(#146): a main marker formats each yield, and a width or precision marker -consumes each yield as a number. The string is then a ranged computation: -assigned, it keeps the final yield; printed, it prints once per yield. An -explicit numeric conversion such as `-i%d` applies to each yield. Several -ranges nest in the order the string names them, the first outermost, whether -a marker formats a range or uses it as a width or precision. +A main marker formats its value whatever the type, so a bare `Range` formats +its descriptor and contributes no iteration: the string is a single value in +assignment and in print alike. A Range identifier used for dynamic width or +precision is consumed as a number, which makes it an iteration driver, and +several such ranges nest in the order the string names them, the first +outermost. An explicit numeric conversion such as `-i%d` is a compile error +while `i` is an undriven Range descriptor — a descriptor is not a number. ```pluto i = 0:3 -s = "item -i" # "item 2" -"item -i" # prints item 0, item 1 and item 2, one per line -"v=-i%d" # prints v=0, v=1 and v=2 +s = "item -i" # "item 0:3" +"item -i" # prints item 0:3 on one line n = 7 w = 1:3 "|-n%(-w)d|" # one line per width: |7| then | 7| -j = 8:10 -"|-n%(-w)d| -j" # w is the outer loop: |7| 8, |7| 9, | 7| 8, | 7| 9 ``` -Within a print statement, a marker and another argument naming the same -`Range` share one driver, so the line prints once per yield. +Within a print statement, another argument that consumes the same named `Range` +makes it a driver for that print loop. During each iteration, the main marker +reads the current `I64` yield, so an explicit numeric conversion applies to the +yield. This sibling binding is specific to print arguments; it does not apply +across sibling values in an assignment. ```pluto i = 0:3 From 75e55aa8011f5772fbe43c903342abd7f1b8fa8f Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 23 Oct 2026 01:33:42 +0530 Subject: [PATCH 23/34] feat(compiler)!: iterate a bare range under a gate Only an assignment without a gate stores or copies a bare range (#146, amended again). A gate makes its values computations, so a bare range literal or name in them iterates and the assignment keeps its final element, whether or not the condition names it: `x = a > 2 m` now gives an element, as `x = a > 2 && m` already did, and `a, b, c = i > 2 i, i + 2, j` iterates j too. Each value of an ungated assignment still counts on its own, so `a, b = i, i + 1` copies i into a. Prints and markers keep their rules. Lowering brings back compileAssignedValue, which runs a gated bare range's loop at the assignment root when no enclosing loop binds it, now for a bare range literal as well as a name. The per-template check no longer scans conditions: a binding holds a range exactly when its latest assignment is a bare range assigned without a gate. cond_accum's four gated range cases now expect elements, and a new case covers a gate with no range of its own, where only the root loop finalizes the value: a name, a literal, and an empty range that keeps the old value. BREAKING CHANGE: a bare range assigned under a gate iterates instead of being copied or stored. To choose a range under a condition, choose its bounds: `lo, hi = n > 0 a, b`, then `r = lo:hi`. Refs #146 Co-Authored-By: Claude Opus 5.5 --- compiler/cfg.go | 64 +++++++------------------------------- compiler/cfg_test.go | 27 +++++++++------- compiler/compiler.go | 31 +++++++++++++++++- compiler/solver.go | 35 +++++++++++---------- compiler/solver_test.go | 31 ++++++++++-------- tests/array/cond_accum.exp | 11 ++++--- tests/array/cond_accum.spt | 37 ++++++++++++++-------- 7 files changed, 124 insertions(+), 112 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index 95dfda82..1eb73253 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -275,10 +275,10 @@ func (cfg *CFG) classifyTemplateWrites(fn *ast.FuncStatement) [][]textWrite { cfg.declareTargets(fn.Outputs) // ranges holds the bindings whose latest assignment gave them a range: a - // bare range literal, or a bare range name the statement's condition does - // not loop over. A parameter holds one element per run, and no call - // returns a range. A change between a range and a non-range value is the - // solver's to reject, like any other change of type. + // bare range literal or range name assigned without a gate. A parameter + // holds one element per run, and no call returns a range. A change between + // a range and a non-range value is the solver's to reject, like any other + // change of type. ranges := make(map[string]struct{}) outputs := identSet(fn.Outputs) writes := make([][]textWrite, len(fn.Body.Statements)) @@ -462,11 +462,10 @@ func (cfg *CFG) classifyWrites(let *ast.LetStatement, ranges map[string]struct{} } gated := len(let.Condition) > 0 - looped := cfg.conditionRanges(let.Condition, ranges) writes := make([]textWrite, 0, len(let.Name)) for _, value := range let.Value { definite := !gated && !cfg.maySkip(value, ranges) - isRange := assignsRange(value, ranges, looped) + isRange := !gated && assignsRange(value, ranges) for slot := 0; slot < cfg.valueSlots(value); slot++ { writes = append(writes, textWrite{definite: definite, holdsRange: isRange}) } @@ -493,58 +492,20 @@ func textNodeFails(expr ast.Expression) bool { return false } -// assignsRange reports whether a value assigns a range, which fills one -// target: a bare range literal, or a bare range name that the statement's -// condition does not loop over, whose range it copies. Under a condition that -// loops over the name, the value is that loop's element. -func assignsRange(value ast.Expression, ranges, looped map[string]struct{}) bool { +// assignsRange reports whether a value assigned without a gate gives its one +// target a range: a bare range literal, or a bare range name whose range it +// copies. Under a gate the value is a computation, so the range iterates. +func assignsRange(value ast.Expression, ranges map[string]struct{}) bool { switch v := value.(type) { case *ast.RangeLiteral: return true case *ast.Identifier: _, isRange := ranges[v.Value] - _, isLooped := looped[v.Value] - return isRange && !isLooped + return isRange } return false } -// conditionRanges returns the range bindings a statement's condition loops -// over: every range name in it, apart from those inside an array literal, -// which settles its cells itself, and a string's main markers, which format -// their ranges. -func (cfg *CFG) conditionRanges(conditions []ast.Expression, ranges map[string]struct{}) map[string]struct{} { - looped := make(map[string]struct{}) - for _, condition := range conditions { - cfg.collectLoopedRanges(condition, ranges, looped) - } - return looped -} - -func (cfg *CFG) collectLoopedRanges(expr ast.Expression, ranges, looped map[string]struct{}) { - switch e := expr.(type) { - case *ast.ArrayLiteral: - return - case *ast.Identifier: - if _, isRange := ranges[e.Value]; isRange { - looped[e.Value] = struct{}{} - } - return - case *ast.StringLiteral: - _, specs := formatMarkerIdentifiers(e.Token.Literal, cfg.isDefined) - for _, name := range specs { - if _, isRange := ranges[name]; isRange { - looped[name] = struct{}{} - } - } - return - } - - for _, child := range ast.ExprChildren(expr) { - cfg.collectLoopedRanges(child, ranges, looped) - } -} - // updateRanges records which of an assignment's targets now hold a range: // exactly those it gives one. func updateRanges(ranges map[string]struct{}, targets []*ast.Identifier, writes []textWrite) { @@ -563,9 +524,8 @@ func updateRanges(ranges map[string]struct{}, targets []*ast.Identifier, writes // drivesValue reports whether a range that may be empty drives an // assignment's value: any range the value iterates, apart from a range it -// assigns whole. A bare range name either copies its range or reads the -// element of a loop its statement's condition runs, and that condition can -// skip the write by itself. +// assigns whole. A bare range name copies its range without a gate, and a +// gated write can be skipped anyway. func (cfg *CFG) drivesValue(value ast.Expression, ranges map[string]struct{}) bool { switch v := value.(type) { case *ast.RangeLiteral: diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index 907d0140..83e27076 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -1213,13 +1213,14 @@ out = F(x) out = last + 1`, }, { - name: "GateOnAnotherRangeKeepsDescriptor", + // A gate makes its value a computation, so a range the condition + // does not name iterates too and kept holds an element. + name: "GateIteratesAnotherRange", code: `out = F(n) other = 0:3 stream = 0:n kept = other > 1 stream out = kept * 2`, - unassigned: []string{"out"}, }, { // A literal or binding under && is iterated. @@ -1526,10 +1527,12 @@ func TestFunctionsCannotReturnRanges(t *testing.T) { errors: []string{rangeOutputMessage("r")}, }, { - name: "GatedRangeOutput", + // Under a gate a range literal iterates, so the output receives an + // element, and the gate can skip the write. + name: "GatedRangeLiteralIterates", code: `out = F(n) out = n > 0 0:n`, - errors: []string{rangeOutputMessage("out")}, + errors: []string{unassignedOutputMessage("out")}, }, { name: "CopiedRangeOutput", @@ -1694,26 +1697,26 @@ func TestTextRangeSummaryAgreesWithSolver(t *testing.T) { ranges: []string{"stream"}, }, { - name: "GateOnAnotherRangeKeepsDescriptor", + name: "GateIteratesAnotherRange", code: `out = F(n) other = 0:3 stream = 0:n kept = other > 1 stream out = [kept]`, - ranges: []string{"kept", "other", "stream"}, + ranges: []string{"other", "stream"}, }, { - name: "WholeLiteralUnderGateKeepsDescriptor", + name: "GatedLiteralIterates", code: `out = F(n) stream = 0:n kept = stream > 1 0:9 out = [kept]`, - ranges: []string{"kept", "stream"}, + ranges: []string{"stream"}, }, { - // A range inside a collector in the condition is collected, so - // the gate does not iterate it. - name: "CollectorInGateKeepsDescriptor", + // The condition only collects stream, but the gated value still + // iterates it. + name: "CollectorInGate", code: `n = Size(xs) t = xs[0] n = t + 2 @@ -1722,7 +1725,7 @@ out = F(n) stream = 0:n kept = Size([stream]) > 1 stream out = [kept]`, - ranges: []string{"kept", "stream"}, + ranges: []string{"stream"}, }, { name: "SpecifierInGateIterates", diff --git a/compiler/compiler.go b/compiler/compiler.go index 30012878..066d655a 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -1171,7 +1171,7 @@ func (c *Compiler) compileExprAssigns(writeIdents []*ast.Identifier, ownershipId i := 0 for _, expr := range exprs { guardPtr := c.pushBoundsGuard("assign_bounds_guard") - res := c.compileExpression(expr, writeIdents[i:]) + res := c.compileAssignedValue(expr, writeIdents[i:]) var bit llvm.Value if c.stmtBoundsUsed() { bit = c.createLoad(guardPtr, Int{Width: 1}, "assign_bounds_ok") @@ -1184,6 +1184,35 @@ func (c *Compiler) compileExprAssigns(writeIdents []*ast.Identifier, ownershipId return assigns } +// compileAssignedValue compiles one assignment value. A bare range that a +// gate makes a computation iterates and keeps its final element, as an +// operation over it would; inside a loop that already binds the range, it +// reads that iteration's element. +func (c *Compiler) compileAssignedValue(expr ast.Expression, dest []*ast.Identifier) []*Symbol { + switch expr.(type) { + case *ast.Identifier, *ast.RangeLiteral: + default: + return c.compileExpression(expr, dest) + } + info := c.ExprCache[key(c.FuncNameMangled, expr)] + if len(c.pendingLoopRanges(info.Ranges)) == 0 { + return c.compileExpression(expr, dest) + } + + PushScope(&c.Scopes, BlockScope) + defer c.popScope() + + outputs := c.makeSeededTempOutputs(dest, info.OutTypes) + c.bindRangedTempOutputs(dest, outputs) + output := outputs[0] + c.withLoopNest(info.Ranges, func() { + value := c.compileIdentifier(info.Rewrite.(*ast.Identifier)) + c.storeRangedOutput(output, value.Val, value.Type) + }) + + return c.loadOutputValues(outputs, "range_final") +} + // 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 { diff --git a/compiler/solver.go b/compiler/solver.go index bc1d6731..797e3f75 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -673,11 +673,12 @@ func rangeDriverNamed(ranges []*RangeInfo, name string) bool { return false } -// resolveBareRangeAssignment distinguishes Range descriptor copies from uses of -// a Range that an enclosing statement condition has already bound as an -// iterator. `copy = source` and `copy = 0:n` preserve the descriptor; in -// `filtered = source > 2 source`, the RHS reads the current scalar yield. -func (ts *TypeSolver) resolveBareRangeAssignment(expr ast.Expression, types []Type, condRanges []*RangeInfo) { +// resolveBareRangeAssignment settles a bare range value at an assignment root. +// Without a gate, a range literal constructs the descriptor and a range name +// copies it (`r = 0:n`, `copy = r`). Under a gate the value is a computation +// like any other: the range iterates and the assignment keeps its final +// element (`last = n > 0 r`). +func (ts *TypeSolver) resolveBareRangeAssignment(expr ast.Expression, types []Type, gated bool) { if len(types) != 1 { return } @@ -687,22 +688,22 @@ func (ts *TypeSolver) resolveBareRangeAssignment(expr ast.Expression, types []Ty return } + switch expr.(type) { + case *ast.Identifier, *ast.RangeLiteral: + default: + return + } + info := ts.ExprCache[key(ts.FuncNameMangled, expr)] - switch e := expr.(type) { - case *ast.Identifier: - if !rangeDriverNamed(condRanges, e.Value) { - info.Ranges = nil - info.HasRanges = false - info.Rewrite = nil - return - } - types[0] = rangeType.Iter - info.OutTypes[0] = rangeType.Iter - case *ast.RangeLiteral: + if !gated { info.Ranges = nil info.HasRanges = false info.Rewrite = nil + return } + info.HasRanges = true + types[0] = rangeType.Iter + info.OutTypes[0] = rangeType.Iter } func (ts *TypeSolver) TypeStatement(stmt ast.Statement) { @@ -969,7 +970,7 @@ func (ts *TypeSolver) TypeLetStatement(stmt *ast.LetStatement) { exprIdxs := make([]int, 0, len(stmt.Name)) for _, expr := range stmt.Value { exprTypes := ts.TypeExpression(expr, true) - ts.resolveBareRangeAssignment(expr, exprTypes, condRanges) + ts.resolveBareRangeAssignment(expr, exprTypes, len(stmt.Condition) > 0) ts.mergeCondRangesIntoValue(expr, condRanges) for idx := range exprTypes { types = append(types, exprTypes[idx]) diff --git a/compiler/solver_test.go b/compiler/solver_test.go index 5dd16b9a..c0c706d3 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -1435,32 +1435,34 @@ res = [idx]` require.IsType(t, &ast.ArrayLiteral{}, info.Rewrite) } -// A bare range name assigned whole copies its range, beside other values and -// under a condition that does not loop over it; under one that does, it is -// the loop's element. A main marker formats the range it names. -func TestBareRangeAssignmentsCopyDescriptors(t *testing.T) { +// A bare range assigned without a gate is the range itself: a name copies it, +// beside other values too. Under a gate the value is a computation, so the +// range iterates whether or not the condition names it. A main marker +// formats the range it names. +func TestBareRangeAssignmentsCopyOnlyWithoutAGate(t *testing.T) { ctx := llvm.NewContext() cc := NewCodeCompiler(ctx, "bareRangeCopies", "", ast.NewCode()) program := mustParseScript(t, `source = 0:5 copy = (source) last = source + 0 outer = 0:2 -gatedCopy = outer < 2 source +gatedName = outer < 2 source filtered = source > 2 source pairCopy, pairSum = source, source + 0 -marked = "v -source"`) +marked = "v -source" +gatedLiteral = outer < 2 0:3`) sc := NewScriptCompiler(ctx, t.Name(), program, cc) ts := NewTypeSolver(sc) ts.Solve() require.Emptyf(t, ts.Errors, "unexpected type errors: %v", ts.Errors) - for _, name := range []string{"source", "copy", "gatedCopy", "pairCopy"} { + for _, name := range []string{"source", "copy", "pairCopy"} { typ, ok := ts.GetIdentifier(name) require.Truef(t, ok, "expected %s binding", name) require.Equal(t, Range{Iter: I64}, typ) } - for _, name := range []string{"last", "filtered", "pairSum"} { + for _, name := range []string{"last", "gatedName", "filtered", "pairSum", "gatedLiteral"} { typ, ok := ts.GetIdentifier(name) require.Truef(t, ok, "expected %s binding", name) require.Equal(t, I64, typ) @@ -1472,11 +1474,14 @@ marked = "v -source"`) require.Empty(t, copyInfo.Ranges) require.Nil(t, copyInfo.Rewrite) - gatedCopyExpr := program.Statements[4].(*ast.LetStatement).Value[0] - gatedCopyInfo := ts.ExprCache[key(ts.FuncNameMangled, gatedCopyExpr)] - require.Equal(t, []Type{Range{Iter: I64}}, gatedCopyInfo.OutTypes) - require.Len(t, gatedCopyInfo.Ranges, 1) - require.Equal(t, "outer", gatedCopyInfo.Ranges[0].Name) + gatedNameExpr := program.Statements[4].(*ast.LetStatement).Value[0] + gatedNameInfo := ts.ExprCache[key(ts.FuncNameMangled, gatedNameExpr)] + require.Equal(t, []Type{I64}, gatedNameInfo.OutTypes) + gatedDrivers := make([]string, len(gatedNameInfo.Ranges)) + for i, driver := range gatedNameInfo.Ranges { + gatedDrivers[i] = driver.Name + } + require.ElementsMatch(t, []string{"outer", "source"}, gatedDrivers) filteredExpr := program.Statements[5].(*ast.LetStatement).Value[0] filteredInfo := ts.ExprCache[key(ts.FuncNameMangled, filteredExpr)] diff --git a/tests/array/cond_accum.exp b/tests/array/cond_accum.exp index 3bce1d8f..d1d6a2f7 100644 --- a/tests/array/cond_accum.exp +++ b/tests/array/cond_accum.exp @@ -37,11 +37,14 @@ MultiCell: [0 10 1 11] ScalarArith: 3 4 ScalarElem: 20 -CrossRange: [0 1 2 3 4] -LitCross: [0 1 2 3 4] +CrossRange: 4 +LitCross: 4 30 -SeedLit: [8 9] -FreshLit: [] +SeedLit: 8 +FreshLit: 0 +GatedName: 4 +GatedLiteral: 3 +GatedEmpty: 7 77 StrSelection: c ["a" "b"] diff --git a/tests/array/cond_accum.spt b/tests/array/cond_accum.spt index adea5774..38b5a41b 100644 --- a/tests/array/cond_accum.spt +++ b/tests/array/cond_accum.spt @@ -205,18 +205,18 @@ q = 0:3 elem = q < 2 arr4[q] "ScalarElem: -elem" -# A statement driver gates a distinct bare Range descriptor without consuming it. +# A gate makes its value a computation, so a bare range the condition does not +# name iterates too: the statement runs over both ranges and keeps the final +# value. r = 0:3 i = 0:5 cross = r < 4 i -crossValues = [cross] -"CrossRange: -crossValues" +"CrossRange: -cross" -# A direct bare Range literal is likewise assigned as a descriptor. +# A bare range literal under a gate iterates the same way. r = 0:3 litcross = r < 2 0:5 -litcrossValues = [litcross] -"LitCross: -litcrossValues" +"LitCross: -litcross" # Scalar conditional iteration: direct range-indexed array expression arr5 = [10 20 30 40] @@ -224,17 +224,28 @@ r = 0:3 selectionCross = r < 2 arr5[0:3] selectionCross -# A rejected descriptor assignment keeps the existing Range value. -seedlit = 8:10 +# A rejected gated range literal keeps the existing value. +seedlit = 8 r = 0:3 seedlit = r < 0 0:5 -seedlitValues = [seedlit] -"SeedLit: -seedlitValues" +"SeedLit: -seedlit" -# A fresh rejected descriptor assignment keeps the zero Range descriptor. +# A fresh rejected one keeps zero. freshlit = r < 0 0:5 -freshlitValues = [freshlit] -"FreshLit: -freshlitValues" +"FreshLit: -freshlit" + +# A gate with no range of its own makes a bare range iterate too: the +# assignment keeps the final element, and an empty range keeps the old value. +gateN = 3 +gateRange = 0:5 +gatedName = gateN > 2 gateRange +"GatedName: -gatedName" +gatedLiteral = gateN > 2 0:4 +"GatedLiteral: -gatedLiteral" +gateEmpty = 0:0 +gatedEmpty = 7 +gatedEmpty = gateN > 2 gateEmpty +"GatedEmpty: -gatedEmpty" # A rejected direct range-indexed array expression keeps the old value seedSelection = 77 From 89f66f97115ec1b2d68fd859d63e8622d546990a Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 23 Oct 2026 01:33:42 +0530 Subject: [PATCH 24/34] docs: say a bare range iterates under a gate The README and the range, memory and IR docs say a bare range is copied only without a gate and iterates under one, and Range Semantics chooses a range under a condition through its bounds. Two review corrections land with it: the bare-name rule names the gate rather than any loop in the statement, and descriptor copies are unconditional writes because they are never gated. Refs #146 Co-Authored-By: Claude Opus 5.5 --- README.md | 11 ++++++----- docs/Pluto IR Plan.md | 15 +++++++-------- docs/Pluto Memory Model.md | 18 +++++++++--------- docs/Pluto Range Semantics.md | 31 +++++++++++++++++-------------- 4 files changed, 39 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 6b948170..f919305c 100644 --- a/README.md +++ b/README.md @@ -233,11 +233,12 @@ lastSquare = Square(i) # 16 i # prints the descriptor: 0:5 ``` -A bare range is a value, written as a literal or held by a name: assignment -copies it and print shows its descriptor, unless the statement already loops -over that name. Using it in an operation creates a ranged computation: the -assignment keeps the final yield, brackets materialize all yields into an -array, and passing it to a template executes the call once for each value. The +A bare range is a value, written as a literal or held by a name: an assignment +without a gate copies it, and print shows its descriptor unless another +argument on the line loops over it. Using it under a gate or in an operation +creates a ranged computation: the assignment keeps the final yield, brackets +materialize all yields into an array, and passing it to a template executes +the call once for each value. The compiler can map these operations to SIMD instructions — this is range-driven auto-vectorization. Each named Range binding is a distinct driver: different names form a cartesian domain when consumed together, even when their bounds diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index c76e8b03..a1a28ffd 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -936,12 +936,11 @@ statements a range drives. A parameter never holds a range, since a range argument drives the whole body; no call yields one, since a function cannot return a range (an output assigned one is rejected at the definition, #146); and struct fields, constants and array elements cannot hold one (`[r]` -collects `r`'s values). A value holds a range when it is a range literal or a -binding assigned a descriptor (`x = r`, not `x = r * 2`). A range literal or -binding operand of `||` and `&&` is iterated (`n > 0 && 0:5` gives an -element). A statement's condition iterates the ranges it names, so its value -reads them as elements (`kept = s > 2 s`); a range the condition only -collects, or does not name, stays a descriptor (#146). A template's own text +collects `r`'s values). A value holds a range only when it is a bare range +literal or binding assigned without a gate (`x = r`, not `x = r * 2` or +`x = n > 0 r`): a gate makes its values computations, so a range in them +iterates (#146). A range literal or binding operand of `||` and `&&` is +iterated too (`n > 0 && 0:5` gives an element). A template's own text therefore decides all of this, without looking at another template. Every check then uses the same classification, and none waits for a type: after `r = 0:n`, `out = r * 2` does not kill an earlier `out = prev`, does not @@ -1466,8 +1465,8 @@ immediate deletion at the last consumer. default both the body and a read of `out` after `out = r * 2` are rejected; `y = Helper(x)` then `out = y + 1`, where `Helper` returns a scalar, is a definite write; each way a value holds or iterates a range (a - copy, a gate naming it, a literal or binding under `&&`) agrees with the - solved types; an output assigned a range is rejected (#146), with no + copy, a gate, a literal or binding under `&&`) agrees with the solved + types; an output assigned a range is rejected (#146), with no flow error in any template, so none in a caller declared before or after it or calling it through a function that forwards the call; a call that names no template, an operator whose sides do not line up, or an diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index 2d32b869..cc5bf002 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -10,11 +10,11 @@ This document describes Pluto's semantic model and compares it with other major 3. **Range Selections are Streams:** `s = arr[i]` keeps the final selected value (an element or owned subarray); `s = [arr[i]]` materializes every selected value. -4. **Ranges are Descriptor Values:** `j = i` copies a Range; consuming it in - `x = i + 1`, `arr[i]`, a call, or `[]` drives a loop. Print and main - interpolation markers format the descriptor itself. Where the statement - already loops over the name, a bare use is that loop's element. A function - cannot return a Range (#146). +4. **Ranges are Descriptor Values:** `j = i` copies a Range when the + assignment has no gate; under a gate, or consumed in `x = i + 1`, `arr[i]`, + a call, or `[]`, it drives a loop. Print and main interpolation markers + format the descriptor itself unless another argument on the line loops + over the name. A function cannot return a Range (#146). 5. **Empty-Domain Initialization:** An empty Range descriptor still assigns. An empty ranged computation leaves an existing destination unchanged and a fresh destination at its type's zero value. @@ -328,10 +328,10 @@ since a range argument runs the function once per element: `Wrap(1:5)`, where `Wrap` returns its input, gives `4`. A function cannot return a range either: an output assigned one is rejected, and the function returns the bounds for its caller to build the range from (#146). A range bound to a -name counts as possibly empty, so `F` below needs its default. A statement's -condition iterates the ranges it names, so its value reads them as elements: -in `kept = s > 2 s`, `kept` gets an element of `s`. Under `||` and `&&`, a -range literal or binding operand is iterated, so `n > 0 && 0:5` gives an +name counts as possibly empty, so `F` below needs its default. A gate makes +its values computations, so a bare range in them iterates: in +`kept = n > 2 s`, `kept` gets an element of `s`. Under `||` and `&&`, a range +literal or binding operand is iterated too, so `n > 0 && 0:5` gives an element. ```python diff --git a/docs/Pluto Range Semantics.md b/docs/Pluto Range Semantics.md index 4014a5fe..b9cfa914 100644 --- a/docs/Pluto Range Semantics.md +++ b/docs/Pluto Range Semantics.md @@ -3,7 +3,7 @@ ## Core Model A `Range` is a descriptor value. A range literal constructs one, and a -complete Range-valued assignment copies it: +complete Range-valued assignment without a gate copies it: ```pluto i = 0:5 @@ -29,8 +29,9 @@ There are two explicit closing steps for a ranged computation: A Range descriptor assignment is not a closing step and does not iterate. This keeps descriptor copying, collection, and final-value selection separate. -A bare range name behaves like the literal it holds, unless the statement -already loops over that name: then it is that loop's current element (#146). +A bare range name behaves like the literal it holds: an assignment without a +gate copies its descriptor, and under a gate, like any other use, it iterates +(#146). ## Migration From Bare-Range Finalization @@ -42,7 +43,8 @@ This change can be silent for a fresh destination. A later call, index, or collector consumes the copied Range and runs its whole domain, while print and interpolation format the descriptor itself. Assigning a Range to an existing scalar is instead rejected as a type-changing reassignment. -Descriptor copies are unconditional writes and participate in the ordinary +Descriptor copies happen only without a gate, so they are unconditional +writes, even of an empty descriptor, and participate in the ordinary dead-store checks. Range-indexed expressions such as `last = data[i]` are unchanged because indexing is already a ranged computation. @@ -69,9 +71,10 @@ descriptors, so `[i]` consumes `i` and collects its yields. Passing a Range to a function likewise consumes it as a driver rather than passing inert metadata. A function cannot return a Range either: an output assigned one is rejected at the function's definition (#146). The function returns the bounds instead, -and the caller builds the range: `lo, hi = Bounds(n)`, then `r = lo:hi`. If -the range was assigned conditionally, the rebuild keeps the condition: -`r = n > 0 lo:hi`. +and the caller builds the range: `lo, hi = Bounds(n)`, then `r = lo:hi`. A +gate makes a range iterate, so a range is chosen conditionally through its +bounds: `lo, hi = n > 0 a, b`, then `r = lo:hi`. When the condition fails, +`lo` and `hi` keep their previous values, and `r` is rebuilt from them. Range-indexed arrays follow the same rule: @@ -460,8 +463,8 @@ x, y = i < 2 [1], j + 0 The statement condition `i < 2` is shared. `x` collects once for each admitted `i`, producing `[1 1]`. `y`'s operation uses its own local `j` driver inside that shared gate and ends -with the final result `1`. A bare `j` in this position would instead copy the -Range descriptor because only `i` belongs to the active statement domain. +with the final result `1`. A bare `j` in this position iterates the same way +and also ends with `1`. Likewise: @@ -483,11 +486,11 @@ the statement condition opens that outer loop first. Inside the RHS, the same name refers to the current scalar iterator value, not to a fresh nested loop. -Consequently, `filtered = i > 2 i` keeps the final admitted scalar, while -`copy = outer > 2 i` copies `i` when only `outer` belongs to the statement -domain. If the statement domain is empty, it performs no write: an existing -Range destination stays unchanged and a fresh one keeps the zero Range -descriptor. +Consequently, `filtered = i > 2 i` keeps the final admitted scalar, and +`cross = outer > 2 i` iterates `i` inside each admitted `outer` point and +keeps the final yield. If the statement domain is empty, it performs no +write: an existing destination stays unchanged and a fresh one keeps its zero +value. For non-collector tuple outputs, one admitted statement iteration is still one shared scalar update step, but each RHS has its own local yield outcome. If one From 5b5ba7e07ce8628fafd74e129428b4c3a43a4ce2 Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 23 Oct 2026 09:02:32 +0530 Subject: [PATCH 25/34] fix(compiler): resolve conditional range bounds before the loop A range literal's bounds now resolve before its loop starts, like any operands: a bound with `&&` or `||` that fails leaves the loop with no iterations, so destinations keep their values and a collector gets no cells. Before, an iterated literal with such a bound stopped with an internal error, on master as well: `0:(n > 0 && n) + 0`, `[0:(n > 0 && n)]`, `Accum(x, 0:(n > 0 && n))` and `arr[0:(n > 0 && n)]`. Since a gate makes a bare range iterate, `x = n > 0 0:(n > 0 && n)` did too. The type checker turns an iterated literal into a loop driver, so the statement never sees its bounds. The loop now runs them through compileCondOperands, the branching path that `&&` operands and call arguments take, and an index access keeps its per-element check instead of building such a literal ahead of its loop. Refs #146 Co-Authored-By: Claude Opus 5.5 --- compiler/bounds.go | 6 ++++++ compiler/loop.go | 14 +++++++++----- tests/cond/empty_range.exp | 3 +++ tests/cond/empty_range.spt | 30 ++++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/compiler/bounds.go b/compiler/bounds.go index f7dc1d22..04f502d0 100644 --- a/compiler/bounds.go +++ b/compiler/bounds.go @@ -481,9 +481,15 @@ func collectArrayIndexAccesses(expr ast.Expression, out *[]*ast.ArrayRangeExpres } } +// loopRangeValuesByName builds the drivers' ranges ahead of their loops. A +// range literal whose bounds can fail resolves only where its loop starts, so +// accesses it indexes keep their per-element checks. func (c *Compiler) loopRangeValuesByName(ranges []*RangeInfo) map[string]llvm.Value { bounds := make(map[string]llvm.Value, len(ranges)) for _, ri := range ranges { + if ri.RangeLit != nil && c.hasCondExprInTree(ri.RangeLit) { + continue + } bounds[ri.Name] = c.rangeAggregateForRI(ri) } return bounds diff --git a/compiler/loop.go b/compiler/loop.go index ad4a3f7e..139323f2 100644 --- a/compiler/loop.go +++ b/compiler/loop.go @@ -63,13 +63,17 @@ func (c *Compiler) rangeAggregateForRI(ri *RangeInfo) llvm.Value { return c.rangeAggregateFromSymbol(sym, ri.Name) } -// iterOverRangeInfo iterates a single driver, binding its per-iteration scalar value. +// iterOverRangeInfo iterates a single driver, binding its per-iteration scalar +// value. A range literal's bounds resolve first, like any operands: a bound +// that fails to yield leaves the loop with no iterations. func (c *Compiler) iterOverRangeInfo(ri *RangeInfo, body func(*Symbol)) { if ri.RangeLit != nil { - rangeType := Range{Iter: Int{Width: 64}} - rangeVal := c.ToRange(ri.RangeLit, rangeType) - c.iterOverRange(rangeType, rangeVal, func(iter llvm.Value, iterType Type) { - body(&Symbol{Val: iter, Type: iterType, Borrowed: true}) + c.compileCondOperands(ri.RangeLit, func() { + rangeType := Range{Iter: Int{Width: 64}} + rangeVal := c.ToRange(ri.RangeLit, rangeType) + c.iterOverRange(rangeType, rangeVal, func(iter llvm.Value, iterType Type) { + body(&Symbol{Val: iter, Type: iterType, Borrowed: true}) + }) }) return } diff --git a/tests/cond/empty_range.exp b/tests/cond/empty_range.exp index 07b33c6f..835f3f0b 100644 --- a/tests/cond/empty_range.exp +++ b/tests/cond/empty_range.exp @@ -8,3 +8,6 @@ CondLiteralRangeExisting: 7 CondLiteralRangeNew: 0 CondMixedExisting: 5 123 CondMixedNew: 0 123 +BoundYields: 2 2 2 [0 1 2] 13 +BoundFails: 7 7 [] 10 +BoundPicks: 30 9 diff --git a/tests/cond/empty_range.spt b/tests/cond/empty_range.spt index 46c1145a..7298727d 100644 --- a/tests/cond/empty_range.spt +++ b/tests/cond/empty_range.spt @@ -38,3 +38,33 @@ a, b = 1 > 0 Accum(a, 0:-2), 123 m, n = 1 > 0 Accum(1, 0:-2), 123 "CondMixedNew: -m -n" + +# A range literal's bounds resolve before its loop, like any operands, so a +# conditional bound works under a gate, in an operation, in a collector and in +# a call. +boundN = 3 +boundGated = boundN > 0 0:(boundN > 0 && boundN) +boundOr = boundN > 0 0:(boundN < 0 || 3) +boundSum = 0:(boundN > 0 && boundN) + 0 +boundCells = [0:(boundN > 0 && boundN)] +boundCall = 10 +boundCall = Accum(boundCall, 0:(boundN > 0 && boundN)) +"BoundYields: -boundGated -boundOr -boundSum -boundCells -boundCall" + +# A bound that fails to yield leaves the loop with no iterations: existing +# values stay, and a collector gets no cells. +failGated = 7 +failGated = boundN > 0 0:(boundN < 0 && boundN) +failSum = 7 +failSum = 0:(boundN < 0 && boundN) + 0 +failCells = [0:(boundN < 0 && boundN)] +failCall = 10 +failCall = Accum(failCall, 0:(boundN < 0 && boundN)) +"BoundFails: -failGated -failSum -failCells -failCall" + +# Indexing through such a range checks each index as it goes. +boundArr = [10 20 30 40] +boundPick = boundArr[0:(boundN > 0 && boundN)] +failPick = 9 +failPick = boundArr[0:(boundN < 0 && boundN)] +"BoundPicks: -boundPick -failPick" From bb8a453f08715610457d0fba1540357c62338796 Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 23 Oct 2026 09:02:32 +0530 Subject: [PATCH 26/34] docs: say how often a gate and an && condition run Conditional Value Semantics says the statement gate and `&&` agree at an assignment root. They agree on ranges too, but a gate's condition runs once for each point of the statement's outer iteration domain, before the values, while an `&&` condition runs once for each element its value iterates. An example with a condition that prints shows the difference. Refs #146 Co-Authored-By: Claude Opus 5.5 --- docs/Pluto Conditional Value Semantics.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/Pluto Conditional Value Semantics.md b/docs/Pluto Conditional Value Semantics.md index d34f64d1..e2e0800a 100644 --- a/docs/Pluto Conditional Value Semantics.md +++ b/docs/Pluto Conditional Value Semantics.md @@ -216,6 +216,19 @@ keep-old rule, reached by propagation or by the gate. The removed bracket form `y = (a > 2 10)` was the odd one out (it always assigned, zero on failure); spell that `y = a > 2 && 10 || 0`. +They also agree on ranges: a bare range in either value iterates (#146). They +differ in how often the condition runs. A statement gate's condition runs once +for each point of the statement's outer iteration domain, before the values, +whose RHS-local ranges run inside each admitted point; an `&&` condition is +part of its value, so it runs once for each element the value iterates. With +`Check` printing its argument and returning 1: + +```pluto +i = 0:3 +a = Check("gate") > 0 i # prints gate once; a is 2 +b = Check("and") > 0 && i # prints and three times; b is 2 +``` + ## Arrays A collector cell is a value position, so the same rule applies: a failed cell is From ab01c2f98ee3c990bdce9cf747ba7a27a7744ae5 Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 23 Oct 2026 11:36:49 +0530 Subject: [PATCH 27/34] fix(compiler)!: build an iterated range literal once, before its loops A range literal that drives a loop now builds its range once, ahead of the loop nest it drives or before a call that runs the loop itself. Every bound must yield, through its conditions and its checked accesses alike, or the range is empty and its loop runs no iterations. 5b5ba7e resolved the bounds where each loop started, which left wrong results. A statement whose value holds a range and an `&&` or `||` loops first and checks its conditions per element, and that check reached into the bounds again: `x = 1 > 0 0:(x < 1 && 3)` gave 1 instead of 2, and an `&&` bound ran once per element. A comparison bound ran twice, once for the statement and once for the range. An out-of-bounds read in a bound went to a guard no commit checked, so `bounds[1]:(n > 0 && 3) + 0` with a one-element `bounds` wrote 2 instead of keeping the old value. One function now builds the range for every loop, for the affine bounds check and for a call's own loop, and conditions read around an iterated literal stop at it. That also fixes three things that were wrong on master: an indexed range's bound ran twice, a literal nested in another range's loop was rebuilt for each outer element (`x = i + 0:x` gave 2 where naming the range first gave 3), and printing a range expression whose bound read out of bounds printed elements. A nested literal's bounds now run even when an outer range is empty, as they would for a range named before the statement. The type checker follows the same boundary: a failed bound empties its range and never fails the value around it, so a bound gives a `||` or `&&` left side no condition. The per-template check still counts the bounds of a range literal assigned whole, which keeps its old range when a bound fails. BREAKING CHANGE: a condition inside a range literal's bound no longer counts as a condition of the value around the range. `(0:(n > 0) + 0) || 99` and `(0:(n > 0) + 0) && 99` are rejected, like `(0:n + 0) || 99`; put a fallback inside the bound instead, as in `0:((n > 0) || 3)`. Refs #146 Co-Authored-By: Claude Opus 5.5 --- compiler/bounds.go | 21 ++------ compiler/cfg.go | 12 ++++- compiler/compiler.go | 12 +++-- compiler/cond.go | 8 +++ compiler/loop.go | 99 ++++++++++++++++++-------------------- compiler/solver.go | 7 +-- compiler/solver_test.go | 14 ++++++ tests/cond/empty_range.exp | 16 ++++++ tests/cond/empty_range.pt | 7 +++ tests/cond/empty_range.spt | 55 ++++++++++++++++++++- 10 files changed, 175 insertions(+), 76 deletions(-) diff --git a/compiler/bounds.go b/compiler/bounds.go index 04f502d0..1c6d43c5 100644 --- a/compiler/bounds.go +++ b/compiler/bounds.go @@ -481,20 +481,6 @@ func collectArrayIndexAccesses(expr ast.Expression, out *[]*ast.ArrayRangeExpres } } -// loopRangeValuesByName builds the drivers' ranges ahead of their loops. A -// range literal whose bounds can fail resolves only where its loop starts, so -// accesses it indexes keep their per-element checks. -func (c *Compiler) loopRangeValuesByName(ranges []*RangeInfo) map[string]llvm.Value { - bounds := make(map[string]llvm.Value, len(ranges)) - for _, ri := range ranges { - if ri.RangeLit != nil && c.hasCondExprInTree(ri.RangeLit) { - continue - } - bounds[ri.Name] = c.rangeAggregateForRI(ri) - } - return bounds -} - func (c *Compiler) arraySymbolForAffineGuard(arrayExpr ast.Expression) (*Symbol, Type, bool) { ident, ok := arrayExpr.(*ast.Identifier) if !ok { @@ -517,7 +503,7 @@ func (c *Compiler) arraySymbolForAffineGuard(arrayExpr ast.Expression) (*Symbol, return arraySym, arrType.ElemType, true } -func (c *Compiler) affineVersioningGuard(exprs []ast.Expression, ranges []*RangeInfo) (llvm.Value, map[*ast.ArrayRangeExpression]struct{}, bool) { +func (c *Compiler) affineVersioningGuard(exprs []ast.Expression, ranges []*RangeInfo, values []*Symbol) (llvm.Value, map[*ast.ArrayRangeExpression]struct{}, bool) { if len(exprs) == 0 || len(ranges) == 0 { return llvm.Value{}, nil, false } @@ -530,7 +516,10 @@ func (c *Compiler) affineVersioningGuard(exprs []ast.Expression, ranges []*Range return llvm.Value{}, nil, false } - boundsByName := c.loopRangeValuesByName(ranges) + boundsByName := make(map[string]llvm.Value, len(ranges)) + for i, ri := range ranges { + boundsByName[ri.Name] = values[i].Val + } fastAccess := make(map[*ast.ArrayRangeExpression]struct{}) guard := llvm.Value{} hasGuard := false diff --git a/compiler/cfg.go b/compiler/cfg.go index 1eb73253..39953bba 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -479,7 +479,17 @@ func (cfg *CFG) classifyWrites(let *ast.LetStatement, ranges map[string]struct{} // alternative can fail, or a call with such an argument), or a range that may // be empty drives it. func (cfg *CFG) maySkip(value ast.Expression, ranges map[string]struct{}) bool { - return treeCanFail(value, textNodeFails) || cfg.drivesValue(value, ranges) + // A range literal assigned whole keeps the old value when a bound fails. + operands := []ast.Expression{value} + if lit, ok := value.(*ast.RangeLiteral); ok { + operands = ast.ExprChildren(lit) + } + for _, operand := range operands { + if treeCanFail(operand, textNodeFails) { + return true + } + } + return cfg.drivesValue(value, ranges) } func textNodeFails(expr ast.Expression) bool { diff --git a/compiler/compiler.go b/compiler/compiler.go index 066d655a..4d4cf8a0 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -1556,10 +1556,14 @@ func (c *Compiler) compileExpression(expr ast.Expression, dest []*ast.Identifier case *ast.RangeLiteral: info := c.ExprCache[key(c.FuncNameMangled, e)] // A consuming expression can lower this literal inside the loop that - // already bound its iterator rewrite. Complete descriptor assignments - // clear the rewrite and materialize the Range aggregate below. - if rewIdent, ok := info.Rewrite.(*ast.Identifier); ok && len(c.pendingLoopRanges(info.Ranges)) == 0 { - return []*Symbol{c.compileIdentifier(rewIdent)} + // already bound its iterator rewrite, and a call that runs the loop + // itself gets the range built as for any loop. Complete descriptor + // assignments clear the rewrite and materialize the Range aggregate below. + if rewIdent, ok := info.Rewrite.(*ast.Identifier); ok { + if len(c.pendingLoopRanges(info.Ranges)) == 0 { + return []*Symbol{c.compileIdentifier(rewIdent)} + } + return []*Symbol{c.rangeLiteralValue(e)} } res = c.compileRangeExpression(e) return diff --git a/compiler/cond.go b/compiler/cond.go index 37d70552..68bb68e0 100644 --- a/compiler/cond.go +++ b/compiler/cond.go @@ -539,6 +539,14 @@ func broadcastConds(cond llvm.Value, n int) []llvm.Value { func (c *Compiler) extractSlotConds(expr ast.Expression, temps []condTemp) ([]llvm.Value, []condTemp) { info := c.ExprCache[key(c.FuncNameMangled, expr)] + // An iterated range literal resolves its bounds where its range is built + // (rangeLiteralValue), so they are no condition of the value around it. + if _, ok := expr.(*ast.RangeLiteral); ok { + if _, iterated := info.Rewrite.(*ast.Identifier); iterated { + return nil, temps + } + } + // Array-literal cells are local-resolution boundaries: each cell resolves // its own condition at its own level. Statement-level extraction must stop // here — descending in would lift a cell's comparison into a gate over the diff --git a/compiler/loop.go b/compiler/loop.go index 139323f2..66bc6434 100644 --- a/compiler/loop.go +++ b/compiler/loop.go @@ -34,60 +34,55 @@ func (c *Compiler) rangeSymbol(sym *Symbol, name string) *Symbol { panic(fmt.Sprintf("internal: %q is not a Range during lowering (got %s)", name, sym.Type.String())) } -func (c *Compiler) rangeAggregateFromSymbol(sym *Symbol, name string) llvm.Value { - return c.rangeSymbol(sym, name).Val -} - -func (c *Compiler) iterOverDriverSymbol(sym *Symbol, name string, body func(*Symbol)) { - rangeSym := c.rangeSymbol(sym, name) - c.iterOverRange(rangeSym.Type.(Range), rangeSym.Val, func(iter llvm.Value, iterType Type) { - body(&Symbol{ - Val: iter, - Type: iterType, - FuncArg: rangeSym.FuncArg, - Borrowed: true, - }) - }) -} - -// rangeAggregateForRI builds the {start,stop,step} aggregate for a driver. -func (c *Compiler) rangeAggregateForRI(ri *RangeInfo) llvm.Value { - if ri.RangeLit != nil { - return c.ToRange(ri.RangeLit, Range{Iter: Int{Width: 64}}) - } +// loopRangeValues builds each driver's range once, ahead of its loop nest. A +// range literal's bounds resolve there like any operands: when one fails to +// yield, its range is empty and its loop runs no iterations. +func (c *Compiler) loopRangeValues(ranges []*RangeInfo) []*Symbol { + values := make([]*Symbol, len(ranges)) + for i, ri := range ranges { + if ri.RangeLit != nil { + values[i] = c.rangeLiteralValue(ri.RangeLit) + continue + } - sym, ok := c.getRawSymbol(ri.Name) - if !ok { - panic(fmt.Sprintf("internal: range driver %q not found in scope (should have been caught by type solver)", ri.Name)) + sym, ok := c.getRawSymbol(ri.Name) + if !ok { + panic(fmt.Sprintf("internal: range driver %q not found in scope (should have been caught by type solver)", ri.Name)) + } + values[i] = c.rangeSymbol(sym, ri.Name) } - return c.rangeAggregateFromSymbol(sym, ri.Name) + return values } -// iterOverRangeInfo iterates a single driver, binding its per-iteration scalar -// value. A range literal's bounds resolve first, like any operands: a bound -// that fails to yield leaves the loop with no iterations. -func (c *Compiler) iterOverRangeInfo(ri *RangeInfo, body func(*Symbol)) { - if ri.RangeLit != nil { - c.compileCondOperands(ri.RangeLit, func() { - rangeType := Range{Iter: Int{Width: 64}} - rangeVal := c.ToRange(ri.RangeLit, rangeType) - c.iterOverRange(rangeType, rangeVal, func(iter llvm.Value, iterType Type) { - body(&Symbol{Val: iter, Type: iterType, Borrowed: true}) - }) - }) - return - } - - sym, ok := c.getRawSymbol(ri.Name) - if !ok { - panic(fmt.Sprintf("internal: range driver %q not found in scope (should have been caught by type solver)", ri.Name)) - } - c.iterOverDriverSymbol(sym, ri.Name, body) +// rangeLiteralValue builds an iterated range literal's range: empty unless +// every bound yields, through its conditions and checked accesses alike. +func (c *Compiler) rangeLiteralValue(lit *ast.RangeLiteral) *Symbol { + rangeType := Range{Iter: Int{Width: 64}} + empty := c.makeZeroValue(rangeType).Val + slot := c.createEntryBlockAlloca(c.mapToLLVMType(rangeType), "range_lit") + c.createStore(empty, slot, rangeType) + + c.compileCondOperands(lit, func() { + guardPtr := c.pushBoundsGuard("range_bounds_guard") + rangeVal := c.ToRange(lit, rangeType) + if c.stmtBoundsUsed() { + inBounds := c.createLoad(guardPtr, Int{Width: 1}, "range_bounds_ok") + rangeVal = c.builder.CreateSelect(inBounds, rangeVal, empty, "range_or_empty") + } + c.popBoundsGuard() + c.createStore(rangeVal, slot, rangeType) + }) + return &Symbol{Val: c.createLoad(slot, rangeType, "range_lit_val"), Type: rangeType} } // Build a nested loop over specs; at each level shadow specs[i].Name with the scalar iter; run body at innermost. func (c *Compiler) withLoopNest(ranges []*RangeInfo, body func()) { ranges = c.pendingLoopRanges(ranges) + c.withLoopNestOver(ranges, c.loopRangeValues(ranges), body) +} + +// withLoopNestOver runs a loop nest over driver ranges built ahead of it. +func (c *Compiler) withLoopNestOver(ranges []*RangeInfo, values []*Symbol, body func()) { if len(ranges) == 0 { body() return @@ -98,9 +93,10 @@ func (c *Compiler) withLoopNest(ranges []*RangeInfo, body func()) { body() return } - c.iterOverRangeInfo(ranges[i], func(iterSym *Symbol) { + rangeSym := values[i] + c.iterOverRange(rangeSym.Type.(Range), rangeSym.Val, func(iter llvm.Value, iterType Type) { PushScope(&c.Scopes, BlockScope) - Put(c.Scopes, ranges[i].Name, iterSym) + Put(c.Scopes, ranges[i].Name, &Symbol{Val: iter, Type: iterType, FuncArg: rangeSym.FuncArg, Borrowed: true}) rec(i + 1) c.popScope() }) @@ -170,9 +166,10 @@ func (c *Compiler) withLoopNestVersioned(ranges []*RangeInfo, probes []ast.Expre return } - guard, fastAccess, ok := c.affineVersioningGuard(probes, pending) + values := c.loopRangeValues(pending) + guard, fastAccess, ok := c.affineVersioningGuard(probes, pending, values) if !ok { - c.withLoopNest(ranges, body) + c.withLoopNestOver(pending, values, body) return } @@ -180,13 +177,13 @@ func (c *Compiler) withLoopNestVersioned(ranges []*RangeInfo, probes []ast.Expre c.builder.SetInsertPointAtEnd(fastBlock) c.pushLoopBoundsMode(loopBoundsModeAffineFast, fastAccess) - c.withLoopNest(ranges, body) + c.withLoopNestOver(pending, values, body) c.popLoopBoundsMode() c.builder.CreateBr(contBlock) c.builder.SetInsertPointAtEnd(checkedBlock) c.pushLoopBoundsMode(loopBoundsModeChecked, nil) - c.withLoopNest(ranges, body) + c.withLoopNestOver(pending, values, body) c.popLoopBoundsMode() c.builder.CreateBr(contBlock) diff --git a/compiler/solver.go b/compiler/solver.go index 797e3f75..2aba0881 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -821,10 +821,11 @@ func (ts *TypeSolver) isRangeDriverCond(expr ast.Expression, condTypes []Type) b // treeCanFail reports whether a value-position expression can propagate a // failed yield to its parent, asking nodeFails to classify each node. An array -// literal settles a failed cell locally, and a || fails only when its final -// fallback does. +// literal settles a failed cell locally, a range literal a failed bound with +// its range, and a || fails only when its final fallback does. func treeCanFail(expr ast.Expression, nodeFails func(ast.Expression) bool) bool { - if _, ok := expr.(*ast.ArrayLiteral); ok { + switch expr.(type) { + case *ast.ArrayLiteral, *ast.RangeLiteral: return false } if infix, ok := ast.IsLogicalOr(expr); ok { diff --git a/compiler/solver_test.go b/compiler/solver_test.go index c0c706d3..9eb96fe2 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -1235,6 +1235,13 @@ func TestLogicalAndDiagnostics(t *testing.T) { script: "n = 5\nx = n && 10", expectError: "logical AND in value position requires a conditional left operand", }, + { + // A failed bound empties its range rather than failing the value + // around it. + name: "RangeBoundCannotFailItsValue", + script: "n = 3\nx = (0:(n > 0) + 0) && 99", + expectError: "logical AND in value position requires a conditional left operand", + }, { // An always-yielding || (a > 0 || a always yields a value) cannot // gate a &&. @@ -1312,6 +1319,13 @@ func TestLogicalOrDiagnostics(t *testing.T) { script: "x = 1 || 2", expectError: "logical OR in value position requires a conditional left operand", }, + { + // A failed bound empties its range rather than failing the value + // around it, as for a range built before the statement. + name: "RangeBoundCannotFailItsValue", + script: "n = 3\nx = (0:(n > 0) + 0) || 99", + expectError: "logical OR in value position requires a conditional left operand", + }, { name: "FallbackTypesMustMatch", script: "a = 1\nx = a > 0 || \"fallback\"", diff --git a/tests/cond/empty_range.exp b/tests/cond/empty_range.exp index 835f3f0b..6a7305df 100644 --- a/tests/cond/empty_range.exp +++ b/tests/cond/empty_range.exp @@ -11,3 +11,19 @@ CondMixedNew: 0 123 BoundYields: 2 2 2 [0 1 2] 13 BoundFails: 7 7 [] 10 BoundPicks: 30 9 +SelfBound: 2 +called 1 +called 2 +called 3 +called 1 +BoundCounts: 0 2 30 [0 1 2] +called 6 +BoundEager: [] +called 4 +BoundMixed: 3 +called 5 +called 4 +BoundCallee: 20 10 +BoundReads: 99 99 10 +HeapBound: 2 +BoundStored: 2 1 diff --git a/tests/cond/empty_range.pt b/tests/cond/empty_range.pt index e1172835..bc5c381f 100644 --- a/tests/cond/empty_range.pt +++ b/tests/cond/empty_range.pt @@ -6,3 +6,10 @@ out = HeapSuffix(s) out = RangeTail(x) out = x + +# The first range stays in use when the second one's bound fails. +out = KeepRange(n) + r = 0:2 + r = 0:(n > 0) + out = 0 + out = r + 0 diff --git a/tests/cond/empty_range.spt b/tests/cond/empty_range.spt index 7298727d..6fb2522d 100644 --- a/tests/cond/empty_range.spt +++ b/tests/cond/empty_range.spt @@ -62,9 +62,62 @@ failCall = 10 failCall = Accum(failCall, 0:(boundN < 0 && boundN)) "BoundFails: -failGated -failSum -failCells -failCall" -# Indexing through such a range checks each index as it goes. +# Indexing through such a range reads one element per index it yields. boundArr = [10 20 30 40] boundPick = boundArr[0:(boundN > 0 && boundN)] failPick = 9 failPick = boundArr[0:(boundN < 0 && boundN)] "BoundPicks: -boundPick -failPick" + +# The bounds run once, before the loop, so a bound that reads the destination +# sees its value from before the statement. +selfBound = 0 +selfBound = 1 > 0 0:(selfBound < 1 && 3) +"SelfBound: -selfBound" + +# A bound runs once whatever its form, also when its range indexes an array or +# nests inside another range's loop. +countI = 0:3 +countCmp = 1 > 0 0:(Loud(1) > 0) +countAnd = 1 > 0 0:(Loud(2) > 0 && 3) +countPick = boundArr[0:Loud(3)] +countInner = [countI + 0:Loud(1)] +"BoundCounts: -countCmp -countAnd -countPick -countInner" + +# Every range in a nest is built before the nest starts, so an inner bound runs +# even when an outer range is empty. +eagerI = 0:0 +eagerCells = [eagerI + 0:Loud(6)] +"BoundEager: -eagerCells" + +# The value's own conditions run per element, without the bounds. +countMixed = (boundN > 0 && 1) + 0:(Loud(4) > 0 && 3) +"BoundMixed: -countMixed" + +# A call that runs the loop itself gets the range built the same way. +calleeCmp = 10 +calleeCmp = Accum(calleeCmp, 0:(Loud(5) > 2)) +calleeFail = 10 +calleeFail = Accum(calleeFail, (Loud(4) > 5):3) +"BoundCallee: -calleeCmp -calleeFail" + +# Every bound must yield before the loop starts, out-of-bounds reads included: +# the loops run no iterations, so nothing is written or printed. +oobBounds = [2] +oobSum = 99 +oobSum = oobBounds[1]:(boundN > 0 && 3) + 0 +oobPick = 99 +oobPick = boundArr[oobBounds[1]:(boundN > 0 && 3)] +oobCall = 10 +oobCall = Accum(oobCall, oobBounds[1]:3) +"BoundReads: -oobSum -oobPick -oobCall" +oobBounds[1]:3 + 0 + +# A heap value compared in a bound is freed once the bound resolves. +heapBound = 1 > 0 0:(HeapSuffix("a") > "a" && 3) +"HeapBound: -heapBound" + +# A range assigned whole keeps its old range when a bound fails. +storedYields = KeepRange(3) +storedFails = KeepRange(0) +"BoundStored: -storedYields -storedFails" From 1d7e9a7cc1387cc424e9e111daf98d2706525411 Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 23 Oct 2026 11:36:49 +0530 Subject: [PATCH 28/34] docs: say a range literal builds its range once Range Semantics says a range literal that drives a loop is constructed once, before its loop nest starts, so its bounds run once even when an outer range is empty or they read a value the loop writes. A bound that fails, through a condition or an out-of-bounds read, leaves the range empty, and never fails the value around it, so `(0:(n > 0) + 0) || 99` is rejected like `(0:n + 0) || 99`. Refs #146 Co-Authored-By: Claude Opus 5.5 --- docs/Pluto Range Semantics.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/Pluto Range Semantics.md b/docs/Pluto Range Semantics.md index b9cfa914..c3fc6811 100644 --- a/docs/Pluto Range Semantics.md +++ b/docs/Pluto Range Semantics.md @@ -64,6 +64,13 @@ Range `start`/`stop`/`step` fields are not part of the language. The bound values are captured when the range is constructed, so later changes to the source variables do not mutate the existing range. Functions that need those bounds as data should currently receive the scalar values explicitly. +A range literal that drives a loop is constructed once, before its loop nest +starts, so its bounds run once: even when an outer range is empty, and even +when they read a value the loop writes. Every bound must yield: when one +fails, through a condition or an out-of-bounds read, the range is empty and +its loop runs no iterations. A failed bound never fails the value around the +range, so `(0:(n > 0) + 0) || 99` is rejected, like `(0:n + 0) || 99`: its +left side cannot fail. Range is nameable and copyable, but is not yet a fully first-class container element. Arrays and tables contain scalar/string elements rather than Range From 4b059b209ba9efeae5226db42ec91639fc13e2ac Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 23 Oct 2026 18:27:26 +0530 Subject: [PATCH 29/34] refactor(compiler): give a template's flow checks named passes checkTemplateFlow now reads as its three steps: forwardPass runs the forward dataflow over the classified writes and returns the outputs it definitely assigns, rejectUnassignedOutputs reports the outputs a body can leave unassigned, and backwardPass finds unused values. The forward loop had been inline since 52f9c8f replaced master's AnalyzeSpecialization and its typedForwardPass. The name pairs with backwardPass, and the script can share the pass once its flow checks use the text classification too. backwardPass also takes the outputs set itself. It updates the set as it walks back, and nothing reads the set afterwards, so the copy made there since master protected nothing. Co-Authored-By: Claude Opus 5.5 --- compiler/cfg.go | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index 39953bba..3daf7751 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -2,7 +2,6 @@ package compiler import ( "fmt" - "maps" "slices" "github.com/thiremani/pluto/ast" @@ -387,6 +386,14 @@ func (cfg *CFG) checkTemplateFlow(template classifiedTemplate) { defer cfg.PopBlock() outputs := identSet(template.fn.Outputs) + assigned := cfg.forwardPass(template, outputs) + cfg.rejectUnassignedOutputs(template.fn.Outputs, assigned) + cfg.backwardPass(outputs) +} + +// forwardPass runs the forward dataflow over a template's statements and +// returns the outputs it definitely assigns. +func (cfg *CFG) forwardPass(template classifiedTemplate, outputs map[string]struct{}) map[string]struct{} { assigned := make(map[string]struct{}, len(outputs)) lastWrites := make(map[string]VarEvent) @@ -405,15 +412,7 @@ func (cfg *CFG) checkTemplateFlow(template classifiedTemplate) { } } - for _, output := range template.fn.Outputs { - if _, ok := assigned[output.Value]; ok { - continue - } - - cfg.addError(output.Tok(), fmt.Sprintf("output %q may be left unassigned; assign it unconditionally first, or pass the previous value as an input and initialize from it (%s = prev), with each caller passing its destination as that input", output.Value, output.Value)) - } - - cfg.backwardPass(maps.Clone(outputs)) + return assigned } func (cfg *CFG) rejectUnassignedOutputReads(reads []VarEvent, outputs, assigned map[string]struct{}) { @@ -427,6 +426,16 @@ func (cfg *CFG) rejectUnassignedOutputReads(reads []VarEvent, outputs, assigned } } +func (cfg *CFG) rejectUnassignedOutputs(outputs []*ast.Identifier, assigned map[string]struct{}) { + for _, output := range outputs { + if _, ok := assigned[output.Value]; ok { + continue + } + + cfg.addError(output.Tok(), fmt.Sprintf("output %q may be left unassigned; assign it unconditionally first, or pass the previous value as an input and initialize from it (%s = prev), with each caller passing its destination as that input", output.Value, output.Value)) + } +} + // textWriteEvents turns an assignment's text classification into write events // at its named targets. func textWriteEvents(let *ast.LetStatement, writes []textWrite) []VarEvent { @@ -807,7 +816,8 @@ func (cfg *CFG) transferWrite(lastWrites map[string]VarEvent, event VarEvent) { lastWrites[event.Name] = event } -// backwardPass identifies unused values and dead stores. +// backwardPass identifies unused values and dead stores. It walks back from +// live, the names read after the body (a template's outputs), updating it. func (cfg *CFG) backwardPass(live map[string]struct{}) { block := cfg.Blocks[len(cfg.Blocks)-1] for i := len(block.Stmts) - 1; i >= 0; i-- { From e5c236be7977345772db6f16a4ab2db4b92cf9c6 Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 23 Oct 2026 22:14:52 +0530 Subject: [PATCH 30/34] refactor(compiler): check a template's outputs at the end of its forward pass forwardPass now ends by reporting the outputs a body can leave unassigned, instead of returning the outputs it definitely assigns for its caller to check. The end of the body is where the caller reads every output, so this is the pass's last read check, against the state the pass already keeps, and nothing else uses that state. checkTemplateFlow is left with the forward and backward passes, like the script's check. Co-Authored-By: Claude Opus 5.5 --- compiler/cfg.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index 3daf7751..8732ea17 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -386,14 +386,14 @@ func (cfg *CFG) checkTemplateFlow(template classifiedTemplate) { defer cfg.PopBlock() outputs := identSet(template.fn.Outputs) - assigned := cfg.forwardPass(template, outputs) - cfg.rejectUnassignedOutputs(template.fn.Outputs, assigned) + cfg.forwardPass(template, outputs) cfg.backwardPass(outputs) } -// forwardPass runs the forward dataflow over a template's statements and -// returns the outputs it definitely assigns. -func (cfg *CFG) forwardPass(template classifiedTemplate, outputs map[string]struct{}) map[string]struct{} { +// forwardPass runs the forward dataflow over a template's statements. An +// output must be definitely assigned wherever it is read and by the end of the +// body, where its caller reads it. +func (cfg *CFG) forwardPass(template classifiedTemplate, outputs map[string]struct{}) { assigned := make(map[string]struct{}, len(outputs)) lastWrites := make(map[string]VarEvent) @@ -412,7 +412,7 @@ func (cfg *CFG) forwardPass(template classifiedTemplate, outputs map[string]stru } } - return assigned + cfg.rejectUnassignedOutputs(template.fn.Outputs, assigned) } func (cfg *CFG) rejectUnassignedOutputReads(reads []VarEvent, outputs, assigned map[string]struct{}) { From 28302bed614f2d1845778c0d8304ed4a30ba9357 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 24 Oct 2026 10:51:41 +0530 Subject: [PATCH 31/34] test: drop two gated range cases that other cases cover CrossRange (`cross = r < 4 i`) and LitCross (`litcross = r < 2 0:5`) leave cond_accum. LitCross repeats `rl = i < 2 0:3 + 10` without the `+ 10`, and breaking the lowering a0abfe1 added it for, a bound range literal reading its element, still fails to compile without either case: SeedLit and FreshLit use the same gated literal, and empty_range's collectors reach the same branch. Unit tests pin the gate rule, and GatedName and GatedLiteral the loop that finalizes a gated bare range. CrossRange was the one bare range name under a gate whose condition names another range; its result is the same whichever loop iterates the name, so it could not tell those apart. Co-Authored-By: Claude Opus 5.5 --- tests/array/cond_accum.exp | 2 -- tests/array/cond_accum.spt | 13 ------------- 2 files changed, 15 deletions(-) diff --git a/tests/array/cond_accum.exp b/tests/array/cond_accum.exp index d1d6a2f7..4eaefa4c 100644 --- a/tests/array/cond_accum.exp +++ b/tests/array/cond_accum.exp @@ -37,8 +37,6 @@ MultiCell: [0 10 1 11] ScalarArith: 3 4 ScalarElem: 20 -CrossRange: 4 -LitCross: 4 30 SeedLit: 8 FreshLit: 0 diff --git a/tests/array/cond_accum.spt b/tests/array/cond_accum.spt index 38b5a41b..a929911c 100644 --- a/tests/array/cond_accum.spt +++ b/tests/array/cond_accum.spt @@ -205,19 +205,6 @@ q = 0:3 elem = q < 2 arr4[q] "ScalarElem: -elem" -# A gate makes its value a computation, so a bare range the condition does not -# name iterates too: the statement runs over both ranges and keeps the final -# value. -r = 0:3 -i = 0:5 -cross = r < 4 i -"CrossRange: -cross" - -# A bare range literal under a gate iterates the same way. -r = 0:3 -litcross = r < 2 0:5 -"LitCross: -litcross" - # Scalar conditional iteration: direct range-indexed array expression arr5 = [10 20 30 40] r = 0:3 From 03541507251dfb2ae74e5973a1c3bb974e6c2910 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 24 Oct 2026 11:20:50 +0530 Subject: [PATCH 32/34] test: give empty_range's || bound a fallback other than boundN boundOr's bound, `boundN < 0 || 3`, fell back to 3, which is boundN's own value: a bound that let the failed comparison's left operand through, as if it had yielded, built the same 0:3 and the same 2, so the case could not tell that the fallback was taken. It falls back to 5 now, and yields 4. Co-Authored-By: Claude Opus 5.5 --- tests/cond/empty_range.exp | 2 +- tests/cond/empty_range.spt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/cond/empty_range.exp b/tests/cond/empty_range.exp index 6a7305df..db249dca 100644 --- a/tests/cond/empty_range.exp +++ b/tests/cond/empty_range.exp @@ -8,7 +8,7 @@ CondLiteralRangeExisting: 7 CondLiteralRangeNew: 0 CondMixedExisting: 5 123 CondMixedNew: 0 123 -BoundYields: 2 2 2 [0 1 2] 13 +BoundYields: 2 4 2 [0 1 2] 13 BoundFails: 7 7 [] 10 BoundPicks: 30 9 SelfBound: 2 diff --git a/tests/cond/empty_range.spt b/tests/cond/empty_range.spt index 6fb2522d..90eb41a6 100644 --- a/tests/cond/empty_range.spt +++ b/tests/cond/empty_range.spt @@ -44,7 +44,7 @@ m, n = 1 > 0 Accum(1, 0:-2), 123 # a call. boundN = 3 boundGated = boundN > 0 0:(boundN > 0 && boundN) -boundOr = boundN > 0 0:(boundN < 0 || 3) +boundOr = boundN > 0 0:(boundN < 0 || 5) boundSum = 0:(boundN > 0 && boundN) + 0 boundCells = [0:(boundN > 0 && boundN)] boundCall = 10 From 4fbbdeeae4edfdcb358007a7fddad78d714e13dc Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 24 Oct 2026 13:16:30 +0530 Subject: [PATCH 33/34] test: give countInner two inner steps and countCmp a value to keep countInner's inner range, 0:Loud(1), held only 0, so `[countI + 0:Loud(1)]` collected the same cells as `[countI + 0]`: the case could not show that the inner loop, its bound built once before the nest, runs every step for each outer value. Its bound is Loud(2) now, and it collects [0 1 1 2 2 3]. countCmp printed 0, which a new variable also keeps when its loop runs no iterations, so a comparison bound that failed under the gate printed the same. It holds 7 before the gated line now, which stays where the bound fails. Co-Authored-By: Claude Opus 5.5 --- tests/cond/empty_range.exp | 4 ++-- tests/cond/empty_range.spt | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/cond/empty_range.exp b/tests/cond/empty_range.exp index db249dca..49f07526 100644 --- a/tests/cond/empty_range.exp +++ b/tests/cond/empty_range.exp @@ -15,8 +15,8 @@ SelfBound: 2 called 1 called 2 called 3 -called 1 -BoundCounts: 0 2 30 [0 1 2] +called 2 +BoundCounts: 0 2 30 [0 1 1 2 2 3] called 6 BoundEager: [] called 4 diff --git a/tests/cond/empty_range.spt b/tests/cond/empty_range.spt index 90eb41a6..5c46901d 100644 --- a/tests/cond/empty_range.spt +++ b/tests/cond/empty_range.spt @@ -78,10 +78,11 @@ selfBound = 1 > 0 0:(selfBound < 1 && 3) # A bound runs once whatever its form, also when its range indexes an array or # nests inside another range's loop. countI = 0:3 +countCmp = 7 countCmp = 1 > 0 0:(Loud(1) > 0) countAnd = 1 > 0 0:(Loud(2) > 0 && 3) countPick = boundArr[0:Loud(3)] -countInner = [countI + 0:Loud(1)] +countInner = [countI + 0:Loud(2)] "BoundCounts: -countCmp -countAnd -countPick -countInner" # Every range in a nest is built before the nest starts, so an inner bound runs From 0105027086c717e9bcadb17b5b1ad1cb7a0cf77e Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 24 Oct 2026 13:28:08 +0530 Subject: [PATCH 34/34] test: check that a failed bound leaves new variables at zero Every scalar under a failed bound in BoundFails and BoundPicks was given a value first, so those cases showed only that an existing value stays; the one new destination was failCells, a collector. Each now has a twin that starts new: freshGated, freshSum, freshCall and freshPick keep 0. A string's "" is freed when a value lands, so freshWord, a string picked through the failed bound, checks that it keeps "", printed with %q so that it shows as "" rather than as a trailing space. Co-Authored-By: Claude Opus 5.5 --- tests/cond/empty_range.exp | 4 ++-- tests/cond/empty_range.spt | 12 +++++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/cond/empty_range.exp b/tests/cond/empty_range.exp index 49f07526..a00b6fcb 100644 --- a/tests/cond/empty_range.exp +++ b/tests/cond/empty_range.exp @@ -9,8 +9,8 @@ CondLiteralRangeNew: 0 CondMixedExisting: 5 123 CondMixedNew: 0 123 BoundYields: 2 4 2 [0 1 2] 13 -BoundFails: 7 7 [] 10 -BoundPicks: 30 9 +BoundFails: 7 0 7 0 [] 10 0 +BoundPicks: 30 9 0 "" SelfBound: 2 called 1 called 2 diff --git a/tests/cond/empty_range.spt b/tests/cond/empty_range.spt index 5c46901d..a75969c2 100644 --- a/tests/cond/empty_range.spt +++ b/tests/cond/empty_range.spt @@ -52,22 +52,28 @@ boundCall = Accum(boundCall, 0:(boundN > 0 && boundN)) "BoundYields: -boundGated -boundOr -boundSum -boundCells -boundCall" # A bound that fails to yield leaves the loop with no iterations: existing -# values stay, and a collector gets no cells. +# values stay, new ones keep their zero value, and a collector gets no cells. failGated = 7 failGated = boundN > 0 0:(boundN < 0 && boundN) +freshGated = boundN > 0 0:(boundN < 0 && boundN) failSum = 7 failSum = 0:(boundN < 0 && boundN) + 0 +freshSum = 0:(boundN < 0 && boundN) + 0 failCells = [0:(boundN < 0 && boundN)] failCall = 10 failCall = Accum(failCall, 0:(boundN < 0 && boundN)) -"BoundFails: -failGated -failSum -failCells -failCall" +freshCall = Accum(10, 0:(boundN < 0 && boundN)) +"BoundFails: -failGated -freshGated -failSum -freshSum -failCells -failCall -freshCall" # Indexing through such a range reads one element per index it yields. boundArr = [10 20 30 40] +boundWords = ["ten" "twenty" "thirty" "forty"] boundPick = boundArr[0:(boundN > 0 && boundN)] failPick = 9 failPick = boundArr[0:(boundN < 0 && boundN)] -"BoundPicks: -boundPick -failPick" +freshPick = boundArr[0:(boundN < 0 && boundN)] +freshWord = boundWords[0:(boundN < 0 && boundN)] +"BoundPicks: -boundPick -failPick -freshPick -freshWord%q" # The bounds run once, before the loop, so a bound that reads the destination # sees its value from before the statement.