Repository navigation
feat(compiler)!: require definite function outputs and stop functions returning ranges - #145
Merged
Merged
Conversation
#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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
#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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This was referenced Oct 7, 2026
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
10 tasks
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ard 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #123, closes #91, closes #92, closes #143, closes #146.
Every function body that runs now writes every output. A template that can leave an output unwritten is rejected at its definition, whether or not anything calls it:
A function keeps an old value only through an input its caller passes, as in
a = Maybe(a, -1)without = prevfirst. A call therefore either writes all of its outputs or does not run, and only the caller's own statement decides whether a destination keeps its value.The PR also stops ranges crossing function boundaries (#146): a function cannot return a range, and a bare range name behaves like the literal it holds: only an assignment without a gate stores or copies a range. That keeps the check's reading of ranges local to each function; see Ranges and functions.
The check
The CFG checks each template once, from its text, the same for every argument type (
compiler/cfg.go). A write may be skipped under a statement gate, when its value can fail (a value-position comparison,&&, a checked access, a||whose last alternative can fail, or a call with such an argument), or when a range that may be empty drives it. Only a range literal with constant, nonempty bounds always runs. A call counts as writing every output, since its callee passes the same check, so a caller of a rejected function is not reported again.Reads before definite assignment, dead stores and write-after-write move into the same pass. The per-specialization CFG, its cached results and the per-script replay are removed (#91, #92). Effects stay for the script root, whose checks move to the text classification in a follow-up.
Which values hold a Range is read from the text as well. Under #146 a binding holds a range when its latest assignment is a bare range literal or name assigned without a gate, which each function's own text decides. Settlement panics when a specialization's Range-typed bindings disagree with that summary. Tests pin each way a value holds or iterates a range against the solver, and a generated sweep checks their combinations.
The pass also reports text it cannot classify, each an error the solver reports in any specialization: a call that names no template, an operator whose sides don't line up (value counts the solver rejects), and an assignment whose values don't fill its targets. Flow checks run only once no template has these errors, so a flow error never stands in for the real one, in that template or in a caller of a function rejected for returning a range. A name reassigned between a range and a non-range value is the type checker's to report, like any other change of type. The pass reads each name by its latest assignment, so a flow error in the text as written comes first, and the type checker reports the retype once the flow checks pass.
Ranges and functions (#146)
out = r). Return the bounds and build the range in the caller:lo, hi = Bounds(n), thenr = lo:hi; to choose a range under a condition, choose its bounds (lo, hi = n > 0 a, b, thenr = lo:hi). A range returned by a call is never iterated: passed to a function, it crashes or miscompiles #143's reproducers now fail at the definition instead of crashing.a, b = i, i + 1copiesiintoa. A gate makes its values computations, like&&:x = a > 2 m,x = m > 2 mandx = a > 2 && mall give an element, andr = n > 0 0:5iterates. Prints keep master's rule: a bare range argument prints as written unless another argument on the line loops over it, soi, i + 1prints each element whilei, jprints both ranges. A main marker shows its range the same way, and width and precision markers take numbers and iterate, in the order the string names them.j = ino longer matchedj = 0:5, and over an emptyi,j = itheny = j + 1gave 1 wherey = i + 1leftyalone. The twenty-first went back to master's copies, including under a gate, where a gate then differed from&&(x = a > 2 mcopied,x = a > 2 && miterated). The twenty-third iterates a bare range under a gate, as&&does, so any valueEother than a bare range assigned without a gate is computed per element, as[E]collects it: when every element yields,x = Ekeeps the last one; otherwisexkeeps the last element that yielded, or its old value if none did, while[E]zero-fills the failed cells. A gate's condition still runs once per point of the statement's outer iteration domain, and an&&condition once per element of its value.||and&&from the sixth commit, and the||range check from the seventh are gone.Behavior changes
y = n, theny = n > 1 Fib(n - 1) + Fib(n - 2)); aprevinput to keep a caller's value; a local for a checked access (t = arr[i], thenout = t) until checked-access fallbacks land.out = x > 0, is rejected even when every caller passes arrays, where the comparison is a mask that always writes. Write a default first:out = prev, asPosdoes, orout = []0in a function that only takes arrays.assignment mismatch: 3 targets but 4 valuesandoperand mismatch: "*" has 2 values on its left but 1 value on its right.out = prevfollowed byout = x > 0is accepted for scalar and arrayx. It used to be a dead store for arrays. The array specialization still copies the default; the docs now say so instead of claiming it is dropped.prevfilters only when shared:Filter(kept, 1:5)keeps 2,Filter(0, 1:5)ends at 0.(0:(n > 0) + 0) || 99and(0:(n > 0) + 0) && 99are rejected, like(0:n + 0) || 99and like the same range named before the statement. For a default result when the range yields nothing, assign the default first:x = 99, thenx = 0:(n > 0) + 0. For a default bound when the condition fails, put the fallback inside the bound:0:((n > 0) || 3); the twenty-seventh commit's footer names only this second form. A nested literal's bounds now run once, before its loop nest, even when an outer range is empty.The ABI doesn't change: the seed and write markers go unused outside callee-owned domains. Removing them is #69 (ABI 3.0), and stable inputs are #144.
Commits
test:rewrites the 34 fixture functions that could skip a write. They already passed on master. Two expected outputs change:ConditionalNestedSkippedprints10 10, andCallStaticShareSkippedcommits two empty static strings. Three labels that named the removed seed or a skipped write are renamed, andacc.expgains theFiltercases.feat(compiler)!:the rule, the per-template pass, the range summary and its settlement check, with unit-test migrations.TestMutualRecursionkeeps its one-output-waits-a-pass scenario through gated recursive locals. New end-to-end cases:Pos(scalar and array) andDoubleLast, a range from a call throughprev.refactor(compiler):removes the body-effect fixed point, which can no longer weaken anything:BodyOutputEffects, the specialization call graph with its Tarjan components and worklist, and per-specialization statement effects (+35 −538).docs:the docs and README describe the rule as implemented and keep stable inputs and ABI 3.0 as planned.fix(compiler):from review: the pass reports text it cannot classify instead of drawing flow errors from it. Before, a template with an arity mismatch and a value that could fail, or with a name reassigned between a range and a non-range value, got "may be left unassigned" errors that hid the real one. The eleventh commit hands the range retype back to the type checker.fix(compiler):from review, superseded by the eighth commit: a call under||or&&passes on each of its outputs' ranges, for calls with several outputs too. Before,kept, k = n > 0 && Two(n)andkept, k = Two(n > 0) || Two(3)stopped with an internal error once called, and outputs written by that||were rejected. New end-to-end case:MultiOutputRanges.fix(compiler):from the sweep: the check reports an operator whose sides don't line up, as the solver does, instead of a flow error that hid it (a, b = Two(n) * 2,out = MakeRange(n > 0) || 0:n).passedRangesnow says, for the range summary, the flow checks and this check alike, which values pass a range on whole; the eighth commit removes it with range outputs.feat(compiler)!:functions cannot return ranges (Functions cannot return ranges, and a bare range name behaves like its literal #146). Each function's range summary becomes its own. Fixtures that returned ranges return bounds instead, or go where the case can no longer arise;DoubleLastkeeps its point with a local range.feat(compiler)!:a bare range name iterates wherever it is used (Functions cannot return ranges, and a bare range name behaves like its literal #146): assignments keep the final value, and prints and markers run once per value. The type checker, the per-function check and lowering follow; fixtures, the README and the docs migrate. The twenty-first commit restores master's copies.fix(compiler):from review: a string's markers drive in the order they appear. The ninth commit took a string's value markers before its width and precision markers, so"|-n%(-w)d| -j"madejthe outer loop althoughwcomes first. Also drops two stale copy statements from Range Semantics. The twenty-first commit drops this ordering with the ninth's rule, since only width and precision markers drive again.refactor(compiler):from review: a range retype is left to the type checker, which already rejects any change of a variable's type; once the flow checks pass, it reports the retype at the same position. The per-function check reads each name by its latest assignment instead of rejecting the change, so a template nothing calls is no longer checked for it, like any other type error. Also drops the IR plan's mention of the||range check the eighth commit removed.docs:from review: the type checker reports a range retype only once the flow checks pass. They read each name by its latest assignment, so a body withr = n, a print ofr, thenr = 0:nandres = ris reported only for leavingrespossibly unassigned. The IR plan and the eleventh commit's test comment said the type checker's error always appears.fix(compiler):from review: the flow checks wait until every template passes the structural and text checks. A caller of a function rejected for returning a range, declared before or after it or reaching it through a function that forwards the call, got two false dead-store errors next to the real one. This predates the PR's last commits.docs:from review: drops the last mentions of ranges returned by calls. Two passages said such a range may be empty, and the PIR matrix saidgate_heapcovers a function-returnedRange; the fixture builds its range from a function's returned bounds.refactor(compiler):checkTemplateFlowtakes theclassifiedTemplatethatAnalyzeFuncsbuilds, instead of its three fields as separate parameters.refactor(compiler):rangeFlowbecomeswriteClassifier. The name dates from when range facts flowed between functions; it now only classifies one template's writes.refactor(compiler):writeClassifiergives way to plain functions. The classification walk keeps one local map of the bindings that hold a range and passes it toclassifyWrites,maySkip, thedrivesfunctions andupdateRanges. Its own set of defined names goes too: the walk opens a function scope and declares names as it goes, so a string's markers resolve against the names defined before it, as in the type checker. Two new cases pin that resolution, and they pass on the sixteenth commit as well.style(compiler):from review: a blank line separates each loop from the return after it in the three functions the seventeenth commit reshaped.refactor(compiler):from review:valueSlots,valueCountandcallTemplatebecome CFG methods, since the template checks are their only callers; they wereCodeCompilermethods only because they sat beside the cross-function range summary that the eighth commit removed.checkSettledRangesmoves tosolver.go, next to its only caller, which passes it the template's summary.refactor(compiler):from review:templateKeymoves next to thefuncKeytype incodecompiler.go, and the two places that built the same key inline, template registration and the type checker'soutputReadslookup, use it.feat(compiler):from review: a bare range name behaves like its literal again (Functions cannot return ranges, and a bare range name behaves like its literal #146, amended). Assigned whole it copies the range, and printed or named in a main marker it shows the range, unless the statement's condition, for a value, or another argument on the line, for a print, loops over that name. This restores master's behavior on top of the per-function check, which tracks copies again: a copy is a definite write, andout = rwith a local range is a range output. Fixtures and unit tests return to their pre-ninth-commit forms, and the seventeenth commit's marker cases now use width operands, since a main marker no longer drives. The twenty-third narrows the copy to assignments without a gate.docs:the README and docs describe the amended rule again. The IR plan's retype example becomesres = r + 1, sinceres = rnow copies the range and is rejected as a range output.feat(compiler)!:from review: a bare range assigned under a gate iterates, as under&&(Functions cannot return ranges, and a bare range name behaves like its literal #146, amended again). The type checker copies a bare range only when the assignment has no gate, lowering brings backcompileAssignedValueto run the loop when no enclosing loop binds the range, now for a bare literal as well as a name, and the per-function check stops scanning conditions.cond_accum's four gated range cases now expect elements, a new case covers a gate with no range of its own (a name, a literal, and an empty range that keeps the old value), and four unit tests that pinned gated copies flip.docs:the README and docs say a bare range iterates under a gate and choose a range under a condition through its bounds. Also two review corrections: 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.fix(compiler):from review: a range literal's bounds resolve before its loop, like any operands, so a bound with&&or||that fails leaves the loop with no iterations: destinations keep their values and a collector gets no cells. An iterated literal with such a bound (0:(n > 0 && n) + 0,[0:(n > 0 && n)],Accum(x, 0:(n > 0 && n)),arr[0:(n > 0 && n)]) stopped with an internal error, on master as well, and the twenty-third commit added the gated form,x = n > 0 0:(n > 0 && n). The loop now runs the bounds throughcompileCondOperands, 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. Newempty_rangecases cover each form, with bounds that yield and bounds that fail. The twenty-seventh commit replaces this lowering, which still ran some bounds again for each element and lost an out-of-bounds read in a bound.docs:from review: Conditional Value Semantics says how often each spelling runs its condition: a gate once per point of the statement's outer iteration domain, before its values, and&&once per element of its value, with an example whose condition prints.fix(compiler)!:from review: an iterated range literal builds its range once, ahead of the loop nest it drives or before a call that runs the loop itself, through one function that every loop, the affine bounds check and a call's own loop share. Every bound must yield, conditions and checked accesses alike, or the range is empty. This fixes the twenty-fifth commit's remaining wrong results: a statement that loops first ran an&&bound again for each element (x = 1 > 0 0:(x < 1 && 3)gave 1, not 2), a comparison bound ran twice, and an out-of-bounds read in a bound was lost (bounds[1]:(n > 0 && 3) + 0wrote 2 instead of keeping the old value). It also fixes three things that were wrong on master: an indexed range's bound ran twice, a nested literal was rebuilt for each outer element (x = i + 0:xgave 2 where naming the range first gave 3), and printing a range expression whose bound read out of bounds printed elements. The type checker follows the same boundary, so a bound never gives a||or&&left side a condition, while the per-template check still counts the bounds of a range literal assigned whole. Newempty_rangecases count bound runs throughLoudand cover a bound that reads its destination, an empty outer range, a value with its own&&, a call's own loop, out-of-bounds bounds in a sum, an index, a call and a print, a heap comparison in a bound, and a range reassignment that keeps its old range; two unit cases pin the rejections.docs:Range Semantics says a range literal builds its range once, before its loop nest, and that a failed bound empties the range without failing the value around it.refactor(compiler):checkTemplateFlowreads as its three steps:forwardPass, the forward loop the second commit had inlined from master'stypedForwardPass;rejectUnassignedOutputs; andbackwardPass, which now takes the outputs set without a copy, since nothing reads the set after it.refactor(compiler):from review:forwardPassends by reporting the outputs a body can leave unassigned, instead of returning the outputs it assigns forcheckTemplateFlowto check. The end of the body is where the caller reads every output, so it is the pass's last read check, andcheckTemplateFlowis left with the forward and backward passes, like the script's check.test:from review:cond_accumdropsCrossRangeandLitCross.LitCrossrepeatsrl = i < 2 0:3 + 10without the+ 10, and breaking the lowering it was added for still fails to compile without either case, throughSeedLit,FreshLitandempty_range's collectors.CrossRangewas the only bare range name under a gate whose condition names another range, and its result does not depend on which loop iterates the name.Verification
go vetclean.go test -race ./lexer ./parser ./compilerpasses, andpython3 test.py --leak-checkpasses 84/84 with every leak check clean, on every code commit, the last at the pushed head (the twelfth changes only docs and a test comment, the twenty-second, twenty-fourth, twenty-sixth and twenty-eighth only docs). Since the twenty-first, the leak-check suite runs on a private compiler cache.maySkip's range check, whose pattern the twenty-seventh had left unmatched. The thirtieth only moves a call; the mutants on the end-of-body check and on output assignment were rerun there and are caught. For the thirty-first, the mutant that stops a bound range literal reading its element was run with both cases removed: the unit tests miss it, and it still fails to compiletests/arrayandtests/cond.&&,||and gates. Under the ninth commit's rule, on all 1,454 of 2,592 shapes the solver accepts, the range summary agrees with it, and every valid value whose write the check calls skippable has a recognized cause. Earlier rounds of the same sweep found the 236 internal errors the sixth commit fixed and the 233 misleading flow errors the seventh fixed. For the twenty-first, a focused sweep of 520 shapes, built from copies, conditions over the copied name, another range or a call, value markers, tuples and a prior range assignment, compiles 348 through a script call with no settlement panic; the other 172 are retypes the type checker rejects. With the type checker changed never to copy, 290 of the 520 panic. For the twenty-third, the same shapes compile 272 with no settlement panic, and the other 248 are retypes, more than before since a gated value now gives an element; with the type checker changed to copy under a gate as well, 234 panic.MakeRangeexample, which this PR rewrites. The README's range examples and the docs' print and marker examples were rerun at the twenty-first commit, and the examples the twenty-fourth changes, a barejunder a shared gate and a range chosen through its bounds, at the twenty-third. The twenty-sixth's example, a gate and an&&whose condition prints, was run at the twenty-fifth, and the twenty-eighth's two rejected statements at the twenty-seventh.Related
FibandFibAuxthe same way (byte-identical code). The bench returns no ranges and copies or prints no range names, so Functions cannot return ranges, and a bare range name behaves like its literal #146 needs nothing there.&&(x = r > 1 3 > 0 && [1]). It predates this PR; An inline array literal under || or && in a ranged statement or call crashes the compiler #118 now covers both forms.&&inside an array literal,[(n > 0) && 0:n]. An inline array literal under || or && in a ranged statement or call crashes the compiler #118 now covers it as a third trigger.🤖 Generated with Claude Code