Skip to content

feat(compiler)!: require definite function outputs and stop functions returning ranges - #145

Merged
thiremani merged 34 commits into
masterfrom
definite-outputs
Oct 24, 2026
Merged

thiremani merged 34 commits into
masterfrom
definite-outputs

Conversation

@thiremani

@thiremani thiremani commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

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:

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

A function keeps an old value only through an input its caller passes, as in a = Maybe(a, -1) with out = prev first. 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)

  • Breaking: a function cannot return a range. An output assigned a range is rejected at its definition, including one output of several, whether the range is a literal or a copy (out = r). Return the bounds and build the range in the caller: lo, hi = Bounds(n), then r = lo:hi; to choose a range under a condition, choose its bounds (lo, hi = n > 0 a, b, then r = 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.
  • Breaking: a bare range assigned under a gate iterates. Only an assignment without a gate stores or copies a bare range, literal or name, and each value of such an assignment counts on its own, so a, b = i, i + 1 copies i into a. A gate makes its values computations, like &&: x = a > 2 m, x = m > 2 m and x = a > 2 && m all give an element, and r = n > 0 0:5 iterates. Prints keep master's rule: a bare range argument prints as written unless another argument on the line loops over it, so i, i + 1 prints each element while i, j prints 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.
  • The rule took three rounds. The ninth commit made every bare range name iterate, which broke substitution: j = i no longer matched j = 0:5, and over an empty i, j = i then y = j + 1 gave 1 where y = i + 1 left y alone. The twenty-first went back to master's copies, including under a gate, where a gate then differed from && (x = a > 2 m copied, x = a > 2 && m iterated). The twenty-third iterates a bare range under a gate, as && does, so any value E other than a bare range assigned without a gate is computed per element, as [E] collects it: when every element yields, x = E keeps the last one; otherwise x keeps 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.
  • The check's range reading needs no other function: the cross-function fixed point, range forwarding through || and && from the sixth commit, and the || range check from the seventh are gone.

Behavior changes

  • Breaking: a body that can skip an output write no longer compiles. Migrations: a default first (y = n, then y = n > 1 Fib(n - 1) + Fib(n - 2)); a prev input to keep a caller's value; a local for a checked access (t = arr[i], then out = t) until checked-access fallbacks land.
  • A body whose only write to an output is a comparison, such as 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, as Pos does, or out = []0 in a function that only takes arrays.
  • Templates nothing calls now get dead-store and write-after-write checks, and a flow error in any template fails the whole package before any script compiles.
  • While any template has a structural or text error, no template gets flow errors; they appear once it is fixed.
  • An undefined function, an operator whose sides don't line up, and an assignment whose values don't fill its targets are reported at the definition, for templates nothing calls too. Before, they appeared only when a script called the template. Scripts now read the same assignment mismatch: 3 targets but 4 values and operand mismatch: "*" has 2 values on its left but 1 value on its right.
  • out = prev followed by out = x > 0 is accepted for scalar and array x. 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.
  • A ranged call through prev filters only when shared: Filter(kept, 1:5) keeps 2, Filter(0, 1:5) ends at 0.
  • Breaking: 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 and like the same range named before the statement. For a default result when the range yields nothing, assign the default first: x = 99, then x = 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

  1. test: rewrites the 34 fixture functions that could skip a write. They already passed on master. Two expected outputs change: ConditionalNestedSkipped prints 10 10, and CallStaticShareSkipped commits two empty static strings. Three labels that named the removed seed or a skipped write are renamed, and acc.exp gains the Filter cases.
  2. feat(compiler)!: the rule, the per-template pass, the range summary and its settlement check, with unit-test migrations. TestMutualRecursion keeps its one-output-waits-a-pass scenario through gated recursive locals. New end-to-end cases: Pos (scalar and array) and DoubleLast, a range from a call through prev.
  3. 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).
  4. docs: the docs and README describe the rule as implemented and keep stable inputs and ABI 3.0 as planned.
  5. 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.
  6. 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) and kept, 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.
  7. 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). passedRanges now 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.
  8. 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; DoubleLast keeps its point with a local range.
  9. 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.
  10. 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" made j the outer loop although w comes 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.
  11. 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.
  12. 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 with r = n, a print of r, then r = 0:n and res = r is reported only for leaving res possibly unassigned. The IR plan and the eleventh commit's test comment said the type checker's error always appears.
  13. 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.
  14. 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 said gate_heap covers a function-returned Range; the fixture builds its range from a function's returned bounds.
  15. refactor(compiler): checkTemplateFlow takes the classifiedTemplate that AnalyzeFuncs builds, instead of its three fields as separate parameters.
  16. refactor(compiler): rangeFlow becomes writeClassifier. The name dates from when range facts flowed between functions; it now only classifies one template's writes.
  17. refactor(compiler): writeClassifier gives way to plain functions. The classification walk keeps one local map of the bindings that hold a range and passes it to classifyWrites, maySkip, the drives functions and updateRanges. 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.
  18. style(compiler): from review: a blank line separates each loop from the return after it in the three functions the seventeenth commit reshaped.
  19. refactor(compiler): from review: valueSlots, valueCount and callTemplate become CFG methods, since the template checks are their only callers; they were CodeCompiler methods only because they sat beside the cross-function range summary that the eighth commit removed. checkSettledRanges moves to solver.go, next to its only caller, which passes it the template's summary.
  20. refactor(compiler): from review: templateKey moves next to the funcKey type in codecompiler.go, and the two places that built the same key inline, template registration and the type checker's outputReads lookup, use it.
  21. 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, and out = r with 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.
  22. docs: the README and docs describe the amended rule again. The IR plan's retype example becomes res = r + 1, since res = r now copies the range and is rejected as a range output.
  23. 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 back compileAssignedValue to 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.
  24. 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.
  25. 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 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. New empty_range cases 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.
  26. 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.
  27. 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) + 0 wrote 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:x gave 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. New empty_range cases count bound runs through Loud and 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.
  28. 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.
  29. refactor(compiler): checkTemplateFlow reads as its three steps: forwardPass, the forward loop the second commit had inlined from master's typedForwardPass; rejectUnassignedOutputs; and backwardPass, which now takes the outputs set without a copy, since nothing reads the set after it.
  30. refactor(compiler): from review: forwardPass ends by reporting the outputs a body can leave unassigned, instead of returning the outputs it assigns for checkTemplateFlow to check. The end of the body is where the caller reads every output, so it is the pass's last read check, and checkTemplateFlow is left with the forward and backward passes, like the script's check.
  31. test: from review: cond_accum drops CrossRange and LitCross. LitCross repeats rl = i < 2 0:3 + 10 without the + 10, and breaking the lowering it was added for still fails to compile without either case, through SeedLit, FreshLit and empty_range's collectors. CrossRange was 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

  • gofmt and go vet clean. go test -race ./lexer ./parser ./compiler passes, and python3 test.py --leak-check passes 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.
  • Mutation sweep over the final classifier, range summary, flow pass, text checks and Functions cannot return ranges, and a bare range name behaves like its literal #146's rules, rerun at the thirteenth commit: 52 single-part mutations, all caught. The fifteenth only changes how the flow checks receive their inputs, and every mutated line still matches. The sixteenth renames the classifier; the ten mutants on the lines it touched were rerun at that commit and are all caught. They include three on the marker scan's order and contents, one where the type checker ignores a string's markers, two on a name's range following its latest assignment, which replace three on the removed retype check, and two on the flow checks waiting for every template. The seventeenth replaces the classifier with functions; at that commit, the 50 mutants that unit tests check were rerun with their patterns updated, plus two new ones on the scope its walk opens (header names never declared, and a scope that sees every name in the body), and all 52 are caught. The eighteenth only adds blank lines, and every mutated line still matches. The nineteenth moves the counting helpers and the settlement check; with the patterns on the moved lines updated, all 52 unit-test mutants were rerun at that commit and are caught. The twentieth only moves a helper and reuses it, and every mutated line still matches. The new cases for callers of a rejected function also fail against the previous per-template gate. The twenty-first restores copies; at that commit the sweep has 62 mutants: the earlier ones with patterns for the restored code, minus two whose code is gone, plus new ones on copies, a condition that loops over the copied name, collectors and width operands in a condition, main markers and print lines. All 60 that unit tests check are caught, and of the two that need the fixtures, one is caught. The other, a bare print argument that never joins its line's loop, appears equivalent, with no behavioral difference found: the line's loop already binds the name, so lowering reads the element either way. The twenty-third replaces the mutants on condition scanning with six on the gate rule and two on the loop that finalizes a gated bare name or literal at the assignment root, 63 in all. All 59 that unit tests check are caught. The two on the root loop first survived, since every gated range fixture had a condition that already looped; a new fixture case with a gate of no range of its own catches both. The print-argument one still appears equivalent. The twenty-fifth adds two mutants, one skipping the operand pass before a literal's loop and one building a literal with a conditional bound ahead of the accesses it indexes; unit tests miss both, and the new fixture cases catch both. The twenty-seventh replaces those two with eight on the new construction: the bounds guard ignored, loop ranges built without resolving their conditions, condition extraction reaching into an iterated literal's bounds, a call's own loop getting an unresolved range, plain and affine-checked loops building their ranges again, the type checker counting a bound's conditions, and a range reassignment that never skips. All eight are caught, seven by the new fixture cases, which the unit tests miss, and one by the new unit cases; the two whose lines changed after the sweep were rerun on the final lines. The twenty-ninth only moves code into two functions and drops a copy; the three mutants on the moved lines were rerun at that commit and are caught, as is the one on 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 compile tests/array and tests/cond.
  • A generated sweep types value shapes through a script call: calls with and without a failing argument or a range argument, range literals, range and plain names, strings with range markers, comparisons, &&, || 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.
  • The 30 template blocks gathered from the README, the docs, pluto-lang.dev's pages and playground examples, and the open issues still compile, apart from Require every function output to be definitely assigned #123's own examples of rejected code and two blocks that return a range: A range returned by a call is never iterated: passed to a function, it crashes or miscompiles #143's report, and the Memory Model's old MakeRange example, 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 bare j under 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

🤖 Generated with Claude Code

thiremani and others added 6 commits October 7, 2026 13:00
#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>
thiremani and others added 2 commits October 7, 2026 20:12
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>
@thiremani thiremani changed the title feat(compiler)!: require every function output to be definitely assigned feat(compiler)!: require definite function outputs and build ranges only from literals Oct 7, 2026
thiremani and others added 5 commits October 7, 2026 21:19
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>
thiremani and others added 7 commits October 11, 2026 16:36
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>
@thiremani thiremani changed the title feat(compiler)!: require definite function outputs and build ranges only from literals feat(compiler)!: require definite function outputs and stop functions returning ranges Oct 22, 2026
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>
thiremani and others added 11 commits October 23, 2026 01:33
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>
@thiremani
thiremani merged commit 96b1c2b into master Oct 24, 2026
2 checks passed
@thiremani
thiremani deleted the definite-outputs branch October 24, 2026 12:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment