From 56d2f48ca63d414c0ca000fe4e7415a650a978f6 Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 11 Sep 2026 21:31:01 +0530 Subject: [PATCH 01/56] feat(compiler): make function outputs write-only and inputs iteration-stable Declared outputs can no longer be read inside their template: values, conditions, call arguments, prints, and formatting markers (including dynamic width and precision) are rejected with `output "y" is read inside its function; outputs are write-only, use a local`. A body transforms inputs into outputs and never observes an output's value, so the incoming destination seed can never leak in; the seed and destination-seeded staging slots stay as an unobservable keep-old carrier and the ABI is unchanged. This supersedes the seed-read effect analysis of the closed #102: the reproducer `y = x > 0 x` then `y = y + 1` is now a template error. Range-bearing variants snapshot every non-iterator input at the start of each scalar iteration. A direct scalar aliased to an output reads the carried output once; an indirect input the body reads after an output write keeps a private copy for the iteration. Before, an aliased input read the output's storage on every read, so `out = current + item` then `seen = current` observed the new value: `value, before = Fold(value, 1:3)` printed `13 13` and now prints `13 11`, and a ranged heap-string accumulator prints `abc ab` instead of `ac`. The promoted-alias path is deleted; nothing reaches it once inputs are snapshotted. Fixtures that read an output are rewritten with locals or single expressions. `Rebuild` changes from reading its freshly written output to reading the previous iteration's result (`[26]` -> `[37]`, `[2]` -> `[6]`), and `shareStaticOutput(0)` no longer blanks its second output. The flow-versus-slot call specialization gap found in review is #103. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- compiler/cfg.go | 69 ++++++++++---- compiler/cfg_test.go | 95 ++++++++++++++++++- compiler/codecompiler.go | 21 ++++- compiler/compiler.go | 115 ++++++++--------------- compiler/compiler_test.go | 15 ++- compiler/solver_test.go | 60 ++---------- docs/Pluto Effects and Follow-up Plan.md | 25 ++++- docs/Pluto IR Plan.md | 12 ++- docs/Pluto Memory Model.md | 28 +++++- tests/alias_input/self_alias.exp | 4 + tests/alias_input/self_alias.pt | 20 ++-- tests/alias_input/self_alias.spt | 13 +++ tests/array/array_func.exp | 4 +- tests/array/array_func.pt | 3 +- tests/array/array_scalar_assign.pt | 3 +- tests/math/math.pt | 5 +- tests/math/range.pt | 10 +- tests/mem/mem_str.exp | 2 +- tests/mem/mem_str.pt | 7 +- 20 files changed, 321 insertions(+), 192 deletions(-) diff --git a/README.md b/README.md index 5c3e2964..7da6ea8b 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ y = Square(x) y = x * x ``` -Inputs are read-only — they flow in. Outputs are writable — they flow out. Every function is a transformation. +Inputs are read-only — they flow in. Outputs are write-only inside the template — they flow out; use a local for intermediate values. Every function is a transformation. A caller may reuse a variable as both argument and destination, `a = Square(a)`, which is how an old value reaches the function. ### Generics by use diff --git a/compiler/cfg.go b/compiler/cfg.go index 8e8178d7..c64b0881 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -193,9 +193,14 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { PushScope(&cfg.Scopes, FuncScope) defer PopScope(&cfg.Scopes) + // Outputs are published up front so that a formatting marker naming one + // resolves as a read and is rejected, instead of passing as literal text. for _, param := range fn.Parameters { cfg.publishTarget(param) } + for _, output := range fn.Outputs { + cfg.publishTarget(output) + } parameterNames := make(map[string]struct{}, len(fn.Parameters)) for _, parameter := range fn.Parameters { @@ -207,7 +212,9 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { outputNames[output.Value] = struct{}{} } - _, readInputs, assignedOutputs := cfg.validateTemplateBody(fn.Body.Statements, parameterNames, outputNames) + body := cfg.validateTemplateBody(fn.Body.Statements, parameterNames, outputNames) + cfg.CodeCompiler.lateInputReads[fn] = body.lateInputReads + readInputs, assignedOutputs := body.readInputs, body.assignedOutputs for _, input := range fn.Parameters { if _, wasRead := readInputs[input.Value]; wasRead { @@ -225,35 +232,51 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { } } -// validateTemplateBody runs structural validation over one template body and -// returns each statement's reads plus the parameter and output names the body -// read and assigned. A script is a zero-input, zero-output template: it passes -// nil name sets and consumes only the reads. -func (cfg *CFG) validateTemplateBody(statements []ast.Statement, parameterNames, outputNames map[string]struct{}) ([][]VarEvent, map[string]struct{}, map[string]struct{}) { - statementReads := make([][]VarEvent, 0, len(statements)) - readInputs := make(map[string]struct{}, len(parameterNames)) - assignedOutputs := make(map[string]struct{}, len(outputNames)) +// templateBody is the structural summary of one template body. lateInputReads +// names the parameters read in a statement after the first statement that +// writes an output; reads within that statement precede its writes. +type templateBody struct { + statementReads [][]VarEvent + readInputs map[string]struct{} + assignedOutputs map[string]struct{} + lateInputReads map[string]struct{} +} + +// validateTemplateBody runs structural validation over one template body. A +// script is a zero-input, zero-output template: it passes nil name sets and +// consumes only the reads. +func (cfg *CFG) validateTemplateBody(statements []ast.Statement, parameterNames, outputNames map[string]struct{}) templateBody { + body := templateBody{ + statementReads: make([][]VarEvent, 0, len(statements)), + readInputs: make(map[string]struct{}, len(parameterNames)), + assignedOutputs: make(map[string]struct{}, len(outputNames)), + lateInputReads: make(map[string]struct{}), + } for _, stmt := range statements { reads := cfg.collectStatementReads(stmt) - targets := cfg.validateStatementStructure(stmt, reads, parameterNames) + targets := cfg.validateStatementStructure(stmt, reads, parameterNames, outputNames) if let, ok := stmt.(*ast.LetStatement); ok { cfg.publishTargets(let.Name) } - statementReads = append(statementReads, reads) + body.statementReads = append(body.statementReads, reads) for _, event := range reads { - if _, isParameter := parameterNames[event.Name]; isParameter { - readInputs[event.Name] = struct{}{} + if _, isParameter := parameterNames[event.Name]; !isParameter { + continue + } + body.readInputs[event.Name] = struct{}{} + if len(body.assignedOutputs) > 0 { + body.lateInputReads[event.Name] = struct{}{} } } for _, target := range targets { if _, isOutput := outputNames[target.Value]; isOutput { - assignedOutputs[target.Value] = struct{}{} + body.assignedOutputs[target.Value] = struct{}{} } } } - return statementReads, readInputs, assignedOutputs + return body } // AnalyzeScript treats the script as a zero-input, zero-output template before @@ -278,8 +301,7 @@ func (cfg *CFG) validateScriptTemplate(statements []ast.Statement) [][]VarEvent PushScope(&cfg.Scopes, BlockScope) defer PopScope(&cfg.Scopes) - statementReads, _, _ := cfg.validateTemplateBody(statements, nil, nil) - return statementReads + return cfg.validateTemplateBody(statements, nil, nil).statementReads } // AnalyzeSpecialization runs only typed dataflow. Structural diagnostics were @@ -334,9 +356,9 @@ func (cfg *CFG) processTypedStatement(stmt ast.Statement, reads []VarEvent, effe // validateStatementStructure reports template-stable read and write errors and // returns named targets for caller-specific bookkeeping. The caller publishes // them only after all statement reads have been checked. -func (cfg *CFG) validateStatementStructure(stmt ast.Statement, reads []VarEvent, parameters map[string]struct{}) []*ast.Identifier { +func (cfg *CFG) validateStatementStructure(stmt ast.Statement, reads []VarEvent, parameters, outputs map[string]struct{}) []*ast.Identifier { for _, event := range reads { - cfg.validateStructuralRead(event) + cfg.validateStructuralRead(event, outputs) } let, ok := stmt.(*ast.LetStatement) @@ -445,7 +467,14 @@ func (cfg *CFG) backwardPass(live map[string]struct{}) { } } -func (cfg *CFG) validateStructuralRead(event VarEvent) { +// validateStructuralRead enforces that a declared output is write-only inside +// its template: a body transforms inputs into outputs and never observes an +// output's value, so the incoming destination seed can never leak in. +func (cfg *CFG) validateStructuralRead(event VarEvent, outputs map[string]struct{}) { + if _, isOutput := outputs[event.Name]; isOutput { + cfg.addError(event.Token, fmt.Sprintf("output %q is read inside its function; outputs are write-only, use a local", event.Name)) + return + } if !cfg.isDefined(event.Name) { cfg.addError(event.Token, fmt.Sprintf("variable %q has not been defined", event.Name)) } diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index bafab129..74deae48 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -142,6 +142,28 @@ func getValidTestCases() []cfgTestCase { name: "Failable Value Protects Only Its Own Destination", input: "x = 7\na = 10\na, b = x < 5, 30\na, b", }, + { + // Writing an output twice never reads it, and a call may target it. + name: "Output Rewritten And Targeted By Nested Call", + code: `res = maybe(x) + res = x > 0 x + +res = refine(x) + res = x + res = x > 5 x * x + res = maybe(x)`, + input: "x = refine(3)\nx", + }, + { + // Intermediate values live in locals; the caller may still reuse a + // variable as both argument and destination. + name: "Local Accumulator Feeds Output", + code: `res = accumulate(a, x) + total = a + x + total = total * 2 + res = total`, + input: "x = 7\nx = accumulate(x, 3)\nx", + }, } } @@ -236,6 +258,77 @@ func getErrorTestCases() []cfgTestCase { input: `"x is", x`, errorContains: `undefined identifier: x`, }, + { + // The seed-dependent body from the effects plan is rejected at the + // read, not silently resolved at the caller. + name: "Output Read After Conditional Write", + code: `res = maybeIncrement(x) + res = x > 0 x + res = res + 1`, + input: "x = maybeIncrement(-1)\nx", + errorContains: `output "res" is read inside its function; outputs are write-only, use a local`, + }, + { + name: "Output Read After Definite Write", + code: `res = overwrite(x) + res = x + res = res + 1`, + input: "x = overwrite(3)\nx", + errorContains: `output "res" is read inside its function; outputs are write-only, use a local`, + }, + { + name: "Output Read In Condition", + code: `res = gated(x) + res = x + res = res > 5 x * x`, + input: "x = gated(3)\nx", + errorContains: `output "res" is read inside its function; outputs are write-only, use a local`, + }, + { + name: "Output Read As Call Argument", + code: `res = id(x) + res = x + +res = forwarded(x) + res = x + res = id(res)`, + input: "x = forwarded(3)\nx", + errorContains: `output "res" is read inside its function; outputs are write-only, use a local`, + }, + { + name: "Output Read By Print", + code: `res = printed(x) + res = x + res`, + input: "x = printed(3)\nx", + errorContains: `output "res" is read inside its function; outputs are write-only, use a local`, + }, + { + // A marker naming an output is a read even before any assignment, + // where it would otherwise pass as literal text. + name: "Output Read By Format Marker", + code: `res = marked(x) + "seed -res" + res = x`, + input: "x = marked(3)\nx", + errorContains: `output "res" is read inside its function; outputs are write-only, use a local`, + }, + { + name: "Output Read By Dynamic Width", + code: `res = widened(x) + res = x + "-x%(-res)d"`, + input: "x = widened(3)\nx", + errorContains: `output "res" is read inside its function; outputs are write-only, use a local`, + }, + { + name: "Sibling Output Read", + code: `a, b = cross(x) + a = x + b = a + 1`, + input: "p, q = cross(3)\np, q", + errorContains: `output "a" is read inside its function; outputs are write-only, use a local`, + }, { name: "Unresolved Dynamic Specifier", input: `x = 42 @@ -764,7 +857,7 @@ res = readFirst(x) res = x * 2 `, wantMsgs: []string{ - `variable "res" has not been defined`, // or your specific "use before definition" text + `output "res" is read inside its function; outputs are write-only, use a local`, }, }, { diff --git a/compiler/codecompiler.go b/compiler/codecompiler.go index cd859dec..2aa7a277 100644 --- a/compiler/codecompiler.go +++ b/compiler/codecompiler.go @@ -13,6 +13,11 @@ type CodeCompiler struct { Code *ast.Code globalBindings map[string]token.Token funcTemplates map[funcKey]*ast.FuncStatement + // lateInputReads records, per template, the parameters read after an + // output has been written. Lowering snapshots those inputs per iteration + // of a range-bearing variant, since an aliased input would otherwise + // observe the output's new value. + lateInputReads map[*ast.FuncStatement]map[string]struct{} } type funcKey struct { @@ -23,12 +28,24 @@ type funcKey struct { func NewCodeCompiler(ctx llvm.Context, modName, relPath string, code *ast.Code) *CodeCompiler { mangledPath := MangleDirPath(modName, relPath) cc := &CodeCompiler{ - Compiler: NewCompiler(ctx, mangledPath, nil), - Code: code, + Compiler: NewCompiler(ctx, mangledPath, nil), + Code: code, + lateInputReads: make(map[*ast.FuncStatement]map[string]struct{}), } return cc } +// lateInputReadsFor returns the parameters a template reads after writing an +// output. AnalyzeFuncs records the fact for every template before any script +// lowers a call, so a missing entry is an ICE. +func (cc *CodeCompiler) lateInputReadsFor(template *ast.FuncStatement) map[string]struct{} { + late, recorded := cc.lateInputReads[template] + if !recorded { + panic(fmt.Sprintf("internal: template %s was lowered before structural analysis", template.Token.Literal)) + } + return late +} + func (cc *CodeCompiler) registerGlobalBinding(name string, tok token.Token) *token.CompileError { previous, exists := cc.globalBindings[name] if !exists { diff --git a/compiler/compiler.go b/compiler/compiler.go index c0d19ebc..f97b9d42 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -69,6 +69,9 @@ type Symbol struct { type FuncArgs struct { Inputs []*Symbol // lowered function inputs (range iterators remain pointer-backed) IterIndices []int // Indices of iterator params + // LateInputs names parameters read after an output write; each scalar + // iteration reads a snapshot taken at its start. + LateInputs map[string]struct{} } type callArg struct { @@ -234,9 +237,9 @@ func identNames(idents []*ast.Identifier) []string { } // bindParamAlias records the output names eagerly, but the outputs themselves -// are resolved lazily from scope when the param is later read or promoted. -// This allows direct outputs to remain values, be replaced in scope, or be -// promoted to slots without invalidating the alias metadata. +// are resolved from scope when an iteration snapshots the param +// (snapshotIterationInputs). This allows direct outputs to remain values or +// be replaced in scope without invalidating the alias metadata. func (c *Compiler) bindParamAlias(name string, sym *Symbol, aliasIndex llvm.Value, outputNames []string) { c.currentParamAliases()[name] = ¶mAlias{ Base: sym, @@ -245,10 +248,6 @@ func (c *Compiler) bindParamAlias(name string, sym *Symbol, aliasIndex llvm.Valu } } -func (c *Compiler) clearParamAlias(name string) { - delete(c.currentParamAliases(), name) -} - func (c *Compiler) paramAliasFor(name string, sym *Symbol) (*paramAlias, bool) { alias, ok := c.currentParamAliases()[name] if !ok || alias.Base != sym { @@ -369,23 +368,6 @@ func (c *Compiler) directReturnSeedForCall(outType Type, dest *ast.Identifier, o return c.makeZeroValue(outType) } -func (c *Compiler) selectAliasedParamPtr(name string, spill llvm.Value, aliasIndex llvm.Value, outputs []*Symbol) llvm.Value { - slotPtr := spill - for i, output := range outputs { - if output == nil { - continue - } - match := c.builder.CreateICmp( - llvm.IntEQ, - aliasIndex, - llvm.ConstInt(c.Context.Int32Type(), uint64(i+1), false), - fmt.Sprintf("%s_alias_%d", name, i), - ) - slotPtr = c.builder.CreateSelect(match, output.Val, slotPtr, fmt.Sprintf("%s_slot_%d", name, i)) - } - return slotPtr -} - func (c *Compiler) localValSymbol(name string, loadName string) (*Symbol, bool) { s, ok := Get(c.Scopes, name) if !ok { @@ -1635,10 +1617,6 @@ func (c *Compiler) promoteToMemory(name string) *Symbol { panic("Compiler error: trying to promote to memory an undefined variable: " + name) } - if alias, ok := c.paramAliasFor(name, sym); ok { - return c.promoteAlias(name, sym, alias) - } - ptr, alreadyPtr := c.makePtr(name, sym) if alreadyPtr { return ptr @@ -1650,43 +1628,6 @@ func (c *Compiler) promoteToMemory(name string) *Symbol { return ptr } -func (c *Compiler) promoteAlias(name string, sym *Symbol, alias *paramAlias) *Symbol { - paramPtr := c.createEntryBlockAlloca(c.mapToLLVMType(sym.Type), name) - c.createStore(sym.Val, paramPtr, sym.Type) - - slotPtr := paramPtr - if len(alias.OutputNames) > 0 { - outputPtrs := make([]*Symbol, len(alias.OutputNames)) - for i, outputName := range alias.OutputNames { - outputSym, _ := Get(c.Scopes, outputName) - // Left nil when the output cannot back this slot, so the selector - // keeps its positional meaning but never picks a mistyped pointer. - if !aliasableOutput(sym.Type, outputSym.Type) { - continue - } - if outputSym.Type.Kind() != PtrKind { - // Only params carry alias bindings, so promoting an output here - // cannot recurse through another param-alias entry. - outputSym = c.promoteToMemory(outputName) - } - outputPtrs[i] = outputSym - } - slotPtr = c.selectAliasedParamPtr(name, paramPtr, alias.AliasIndex, outputPtrs) - } - - ptr := &Symbol{ - Val: slotPtr, - Type: Ptr{Elem: sym.Type}, - FuncArg: sym.FuncArg, - Borrowed: sym.Borrowed, - ReadOnly: sym.ReadOnly, - WriteFlag: sym.WriteFlag, - } - Put(c.Scopes, name, ptr) - c.clearParamAlias(name) - return ptr -} - // createStore is a simple helper that creates an LLVM store instruction and sets its alignment. // It has NO side effects on the Go compiler state or symbols. // the val is the value to be stored and the ptr is the memory location it is to be stored to @@ -2757,6 +2698,7 @@ func (c *Compiler) compileFuncIter(template *ast.FuncStatement, inputs []*Symbol fa := &FuncArgs{ Inputs: inputs, IterIndices: iterIndices, + LateInputs: c.CodeCompiler.lateInputReadsFor(template), } return c.funcLoopNest(template, fa, 0, currentOutput) } @@ -2909,11 +2851,18 @@ func (c *Compiler) funcLoopNest(fn *ast.FuncStatement, fa *FuncArgs, level int, if level == len(fa.IterIndices) { PushScope(&c.Scopes, BlockScope) defer c.popScope() + // Direct-return ABI is single-output today, so the loop body only + // needs the current scalar output binding for fn.Outputs[0]. + if currentOutput != nil { + Put(c.Scopes, fn.Outputs[0].Value, currentOutput) + } + c.snapshotIterationInputs(fn, fa) + c.compileFuncBody(fn) if currentOutput == nil { - c.compileFuncBody(fn) return nil } - return c.compileDirectOutputIterBody(fn, currentOutput) + output, _ := c.localValSymbol(fn.Outputs[0].Value, fn.Outputs[0].Value+"_iter_out") + return output } paramIdx := fa.IterIndices[level] @@ -2960,14 +2909,32 @@ func (c *Compiler) funcLoopNest(fn *ast.FuncStatement, fa *FuncArgs, level int, return result } -func (c *Compiler) compileDirectOutputIterBody(fn *ast.FuncStatement, currentOutput *Symbol) *Symbol { - // Direct-return ABI is single-output today, so the loop body only needs the - // current scalar output binding for fn.Outputs[0]. - Put(c.Scopes, fn.Outputs[0].Value, currentOutput) - c.compileFuncBody(fn) +// snapshotIterationInputs fixes each non-iterator input for one scalar +// iteration. An input that aliases an output shares its storage, so a read +// after the output's write would observe the new value. A direct scalar reads +// the carried output once here; an indirect input read after an output write +// keeps a private copy of its value for the iteration, freed with the scope. +func (c *Compiler) snapshotIterationInputs(fn *ast.FuncStatement, fa *FuncArgs) { + for i, param := range fn.Parameters { + if slices.Contains(fa.IterIndices, i) { + continue + } + + name := param.Value + sym, _ := Get(c.Scopes, name) + if alias, aliased := c.paramAliasFor(name, sym); aliased { + Put(c.Scopes, name, c.directParamValue(name, sym, alias)) + continue + } + if _, late := fa.LateInputs[name]; !late || sym.Type.Kind() != PtrKind { + continue + } - output, _ := c.localValSymbol(fn.Outputs[0].Value, fn.Outputs[0].Value+"_iter_out") - return output + snapshot := c.deepCopyIfNeeded(c.derefIfPointer(sym, name+"_iter_input")) + snapshot.FuncArg = true + snapshot.ReadOnly = true + Put(c.Scopes, name, snapshot) + } } func (c *Compiler) compileFuncBody(fn *ast.FuncStatement) { diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 5127824b..38a9de98 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -435,23 +435,22 @@ out = Echo(value) // slot by pointer. Opaque pointers make a mistyped pointer select valid IR and // the selector never matches the skipped index at runtime, so only the emitted // slot selects distinguish this path. -func TestPromotedAliasTypeGap(t *testing.T) { +func TestIterationSnapshotSelectsCompatibleOutput(t *testing.T) { code := `half, res = Rev(a, x) - "count-a%n chars" half = x * 0.5 res = a + x` script := `r = 10 h, r = Rev(r, 1:4) h, r` - ir, _ := compileScriptAndCodeIR(t, "pointer_promotion_gap", code, script) + ir, _ := compileScriptAndCodeIR(t, "iteration_snapshot_gap", code, script) - require.Regexp(t, `%a_alias_1 = icmp eq i32 %\d+, 2`, ir, + require.Regexp(t, `%a_alias_match_1 = icmp eq i32 %\d+, 2`, ir, "the compatible output is the second one, so its ABI selector value must be 2") - require.Contains(t, ir, "%a_slot_1 = select i1 %a_alias_1, ptr %res_dest, ptr %a", - "selector 2 must choose the caller's res destination, falling back to the parameter spill") - require.NotContains(t, ir, "%a_slot_0 = select", - "the mismatched leading output must never be selectable as the parameter's slot") + require.Regexp(t, `%a_alias_value_1 = select i1 %a_alias_match_1, i64 %res_alias_load_1, i64 %\d+`, ir, + "selector 2 must read the caller's res destination once per iteration, falling back to the parameter") + require.NotContains(t, ir, "%a_alias_match_0", + "the mismatched leading output must never be selectable as the parameter's value") } func TestRangeCollectorScalarVariant(t *testing.T) { diff --git a/compiler/solver_test.go b/compiler/solver_test.go index aea3b290..d01dda45 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -1615,9 +1615,10 @@ func TestSpecializationTraceCapsIndividualFrames(t *testing.T) { const fixedRankRecursionSource = `res = FixedRank(x) "-x" - res = 0 + total = 0 nested = FixedRank([[1]]) - res = res + nested + total = total + nested + res = total ` func TestRecursiveGrowthReachesFixedClosure(t *testing.T) { @@ -1674,14 +1675,16 @@ func TestRecursiveLimitCountsColdDiscovery(t *testing.T) { func TestFinitePolymorphicRecursionIsAccepted(t *testing.T) { code := mustParseCode(t, `res = Outer(x) - res = 0 + total = 0 inner = Inner([x]) - res = res + inner + total = total + inner + res = total res = Inner(xs) - res = 0 + total = 0 outer = Outer(xs[0]) - res = res + outer + total = total + outer + res = total `) ctx := llvm.NewContext() @@ -2129,51 +2132,6 @@ res = Relay(k) } } -// Consume precedes Root's StrH refinement and must be remangled on the stable sweep. -func TestRefinedOutputSeedsStableBody(t *testing.T) { - code := mustParseCode(t, `res = Root(k) - res = "lit" - tmp = Consume(res) - "-tmp" - res = k > 0 Relay(k) - -res = Relay(k) - res = Root(k - 1) ⊕ "x" - -res = Consume(x) - res = x -`) - ctx := llvm.NewContext() - defer ctx.Dispose() - cc := NewCodeCompiler(ctx, "seedRefinedOutput", "", code) - require.Empty(t, cc.Compile()) - - sl := lexer.New("TestSeedRefinedOutputScript", "v = Root(3)\nv") - sp := parser.NewScriptParser(sl) - program := sp.Parse() - require.Empty(t, sp.Errors()) - - sc := NewScriptCompiler(ctx, t.Name(), program, cc) - ts := NewTypeSolver(sc) - ts.Solve() - - require.Empty(t, ts.Errors) - heapConsumer := cc.Compiler.FuncCache[Mangle(cc.Compiler.MangledPath, "Consume", []Type{StrH{}})] - require.NotNil(t, heapConsumer, "the stable body sweep must remangle Consume with Root's StrH output slot") - require.True(t, heapConsumer.AllTypesInferred()) - - root := code.Statements[0].(*ast.FuncStatement) - consumeStmt := root.Body.Statements[1].(*ast.LetStatement) - consumeCall := consumeStmt.Value[0].(*ast.CallExpression) - rootMangled := Mangle(cc.Compiler.MangledPath, "Root", []Type{I64}) - callInfo := ts.ExprCache[key(rootMangled, consumeCall)] - require.NotNil(t, callInfo) - require.Len(t, callInfo.CallParamTypes, 1) - require.Len(t, callInfo.ScalarCallParamTypes, 1) - require.True(t, TypeEqual(StrH{}, callInfo.CallParamTypes[0]), "final call metadata must use the output slot's StrH storage type") - require.True(t, TypeEqual(StrH{}, callInfo.ScalarCallParamTypes[0]), "final scalar-call metadata must use the output slot's StrH storage type") -} - func TestFunctionOutputTableJoinMatchesStorage(t *testing.T) { code := mustParseCode(t, `res = RefineTable(k) "-k" diff --git a/docs/Pluto Effects and Follow-up Plan.md b/docs/Pluto Effects and Follow-up Plan.md index c4a7070b..1197b7a9 100644 --- a/docs/Pluto Effects and Follow-up Plan.md +++ b/docs/Pluto Effects and Follow-up Plan.md @@ -19,9 +19,26 @@ type, and stored type separately, as the corrected code comment already does. ## 1. Next compiler PR: seed dependency analysis -Preserve the existing seeded-output semantics and public ABI. Correct the -analysis before deciding whether a later language version should change those -semantics. +Resolved by a language rule instead of an analysis (branch +`feat-write-only-outputs`, superseding the closed +[PR #102](https://github.com/thiremani/pluto/pull/102)): declared outputs are +write-only inside their template, so a body can never observe its incoming +seed and the reproducer below is rejected at `y = y + 1`. The hidden seed and +destination-seeded staging slots stay as an unobservable keep-old carrier and +the public ABI is unchanged. Range-bearing variants additionally snapshot each +non-iterator input at the start of every scalar iteration, so an input +aliased to a destination reads the previous iteration's output rather than the +current iteration's write. The canonical description is in +[the memory model](./Pluto%20Memory%20Model.md) under "Parameters and Outputs". + +Still open from the same review: a call argument is specialized on the +binding's flow type at the call, while its storage uses the merged slot type. +`s = "a"` followed by `s, prev = FoldStr(s, "b")`, where `FoldStr` writes +`out = current ⊕ item` and `seen = current`, prints an empty `prev`, and a +static destination used as a ranged accumulator does not feed back across +flavors. Both need the callee specialized on the destination's slot type. + +The original analysis plan is kept below for the record. ### Confirmed failure @@ -198,7 +215,7 @@ and [ABI stability plan](./Pluto%20ABI%20Optimization%20Plan.md). | Work | Completion criterion / existing reference | | --- | --- | -| Seed/effect correctness | Section 1; next compiler PR before broadening call routing | +| Seed/effect correctness | Section 1; resolved by the write-only-outputs rule on `feat-write-only-outputs`; flow-versus-slot call specialization still open | | `%n` effect contract | Section 2; separate bounded change with formatting semantics updated | | Output path protection | [Issue #80](https://github.com/thiremani/pluto/issues/80): compilation cannot overwrite source/configuration through name collisions or unsafe path resolution | | Numeric edge behavior | Define and guard integer divide/remainder faults and invalid shift counts; audit range/count/allocation arithmetic | diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index c5f35aff..d9f6186e 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -850,10 +850,14 @@ Boundary resolution implies an **implicit read of the destination seed**, and only where the dependency is real: after a successful invocation, at an *existing* target whose direct callee output is `MayWrite`, resolved at `=`. A fresh destination, a discard, a nested or targetless call, or an -all-`MustWrite` callee reads nothing. Step 2A records this as a `ReadsSeed` -fact on the call site — the CFG is untouched in 2A — and Step 2B converts the -fact into an ordinary CFG read event, so a `MustWrite` classification cannot -let backward liveness kill the prior value. +all-`MustWrite` callee reads nothing. That last case holds by construction: +declared outputs are write-only inside their template (the structural CFG +rejects every read, including formatting markers), so a body can never observe +its incoming seed and the seed stays an unobservable keep-old carrier. Step 2A +records boundary resolution as a `ReadsSeed` fact on the call site — the CFG +is untouched in 2A — and Step 2B converts the fact into an ordinary CFG read +event, so a `MustWrite` classification cannot let backward liveness kill the +prior value. The validity-carrying result comes from a **private direct-call variant** behind the stable seeded entry point (§1). The clone **keeps the seed diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index 4c267e46..62275907 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -257,11 +257,19 @@ res = sum(a, b) res = a + b ``` -- **Parameters**: Input values (passed by value for scalars) -- **Outputs**: Independently staged result slots. An existing destination - supplies the initial value, while a fresh destination starts at its type's - zero value. The real destinations are committed only after every sibling - right-hand side has been evaluated. +- **Parameters**: Input values (passed by value for scalars). Inside the + body an input is fixed: a range-bearing variant captures every non-iterator + input at the start of each scalar iteration, so an input that the caller + aliases to a destination never observes that output's write mid-iteration. +- **Outputs**: Write-only inside their template. A body may assign an output + any number of times, conditionally or not, and a nested call may target it, + but reading it anywhere — a value, a condition, a call argument, a print, or + a formatting marker — is a compile error. Intermediate values live in + locals. Outputs are independently staged result slots: an existing + destination supplies the initial value and a fresh destination starts at + its type's zero value, so a body that writes nothing preserves the caller's + value without ever seeing it. The real destinations are committed only + after every sibling right-hand side has been evaluated. - **No name overlap**: Parameters and outputs must have distinct names When a caller destination and a function's declared output use different @@ -283,6 +291,16 @@ res = sum(res, 5) # - Result commits back to the caller's res after sibling RHS evaluation ``` +Reusing a variable as both an argument and a destination is how a caller +feeds an old value into a transformation. The template itself sees only its +declared inputs; `res = res + 1` inside `sum` would be rejected. + +With a range, the same reuse is an accumulation: `sum = Acc(sum, 1:5)` runs +the body once per yield, and the input that aliases the destination receives +the previous iteration's output at the start of the next iteration. Within an +iteration that input is stable. An empty range leaves an existing destination +unchanged and a fresh destination at its zero value. + ### Range Parameters ```python diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index 71afb345..f3a00101 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -1,3 +1,7 @@ 15 hi!hi [1 2 9 1 2] +15 10 +13 11 +10 0 +abc ab diff --git a/tests/alias_input/self_alias.pt b/tests/alias_input/self_alias.pt index 891a8b64..7594e7e6 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -1,11 +1,19 @@ y = Twice(x) - y = x * 2 - y = y + x + y = x * 2 + x s = Shout(t) - s = t ⊕ "!" - s = s ⊕ t + s = t ⊕ "!" ⊕ t r = Grow(q) - r = q ⊕ [9] - r = r ⊕ q + r = q ⊕ [9] ⊕ q + +# The input is read after the output is written. Without a range, the whole +# call sees the pre-call value; with a range, each iteration sees the value +# it started with, and the next iteration receives the written output. +out, seen = Fold(current, item) + out = current + item + seen = current + +out, seen = FoldStr(current, item) + out = current ⊕ item + seen = current diff --git a/tests/alias_input/self_alias.spt b/tests/alias_input/self_alias.spt index 9c98ec69..d26427e4 100644 --- a/tests/alias_input/self_alias.spt +++ b/tests/alias_input/self_alias.spt @@ -8,3 +8,16 @@ w v = [1 2] v = Grow(v) v +single = 10 +single, was = Fold(single, 5) +single, was +value = 10 +value, before = Fold(value, 1:3) +value, before +empty = 10 +empty, never = Fold(empty, 0:0) +empty, never +items = ["b" "c"] +text = "a" ⊕ "" +text, last = FoldStr(text, items[0:2]) +text, last diff --git a/tests/array/array_func.exp b/tests/array/array_func.exp index 9e6b2292..f8d2f09f 100644 --- a/tests/array/array_func.exp +++ b/tests/array/array_func.exp @@ -82,9 +82,9 @@ SquareVecRange: [0 1 4 9 16] SquareInline: [0 1 4 9 16] SquareSameDriver: [4 5 8 13 20] NestedCallCollector: [5 6 9 14 21] -RebuildAssign: [26] +RebuildAssign: [37] RebuildNoAlias: [14] -RebuildAssign2: [2] +RebuildAssign2: [6] RebuildNoAlias2: [6] PairSumRange: [3 4 4 5] ArrayRangeLastNamedRow: [3 4] diff --git a/tests/array/array_func.pt b/tests/array/array_func.pt index 1b075a48..c968d9af 100644 --- a/tests/array/array_func.pt +++ b/tests/array/array_func.pt @@ -10,8 +10,7 @@ res = BiasAndScale(x, bias, scale) res = tmp * scale res = Rebuild(vec, i) - res = [i + 1] - res = res + vec + res = [i + 1] + vec res = PairSum(i, j) res = i + j diff --git a/tests/array/array_scalar_assign.pt b/tests/array/array_scalar_assign.pt index 38a3f494..aa1e95ce 100644 --- a/tests/array/array_scalar_assign.pt +++ b/tests/array/array_scalar_assign.pt @@ -5,5 +5,4 @@ res = ArrayScalarAdd(i) res = [0:6] + i res = ArraySetAdd(i) - res = [0:i] - res = res + 4 + res = [0:i] + 4 diff --git a/tests/math/math.pt b/tests/math/math.pt index f9166b25..fcbff42e 100644 --- a/tests/math/math.pt +++ b/tests/math/math.pt @@ -21,8 +21,9 @@ quo, rem = Div(dividend, divisor) rem = dividend % divisor mod, res = IsDiv(x, y) - mod, res = x % y, "no" - res = mod == 0 "yes" + remainder = x % y + mod, res = remainder, "no" + res = remainder == 0 "yes" x, y = F(i) x, y = 2 + i, 3 + i diff --git a/tests/math/range.pt b/tests/math/range.pt index 9c06c576..0c986055 100644 --- a/tests/math/range.pt +++ b/tests/math/range.pt @@ -1,10 +1,12 @@ y = Triple(x) - y = 3x - y + tripled = 3x + tripled + y = tripled res = Sum(curr, x) - res = curr + x - res + total = curr + x + total + res = total yes = Divides(in, y, x) yes = in * (y % x) diff --git a/tests/mem/mem_str.exp b/tests/mem/mem_str.exp index 1627afa8..4095782c 100644 --- a/tests/mem/mem_str.exp +++ b/tests/mem/mem_str.exp @@ -28,7 +28,7 @@ keep_heap CallStaticSeedKeep: keep_heap CallStaticSeedWrite: static changed CallStaticShareBefore: < shared_left > < shared_right > -CallStaticShareSkipped: < shared_left > < > +CallStaticShareSkipped: < shared_left > < shared_right > CallStaticShareWritten: < shared static > < shared static > slot plain diff --git a/tests/mem/mem_str.pt b/tests/mem/mem_str.pt index 59d20312..934b7eb2 100644 --- a/tests/mem/mem_str.pt +++ b/tests/mem/mem_str.pt @@ -15,10 +15,11 @@ s = getStaticAt(x) s = maybeStatic(flag) s = flag > 0 "static changed" -# Propagate one conditionally written static output into another output. +# Write one conditionally chosen static value into two outputs. s, t = shareStaticOutput(flag) - s = flag > 0 "shared static" - t = s + shared = flag > 0 "shared static" + s = flag > 0 shared + t = flag > 0 shared # Mixed indirect outputs exercise one widened string slot and one exact scalar slot. s, n = getStaticPair(x) From 7387308a7a7a18add0c5ac836e6a576ac9f3d1d0 Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 11 Sep 2026 21:31:22 +0530 Subject: [PATCH 02/56] docs(effects): record PR #104 and issue #103 in the seed disposition Co-Authored-By: Claude Fable 5.1 --- docs/Pluto Effects and Follow-up Plan.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/Pluto Effects and Follow-up Plan.md b/docs/Pluto Effects and Follow-up Plan.md index 1197b7a9..600016e1 100644 --- a/docs/Pluto Effects and Follow-up Plan.md +++ b/docs/Pluto Effects and Follow-up Plan.md @@ -19,8 +19,8 @@ type, and stored type separately, as the corrected code comment already does. ## 1. Next compiler PR: seed dependency analysis -Resolved by a language rule instead of an analysis (branch -`feat-write-only-outputs`, superseding the closed +Resolved by a language rule instead of an analysis +([PR #104](https://github.com/thiremani/pluto/pull/104), superseding the closed [PR #102](https://github.com/thiremani/pluto/pull/102)): declared outputs are write-only inside their template, so a body can never observe its incoming seed and the reproducer below is rejected at `y = y + 1`. The hidden seed and @@ -31,8 +31,10 @@ aliased to a destination reads the previous iteration's output rather than the current iteration's write. The canonical description is in [the memory model](./Pluto%20Memory%20Model.md) under "Parameters and Outputs". -Still open from the same review: a call argument is specialized on the -binding's flow type at the call, while its storage uses the merged slot type. +Still open from the same review, filed as +[issue #103](https://github.com/thiremani/pluto/issues/103): a call argument is +specialized on the binding's flow type at the call, while its storage uses the +merged slot type. `s = "a"` followed by `s, prev = FoldStr(s, "b")`, where `FoldStr` writes `out = current ⊕ item` and `seen = current`, prints an empty `prev`, and a static destination used as a ranged accumulator does not feed back across @@ -215,7 +217,7 @@ and [ABI stability plan](./Pluto%20ABI%20Optimization%20Plan.md). | Work | Completion criterion / existing reference | | --- | --- | -| Seed/effect correctness | Section 1; resolved by the write-only-outputs rule on `feat-write-only-outputs`; flow-versus-slot call specialization still open | +| Seed/effect correctness | Section 1; resolved by the write-only-outputs rule in [PR #104](https://github.com/thiremani/pluto/pull/104); flow-versus-slot call specialization is [#103](https://github.com/thiremani/pluto/issues/103) | | `%n` effect contract | Section 2; separate bounded change with formatting semantics updated | | Output path protection | [Issue #80](https://github.com/thiremani/pluto/issues/80): compilation cannot overwrite source/configuration through name collisions or unsafe path resolution | | Numeric edge behavior | Define and guard integer divide/remainder faults and invalid shift counts; audit range/count/allocation arithmetic | From 1eac2fea22d8936de12f7b9aac2f6217dfdd9adb Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 11 Sep 2026 22:17:57 +0530 Subject: [PATCH 03/56] perf(compiler): snapshot only inputs an output could alias snapshotIterationInputs copied every indirect input read after an output write, even one no output could share storage with, so `count, value = Read(data, 0:100000)` with integer outputs copied and freed the whole array on every iteration: 2.2s for the probe against milliseconds on master. The caller aliases an input to an output only when the two types lower identically (setCallArgAliasSelectors), so mirror that check before allocating a snapshot. The probe is back under the timer's resolution and its IR carries no arr_i64_copy; a heap-string input read after a heap-string output write still copies per iteration. Co-Authored-By: Claude Fable 5.1 --- compiler/compiler.go | 20 +++++++++++++++++++- compiler/compiler_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/compiler/compiler.go b/compiler/compiler.go index f97b9d42..13939b5a 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -2913,7 +2913,9 @@ func (c *Compiler) funcLoopNest(fn *ast.FuncStatement, fa *FuncArgs, level int, // iteration. An input that aliases an output shares its storage, so a read // after the output's write would observe the new value. A direct scalar reads // the carried output once here; an indirect input read after an output write -// keeps a private copy of its value for the iteration, freed with the scope. +// keeps a private copy of its value for the iteration, freed with the scope, +// but only when some output could back it: the caller aliases identical +// storage types only, so any other input never shares output storage. func (c *Compiler) snapshotIterationInputs(fn *ast.FuncStatement, fa *FuncArgs) { for i, param := range fn.Parameters { if slices.Contains(fa.IterIndices, i) { @@ -2929,6 +2931,9 @@ func (c *Compiler) snapshotIterationInputs(fn *ast.FuncStatement, fa *FuncArgs) if _, late := fa.LateInputs[name]; !late || sym.Type.Kind() != PtrKind { continue } + if !c.inputCanAliasOutput(fn, sym.Type.(Ptr).Elem) { + continue + } snapshot := c.deepCopyIfNeeded(c.derefIfPointer(sym, name+"_iter_input")) snapshot.FuncArg = true @@ -2937,6 +2942,19 @@ func (c *Compiler) snapshotIterationInputs(fn *ast.FuncStatement, fa *FuncArgs) } } +// inputCanAliasOutput mirrors setCallArgAliasSelectors: a caller passes an +// output's staged storage as an input only when the two types lower +// identically. +func (c *Compiler) inputCanAliasOutput(fn *ast.FuncStatement, paramType Type) bool { + for _, output := range fn.Outputs { + outputSym, _ := Get(c.Scopes, output.Value) + if aliasableOutput(paramType, outputSym.Type) { + return true + } + } + return false +} + func (c *Compiler) compileFuncBody(fn *ast.FuncStatement) { for _, stmt := range fn.Body.Statements { c.compileStatement(stmt) diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 38a9de98..4f295b58 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -453,6 +453,41 @@ h, r` "the mismatched leading output must never be selectable as the parameter's value") } +func TestIterationSnapshotSkipsInputsNoOutputCanAlias(t *testing.T) { + // Both outputs are integers, so writing them can never change the array + // input even though it is read after the first output write. Copying it + // per iteration would make the call quadratic. + code := `count, value = Read(data, index) + count = index + value = data[index]` + script := `data = [0:8] +count, value = Read(data, 0:8) +count, value` + + ir, _ := compileScriptAndCodeIR(t, "iteration_snapshot_skip", code, script) + + require.NotContains(t, ir, "@arr_i64_copy", + "an input no output can alias must not be copied per iteration") +} + +func TestIterationSnapshotCopiesAliasableInputReadAfterWrite(t *testing.T) { + // The heap-string input can back the heap-string output, and the body + // reads it after writing that output, so each iteration works on a copy. + code := `out, seen = FoldStr(current, item) + out = current ⊕ item + seen = current` + script := `items = ["b" "c"] +text = "a" ⊕ "" +text, last = FoldStr(text, items[0:2]) +text, last` + + ir, _ := compileScriptAndCodeIR(t, "iteration_snapshot_copy", code, script) + + require.Contains(t, ir, "%current_iter_input", "the aliased input is loaded once per iteration") + require.Regexp(t, `%str_copy\d* = call ptr @\w+\(ptr %current_iter_input\)`, ir, + "the snapshot copies the loaded input before the body runs") +} + func TestRangeCollectorScalarVariant(t *testing.T) { code := `res = Scale(x) res = x * 3` From 19dcfe36f08770c3575b1991a9d0b76b08f958e4 Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 11 Sep 2026 23:54:51 +0530 Subject: [PATCH 04/56] refactor(compiler): snapshot every aliasable input, drop lateInputReads The structural CFG recorded which inputs a template reads after its first output write so lowering could skip the per-iteration copy for inputs read only before it. Measurement shows ranged string accumulation is quadratic with or without that copy, because today's lowering allocates a fresh string every iteration; the amortized in-place append is planned, not implemented. The fact therefore bought about a 2x constant on an already-quadratic path (0.99s to 1.97s at 160000 appends) while making correctness depend on a read-ordering analysis being complete. Remove the fact and its plumbing from the CFG, CodeCompiler, and FuncArgs. An indirect input that some output could back is now copied at the start of every scalar iteration; direct scalars still re-read the carried output once. Inputs no output can alias are still left in place. Linear accumulation will come from consuming the input on its last use when carried appends land. Co-Authored-By: Claude Fable 5.1 --- compiler/cfg.go | 15 +++------------ compiler/codecompiler.go | 21 ++------------------- compiler/compiler.go | 17 +++++------------ compiler/compiler_test.go | 6 +++--- 4 files changed, 13 insertions(+), 46 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index c64b0881..8ccc62e3 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -213,7 +213,6 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { } body := cfg.validateTemplateBody(fn.Body.Statements, parameterNames, outputNames) - cfg.CodeCompiler.lateInputReads[fn] = body.lateInputReads readInputs, assignedOutputs := body.readInputs, body.assignedOutputs for _, input := range fn.Parameters { @@ -232,14 +231,11 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { } } -// templateBody is the structural summary of one template body. lateInputReads -// names the parameters read in a statement after the first statement that -// writes an output; reads within that statement precede its writes. +// templateBody is the structural summary of one template body. type templateBody struct { statementReads [][]VarEvent readInputs map[string]struct{} assignedOutputs map[string]struct{} - lateInputReads map[string]struct{} } // validateTemplateBody runs structural validation over one template body. A @@ -250,7 +246,6 @@ func (cfg *CFG) validateTemplateBody(statements []ast.Statement, parameterNames, statementReads: make([][]VarEvent, 0, len(statements)), readInputs: make(map[string]struct{}, len(parameterNames)), assignedOutputs: make(map[string]struct{}, len(outputNames)), - lateInputReads: make(map[string]struct{}), } for _, stmt := range statements { reads := cfg.collectStatementReads(stmt) @@ -261,12 +256,8 @@ func (cfg *CFG) validateTemplateBody(statements []ast.Statement, parameterNames, body.statementReads = append(body.statementReads, reads) for _, event := range reads { - if _, isParameter := parameterNames[event.Name]; !isParameter { - continue - } - body.readInputs[event.Name] = struct{}{} - if len(body.assignedOutputs) > 0 { - body.lateInputReads[event.Name] = struct{}{} + if _, isParameter := parameterNames[event.Name]; isParameter { + body.readInputs[event.Name] = struct{}{} } } for _, target := range targets { diff --git a/compiler/codecompiler.go b/compiler/codecompiler.go index 2aa7a277..cd859dec 100644 --- a/compiler/codecompiler.go +++ b/compiler/codecompiler.go @@ -13,11 +13,6 @@ type CodeCompiler struct { Code *ast.Code globalBindings map[string]token.Token funcTemplates map[funcKey]*ast.FuncStatement - // lateInputReads records, per template, the parameters read after an - // output has been written. Lowering snapshots those inputs per iteration - // of a range-bearing variant, since an aliased input would otherwise - // observe the output's new value. - lateInputReads map[*ast.FuncStatement]map[string]struct{} } type funcKey struct { @@ -28,24 +23,12 @@ type funcKey struct { func NewCodeCompiler(ctx llvm.Context, modName, relPath string, code *ast.Code) *CodeCompiler { mangledPath := MangleDirPath(modName, relPath) cc := &CodeCompiler{ - Compiler: NewCompiler(ctx, mangledPath, nil), - Code: code, - lateInputReads: make(map[*ast.FuncStatement]map[string]struct{}), + Compiler: NewCompiler(ctx, mangledPath, nil), + Code: code, } return cc } -// lateInputReadsFor returns the parameters a template reads after writing an -// output. AnalyzeFuncs records the fact for every template before any script -// lowers a call, so a missing entry is an ICE. -func (cc *CodeCompiler) lateInputReadsFor(template *ast.FuncStatement) map[string]struct{} { - late, recorded := cc.lateInputReads[template] - if !recorded { - panic(fmt.Sprintf("internal: template %s was lowered before structural analysis", template.Token.Literal)) - } - return late -} - func (cc *CodeCompiler) registerGlobalBinding(name string, tok token.Token) *token.CompileError { previous, exists := cc.globalBindings[name] if !exists { diff --git a/compiler/compiler.go b/compiler/compiler.go index 13939b5a..96154307 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -69,9 +69,6 @@ type Symbol struct { type FuncArgs struct { Inputs []*Symbol // lowered function inputs (range iterators remain pointer-backed) IterIndices []int // Indices of iterator params - // LateInputs names parameters read after an output write; each scalar - // iteration reads a snapshot taken at its start. - LateInputs map[string]struct{} } type callArg struct { @@ -2698,7 +2695,6 @@ func (c *Compiler) compileFuncIter(template *ast.FuncStatement, inputs []*Symbol fa := &FuncArgs{ Inputs: inputs, IterIndices: iterIndices, - LateInputs: c.CodeCompiler.lateInputReadsFor(template), } return c.funcLoopNest(template, fa, 0, currentOutput) } @@ -2912,10 +2908,10 @@ func (c *Compiler) funcLoopNest(fn *ast.FuncStatement, fa *FuncArgs, level int, // snapshotIterationInputs fixes each non-iterator input for one scalar // iteration. An input that aliases an output shares its storage, so a read // after the output's write would observe the new value. A direct scalar reads -// the carried output once here; an indirect input read after an output write -// keeps a private copy of its value for the iteration, freed with the scope, -// but only when some output could back it: the caller aliases identical -// storage types only, so any other input never shares output storage. +// the carried output once here; an indirect input that some output could back +// keeps a private copy of its value for the iteration, freed with the scope. +// The caller aliases identical storage types only, so any other input never +// shares output storage and is left in place. func (c *Compiler) snapshotIterationInputs(fn *ast.FuncStatement, fa *FuncArgs) { for i, param := range fn.Parameters { if slices.Contains(fa.IterIndices, i) { @@ -2928,10 +2924,7 @@ func (c *Compiler) snapshotIterationInputs(fn *ast.FuncStatement, fa *FuncArgs) Put(c.Scopes, name, c.directParamValue(name, sym, alias)) continue } - if _, late := fa.LateInputs[name]; !late || sym.Type.Kind() != PtrKind { - continue - } - if !c.inputCanAliasOutput(fn, sym.Type.(Ptr).Elem) { + if sym.Type.Kind() != PtrKind || !c.inputCanAliasOutput(fn, sym.Type.(Ptr).Elem) { continue } diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 4f295b58..47b43d5b 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -470,9 +470,9 @@ count, value` "an input no output can alias must not be copied per iteration") } -func TestIterationSnapshotCopiesAliasableInputReadAfterWrite(t *testing.T) { - // The heap-string input can back the heap-string output, and the body - // reads it after writing that output, so each iteration works on a copy. +func TestIterationSnapshotCopiesAliasableInput(t *testing.T) { + // The heap-string input can back the heap-string output, so each + // iteration works on a private copy whatever the statement order. code := `out, seen = FoldStr(current, item) out = current ⊕ item seen = current` From 92bddd8c454fe04882581f6b696f4ce413581d8c Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 12 Sep 2026 10:36:02 +0530 Subject: [PATCH 05/56] fix(llvm): preserve loop metadata when adding unroll hints Live input alias selectors change the optimized Fib tail loop so its latch already carries llvm.loop.peeled.count. Preserve existing loop properties when adding scalar unroll hints, while respecting explicit unroll policies. Read metadata operands through a native C pointer buffer rather than relying on the layout of llvm.Value. Existing metadata tests cover preservation, policy handling, and idempotence. --- llvm_metadata_byollvm.go | 53 +++++++++++++++++++++++++++++++++ llvm_pipeline.go | 38 +++++++++++++++++++++--- llvm_pipeline_test.go | 64 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 llvm_metadata_byollvm.go diff --git a/llvm_metadata_byollvm.go b/llvm_metadata_byollvm.go new file mode 100644 index 00000000..e5e09c78 --- /dev/null +++ b/llvm_metadata_byollvm.go @@ -0,0 +1,53 @@ +//go:build byollvm + +package main + +/* +#include "llvm-c/Core.h" +*/ +import "C" + +import ( + "unsafe" + + "tinygo.org/x/go-llvm" +) + +// go-LLVM exposes metadata handles but not these operand inspection APIs. +// The returned nodes are borrowed from the module's LLVM context. +func llvmMetadataOperands(node llvm.Value) []llvm.Value { + if node.IsNil() { + return nil + } + count := int(C.LLVMGetMDNodeNumOperands(C.LLVMValueRef(unsafe.Pointer(node.C)))) + if count == 0 { + return nil + } + // Use a C pointer buffer without depending on llvm.Value's struct layout. + refs := make([]C.LLVMValueRef, count) + C.LLVMGetMDNodeOperands(C.LLVMValueRef(unsafe.Pointer(node.C)), &refs[0]) + + operands := make([]llvm.Value, count) + for i, ref := range refs { + *(*unsafe.Pointer)(unsafe.Pointer(&operands[i].C)) = unsafe.Pointer(ref) + } + + return operands +} + +func llvmMetadataString(value llvm.Value) string { + if value.IsNil() { + return "" + } + var length C.unsigned + str := C.LLVMGetMDString(C.LLVMValueRef(unsafe.Pointer(value.C)), &length) + + return C.GoStringN(str, C.int(length)) +} + +func llvmValueAsMetadata(value llvm.Value) llvm.Metadata { + var metadata llvm.Metadata + *(*unsafe.Pointer)(unsafe.Pointer(&metadata.C)) = unsafe.Pointer(C.LLVMValueAsMetadata(C.LLVMValueRef(unsafe.Pointer(value.C)))) + + return metadata +} diff --git a/llvm_pipeline.go b/llvm_pipeline.go index f5f0f6af..200baf3d 100644 --- a/llvm_pipeline.go +++ b/llvm_pipeline.go @@ -152,10 +152,11 @@ func annotateScalarUnrollLoops(module llvm.Module) int { for fn := module.FirstFunction(); !fn.IsNil(); fn = llvm.NextFunction(fn) { loops := scalarUnrollCandidates(fn) for _, loop := range loops { - if !loop.term.Metadata(loopMDKind).IsNil() { + metadata := loop.term.Metadata(loopMDKind) + if llvmLoopHasUnrollDirective(metadata) { continue } - loop.term.SetMetadata(loopMDKind, llvmUnrollCountMetadata(ctx, llvmScalarUnrollCount)) + loop.term.SetMetadata(loopMDKind, llvmUnrollCountMetadata(ctx, metadata, llvmScalarUnrollCount)) annotated++ } } @@ -450,14 +451,43 @@ func valueUsesVectorType(v llvm.Value) bool { return false } -func llvmUnrollCountMetadata(ctx llvm.Context, count int) llvm.Metadata { +func llvmLoopHasUnrollDirective(loopID llvm.Value) bool { + for i, property := range llvmMetadataOperands(loopID) { + if i == 0 { + continue + } + operands := llvmMetadataOperands(property) + if len(operands) == 0 { + continue + } + name := llvmMetadataString(operands[0]) + if strings.HasPrefix(name, "llvm.loop.unroll.") || + strings.HasPrefix(name, "llvm.loop.unroll_and_jam.") || + name == "llvm.loop.disable_nonforced" { + return true + } + } + + return false +} + +func llvmUnrollCountMetadata(ctx llvm.Context, previous llvm.Value, count int) llvm.Metadata { temp := ctx.TemporaryMDNode(nil) + properties := []llvm.Metadata{temp} + for i, property := range llvmMetadataOperands(previous) { + if i > 0 { + properties = append(properties, llvmValueAsMetadata(property)) + } + } + countMD := llvm.ConstInt(ctx.Int32Type(), uint64(count), false).ConstantAsMetadata() countNode := ctx.MDNode([]llvm.Metadata{ ctx.MDString("llvm.loop.unroll.count"), countMD, }) - loopID := ctx.MDNode([]llvm.Metadata{temp, countNode}) + loopID := ctx.MDNode(append(properties, countNode)) + // The C API replacement also disposes the temporary node. temp.ReplaceAllUsesWith(loopID) + return loopID } diff --git a/llvm_pipeline_test.go b/llvm_pipeline_test.go index 029a3519..654fe81a 100644 --- a/llvm_pipeline_test.go +++ b/llvm_pipeline_test.go @@ -95,6 +95,62 @@ exit: } } +func TestAnnotateScalarUnrollLoopsPreservesMetadata(t *testing.T) { + for _, tt := range []struct { + name string + property string + want int + }{ + {"peeled", `!{!"llvm.loop.peeled.count", i32 1}`, 1}, + {"unroll disabled", `!{!"llvm.loop.unroll.disable"}`, 0}, + {"unroll and jam", `!{!"llvm.loop.unroll_and_jam.disable"}`, 0}, + {"nonforced disabled", `!{!"llvm.loop.disable_nonforced"}`, 0}, + } { + t.Run(tt.name, func(t *testing.T) { + mod := parseTestIR(t, ` +define i64 @fib_like(i64 %n) { +entry: + br label %loop + +loop: + %a = phi i64 [ 0, %entry ], [ %b, %loop ] + %b = phi i64 [ 1, %entry ], [ %sum, %loop ] + %i = phi i64 [ %n, %entry ], [ %dec, %loop ] + %dec = add i64 %i, -1 + %sum = add i64 %a, %b + %done = icmp eq i64 %dec, 0 + br i1 %done, label %exit, label %loop, !llvm.loop !0 + +exit: + ret i64 %b +} + +!0 = distinct !{!0, !1} +!1 = `+tt.property) + before := mod.String() + if got := annotateScalarUnrollLoops(mod); got != tt.want { + t.Fatalf("annotateScalarUnrollLoops() = %d, want %d", got, tt.want) + } + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatalf("invalid loop metadata after annotation: %v", err) + } + after := mod.String() + if !strings.Contains(after, tt.property) { + t.Fatalf("existing metadata was lost:\n%s", after) + } + if tt.want == 0 && after != before { + t.Fatalf("existing unroll policy was changed:\n%s", after) + } + if tt.want == 1 && !strings.Contains(after, `!{!"llvm.loop.unroll.count", i32 4}`) { + t.Fatalf("peeled loop did not receive an unroll count:\n%s", after) + } + if got := annotateScalarUnrollLoops(mod); got != 0 { + t.Fatalf("second annotation added %d duplicate hints", got) + } + }) + } +} + func TestAnnotateScalarUnrollLoopsSkipsCallHeavyLoops(t *testing.T) { mod := parseTestIR(t, ` declare void @side_effect() @@ -337,7 +393,7 @@ res t.Fatalf("run O3 pipeline: %v", err) } loopMDKind := scriptModule.Context().MDKindID("llvm.loop") - // Loops the O3 run already marked must contribute no add chains; that + // Loops the O3 run already marked for unrolling contribute no add chains; that // makes any post-unroll chain growth attributable to the loops annotated // below rather than to pre-existing loop metadata. if got := maxChainedAddsInMarkedLatch(scriptModule); got != 0 { @@ -349,7 +405,7 @@ res preUnrollChain := 0 for fn := scriptModule.FirstFunction(); !fn.IsNil(); fn = llvm.NextFunction(fn) { for _, loop := range scalarUnrollCandidates(fn) { - if !loop.term.Metadata(loopMDKind).IsNil() { + if llvmLoopHasUnrollDirective(loop.term.Metadata(loopMDKind)) { continue } candidates++ @@ -386,7 +442,7 @@ res } // maxChainedAddsInMarkedLatch returns, across all loop latches that carry -// llvm.loop metadata, the largest number of add instructions that consume +// unroll metadata, the largest number of add instructions that consume // another add from the same block. An unrolled scalar recurrence leaves its // replicated adds chained together inside the marked latch, so this grows // when the annotated loop is actually unrolled. @@ -396,7 +452,7 @@ func maxChainedAddsInMarkedLatch(module llvm.Module) int { for fn := module.FirstFunction(); !fn.IsNil(); fn = llvm.NextFunction(fn) { for bb := fn.FirstBasicBlock(); !bb.IsNil(); bb = llvm.NextBasicBlock(bb) { term := bb.LastInstruction() - if term.IsNil() || term.Metadata(loopMDKind).IsNil() { + if term.IsNil() || !llvmLoopHasUnrollDirective(term.Metadata(loopMDKind)) { continue } if chained := chainedAddsInBlock(bb); chained > best { From 1f1b20822595180f6f525d4e2cb3019c1818354e Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 12 Sep 2026 10:36:13 +0530 Subject: [PATCH 06/56] feat(compiler): preserve live input references across output writes Keep outputs write-only while allowing explicitly aliased inputs to observe earlier output writes in ordinary and ranged calls. Remove iteration snapshots, preserve nested reference identity, and specialize calls on settled binding storage, including compatible wider output slots. Reject formatting %n writes to input and iterator parameters; writable local copies retain their own permissions. Document the live-reference rule and cover both Fold orders and sequential versus simultaneous swaps without adding duplicate test combinations. BREAKING CHANGE: native ABI 2.1 adds an i32 alias selector for every direct scalar parameter in ordinary as well as ranged variants. Native callers must supply zero for inputs that do not alias an output. Fixes #103. --- README.md | 15 +- compiler/abi.go | 22 +-- compiler/cfg.go | 40 +++- compiler/cfg_replay_test.go | 5 +- compiler/cfg_test.go | 27 +++ compiler/compiler.go | 229 ++++++++++++++--------- compiler/compiler_test.go | 55 +++--- compiler/format.go | 6 + compiler/live_alias_format_test.go | 38 ++++ compiler/solver.go | 63 ++++++- compiler/solver_test.go | 78 ++++++++ docs/Pluto ABI Optimization Plan.md | 15 +- docs/Pluto C ABI Spec.md | 48 +++-- docs/Pluto Effects and Follow-up Plan.md | 64 ++++--- docs/Pluto IR Plan.md | 26 ++- docs/Pluto Memory Model.md | 72 +++++-- tests/alias_input/self_alias.exp | 27 ++- tests/alias_input/self_alias.pt | 55 +++++- tests/alias_input/self_alias.spt | 96 ++++++++-- tests/math/acc_fmt.pt | 5 +- tests/mem/mem_alias_refine.exp | 2 +- tests/mem/mem_alias_refine.pt | 5 +- tests/mem/mem_alias_refine.spt | 3 +- 23 files changed, 743 insertions(+), 253 deletions(-) create mode 100644 compiler/live_alias_format_test.go diff --git a/README.md b/README.md index 7da6ea8b..b206bc21 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ Compile and run: Templates are defined once with a clear input/output contract. The first line declares the output and input — the indented body describes the transformation. -Think of a template as a **black box**: data flows in through inputs, gets transformed, and flows out through outputs. Outputs work **by reference** — calling a template directly modifies the output variable in the caller's scope. +Think of a template as a **black box**: data flows in through inputs, gets transformed, and flows out through outputs. Outputs work **by reference**. A caller may connect an input and an output to the same variable; inside the call, later input reads observe writes through that output. The caller's variable receives the result after every right-hand side of the assignment has been evaluated. `math.pt` ```python @@ -134,7 +134,18 @@ y = Square(x) y = x * x ``` -Inputs are read-only — they flow in. Outputs are write-only inside the template — they flow out; use a local for intermediate values. Every function is a transformation. A caller may reuse a variable as both argument and destination, `a = Square(a)`, which is how an old value reaches the function. +Inputs are read-only — they flow in. Outputs are write-only inside the template — they flow out; use a local for intermediate values. Read-only means the template cannot assign through the input name; it does not freeze a value shared with an output. A caller may reuse a variable as both argument and destination, `a = Square(a)`. + +```python +out, seen = Fold(current, item) + out = current + item + seen = current + +value = 10 +value, seen = Fold(value, 5) # value = 15, seen = 15 +``` + +Moving `seen = current` before `out = current + item` instead makes `seen` equal 10. The same order applies to each iteration of a ranged call. ### Generics by use diff --git a/compiler/abi.go b/compiler/abi.go index 48e022f3..98f60d80 100644 --- a/compiler/abi.go +++ b/compiler/abi.go @@ -29,12 +29,11 @@ type ABIReturn struct { // FuncABI captures the lowered function boundary for one mangled variant. // Direct scalar returns carry a hidden destination seed so a skipped write -// preserves the caller's value. Range-bearing variants may additionally need -// hidden alias state for loop-carried accumulation. +// preserves the caller's value. Direct scalar inputs carry hidden alias state +// so reads can observe writes through an output that shares their binding. type FuncABI struct { - Params []ABIParam - Return ABIReturn - HasRangeParams bool + Params []ABIParam + Return ABIReturn } func isDirectScalarABIType(t Type) bool { @@ -79,13 +78,6 @@ func classifyFuncABI(paramTypes []Type, outTypes []Type) FuncABI { }, } - for _, paramType := range paramTypes { - if isRangeDriverType(paramType) { - abi.HasRangeParams = true - break - } - } - aliasSlot := 0 for i, paramType := range paramTypes { paramABI := ABIParam{ @@ -97,10 +89,8 @@ func classifyFuncABI(paramTypes []Type, outTypes []Type) FuncABI { if isDirectScalarABIType(paramType) { paramABI.Mode = ABIParamDirect paramABI.Lowered = paramType - if abi.HasRangeParams { - paramABI.AliasSlot = aliasSlot - aliasSlot++ - } + paramABI.AliasSlot = aliasSlot + aliasSlot++ } abi.Params[i] = paramABI } diff --git a/compiler/cfg.go b/compiler/cfg.go index 8ccc62e3..e6249ad3 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -307,7 +307,7 @@ func (cfg *CFG) AnalyzeSpecialization(template *ast.FuncStatement, info *FuncInf cfg.publishTarget(param) } - cfg.typedForwardPass(template.Body.Statements, info.StatementEffects) + cfg.typedForwardPass(template, info) live := make(map[string]struct{}, len(template.Outputs)) for _, output := range template.Outputs { @@ -316,11 +316,39 @@ func (cfg *CFG) AnalyzeSpecialization(template *ast.FuncStatement, info *FuncInf cfg.backwardPass(live) } -func (cfg *CFG) typedForwardPass(statements []ast.Statement, effects map[*ast.LetStatement]StatementEffect) { +// inputOutputAliases lists outputs that a caller could share with each input. +// Specializations are reused across calls, so liveness must conservatively +// retain writes observable through any compatible input reference. These are +// scalar body types, so this also conservatively includes iterator inputs. +func inputOutputAliases(template *ast.FuncStatement, info *FuncInfo) map[string][]*ast.Identifier { + aliases := make(map[string][]*ast.Identifier, len(template.Parameters)) + for i, paramType := range info.Sig.Params { + for j, outputType := range info.Sig.OutTypes { + if !bindingSlotCompatible(paramType, outputType) { + continue + } + name := template.Parameters[i].Value + aliases[name] = append(aliases[name], template.Outputs[j]) + } + } + + return aliases +} + +func (cfg *CFG) typedForwardPass(template *ast.FuncStatement, info *FuncInfo) { + aliases := inputOutputAliases(template, info) lastWrites := make(map[string]VarEvent) - for _, stmt := range statements { + for _, stmt := range template.Body.Statements { reads := cfg.collectStatementReads(stmt) - cfg.processTypedStatement(stmt, reads, effects, lastWrites) + for _, read := range reads { + for _, output := range aliases[read.Name] { + if !cfg.isDefined(output.Value) { + continue + } + reads = append(reads, VarEvent{Name: output.Value, Kind: Read, Token: read.Token}) + } + } + cfg.processTypedStatement(stmt, reads, info.StatementEffects, lastWrites) } } @@ -459,8 +487,8 @@ func (cfg *CFG) backwardPass(live map[string]struct{}) { } // validateStructuralRead enforces that a declared output is write-only inside -// its template: a body transforms inputs into outputs and never observes an -// output's value, so the incoming destination seed can never leak in. +// its template. A body may observe output writes through an explicitly passed +// input that shares the output's binding, but never through the output name. func (cfg *CFG) validateStructuralRead(event VarEvent, outputs map[string]struct{}) { if _, isOutput := outputs[event.Name]; isOutput { cfg.addError(event.Token, fmt.Sprintf("output %q is read inside its function; outputs are write-only, use a local", event.Name)) diff --git a/compiler/cfg_replay_test.go b/compiler/cfg_replay_test.go index e0ab280e..eace49a9 100644 --- a/compiler/cfg_replay_test.go +++ b/compiler/cfg_replay_test.go @@ -285,8 +285,9 @@ result = Diamond(x) func TestCFGResultsAreIndependentPerType(t *testing.T) { code := mustParseCode(t, `result = MaskOrKeep(x) - result = x - result = x > 0 + local = x + result = local + result = local > 0 `) ctx := llvm.NewContext() diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index 74deae48..a6b326f4 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -73,6 +73,33 @@ func TestFunctionDataflowWaitsForSpecialization(t *testing.T) { require.Equal(t, 2, deadStores) } +func TestInputAliasOutputWriteLiveness(t *testing.T) { + tests := []cfgTestCase{ + { + name: "Repeated Output Write", + code: `out = BumpTwice(current, item) + out = current + item + out = current + item`, + input: "value = 10\nvalue = BumpTwice(value, 5)\nvalue", + }, + { + name: "Incompatible Input Output Storage", + code: `out = Replaced(current) + out = "first" + current + out = "second"`, + input: "value = Replaced(1)\nvalue", + errorContains: `unconditional assignment to "out" overwrites a previous value that was never used`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runCFGTest(t, tt, tt.errorContains != "") + }) + } +} + func getValidTestCases() []cfgTestCase { return []cfgTestCase{ { diff --git a/compiler/compiler.go b/compiler/compiler.go index 96154307..b960f1bc 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -33,7 +33,7 @@ type Symbol struct { // // Assignment semantics: when assigning a borrowed symbol to a local variable, the value // is COPIED, just like `x = s` copies in regular scope. This ensures: -// - No aliasing between caller's input and output variables +// - Input/output references may alias; ordinary local assignments still copy // - Local variables get independent copies (with Borrowed=false) // - Consistent semantics: x = identity(s) behaves like x = s // @@ -81,16 +81,17 @@ type callArg struct { // transmit it as a hidden ABI argument; indirect params consume it // caller-side to pass that output's staged pointer in place of the lowered // argument. One-based keeps the zero value correct for arguments that - // alias nothing. - AliasSelector int + // alias nothing. A nested call may forward a run-time alias relationship. + AliasSelector llvm.Value } type callSignature struct { - FuncName string - Mangled string - ParamTypes []Type - FnInfo *FuncInfo - ABI FuncABI + FuncName string + Mangled string + StorageName string // private lowering variant when output slots have wider storage + ParamTypes []Type + FnInfo *FuncInfo + ABI FuncABI } type preparedCall struct { @@ -138,6 +139,7 @@ type Compiler struct { FuncNameMangled string // current script root or function specialization key Errors []*token.CompileError paramAliasStack []map[string]*paramAlias + outputSlotTypes map[string]Type stmtCtxStack []stmtCtx } @@ -202,6 +204,9 @@ func freshCompilerIdentifier(prefix identifierPrefix, role string, counter *int) } func (c *Compiler) bindingSlotType(name string, fallback Type) Type { + if typ, exists := c.outputSlotTypes[name]; exists { + return typ + } f := c.FuncCache[c.FuncNameMangled] typ, ok := f.Vars[name] if !ok { @@ -233,10 +238,9 @@ func identNames(idents []*ast.Identifier) []string { return names } -// bindParamAlias records the output names eagerly, but the outputs themselves -// are resolved from scope when an iteration snapshots the param -// (snapshotIterationInputs). This allows direct outputs to remain values or -// be replaced in scope without invalidating the alias metadata. +// bindParamAlias records the output names eagerly, but resolves their current +// values on every input read. Outputs may remain values or be replaced in scope +// without invalidating the input's reference to them. func (c *Compiler) bindParamAlias(name string, sym *Symbol, aliasIndex llvm.Value, outputNames []string) { c.currentParamAliases()[name] = ¶mAlias{ Base: sym, @@ -316,15 +320,16 @@ func (c *Compiler) resolveCallSignature(funcName string, ce *ast.CallExpression, } // setCallArgAliasSelectors records on each argument which caller destination it -// aliases for range-bearing variants. Direct scalar params encode the selected +// aliases. Direct scalar params encode the selected // output through a hidden ABI index; indirect params receive that output's // staged pointer directly. Arguments that alias nothing keep selector 0. func (c *Compiler) setCallArgAliasSelectors(sig *callSignature, args []callArg, dest []*ast.Identifier) { - if !sig.ABI.HasRangeParams || dest == nil { + if dest == nil { return } for paramIndex, arg := range args { + args[paramIndex].AliasSelector = llvm.ConstInt(c.Context.Int32Type(), 0, false) if arg.Name == "" { continue } @@ -333,24 +338,48 @@ func (c *Compiler) setCallArgAliasSelectors(sig *callSignature, args []callArg, if outputIndex >= len(sig.ABI.Return.OutTypes) { break } - if output.Value != arg.Name { - continue - } - // An indirect parameter and a same-named output can legitimately - // differ in ownership flavor, such as a StrH binding receiving a - // StrG output. Redirecting the input to that output's adapter would - // make a sibling output that reads the input see the adapter's - // value instead. Direct scalars cannot reach this: the solver - // rejects a name that would need two numeric types. + // Output storage variants preserve compatible ownership widening. + // A remaining type mismatch cannot share the input's representation + // and must not select that output as its storage. if !aliasableOutput(sig.ParamTypes[paramIndex], sig.ABI.Return.OutTypes[outputIndex]) { continue } - args[paramIndex].AliasSelector = outputIndex + 1 - break + selected := llvm.ConstInt(c.Context.Int32Type(), uint64(outputIndex+1), false) + if output.Value == arg.Name { + args[paramIndex].AliasSelector = selected + break + } + + if same := c.inputAliasesBinding(arg.Name, output.Value); !same.IsNil() { + args[paramIndex].AliasSelector = c.builder.CreateSelect(same, selected, args[paramIndex].AliasSelector, arg.Name+"_call_alias") + } } } } +// inputAliasesBinding preserves reference identity across nested calls, where +// the input and output may have different source names but share storage. +func (c *Compiler) inputAliasesBinding(input, output string) llvm.Value { + sym, ok := Get(c.Scopes, input) + if !ok { + return llvm.Value{} + } + if alias, ok := c.paramAliasFor(input, sym); ok { + for i, name := range alias.OutputNames { + if name == output { + return c.builder.CreateICmp(llvm.IntEQ, alias.AliasIndex, + llvm.ConstInt(c.Context.Int32Type(), uint64(i+1), false), input+"_forwards_alias") + } + } + } + + dest, ok := Get(c.Scopes, output) + if ok && sym.Type.Kind() == PtrKind && dest.Type.Kind() == PtrKind && sym.FuncArg && sym.ReadOnly { + return c.builder.CreateICmp(llvm.IntEQ, sym.Val, dest.Val, input+"_shares_output") + } + return llvm.Value{} +} + // directReturnSeedForCall captures the caller's current destination value for a // direct scalar return. The callee receives it through a hidden ABI parameter // so a skipped output write preserves the destination even though the LLVM @@ -1019,7 +1048,12 @@ func (c *Compiler) storeValue(name string, rhsSym *Symbol, shouldCopy bool) { if !exists || oldSym.Type.Kind() != PtrKind { targetType := c.bindingSlotType(name, valueToStore.Type) valueToStore = c.coerceSymbolForType(valueToStore, targetType, name+"_rhs_load") - Put(c.Scopes, name, valueToStore) + + // Parameter permissions belong to the binding, not a copied value. + stored := GetCopy(valueToStore) + stored.FuncArg = exists && oldSym.FuncArg + stored.ReadOnly = exists && oldSym.ReadOnly + Put(c.Scopes, name, stored) return } @@ -2343,10 +2377,11 @@ func (c *Compiler) cleanupSkippedCallOutputAdapters(adapters []callOutputAdapter // bindRangedTempOutputs makes each destination name resolve to its staged slot // while that one ranged expression is compiled. Conditional lowering can make // the real destination and a synthetic conditional write name alias the same -// slot, so bind every visible name for that slot as well. This preserves -// loop-carried self-reference (res = res + i) without exposing the staged value -// to sibling right-hand sides in a simultaneous assignment; the caller's -// BlockScope is popped before the next expression is compiled. +// slot, so bind every visible name for that slot as well. Input references may +// share it at run time and follow the staged slot through a pointer select. +// This preserves loop-carried self-reference without exposing staged values to +// sibling right-hand sides in a simultaneous assignment. The caller pops its +// BlockScope before compiling the next expression. func (c *Compiler) bindRangedTempOutputs(dest []*ast.Identifier, outputs []*Symbol) { for i := 0; i < len(dest) && i < len(outputs); i++ { // A blank binds nothing and nothing can read it back, so it has no @@ -2361,11 +2396,19 @@ func (c *Compiler) bindRangedTempOutputs(dest []*ast.Identifier, outputs []*Symb seen := make(map[string]struct{}) for scopeIdx := len(c.Scopes) - 1; scopeIdx >= 0; scopeIdx-- { scope := c.Scopes[scopeIdx] - for name, sym := range scope.Elems { + for _, name := range scope.BindingOrder { + sym := scope.Elems[name] if _, visited := seen[name]; visited { continue } seen[name] = struct{}{} + if sym.FuncArg && sym.ReadOnly && TypeEqual(sym.Type, current.Type) && TypeEqual(sym.Type, outputs[i].Type) { + shared := c.builder.CreateICmp(llvm.IntEQ, sym.Val, current.Val, name+"_range_alias") + reference := GetCopy(sym) + reference.Val = c.builder.CreateSelect(shared, outputs[i].Val, sym.Val, name+"_range_ref") + Put(c.Scopes, name, reference) + continue + } if sym.Type.Kind() == PtrKind && sym.Val == current.Val { names = append(names, name) } @@ -2541,7 +2584,10 @@ func (c *Compiler) addPointerParamAttributes(function llvm.Value, index int) { } func (c *Compiler) compileFunc(template *ast.FuncStatement, sig *callSignature, funcType llvm.Type, retStruct llvm.Type) llvm.Value { - function := llvm.AddFunction(c.Module, sig.Mangled, funcType) + function := llvm.AddFunction(c.Module, sig.loweredName(), funcType) + if sig.StorageName != "" { + function.SetLinkage(llvm.InternalLinkage) + } if sig.ABI.UsesIndirectReturn() { sretAttr := c.Context.CreateTypeAttribute(llvm.AttributeKindID("sret"), retStruct) @@ -2575,9 +2621,15 @@ func (c *Compiler) compileFunc(template *ast.FuncStatement, sig *callSignature, // Set FuncNameMangled so ExprCache entries are keyed to this function savedFuncNameMangled := c.FuncNameMangled c.FuncNameMangled = sig.Mangled + savedOutputSlots := c.outputSlotTypes + c.outputSlotTypes = make(map[string]Type, len(template.Outputs)) + for i, output := range template.Outputs { + c.outputSlotTypes[output.Value] = sig.ABI.Return.OutTypes[i] + } c.pushParamAliases() retVal, hasDirectRet := c.compileFuncBlock(template, sig, retStruct, function) c.popParamAliases() + c.outputSlotTypes = savedOutputSlots c.FuncNameMangled = savedFuncNameMangled if hasDirectRet { @@ -2709,7 +2761,7 @@ func (c *Compiler) compileFuncBlock(template *ast.FuncStatement, sig *callSignat var outputs []*Symbol if sig.ABI.UsesIndirectReturn() { sretPtr := function.Param(0) - outputs = c.processIndirectOutputs(template, retStruct, sretPtr, sig.FnInfo.Sig.OutTypes) + outputs = c.processIndirectOutputs(template, retStruct, sretPtr, sig.ABI.Return.OutTypes) } else { outputs = c.processDirectOutputValues(template, sig, function) } @@ -2852,7 +2904,6 @@ func (c *Compiler) funcLoopNest(fn *ast.FuncStatement, fa *FuncArgs, level int, if currentOutput != nil { Put(c.Scopes, fn.Outputs[0].Value, currentOutput) } - c.snapshotIterationInputs(fn, fa) c.compileFuncBody(fn) if currentOutput == nil { return nil @@ -2871,7 +2922,7 @@ func (c *Compiler) funcLoopNest(fn *ast.FuncStatement, fa *FuncArgs, level int, Type: iterType, FuncArg: true, Borrowed: true, - ReadOnly: false, + ReadOnly: true, } PushScope(&c.Scopes, BlockScope) Put(c.Scopes, name, iterSym) @@ -2905,49 +2956,6 @@ func (c *Compiler) funcLoopNest(fn *ast.FuncStatement, fa *FuncArgs, level int, return result } -// snapshotIterationInputs fixes each non-iterator input for one scalar -// iteration. An input that aliases an output shares its storage, so a read -// after the output's write would observe the new value. A direct scalar reads -// the carried output once here; an indirect input that some output could back -// keeps a private copy of its value for the iteration, freed with the scope. -// The caller aliases identical storage types only, so any other input never -// shares output storage and is left in place. -func (c *Compiler) snapshotIterationInputs(fn *ast.FuncStatement, fa *FuncArgs) { - for i, param := range fn.Parameters { - if slices.Contains(fa.IterIndices, i) { - continue - } - - name := param.Value - sym, _ := Get(c.Scopes, name) - if alias, aliased := c.paramAliasFor(name, sym); aliased { - Put(c.Scopes, name, c.directParamValue(name, sym, alias)) - continue - } - if sym.Type.Kind() != PtrKind || !c.inputCanAliasOutput(fn, sym.Type.(Ptr).Elem) { - continue - } - - snapshot := c.deepCopyIfNeeded(c.derefIfPointer(sym, name+"_iter_input")) - snapshot.FuncArg = true - snapshot.ReadOnly = true - Put(c.Scopes, name, snapshot) - } -} - -// inputCanAliasOutput mirrors setCallArgAliasSelectors: a caller passes an -// output's staged storage as an input only when the two types lower -// identically. -func (c *Compiler) inputCanAliasOutput(fn *ast.FuncStatement, paramType Type) bool { - for _, output := range fn.Outputs { - outputSym, _ := Get(c.Scopes, output.Value) - if aliasableOutput(paramType, outputSym.Type) { - return true - } - } - return false -} - func (c *Compiler) compileFuncBody(fn *ast.FuncStatement) { for _, stmt := range fn.Body.Statements { c.compileStatement(stmt) @@ -3205,16 +3213,16 @@ func (c *Compiler) compileCallExpression(ce *ast.CallExpression, dest []*ast.Ide // them at independent, destination-seeded slots so a call in one RHS cannot // mutate a real destination before sibling RHS expressions have read the // statement-start values. The outer assignment owns the eventual commit and - // cleanup. ABI-flavor adapters handle established slots such as StrH when a - // callee declares StrG. + // cleanup. Private output-storage variants preserve compatible widening, + // such as an established StrH slot receiving a declared StrG output. outputs := c.makeSeededTempOutputs(dest, info.OutTypes) c.compileIndirectCallIntoStagedOutputs(sig, ce, dest, outputs) return c.loadOutputValues(outputs, "call_final") } func (c *Compiler) getOrCompileCallFunction(sig *callSignature) (llvm.Value, llvm.Type, llvm.Type) { - funcType, retStruct := c.getFuncType(sig.Mangled, sig.ABI) - fn := c.Module.NamedFunction(sig.Mangled) + funcType, retStruct := c.getFuncType(sig.loweredName(), sig.ABI) + fn := c.Module.NamedFunction(sig.loweredName()) if !fn.IsNil() { return fn, funcType, retStruct } @@ -3270,6 +3278,7 @@ func (c *Compiler) compileIndirectCallIntoStagedOutputs( dest []*ast.Identifier, staged []*Symbol, ) { + c.specializeOutputStorage(sig, staged) adapters := c.makeCallOutputAdapters(staged, sig.ABI.Return.OutTypes) callOutputs := callAdapterOutputs(adapters) c.compileIndirectCallIntoOutputs( @@ -3282,6 +3291,42 @@ func (c *Compiler) compileIndirectCallIntoStagedOutputs( ) } +func (sig *callSignature) loweredName() string { + if sig.StorageName != "" { + return sig.StorageName + } + return sig.Mangled +} + +// specializeOutputStorage keeps a writable output and a compatible input on +// the same representation. In particular an untyped empty array result must +// reset the actual array slot, rather than a separate zero-seeded adapter that +// its input cannot observe. Solver facts remain keyed by the source signature; +// only the private function's output storage and ownership change. +func (c *Compiler) specializeOutputStorage(sig *callSignature, outputs []*Symbol) { + changed := false + for i, output := range outputs { + storage := output.Type.(Ptr).Elem + declared := sig.ABI.Return.OutTypes[i] + if TypeEqual(storage, declared) || !bindingSlotCompatible(storage, declared) { + continue + } + if !TypeEqual(mergeBindingSlotType(storage, declared), storage) { + continue + } + sig.ABI.Return.OutTypes[i] = storage + changed = true + } + if !changed { + return + } + + sig.StorageName = sig.Mangled + "$outputs" + for _, output := range sig.ABI.Return.OutTypes { + sig.StorageName += "$" + output.Mangle() + } +} + func (c *Compiler) makeCallOutputWriteFlags(count int) []llvm.Value { flags := make([]llvm.Value, count) for i := range flags { @@ -3341,12 +3386,21 @@ func (c *Compiler) callArgs( } for i, arg := range call.Args { argVal := arg.Lowered.Val - if sig.ABI.Params[i].Mode == ABIParamIndirect && arg.AliasSelector > 0 && arg.AliasSelector <= len(outputs) { - argVal = outputs[arg.AliasSelector-1].Val + hasAlias := !arg.AliasSelector.IsNil() && + (!arg.AliasSelector.IsConstant() || arg.AliasSelector.ZExtValue() != 0) + if sig.ABI.Params[i].Mode == ABIParamIndirect && hasAlias { + for j, output := range outputs { + if !aliasableOutput(sig.ParamTypes[i], sig.ABI.Return.OutTypes[j]) { + continue + } + match := c.builder.CreateICmp(llvm.IntEQ, arg.AliasSelector, + llvm.ConstInt(c.Context.Int32Type(), uint64(j+1), false), arg.Name+"_arg_alias") + argVal = c.builder.CreateSelect(match, output.Val, argVal, arg.Name+"_arg_ref") + } } llvmArgs = append(llvmArgs, argVal) } - aliasIndices := make([]int, sig.ABI.NumAliasSlots()) + aliasIndices := make([]llvm.Value, sig.ABI.NumAliasSlots()) for i, arg := range call.Args { slot := sig.ABI.Params[i].AliasSlot if slot < 0 { @@ -3355,7 +3409,10 @@ func (c *Compiler) callArgs( aliasIndices[slot] = arg.AliasSelector } for _, aliasIndex := range aliasIndices { - llvmArgs = append(llvmArgs, llvm.ConstInt(c.Context.Int32Type(), uint64(aliasIndex), false)) + if aliasIndex.IsNil() { + aliasIndex = llvm.ConstInt(c.Context.Int32Type(), 0, false) + } + llvmArgs = append(llvmArgs, aliasIndex) } if sig.ABI.Return.Mode == ABIReturnDirect { seed := c.coerceSymbolForType(directSeed, sig.ABI.Return.DirectType, sig.FuncName+"_seed") diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 47b43d5b..9305bd09 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -111,8 +111,8 @@ res` scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) mangled := Mangle(MangleDirPath(moduleName, ""), "Add", []Type{I64, I64}) - require.Contains(t, scriptIR, "define noundef i64 @"+mangled+"(i64 noundef %0, i64 noundef %1, i64 noundef %2)", "expected direct scalar signature with a hidden destination seed") - require.Contains(t, scriptIR, "call i64 @"+mangled+"(i64 2, i64 3, i64 0)", "expected direct scalar call with a fresh-destination seed") + require.Contains(t, scriptIR, "define noundef i64 @"+mangled+"(i64 noundef %0, i64 noundef %1, i32 noundef %2, i32 noundef %3, i64 noundef %4)", "expected direct scalar signature with alias selectors and a hidden destination seed") + require.Contains(t, scriptIR, "call i64 @"+mangled+"(i64 2, i64 3, i32 0, i32 0, i64 0)", "expected direct scalar call with no aliases and a fresh-destination seed") require.NotContains(t, scriptIR, mangled+"_ret", "single-scalar return should not use sret struct") } @@ -152,8 +152,8 @@ res` scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) mangled := Mangle(MangleDirPath(moduleName, ""), "AddF", []Type{F64, F64}) - require.Contains(t, scriptIR, "define noundef double @"+mangled+"(double noundef %0, double noundef %1, double noundef %2)", "expected direct float signature with a hidden destination seed") - require.Contains(t, scriptIR, "call double @"+mangled+"(double 2.500000e+00, double 3.500000e+00, double 0.000000e+00)", "expected direct float call with a fresh-destination seed") + require.Contains(t, scriptIR, "define noundef double @"+mangled+"(double noundef %0, double noundef %1, i32 noundef %2, i32 noundef %3, double noundef %4)", "expected direct float signature with alias selectors and a hidden destination seed") + require.Contains(t, scriptIR, "call double @"+mangled+"(double 2.500000e+00, double 3.500000e+00, i32 0, i32 0, double 0.000000e+00)", "expected direct float call with no aliases and a fresh-destination seed") require.NotContains(t, scriptIR, mangled+"_ret", "single-scalar float return should not use sret struct") } @@ -431,11 +431,9 @@ out = Echo(value) } } -// Writing a parameter through %n promotes it to memory, which picks the aliased -// slot by pointer. Opaque pointers make a mistyped pointer select valid IR and -// the selector never matches the skipped index at runtime, so only the emitted -// slot selects distinguish this path. -func TestIterationSnapshotSelectsCompatibleOutput(t *testing.T) { +// Alias selectors retain the declared output positions even when an earlier +// output has a type that cannot back the input. +func TestInputAliasSelectsCompatibleOutput(t *testing.T) { code := `half, res = Rev(a, x) half = x * 0.5 res = a + x` @@ -443,17 +441,17 @@ func TestIterationSnapshotSelectsCompatibleOutput(t *testing.T) { h, r = Rev(r, 1:4) h, r` - ir, _ := compileScriptAndCodeIR(t, "iteration_snapshot_gap", code, script) + ir, _ := compileScriptAndCodeIR(t, "input_alias_gap", code, script) require.Regexp(t, `%a_alias_match_1 = icmp eq i32 %\d+, 2`, ir, "the compatible output is the second one, so its ABI selector value must be 2") require.Regexp(t, `%a_alias_value_1 = select i1 %a_alias_match_1, i64 %res_alias_load_1, i64 %\d+`, ir, - "selector 2 must read the caller's res destination once per iteration, falling back to the parameter") + "selector 2 must read the current res output, falling back to the parameter") require.NotContains(t, ir, "%a_alias_match_0", "the mismatched leading output must never be selectable as the parameter's value") } -func TestIterationSnapshotSkipsInputsNoOutputCanAlias(t *testing.T) { +func TestRangedCallDoesNotCopyUnrelatedArrayInput(t *testing.T) { // Both outputs are integers, so writing them can never change the array // input even though it is read after the first output write. Copying it // per iteration would make the call quadratic. @@ -464,28 +462,27 @@ func TestIterationSnapshotSkipsInputsNoOutputCanAlias(t *testing.T) { count, value = Read(data, 0:8) count, value` - ir, _ := compileScriptAndCodeIR(t, "iteration_snapshot_skip", code, script) + ir, _ := compileScriptAndCodeIR(t, "unrelated_array_input", code, script) require.NotContains(t, ir, "@arr_i64_copy", "an input no output can alias must not be copied per iteration") } -func TestIterationSnapshotCopiesAliasableInput(t *testing.T) { - // The heap-string input can back the heap-string output, so each - // iteration works on a private copy whatever the statement order. - code := `out, seen = FoldStr(current, item) - out = current ⊕ item - seen = current` - script := `items = ["b" "c"] -text = "a" ⊕ "" -text, last = FoldStr(text, items[0:2]) -text, last` +func TestRangedCallDoesNotCopyArrayInputWithMatchingOutputType(t *testing.T) { + // The output has the same type as the input, but each iteration selects + // only one element. Copying the input would turn this linear call quadratic. + code := `out = Pick(data, index) + out = [data[index]]` + script := `data = [0:8] +result = Pick(data, 0:8) +result` - ir, _ := compileScriptAndCodeIR(t, "iteration_snapshot_copy", code, script) + ir, _ := compileScriptAndCodeIR(t, "matching_array_input", code, script) - require.Contains(t, ir, "%current_iter_input", "the aliased input is loaded once per iteration") - require.Regexp(t, `%str_copy\d* = call ptr @\w+\(ptr %current_iter_input\)`, ir, - "the snapshot copies the loaded input before the body runs") + require.NotContains(t, ir, "@arr_i64_copy", + "a matching output type must not introduce an input copy on every iteration") + require.NotContains(t, ir, "%data_arg_ref", + "an input with a known-zero alias selector must use its original pointer directly") } func TestRangeCollectorScalarVariant(t *testing.T) { @@ -629,11 +626,11 @@ res` Range{Iter: I64}, }) - require.Contains(t, scriptIR, "define noundef i64 @"+scalarMangled+"(i64 noundef %0, i64 noundef %1, i64 noundef %2)", + require.Contains(t, scriptIR, "define noundef i64 @"+scalarMangled+"(i64 noundef %0, i64 noundef %1, i32 noundef %2, i32 noundef %3, i64 noundef %4)", "a shared driver must select the ordinary scalar specialization") require.GreaterOrEqual(t, strings.Count(scriptIR, "call i64 @"+scalarMangled+"("), 1, "the shared caller-side loop should invoke the scalar specialization") - require.Contains(t, scriptIR, "call i64 @"+scalarMangled+"(i64 %get, i64 %iter, i64 %call_seed)", + require.Contains(t, scriptIR, "call i64 @"+scalarMangled+"(i64 %get, i64 %iter, i32 0, i32 0, i64 %call_seed)", "the array access and scalar argument should use the same caller-loop iterator") require.NotContains(t, scriptIR, arrayRangeMangled, "arr[i] and i must not become independent callee iterators") diff --git a/compiler/format.go b/compiler/format.go index 39e52331..cf61b3d4 100644 --- a/compiler/format.go +++ b/compiler/format.go @@ -682,6 +682,12 @@ func (c *Compiler) formatSpecialValue(tok token.Token, mainID string, mainSym *S Msg: fmt.Sprintf("cannot write to constant %q", mainID), } } + if mainSym.ReadOnly { + return true, &token.CompileError{ + Token: tok, + Msg: fmt.Sprintf("cannot write to input parameter %q", mainID), + } + } s := c.promoteToMemory(mainID) result.args = append(result.args, s.Val) return true, nil diff --git a/compiler/live_alias_format_test.go b/compiler/live_alias_format_test.go new file mode 100644 index 00000000..b5b4e297 --- /dev/null +++ b/compiler/live_alias_format_test.go @@ -0,0 +1,38 @@ +package compiler + +import ( + "testing" + + "github.com/stretchr/testify/require" + "tinygo.org/x/go-llvm" +) + +func TestFormatCountRejectsInputParameter(t *testing.T) { + tests := []struct { + name string + script string + }{ + {name: "plain", script: "value = 10\nvalue = Count(value)\nvalue"}, + {name: "range", script: "value = Count(1:3)\nvalue"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + code := mustParseCode(t, `out = Count(current) + "count-current%n" + out = current`) + cc := NewCodeCompiler(ctx, "format_input_parameter", "", code) + require.Empty(t, cc.Compile()) + + sc := NewScriptCompiler(ctx, t.Name(), mustParseScript(t, tt.script), cc) + linkCodeModuleForTest(t, ctx, sc.Compiler.Module, cc.Compiler.Module) + errs := sc.Compile() + + require.Len(t, errs, 1) + require.Equal(t, `cannot write to input parameter "current"`, errs[0].Msg) + }) + } +} diff --git a/compiler/solver.go b/compiler/solver.go index 10176250..29cd39ab 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -2,6 +2,7 @@ package compiler import ( "fmt" + "maps" "slices" "github.com/thiremani/pluto/ast" @@ -145,6 +146,8 @@ type TypeSolver struct { PendingAssignments map[pendingAssignment]struct{} walkedFuncs map[string]walkedSpecialization // specializations walked in the current pass firstUnresolved *ast.FuncStatement + storageRevision uint64 // increments when a previously observed binding slot widens + previousSlotTypes map[string]Type // prior walk's slots for the body being inferred recLimit recursionLimit } @@ -172,6 +175,20 @@ func (ts *TypeSolver) recordBindingSlotType(name string, typ Type) { if f == nil { panic(fmt.Sprintf("internal: missing cached body %s while recording variable %s", ts.FuncNameMangled, name)) } + previous, exists := f.Vars[name] + if !exists { + previous, exists = ts.previousSlotTypes[name] + } + if exists { + // Rewalks retain storage learned from later statements. Publishing it + // only after the declaration keeps name resolution in source order. + if bindingSlotCompatible(typ, previous) { + typ = mergeBindingSlotType(typ, previous) + } + if !TypeEqual(previous, typ) { + ts.storageRevision++ + } + } f.Vars[name] = typ } @@ -714,10 +731,26 @@ func (ts *TypeSolver) TypeStatement(stmt ast.Statement) { func (ts *TypeSolver) Solve() { program := ts.ScriptCompiler.Program oldErrs := len(ts.Errors) - for _, stmt := range program.Statements { - ts.TypeStatement(stmt) - if len(ts.Errors) > oldErrs { - return + initialScope := ts.Scopes[0] + + // A later assignment can widen the storage read by an earlier call. + // Rebuild source-order facts until those call signatures match the slots. + for { + ts.Scopes[0] = Scope[Type]{ + Elems: maps.Clone(initialScope.Elems), + BindingOrder: slices.Clone(initialScope.BindingOrder), + ScopeKind: initialScope.ScopeKind, + } + revision := ts.storageRevision + + for _, stmt := range program.Statements { + ts.TypeStatement(stmt) + if len(ts.Errors) > oldErrs { + return + } + } + if revision == ts.storageRevision { + break } } @@ -2387,10 +2420,21 @@ func (ts *TypeSolver) callScopedArrayRangeType(expr ast.Expression) (ArrayRange, // Uses the shared TypeExprsForIter for the core logic. func (ts *TypeSolver) collectCallArgs(ce *ast.CallExpression, isRoot bool) (args []Type, innerArgs []Type, loopInside bool) { outerTypesPerArg, loopInside, _ := ts.TypeExprsForIter(ce.Arguments, isRoot) + _, builtin := Builtins[ce.Function.Value] // Build args and innerArgs from outer types // If loopInside=false, ALL range args become their inner type (loop outside) for argIndex, outerTypes := range outerTypesPerArg { + if ident, ok := ce.Arguments[argIndex].(*ast.Identifier); ok && !builtin { + // Calls receive the binding's actual slot, including ownership + // widening learned from later writes. Other expressions retain + // their flow type (an empty value can still reset another array). + body := ts.ScriptCompiler.Compiler.FuncCache[ts.FuncNameMangled] + if slotType, exists := body.Vars[ident.Value]; exists { + outerTypes = []Type{slotType} + } + } + if loopInside { if arrayRangeType, yieldedType, ok := ts.callScopedArrayRangeType(ce.Arguments[argIndex]); ok { args = append(args, arrayRangeType) @@ -2610,7 +2654,12 @@ func (ts *TypeSolver) TypeFunc(mangled string, template *ast.FuncStatement) bool info: f, template: template, } - clear(f.Vars) + revision := ts.storageRevision + previousSlots := ts.previousSlotTypes + ts.previousSlotTypes = f.Vars + f.Vars = make(map[string]Type) + defer func() { ts.previousSlotTypes = previousSlots }() + previousCycleStart := ts.recLimit.push(specializationFrame{ mangled: mangled, template: template, @@ -2623,6 +2672,10 @@ func (ts *TypeSolver) TypeFunc(mangled string, template *ast.FuncStatement) bool defer func() { ts.FuncNameMangled = savedFuncNameMangled }() ts.TypeBlock(template, f) + if revision != ts.storageRevision { + ts.Converging = true + } + return f.OutputTypesInferred() } diff --git a/compiler/solver_test.go b/compiler/solver_test.go index d01dda45..e068ac65 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -643,6 +643,84 @@ a = a ⊕ "d"` require.True(t, IsStrH(secondInfo.OutTypes[0]), "concat expression should remain StrH") } +func TestCallArgumentsUseSettledBindingSlotTypes(t *testing.T) { + for _, tt := range []struct { + name string + seed string + append string + item string + want Type + }{ + {"string scalar", `"hello"`, "item", `"abc"`, StrH{}}, + {"array range", "[]", "[item]", "1:3", Array{ElemType: I64, Rank: 1}}, + } { + t.Run(tt.name, func(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + code := mustParseCode(t, fmt.Sprintf(`out, before = Fold(current, item) + out = current ⊕ %s + before = current +`, tt.append)) + cc := NewCodeCompiler(ctx, t.Name(), "", code) + require.Empty(t, cc.Compile()) + source := fmt.Sprintf("value = %s\nvalue, before = Fold(value, %s)\nvalue, before", tt.seed, tt.item) + + for _, run := range []string{"Cold", "Warm"} { + ts := solveScriptTypes(t, ctx, cc, t.Name()+run, source) + stmt := ts.ScriptCompiler.Program.Statements[1].(*ast.LetStatement) + call := stmt.Value[0].(*ast.CallExpression) + info := ts.ExprCache[key(ts.FuncNameMangled, call)] + root := ts.ScriptCompiler.Script.Root + require.True(t, TypeEqual(tt.want, root.Vars["value"])) + require.True(t, TypeEqual(tt.want, info.CallParamTypes[0]), "call must specialize on the storage used by lowering") + require.True(t, TypeEqual(tt.want, info.ScalarCallParamTypes[0])) + require.True(t, TypeEqual(tt.want, info.OutTypes[1]), "copying the input must retain its ownership type") + argInfo := ts.ExprCache[key(ts.FuncNameMangled, call.Arguments[0])] + seed := ts.ScriptCompiler.Program.Statements[0].(*ast.LetStatement).Value[0] + seedInfo := ts.ExprCache[key(ts.FuncNameMangled, seed)] + require.True(t, TypeEqual(seedInfo.OutTypes[0], argInfo.OutTypes[0]), "argument expressions retain their flow type") + callee := cc.Compiler.FuncCache[Mangle(cc.Compiler.MangledPath, "Fold", info.CallParamTypes)] + require.NotNil(t, callee) + require.True(t, callee.Settled) + } + }) + } +} + +func TestLocalSlotRefinementRemanglesNestedCalls(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + code := mustParseCode(t, `out, before = Wrapper(item) + current = "hello" + current, previous = Fold(current, item) + out = current + before = previous + +out, before = Fold(current, item) + out = current ⊕ "-item" + before = current +`) + cc := NewCodeCompiler(ctx, t.Name(), "", code) + require.Empty(t, cc.Compile()) + wrapperKey := Mangle(cc.Compiler.MangledPath, "Wrapper", []Type{I64}) + foldKey := Mangle(cc.Compiler.MangledPath, "Fold", []Type{StrH{}, I64}) + wrapperTemplate := code.Statements[0].(*ast.FuncStatement) + call := wrapperTemplate.Body.Statements[1].(*ast.LetStatement).Value[0].(*ast.CallExpression) + + for _, run := range []string{"Cold", "Warm"} { + ts := solveScriptTypes(t, ctx, cc, t.Name()+run, "value, before = Wrapper(2)\nvalue, before") + wrapper := cc.Compiler.FuncCache[wrapperKey] + require.NotNil(t, wrapper) + require.True(t, wrapper.Settled) + require.True(t, IsStrH(wrapper.Vars["current"])) + require.True(t, IsStrH(wrapper.Sig.OutTypes[1])) + require.Equal(t, []string{foldKey}, wrapper.CFGResult.DirectCallees) + info := ts.ExprCache[key(wrapperKey, call)] + require.True(t, IsStrH(info.CallParamTypes[0])) + require.True(t, IsStrH(info.OutTypes[1])) + } +} + func TestMergeBindingSlotTypeIsMonotonic(t *testing.T) { headerOnly := Table{Columns: []TableColumn{ {Name: "Name", ElemType: Empty{}}, diff --git a/docs/Pluto ABI Optimization Plan.md b/docs/Pluto ABI Optimization Plan.md index 0fb2d699..12e732a6 100644 --- a/docs/Pluto ABI Optimization Plan.md +++ b/docs/Pluto ABI Optimization Plan.md @@ -28,10 +28,14 @@ After Phase 1, `fib_tail` is no longer a strong argument for a Pluto-level tail- Pluto's source-level semantics stay unchanged: - assignments copy -- inputs are logically read-only -- outputs are logically writable results flowing back to the caller +- input names are read-only, but can observe writes through a shared output +- output names are write-only, and results reach the caller at assignment commit -These are **language semantics**. How values physically move across a call boundary is the **lowered calling convention** — a separate concern. A read-only `I64` input can be passed by value without changing Pluto semantics. A single `I64` output can be returned in a register while still behaving like a Pluto output. +These are **language semantics**. How values physically move across a call +boundary is the **lowered calling convention** — a separate concern. An `I64` +input can be passed by value provided alias metadata redirects each read to +its shared output when required. A single `I64` output can be returned in a +register while still behaving like a Pluto output. ## 3. Architecture @@ -90,8 +94,9 @@ Direct lowering for scalar numeric inputs and single scalar outputs. - give every direct scalar return a final hidden destination seed, preserving skipped conditional writes and empty-range behavior without making the physical signature depend on the function body -- preserve range-bearing accumulator behavior with additional hidden alias - selectors where needed +- preserve live input/output sharing in both ordinary and range-bearing calls + with one hidden alias selector for every direct scalar input; reads use the + selected output's current value, including writes in the same iteration `MustWrite`/`MayWrite` has limited utility at the public boundary and must not decide whether the seed parameter exists. Adding one conditional output write diff --git a/docs/Pluto C ABI Spec.md b/docs/Pluto C ABI Spec.md index 0bc933e0..09bab589 100644 --- a/docs/Pluto C ABI Spec.md +++ b/docs/Pluto C ABI Spec.md @@ -1,6 +1,6 @@ # Pluto C ABI & Name Mangling Specification -**Version:** 2.0 | **Status:** Draft | **Target:** C11 / C++17 +**Version:** 2.1 | **Status:** Draft | **Target:** C11 / C++17 ## 1. Overview @@ -331,8 +331,10 @@ Module: `github.com/user/math`, RelPath: `stats/integral` The native calling convention is selected from the solved parameter and output types: -- `I64` and `F64` parameters are passed directly. Ranges, internal - `ArrayRange` descriptors, and other values are passed indirectly. +- `I64` and `F64` parameters are passed directly, with a hidden `i32` alias + selector for each direct parameter after all source parameters, in source + order. Ranges, internal `ArrayRange` descriptors, and other values are + passed indirectly. - A function with exactly one `I64` or `F64` output returns that scalar directly and receives one hidden seed value. The seed preserves the caller's staged value when the callee does not write its output, including a failed @@ -346,17 +348,20 @@ types: - Output expressions are staged independently at the call site, so one output cannot mutate a destination before a sibling right-hand side reads its statement-start value. -- When a compatible caller destination has a different ownership or shape - representation from the declared output, the ABI slot starts at the declared - type's zero value. The caller commits it only if its write marker is set. +- When a caller destination has a compatible wider ownership or shape + representation than the declared output, a private lowering variant uses + that output storage so an aliased input can observe its writes. It has a + distinct internal symbol; the source specialization and its effect facts + remain unchanged. Other representation changes use a separate ABI output + adapter initialized to zero and committed only if its write marker is set. The direct-return seed is always present, even when the function body unconditionally overwrites its output. Schematically, with mangled names abbreviated: ```c -int64_t Pt_Square_I64(int64_t x, int64_t seed); -int64_t Pt_ConditionalSquare_I64(int64_t x, int64_t seed); +int64_t Pt_Square_I64(int64_t x, int32_t x_output_alias, int64_t seed); +int64_t Pt_ConditionalSquare_I64(int64_t x, int32_t x_output_alias, int64_t seed); int64_t Pt_Acc_I64_Range( int64_t a, const PtRangeI64 *range, @@ -385,17 +390,28 @@ struct Results { bool *wrote1; }; -void Pt_example(Results *results, I64 direct_arg, Other *indirect_arg); +void Pt_example( + Results *results, + I64 direct_arg, + Other *indirect_arg, + int32_t direct_arg_output_alias +); ``` -Range-bearing variants may also receive hidden alias selectors for direct -scalar parameters that refer to an output destination. These preserve -loop-carried accumulation without changing the source signature or mangled -specialization identity. They do change the native C signature. +Every ordinary or range-bearing variant receives one hidden alias selector +for each direct scalar parameter. Zero selects the explicit argument value; +a positive value `k` selects output slot `k - 1`, whose type must match the +parameter. Each input read observes the selected output's current value, so a +write through an output is visible to a later read through an aliased input. For compatible indirect parameters, the caller instead passes the matching -staged output pointer itself, so the callee observes the same loop-carried -value without another hidden parameter. -Hidden ABI fields and parameters are not part of name mangling. +staged output pointer itself, without another hidden parameter. Both forms +also carry output values into subsequent range iterations. The caller's real +destinations remain unchanged until the surrounding assignment commits. + +Version 2.1 adds these selectors to ordinary variants as well as ranged ones; +C callers must supply zero for inputs that do not alias an output. This changes +the native C signature. Hidden ABI fields and parameters are not part of name +mangling. An eligible immediate bare `array[range]` call argument may therefore select an `ArrayRange` specialization and run its loop inside the callee. This diff --git a/docs/Pluto Effects and Follow-up Plan.md b/docs/Pluto Effects and Follow-up Plan.md index 600016e1..6dbe396c 100644 --- a/docs/Pluto Effects and Follow-up Plan.md +++ b/docs/Pluto Effects and Follow-up Plan.md @@ -22,23 +22,26 @@ type, and stored type separately, as the corrected code comment already does. Resolved by a language rule instead of an analysis ([PR #104](https://github.com/thiremani/pluto/pull/104), superseding the closed [PR #102](https://github.com/thiremani/pluto/pull/102)): declared outputs are -write-only inside their template, so a body can never observe its incoming -seed and the reproducer below is rejected at `y = y + 1`. The hidden seed and -destination-seeded staging slots stay as an unobservable keep-old carrier and -the public ABI is unchanged. Range-bearing variants additionally snapshot each -non-iterator input at the start of every scalar iteration, so an input -aliased to a destination reads the previous iteration's output rather than the -current iteration's write. The canonical description is in +write-only inside their template, so the reproducer below is rejected at +`y = y + 1`. The hidden seed and destination-seeded staging slots continue to +preserve outputs that are not written. A caller can explicitly connect an +input to an output by reusing the same binding: later statements then observe +writes through that output, in ordinary and ranged calls alike. Inputs are +read-only bindings, not frozen values. No per-iteration input snapshot is +needed. Direct scalar inputs use hidden alias selectors for ordinary as well +as ranged variants, which changes the native calling convention while keeping +its classification independent of body effects. The canonical description is in [the memory model](./Pluto%20Memory%20Model.md) under "Parameters and Outputs". -Still open from the same review, filed as -[issue #103](https://github.com/thiremani/pluto/issues/103): a call argument is -specialized on the binding's flow type at the call, while its storage uses the -merged slot type. -`s = "a"` followed by `s, prev = FoldStr(s, "b")`, where `FoldStr` writes -`out = current ⊕ item` and `seen = current`, prints an empty `prev`, and a -static destination used as a ranged accumulator does not feed back across -flavors. Both need the callee specialized on the destination's slot type. +The storage mismatch filed as +[issue #103](https://github.com/thiremani/pluto/issues/103) is addressed by +specializing binding arguments on their merged storage type and revisiting +calls when a later assignment widens that storage. Under live-reference +semantics, `s = "a"` followed by `s, prev = FoldStr(s, "b")`, where the body +writes `out = current ⊕ item` before `seen = current`, must produce `ab ab`. +Compatible wider output storage is handled by a private lowering variant, +preserving sharing without changing unrelated input types. These cases are +covered by `tests/alias_input`. The original analysis plan is kept below for the record. @@ -140,18 +143,24 @@ Retaining `%n` with a real write contract is a viable proposed direction. Its destination is an effectful operand even though it appears inside formatting syntax. This plan does not choose new source syntax or silently remove `%n`. -The baseline accepts a function that receives `x = 99`, evaluates -`"hello-x%n"`, and then returns `x`; it prints `hello` and returns 5. -`formatSpecialValue` in `compiler/format.go` checks the type and code globals, -but does not reject read-only parameters. CFG marker handling records reads. -`TestPromotedAliasTypeGap` deliberately uses this path, so its coverage needs a -replacement when the read-only rule is enforced. +The recorded baseline `840b147` accepts a function that receives `x = 99`, +evaluates `"hello-x%n"`, and then returns `x`; it prints `hello` and returns 5. +At that baseline, `formatSpecialValue` checks the type and code globals but +does not reject read-only parameters. -Required work if `%n` is retained: +The live-reference update now rejects `%n` writes to input and iterator +parameters through `Symbol.ReadOnly`, with ordinary and ranged rejection +covered by `TestFormatCountRejectsInputParameter`. The former +`TestPromotedAliasTypeGap` no longer mutates an input; its output-selector +coverage remains in `TestInputAliasSelectsCompatibleOutput`. The `acc_fmt` +fixture now writes a local count. CFG marker handling still records reads, +so the formatting write effects below remain unimplemented. -- Resolve and validate the destination as a writable location. Reject input - parameters, constants, and unsupported targets through the normal rules. - Identify inputs structurally; `Symbol.FuncArg` also covers writable outputs. +Remaining work if `%n` is retained: + +- Resolve and validate the destination as a writable location through the + normal rules, including unsupported targets. Retain the implemented input + and constant rejection; `Symbol.FuncArg` alone also covers writable outputs. - Record its write separately from reads of other markers and dynamic widths or precisions. `%n` does not inherently read the destination's previous value. - Describe whether execution reaches the write and whether it initializes the @@ -168,8 +177,9 @@ Required work if `%n` is retained: `vsnprintf` twice, so sizing and output passes need an explicit effect contract. - Do not let an unmodeled formatting write enter an ordinary PIR `eval` as if it were effect-free. Keep unsupported cases legacy or reject them explicitly. -- Test read-only rejection, writable locals/outputs, old-value liveness, - repeated markers, sequencing, skipped execution, aliases, and failure paths. +- Extend the existing rejection tests with writable locals/outputs, old-value + liveness, repeated markers, sequencing, skipped execution, aliases, and + failure paths. An explicit formatter/count output is another possible surface design. Choose that separately if it makes programs clearer; correctness does not require it. diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index d9f6186e..45b1b3a2 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -285,12 +285,16 @@ For owned heap values this may lower to an ownership swap without deep copies. If one owned source feeds multiple targets, at most one consumer takes it; the others require a derived copy. -The same snapshot rule holds across a call boundary: in `a = F(a)` the callee -reads the pre-call value through its read-only input for the whole call, -while its output writes land in the destination-seeded staging slot and reach -`a` only at commit. `tests/alias_input` pins this for direct scalars, heap -strings, and arrays (`y = x * 2` then `y = y + x` yields 15 for `a = 5`, not -20); Step 4's call lowering must preserve it. +At a call boundary, `a = F(a)` connects the callee input and output to the +same destination-seeded staging slot. The input name is read-only, but each +read observes earlier output writes to that slot. Reads within one assignment +still precede its writes. The real `a` changes only at the outer assignment's +commit, so sibling RHS expressions continue to read the pre-commit binding. +`tests/alias_input` pins both statement orders for ordinary and ranged calls, +with direct scalars, static and heap strings, and arrays: starting at 10, +`out = current + item` before `seen = current` yields `15 15` for item 5; +reversing those body statements yields `15 10`. Step 4's call lowering must +preserve this distinction between internal sharing and external commit. ## 7. Loop-Carried State @@ -850,10 +854,12 @@ Boundary resolution implies an **implicit read of the destination seed**, and only where the dependency is real: after a successful invocation, at an *existing* target whose direct callee output is `MayWrite`, resolved at `=`. A fresh destination, a discard, a nested or targetless call, or an -all-`MustWrite` callee reads nothing. That last case holds by construction: -declared outputs are write-only inside their template (the structural CFG -rejects every read, including formatting markers), so a body can never observe -its incoming seed and the seed stays an unobservable keep-old carrier. Step 2A +all-`MustWrite` callee introduces no implicit seed read. Declared outputs are +write-only inside their template (the structural CFG rejects every read, +including formatting markers), so the body cannot read the hidden seed through +an output name. An input explicitly shared with an output can observe the +staged value and later writes; that dependency is already an explicit argument +read at the call site. Step 2A records boundary resolution as a `ReadsSeed` fact on the call site — the CFG is untouched in 2A — and Step 2B converts the fact into an ordinary CFG read event, so a `MustWrite` classification cannot let backward liveness kill the diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index 62275907..cfcaafb8 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -257,10 +257,12 @@ res = sum(a, b) res = a + b ``` -- **Parameters**: Input values (passed by value for scalars). Inside the - body an input is fixed: a range-bearing variant captures every non-iterator - input at the start of each scalar iteration, so an input that the caller - aliases to a destination never observes that output's write mid-iteration. +- **Parameters**: Read-only bindings. A template cannot assign through an + input name, but an input may share a result slot with an output when the + caller uses the same binding as argument and destination. Each input read + observes that slot's current value, including writes from earlier statements + in the body. This rule applies to both ordinary and ranged calls and is + independent of whether the implementation passes the value or a pointer. - **Outputs**: Write-only inside their template. A body may assign an output any number of times, conditionally or not, and a nested call may target it, but reading it anywhere — a value, a condition, a call argument, a print, or @@ -268,23 +270,26 @@ res = sum(a, b) locals. Outputs are independently staged result slots: an existing destination supplies the initial value and a fresh destination starts at its type's zero value, so a body that writes nothing preserves the caller's - value without ever seeing it. The real destinations are committed only + value. The body may observe that value through an explicitly aliased input; + it cannot read the output name itself. The real destinations are committed only after every sibling right-hand side has been evaluated. - **No name overlap**: Parameters and outputs must have distinct names -When a caller destination and a function's declared output use different -representations of a compatible value (for example, owned versus static -strings, or an empty array type versus a concrete-rank array), the callee sees -the zero value of its declared representation. A per-output write marker tells -the caller whether to commit that adapted value. If the function does not -write the output, the caller's staged value is preserved. This avoids treating -one ownership or shape representation as if it were another. +Calls specialize binding arguments on their actual storage type. When a +caller's destination has a compatible wider representation than the declared +output (for example, an owned string slot receiving a static string, or a +concrete-rank array slot receiving `[]`), a private lowering variant uses that +wider output storage. An aliased input and output therefore continue to share +one slot: assigning `[]` makes a later input read observe the empty array. +An unrelated input keeps its own type and value. Other representation changes +use a separate output adapter with a per-output write marker; the caller only +commits its value when the callee actually writes the output. ### Call Site ```python res = sum(res, 5) -# - Parameter 'a' receives value of 'res' +# - Parameter 'a' shares the call's staged result slot for 'res' # - Parameter 'b' receives 5 # - Staged output 'res' starts with the caller destination's existing value # - Body executes: res = a + b @@ -292,14 +297,43 @@ res = sum(res, 5) ``` Reusing a variable as both an argument and a destination is how a caller -feeds an old value into a transformation. The template itself sees only its -declared inputs; `res = res + 1` inside `sum` would be rejected. +connects an input to a call's staged output. The template itself reads through +its declared inputs; `res = res + 1` inside `sum` would be rejected. + +```python +out, before = FoldBefore(current, item) + before = current + out = current + item + +out, after = FoldAfter(current, item) + out = current + item + after = current +``` + +Starting with `value = 10`, `value, seen = FoldBefore(value, 5)` produces +`15 10`, while `value, seen = FoldAfter(value, 5)` produces `15 15`. Assigning +the first output to a different binding leaves `current` unchanged, so +`other, seen = FoldAfter(value, 5)` instead produces `15 10`. Reads within one +assignment still precede its writes. + +Use a simultaneous assignment when swapping through shared inputs. In +`a, b = Swap(x, y)`, the body `a = y` followed by `b = x` makes +`p, q = Swap(p, q)` produce `2 2` from `p, q = 1, 2`: the second statement +reads the value just written through `a`. The body `a, b = y, x` instead +produces `2 1`, because both reads happen before either write. + +The sharing is internal to each call. For +`value, seen, old = FoldAfter(value, 5), value`, the result is `15 15 10`: +`seen` observes the call's updated slot, while the sibling right-hand side +reads the caller's binding before the assignment commits. With a range, the same reuse is an accumulation: `sum = Acc(sum, 1:5)` runs -the body once per yield, and the input that aliases the destination receives -the previous iteration's output at the start of the next iteration. Within an -iteration that input is stable. An empty range leaves an existing destination -unchanged and a fresh destination at its zero value. +the body once per yield, and each iteration continues from the previous +iteration's output. The body's statement order still applies within each +iteration. Starting from 10, `FoldBefore(value, 1:3)` produces `13 11` and +`FoldAfter(value, 1:3)` produces `13 13` when their first output targets +`value`. An empty range leaves an existing destination unchanged and a fresh +destination at its zero value. ### Range Parameters diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index f3a00101..791d5280 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -1,7 +1,26 @@ 15 hi!hi [1 2 9 1 2] -15 10 -13 11 -10 0 -abc ab +SequentialSwap: 2 2 +SimultaneousSwap: 2 1 +IntPlainBefore: 15 10 +IntPlainAfter: 15 15 +IntRangeBefore: 13 11 +IntRangeAfter: 13 13 +Empty: 10 0 +Separate: 10 15 10 +HeapRangeBefore: abc ab +HeapRangeAfter: abc abc +StaticPlainAfter: helloabc helloabc +ArrayRangeBefore: [10 1 2] [10 1] +ArrayRangeAfter: [10 1 2] [10 1 2] +NestedNumber: 15 15 +NestedString: helloabc helloabc +NestedArrayRange: [10 1 2] [10 1 2] +ConditionalTaken: 15 15 +ConditionalSkipped: 10 10 +RepeatedWrites: 16 +StagedString: helloabc helloabc hello +ResetArray: [ +] [1 2] [ +] diff --git a/tests/alias_input/self_alias.pt b/tests/alias_input/self_alias.pt index 7594e7e6..029c5dbc 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -7,13 +7,56 @@ s = Shout(t) r = Grow(q) r = q ⊕ [9] ⊕ q -# The input is read after the output is written. Without a range, the whole -# call sees the pre-call value; with a range, each iteration sees the value -# it started with, and the next iteration receives the written output. -out, seen = Fold(current, item) +a, b = SequentialSwap(x, y) + a = y + b = x + +a, b = SimultaneousSwap(x, y) + a, b = y, x + +# An input shared with an output observes writes from earlier statements. +out, before = FoldBefore(current, item) + before = current out = current + item - seen = current -out, seen = FoldStr(current, item) +out, after = FoldAfter(current, item) + out = current + item + after = current + +out, before = ConcatBefore(current, item) + before = current out = current ⊕ item + +out, after = ConcatAfter(current, item) + out = current ⊕ item + after = current + +out, before = ArrayBefore(current, item) + before = current + out = current ⊕ [item] + +out, after = ArrayAfter(current, item) + out = current ⊕ [item] + after = current + +out, seen = NestedFold(current, item) + out, seen = FoldAfter(current, item) + +out, seen = NestedConcat(current, item) + out, seen = ConcatAfter(current, item) + +out, seen = NestedArrayRange(current) + out, seen = ArrayAfter(current, (1:3) + 0) + +out, seen = ConditionalFold(current, item) + out = item > 0 current + item seen = current + +out = BumpTwice(current, item) + out = current + item + out = current + item + +out, left, right = ResetPair(first, second) + out = [] + left = first + right = second diff --git a/tests/alias_input/self_alias.spt b/tests/alias_input/self_alias.spt index d26427e4..aa395078 100644 --- a/tests/alias_input/self_alias.spt +++ b/tests/alias_input/self_alias.spt @@ -1,4 +1,4 @@ -# An input that aliases the output reads the pre-call value for the whole call. +# Reads in a single assignment precede its output write. a = 5 a = Twice(a) a @@ -8,16 +8,88 @@ w v = [1 2] v = Grow(v) v -single = 10 -single, was = Fold(single, 5) -single, was -value = 10 -value, before = Fold(value, 1:3) -value, before + +# Cross-aliases make statement order significant: only the simultaneous form swaps. +sequentialLeft, sequentialRight = 1, 2 +sequentialLeft, sequentialRight = SequentialSwap(sequentialLeft, sequentialRight) +"SequentialSwap:", sequentialLeft, sequentialRight +simultaneousLeft, simultaneousRight = 1, 2 +simultaneousLeft, simultaneousRight = SimultaneousSwap(simultaneousLeft, simultaneousRight) +"SimultaneousSwap:", simultaneousLeft, simultaneousRight + +# Both statement orders, in ordinary and ranged calls. +plainBefore = 10 +plainBefore, seenBefore = FoldBefore(plainBefore, 5) +"IntPlainBefore:", plainBefore, seenBefore +plainAfter = 10 +plainAfter, seenAfter = FoldAfter(plainAfter, 5) +"IntPlainAfter:", plainAfter, seenAfter +rangeBefore = 10 +rangeBefore, rangeSeenBefore = FoldBefore(rangeBefore, 1:3) +"IntRangeBefore:", rangeBefore, rangeSeenBefore +rangeAfter = 10 +rangeAfter, rangeSeenAfter = FoldAfter(rangeAfter, 1:3) +"IntRangeAfter:", rangeAfter, rangeSeenAfter empty = 10 -empty, never = Fold(empty, 0:0) -empty, never +empty, never = FoldAfter(empty, 0:0) +"Empty:", empty, never +separate = 10 +separateOut, separateSeen = FoldAfter(separate, 5) +"Separate:", separate, separateOut, separateSeen + +# Heap ownership and the static-to-heap specialization regression. items = ["b" "c"] -text = "a" ⊕ "" -text, last = FoldStr(text, items[0:2]) -text, last +heapBefore = "a" ⊕ "" +heapBefore, heapSeenBefore = ConcatBefore(heapBefore, items[0:2]) +"HeapRangeBefore:", heapBefore, heapSeenBefore +heapAfter = "a" ⊕ "" +heapAfter, heapSeenAfter = ConcatAfter(heapAfter, items[0:2]) +"HeapRangeAfter:", heapAfter, heapSeenAfter +staticText = "hello" +staticText, staticSeen = ConcatAfter(staticText, "abc") +"StaticPlainAfter:", staticText, staticSeen + +# Array copies must preserve a value saved before a later append. +arrayBefore = [10] +arrayBefore, arraySeenBefore = ArrayBefore(arrayBefore, 1:3) +"ArrayRangeBefore:", arrayBefore, arraySeenBefore +arrayAfter = [10] +arrayAfter, arraySeenAfter = ArrayAfter(arrayAfter, 1:3) +"ArrayRangeAfter:", arrayAfter, arraySeenAfter + +# Nested calls forward sharing even though their argument and target use +# different local names in the outer template. +nested = 10 +nested, nestedSeen = NestedFold(nested, 5) +"NestedNumber:", nested, nestedSeen +nestedText = "hello" ⊕ "" +nestedText, nestedTextSeen = NestedConcat(nestedText, "abc") +"NestedString:", nestedText, nestedTextSeen +nestedArray = [10] +nestedArray, nestedArraySeen = NestedArrayRange(nestedArray) +"NestedArrayRange:", nestedArray, nestedArraySeen + +# A skipped write leaves the shared value unchanged; multiple writes each +# read the latest value through the input. +taken = 10 +taken, takenSeen = ConditionalFold(taken, 5) +"ConditionalTaken:", taken, takenSeen +skipped = 10 +skipped, skippedSeen = ConditionalFold(skipped, -1) +"ConditionalSkipped:", skipped, skippedSeen +repeated = 10 +repeated = BumpTwice(repeated, 1:3) +"RepeatedWrites:", repeated + +# Sharing is internal to the call: a sibling RHS still reads the caller's +# pre-assignment binding until every RHS finishes. +staged = "hello" ⊕ "" +staged, stagedSeen, old = ConcatAfter(staged, "abc"), staged +"StagedString:", staged, stagedSeen, old + +# An untyped empty output resets the actual shared slot. The output's storage +# must follow its aliased second input, without changing the unrelated rank. +flat = [1 2] +matrix = [[3 4]] +matrix, flatSeen, matrixSeen = ResetPair(flat, matrix) +"ResetArray:", matrix, flatSeen, matrixSeen diff --git a/tests/math/acc_fmt.pt b/tests/math/acc_fmt.pt index 8949a6e4..38810a3a 100644 --- a/tests/math/acc_fmt.pt +++ b/tests/math/acc_fmt.pt @@ -1,3 +1,4 @@ res = AccFmt(a, x) - "count-a%n chars" - res = a + x + count = a + "count-count%n chars" + res = count + x diff --git a/tests/mem/mem_alias_refine.exp b/tests/mem/mem_alias_refine.exp index 8b32da5b..39b29f1e 100644 --- a/tests/mem/mem_alias_refine.exp +++ b/tests/mem/mem_alias_refine.exp @@ -1 +1 @@ -Refined: static Sibling: hello! +Refined: static Sibling: static! diff --git a/tests/mem/mem_alias_refine.pt b/tests/mem/mem_alias_refine.pt index 26a0ff20..f13d3726 100644 --- a/tests/mem/mem_alias_refine.pt +++ b/tests/mem/mem_alias_refine.pt @@ -1,7 +1,6 @@ # A heap-string argument whose same-named destination receives a static output. -# The ownership flavors differ (StrH in, StrG out), so the input must keep its -# own pointer: redirecting it to the output's adapter would make this sibling -# output, which reads the input, see the adapter's value instead. +# The output uses the destination's heap storage, so the later input read +# observes the replacement even though its expression produces a static string. out, echo = RefineEcho(s, x) out = "static" echo = x > -1 s ⊕ "!" diff --git a/tests/mem/mem_alias_refine.spt b/tests/mem/mem_alias_refine.spt index c32420dc..f6b37709 100644 --- a/tests/mem/mem_alias_refine.spt +++ b/tests/mem/mem_alias_refine.spt @@ -1,5 +1,4 @@ -# The range driver is what makes the callee alias-bearing, so this only covers -# the intended path while the call carries a range. +# Each iteration observes the output's earlier write through its shared input. text = "he" ⊕ "llo" sibling = "z" i = 0:3 From 31d99772c0f7e4bc4f4d273873333bc04dc97416 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 12 Sep 2026 18:32:40 +0530 Subject: [PATCH 07/56] docs(memory): state the in-body read order and explicit-save rule Reads precede writes in every simultaneous assignment, inside a body as at the call site, so a shared input still yields its prior value within the statement that writes the output. Keeping an old value across a write is an explicit assignment; that is where any copy is paid. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- docs/Pluto Memory Model.md | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b206bc21..b2d901cf 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ value = 10 value, seen = Fold(value, 5) # value = 15, seen = 15 ``` -Moving `seen = current` before `out = current + item` instead makes `seen` equal 10. The same order applies to each iteration of a ranged call. +Moving `seen = current` before `out = current + item` instead makes `seen` equal 10. The same order applies to each iteration of a ranged call. To keep an old value across a write, save it first with an explicit assignment; that is where any copy happens. ### Generics by use diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index cfcaafb8..e649fa0e 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -313,8 +313,15 @@ out, after = FoldAfter(current, item) Starting with `value = 10`, `value, seen = FoldBefore(value, 5)` produces `15 10`, while `value, seen = FoldAfter(value, 5)` produces `15 15`. Assigning the first output to a different binding leaves `current` unchanged, so -`other, seen = FoldAfter(value, 5)` instead produces `15 10`. Reads within one -assignment still precede its writes. +`other, seen = FoldAfter(value, 5)` instead produces `15 10`. + +Reads within one assignment precede its writes, inside a body as much as at +the call site. `out, before = current + item, current` therefore gives +`before` the value from before that statement even when `current` shares +`out`; the sharing becomes visible only to later statements. Keeping an old +value across a write is an explicit assignment that creates an independent +value, such as `saved = current` before `out = current + item`; the copy it +may cost sits at that assignment, not inside the call. Use a simultaneous assignment when swapping through shared inputs. In `a, b = Swap(x, y)`, the body `a = y` followed by `b = x` makes From 38c4eea35bb2b6d80d277370642286b1d3d2218f Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 12 Sep 2026 18:45:21 +0530 Subject: [PATCH 08/56] refactor(compiler): decide input/output aliasing at compile time Whether a call's argument shares a binding with one of its destinations is known from the call's names, so it no longer travels as a hidden i32 alias selector on every direct scalar parameter. A call whose argument names its own destination lowers to a private variant of the specialization, `$alias$` with internal linkage, in which a direct scalar input reads the output's current value and a compatible indirect input receives the staged output pointer. Nested calls and caller-driven ranges forward the sharing by name through the same alias bindings, replacing the run-time selects and pointer comparisons. The exported prototypes return to ABI 2.0, `(params..., seed)`, and the plain variant's IR matches master's, which recovers the 10% loss the selectors had caused on fib_tail: master-relative timings are now fib 0.97x, fib_tail 1.01x, harmonic 1.01x. Behavior is unchanged; every alias fixture prints the same output. Co-Authored-By: Claude Fable 5.1 --- compiler/abi.go | 48 ++--- compiler/compiler.go | 215 ++++++++++------------- compiler/compiler_test.go | 41 +++-- docs/Pluto ABI Optimization Plan.md | 5 +- docs/Pluto C ABI Spec.md | 51 +++--- docs/Pluto Effects and Follow-up Plan.md | 10 +- 6 files changed, 154 insertions(+), 216 deletions(-) diff --git a/compiler/abi.go b/compiler/abi.go index 98f60d80..b6c55a3c 100644 --- a/compiler/abi.go +++ b/compiler/abi.go @@ -15,10 +15,9 @@ const ( ) type ABIParam struct { - Source Type - Lowered Type - Mode ABIParamMode - AliasSlot int + Source Type + Lowered Type + Mode ABIParamMode } type ABIReturn struct { @@ -29,8 +28,9 @@ type ABIReturn struct { // FuncABI captures the lowered function boundary for one mangled variant. // Direct scalar returns carry a hidden destination seed so a skipped write -// preserves the caller's value. Direct scalar inputs carry hidden alias state -// so reads can observe writes through an output that shares their binding. +// preserves the caller's value. Whether an input shares a caller binding with +// an output is a compile-time property of each call site, lowered as a private +// variant of the function; it never appears in the native signature. type FuncABI struct { Params []ABIParam Return ABIReturn @@ -48,7 +48,7 @@ func isDirectScalarABIType(t Type) bool { } // aliasableOutput reports whether an output can back a parameter's alias slot. -// The hidden selector picks an output by position and the callee then reads that +// The alias pattern names an output by position and the callee then reads that // storage as the parameter's own type, so the two must lower identically. There // is no numeric conversion anywhere on this path, and a pointer selected across // mismatched types would be loaded as the wrong type. @@ -78,19 +78,15 @@ func classifyFuncABI(paramTypes []Type, outTypes []Type) FuncABI { }, } - aliasSlot := 0 for i, paramType := range paramTypes { paramABI := ABIParam{ - Source: paramType, - Lowered: Ptr{Elem: paramType}, - Mode: ABIParamIndirect, - AliasSlot: -1, + Source: paramType, + Lowered: Ptr{Elem: paramType}, + Mode: ABIParamIndirect, } if isDirectScalarABIType(paramType) { paramABI.Mode = ABIParamDirect paramABI.Lowered = paramType - paramABI.AliasSlot = aliasSlot - aliasSlot++ } abi.Params[i] = paramABI } @@ -110,16 +106,6 @@ func (abi FuncABI) UsesIndirectReturn() bool { return abi.Return.Mode == ABIReturnIndirect } -func (abi FuncABI) NumAliasSlots() int { - count := 0 - for _, param := range abi.Params { - if param.AliasSlot >= 0 { - count++ - } - } - return count -} - func (abi FuncABI) sourceParamBaseIndex() int { if abi.UsesIndirectReturn() { return 1 @@ -131,21 +117,9 @@ func (abi FuncABI) SourceFunctionParamIndex(paramIndex int) int { return abi.sourceParamBaseIndex() + paramIndex } -func (abi FuncABI) AliasParamBaseIndex() int { - return abi.sourceParamBaseIndex() + len(abi.Params) -} - -func (abi FuncABI) AliasFunctionParamIndex(paramIndex int) int { - slot := abi.Params[paramIndex].AliasSlot - if slot < 0 { - return -1 - } - return abi.AliasParamBaseIndex() + slot -} - func (abi FuncABI) DirectReturnSeedParamIndex() int { if abi.Return.Mode != ABIReturnDirect { return -1 } - return abi.AliasParamBaseIndex() + abi.NumAliasSlots() + return abi.sourceParamBaseIndex() + len(abi.Params) } diff --git a/compiler/compiler.go b/compiler/compiler.go index b960f1bc..68be9fae 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -76,22 +76,28 @@ type callArg struct { Name string Symbol *Symbol Lowered *Symbol - // AliasSelector is the one-based selector for the caller destination this - // argument aliases: 0 means none, N means output N-1. Direct scalar params - // transmit it as a hidden ABI argument; indirect params consume it - // caller-side to pass that output's staged pointer in place of the lowered - // argument. One-based keeps the zero value correct for arguments that - // alias nothing. A nested call may forward a run-time alias relationship. - AliasSelector llvm.Value -} - + // AliasOutput is the one-based caller destination this argument shares a + // binding with: 0 means none, N means output N-1. It is decided at compile + // time from the call's names, so it selects a lowering variant rather than + // travelling as an argument. One-based keeps the zero value correct for + // arguments that alias nothing. + AliasOutput int +} + +// callSignature is one call site's view of a specialization. Mangled is the +// solver's key; the lowered symbol additionally encodes call-site facts that +// change the emitted body but not its types: wider output storage and which +// inputs share a binding with which outputs. type callSignature struct { FuncName string Mangled string StorageName string // private lowering variant when output slots have wider storage - ParamTypes []Type - FnInfo *FuncInfo - ABI FuncABI + // AliasPattern holds, per parameter, the one-based output it shares a + // binding with at this call site, or 0. Nil means no parameter aliases. + AliasPattern []int + ParamTypes []Type + FnInfo *FuncInfo + ABI FuncABI } type preparedCall struct { @@ -101,13 +107,12 @@ type preparedCall struct { RetStruct llvm.Type } -// paramAlias tracks an aliased direct scalar param binding for the active -// function body. The Base check prevents alias behavior from leaking onto a -// same-name binding introduced later in the scope tree. +// paramAlias records that a parameter of the active function body shares its +// caller binding with the named output. The Base check prevents alias behavior +// from leaking onto a same-name binding introduced later in the scope tree. type paramAlias struct { - Base *Symbol - AliasIndex llvm.Value - OutputNames []string + Base *Symbol + Output string } type symbolSource int @@ -238,15 +243,11 @@ func identNames(idents []*ast.Identifier) []string { return names } -// bindParamAlias records the output names eagerly, but resolves their current -// values on every input read. Outputs may remain values or be replaced in scope -// without invalidating the input's reference to them. -func (c *Compiler) bindParamAlias(name string, sym *Symbol, aliasIndex llvm.Value, outputNames []string) { - c.currentParamAliases()[name] = ¶mAlias{ - Base: sym, - AliasIndex: aliasIndex, - OutputNames: append([]string(nil), outputNames...), - } +// bindParamAlias records the shared output by name and resolves its current +// value on every input read. The output may remain a value or be replaced in +// scope without invalidating the input's reference to it. +func (c *Compiler) bindParamAlias(name string, sym *Symbol, output string) { + c.currentParamAliases()[name] = ¶mAlias{Base: sym, Output: output} } func (c *Compiler) paramAliasFor(name string, sym *Symbol) (*paramAlias, bool) { @@ -319,17 +320,19 @@ func (c *Compiler) resolveCallSignature(funcName string, ce *ast.CallExpression, }, true } -// setCallArgAliasSelectors records on each argument which caller destination it -// aliases. Direct scalar params encode the selected -// output through a hidden ABI index; indirect params receive that output's -// staged pointer directly. Arguments that alias nothing keep selector 0. -func (c *Compiler) setCallArgAliasSelectors(sig *callSignature, args []callArg, dest []*ast.Identifier) { +// setCallArgAliases records on each argument which caller destination it +// shares a binding with, and derives the call's alias pattern from them. A +// direct scalar param then reads the output's current value inside the +// variant; an indirect param receives that output's staged pointer instead of +// its own. Everything is decided from names, so a nested call inside a variant +// forwards its enclosing input's alias without any run-time state. +func (c *Compiler) setCallArgAliases(sig *callSignature, args []callArg, dest []*ast.Identifier) { if dest == nil { return } + var pattern []int for paramIndex, arg := range args { - args[paramIndex].AliasSelector = llvm.ConstInt(c.Context.Int32Type(), 0, false) if arg.Name == "" { continue } @@ -344,40 +347,31 @@ func (c *Compiler) setCallArgAliasSelectors(sig *callSignature, args []callArg, if !aliasableOutput(sig.ParamTypes[paramIndex], sig.ABI.Return.OutTypes[outputIndex]) { continue } - selected := llvm.ConstInt(c.Context.Int32Type(), uint64(outputIndex+1), false) - if output.Value == arg.Name { - args[paramIndex].AliasSelector = selected - break + if output.Value != arg.Name && !c.inputAliasesOutput(arg.Name, output.Value) { + continue } - - if same := c.inputAliasesBinding(arg.Name, output.Value); !same.IsNil() { - args[paramIndex].AliasSelector = c.builder.CreateSelect(same, selected, args[paramIndex].AliasSelector, arg.Name+"_call_alias") + if pattern == nil { + pattern = make([]int, len(args)) } + args[paramIndex].AliasOutput = outputIndex + 1 + pattern[paramIndex] = outputIndex + 1 + break } } + + sig.AliasPattern = pattern } -// inputAliasesBinding preserves reference identity across nested calls, where -// the input and output may have different source names but share storage. -func (c *Compiler) inputAliasesBinding(input, output string) llvm.Value { +// inputAliasesOutput reports whether a name read inside a variant is an input +// that already shares the given output's binding, so a nested call targeting +// that output with this input keeps the same storage. +func (c *Compiler) inputAliasesOutput(input, output string) bool { sym, ok := Get(c.Scopes, input) if !ok { - return llvm.Value{} - } - if alias, ok := c.paramAliasFor(input, sym); ok { - for i, name := range alias.OutputNames { - if name == output { - return c.builder.CreateICmp(llvm.IntEQ, alias.AliasIndex, - llvm.ConstInt(c.Context.Int32Type(), uint64(i+1), false), input+"_forwards_alias") - } - } - } - - dest, ok := Get(c.Scopes, output) - if ok && sym.Type.Kind() == PtrKind && dest.Type.Kind() == PtrKind && sym.FuncArg && sym.ReadOnly { - return c.builder.CreateICmp(llvm.IntEQ, sym.Val, dest.Val, input+"_shares_output") + return false } - return llvm.Value{} + alias, ok := c.paramAliasFor(input, sym) + return ok && alias.Output == output } // directReturnSeedForCall captures the caller's current destination value for a @@ -433,37 +427,24 @@ func (c *Compiler) putGlobal(name, mangledName string, sym *Symbol) { c.MangledNames[name] = mangledName } +// directParamValue reads a direct scalar input that shares its binding with +// an output: the output's current value is the input's value. func (c *Compiler) directParamValue(name string, sym *Symbol, alias *paramAlias) *Symbol { - if alias == nil || len(alias.OutputNames) == 0 { - return sym - } - - value := sym.Val - for i, outputName := range alias.OutputNames { - // Skip rather than filter: the selector names an output by position, so - // index i must keep meaning the i-th output for the remaining slots. - outputSym, ok := Get(c.Scopes, outputName) - if !ok || !aliasableOutput(sym.Type, outputSym.Type) { - continue - } - match := c.builder.CreateICmp( - llvm.IntEQ, - alias.AliasIndex, - llvm.ConstInt(c.Context.Int32Type(), uint64(i+1), false), - fmt.Sprintf("%s_alias_match_%d", name, i), - ) - output, _ := c.localValSymbol(outputName, fmt.Sprintf("%s_alias_load_%d", outputName, i)) - aliasVal := c.coerceSymbolForType(output, sym.Type, fmt.Sprintf("%s_alias_value_%d", outputName, i)) - value = c.builder.CreateSelect(match, aliasVal.Val, value, fmt.Sprintf("%s_alias_value_%d", name, i)) + output, ok := c.localValSymbol(alias.Output, name+"_alias_load") + if !ok { + panic(fmt.Sprintf("internal: input %s aliases unbound output %s", name, alias.Output)) } resolved := GetCopy(sym) - resolved.Val = value + resolved.Val = c.coerceSymbolForType(output, sym.Type, name+"_alias_value").Val return resolved } +// valueSymbol reads a binding. An aliased direct scalar input reads its +// output's current value; an aliased indirect input already points at that +// output's storage, so it reads through its own pointer like any other. func (c *Compiler) valueSymbol(name string, sym *Symbol, loadName string) *Symbol { - if alias, ok := c.paramAliasFor(name, sym); ok { + if alias, ok := c.paramAliasFor(name, sym); ok && sym.Type.Kind() != PtrKind { return c.directParamValue(name, sym, alias) } return c.derefIfPointer(sym, loadName) @@ -2402,11 +2383,8 @@ func (c *Compiler) bindRangedTempOutputs(dest []*ast.Identifier, outputs []*Symb continue } seen[name] = struct{}{} - if sym.FuncArg && sym.ReadOnly && TypeEqual(sym.Type, current.Type) && TypeEqual(sym.Type, outputs[i].Type) { - shared := c.builder.CreateICmp(llvm.IntEQ, sym.Val, current.Val, name+"_range_alias") - reference := GetCopy(sym) - reference.Val = c.builder.CreateSelect(shared, outputs[i].Val, sym.Val, name+"_range_ref") - Put(c.Scopes, name, reference) + if alias, aliased := c.paramAliasFor(name, sym); aliased && alias.Output == dest[i].Value { + names = append(names, name) continue } if sym.Type.Kind() == PtrKind && sym.Val == current.Val { @@ -2553,9 +2531,6 @@ func (c *Compiler) getFuncType(mangled string, abi FuncABI) (llvm.Type, llvm.Typ for _, param := range abi.Params { llvmParams = append(llvmParams, c.mapToLLVMType(param.Lowered)) } - for i := 0; i < abi.NumAliasSlots(); i++ { - llvmParams = append(llvmParams, c.Context.Int32Type()) - } if abi.Return.Mode == ABIReturnDirect { llvmParams = append(llvmParams, c.mapToLLVMType(abi.Return.DirectType)) } @@ -2585,7 +2560,7 @@ func (c *Compiler) addPointerParamAttributes(function llvm.Value, index int) { func (c *Compiler) compileFunc(template *ast.FuncStatement, sig *callSignature, funcType llvm.Type, retStruct llvm.Type) llvm.Value { function := llvm.AddFunction(c.Module, sig.loweredName(), funcType) - if sig.StorageName != "" { + if sig.isVariant() { function.SetLinkage(llvm.InternalLinkage) } @@ -2606,9 +2581,6 @@ func (c *Compiler) compileFunc(template *ast.FuncStatement, sig *callSignature, c.addPointerParamAttributes(function, paramIndex) } - for i := 0; i < sig.ABI.NumAliasSlots(); i++ { - c.addNoundefAttribute(function, sig.ABI.AliasParamBaseIndex()+i+1) - } if seedParamIndex := sig.ABI.DirectReturnSeedParamIndex(); seedParamIndex >= 0 { c.addNoundefAttribute(function, seedParamIndex+1) } @@ -2720,9 +2692,6 @@ func (c *Compiler) processParams(template *ast.FuncStatement, sig *callSignature FuncArg: true, ReadOnly: true, } - if aliasParamIndex := sig.ABI.AliasFunctionParamIndex(i); aliasParamIndex >= 0 { - c.bindParamAlias(name, inputs[i], function.Param(aliasParamIndex), outputNames) - } } else { inputs[i] = &Symbol{ Val: paramVal, @@ -2732,6 +2701,12 @@ func (c *Compiler) processParams(template *ast.FuncStatement, sig *callSignature ReadOnly: true, } } + // An aliased indirect param already points at the output's staged + // storage; the binding lets nested calls and caller-side ranges + // forward that sharing by name. + if i < len(sig.AliasPattern) && sig.AliasPattern[i] > 0 { + c.bindParamAlias(name, inputs[i], outputNames[sig.AliasPattern[i]-1]) + } if isRangeDriverType(elemType) { iterIndices = append(iterIndices, i) @@ -3088,7 +3063,7 @@ func (c *Compiler) freeCallArgTemps(callArgs []callArg) { func (c *Compiler) prepareCall(sig *callSignature, ce *ast.CallExpression, dest []*ast.Identifier) preparedCall { callArgs := c.compileCallArgs(sig, ce) - c.setCallArgAliasSelectors(sig, callArgs, dest) + c.setCallArgAliases(sig, callArgs, dest) c.lowerCallArgs(sig.FuncName, callArgs, sig) fn, funcType, retStruct := c.getOrCompileCallFunction(sig) return preparedCall{ @@ -3291,11 +3266,26 @@ func (c *Compiler) compileIndirectCallIntoStagedOutputs( ) } +// loweredName is the symbol of the private variant this call site lowers to, +// or the public specialization when no call-site fact changes the body. func (sig *callSignature) loweredName() string { + name := sig.Mangled if sig.StorageName != "" { - return sig.StorageName + name = sig.StorageName + } + if sig.AliasPattern == nil { + return name } - return sig.Mangled + + name += "$alias" + for _, output := range sig.AliasPattern { + name += fmt.Sprintf("$%d", output) + } + return name +} + +func (sig *callSignature) isVariant() bool { + return sig.StorageName != "" || sig.AliasPattern != nil } // specializeOutputStorage keeps a writable output and a compatible input on @@ -3386,34 +3376,11 @@ func (c *Compiler) callArgs( } for i, arg := range call.Args { argVal := arg.Lowered.Val - hasAlias := !arg.AliasSelector.IsNil() && - (!arg.AliasSelector.IsConstant() || arg.AliasSelector.ZExtValue() != 0) - if sig.ABI.Params[i].Mode == ABIParamIndirect && hasAlias { - for j, output := range outputs { - if !aliasableOutput(sig.ParamTypes[i], sig.ABI.Return.OutTypes[j]) { - continue - } - match := c.builder.CreateICmp(llvm.IntEQ, arg.AliasSelector, - llvm.ConstInt(c.Context.Int32Type(), uint64(j+1), false), arg.Name+"_arg_alias") - argVal = c.builder.CreateSelect(match, output.Val, argVal, arg.Name+"_arg_ref") - } + if sig.ABI.Params[i].Mode == ABIParamIndirect && arg.AliasOutput > 0 && arg.AliasOutput <= len(outputs) { + argVal = outputs[arg.AliasOutput-1].Val } llvmArgs = append(llvmArgs, argVal) } - aliasIndices := make([]llvm.Value, sig.ABI.NumAliasSlots()) - for i, arg := range call.Args { - slot := sig.ABI.Params[i].AliasSlot - if slot < 0 { - continue - } - aliasIndices[slot] = arg.AliasSelector - } - for _, aliasIndex := range aliasIndices { - if aliasIndex.IsNil() { - aliasIndex = llvm.ConstInt(c.Context.Int32Type(), 0, false) - } - llvmArgs = append(llvmArgs, aliasIndex) - } if sig.ABI.Return.Mode == ABIReturnDirect { seed := c.coerceSymbolForType(directSeed, sig.ABI.Return.DirectType, sig.FuncName+"_seed") llvmArgs = append(llvmArgs, seed.Val) diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 9305bd09..d5431776 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -111,8 +111,8 @@ res` scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) mangled := Mangle(MangleDirPath(moduleName, ""), "Add", []Type{I64, I64}) - require.Contains(t, scriptIR, "define noundef i64 @"+mangled+"(i64 noundef %0, i64 noundef %1, i32 noundef %2, i32 noundef %3, i64 noundef %4)", "expected direct scalar signature with alias selectors and a hidden destination seed") - require.Contains(t, scriptIR, "call i64 @"+mangled+"(i64 2, i64 3, i32 0, i32 0, i64 0)", "expected direct scalar call with no aliases and a fresh-destination seed") + require.Contains(t, scriptIR, "define noundef i64 @"+mangled+"(i64 noundef %0, i64 noundef %1, i64 noundef %2)", "expected direct scalar signature with a hidden destination seed") + require.Contains(t, scriptIR, "call i64 @"+mangled+"(i64 2, i64 3, i64 0)", "expected direct scalar call with a fresh-destination seed") require.NotContains(t, scriptIR, mangled+"_ret", "single-scalar return should not use sret struct") } @@ -152,8 +152,8 @@ res` scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) mangled := Mangle(MangleDirPath(moduleName, ""), "AddF", []Type{F64, F64}) - require.Contains(t, scriptIR, "define noundef double @"+mangled+"(double noundef %0, double noundef %1, i32 noundef %2, i32 noundef %3, double noundef %4)", "expected direct float signature with alias selectors and a hidden destination seed") - require.Contains(t, scriptIR, "call double @"+mangled+"(double 2.500000e+00, double 3.500000e+00, i32 0, i32 0, double 0.000000e+00)", "expected direct float call with no aliases and a fresh-destination seed") + require.Contains(t, scriptIR, "define noundef double @"+mangled+"(double noundef %0, double noundef %1, double noundef %2)", "expected direct float signature with a hidden destination seed") + require.Contains(t, scriptIR, "call double @"+mangled+"(double 2.500000e+00, double 3.500000e+00, double 0.000000e+00)", "expected direct float call with a fresh-destination seed") require.NotContains(t, scriptIR, mangled+"_ret", "single-scalar float return should not use sret struct") } @@ -431,9 +431,11 @@ out = Echo(value) } } -// Alias selectors retain the declared output positions even when an earlier -// output has a type that cannot back the input. -func TestInputAliasSelectsCompatibleOutput(t *testing.T) { +// A call whose argument names one of its own destinations lowers to a private +// variant in which that input reads the output's storage. The pattern names +// outputs by declared position, so a leading output whose type cannot back the +// input keeps its slot in the name. +func TestAliasedInputReadsOutputInVariant(t *testing.T) { code := `half, res = Rev(a, x) half = x * 0.5 res = a + x` @@ -441,14 +443,15 @@ func TestInputAliasSelectsCompatibleOutput(t *testing.T) { h, r = Rev(r, 1:4) h, r` - ir, _ := compileScriptAndCodeIR(t, "input_alias_gap", code, script) + ir, _ := compileScriptAndCodeIR(t, "input_alias_variant", code, script) + mangled := Mangle(MangleDirPath("input_alias_variant", ""), "Rev", []Type{I64, Range{Iter: I64}}) - require.Regexp(t, `%a_alias_match_1 = icmp eq i32 %\d+, 2`, ir, - "the compatible output is the second one, so its ABI selector value must be 2") - require.Regexp(t, `%a_alias_value_1 = select i1 %a_alias_match_1, i64 %res_alias_load_1, i64 %\d+`, ir, - "selector 2 must read the current res output, falling back to the parameter") - require.NotContains(t, ir, "%a_alias_match_0", - "the mismatched leading output must never be selectable as the parameter's value") + require.Contains(t, ir, `define internal void @"`+mangled+`$alias$2$0"(`, + "the aliased call must lower to a private variant naming the second output for the first input") + require.Contains(t, ir, "%a_alias_load = load i64, ptr %res_dest", + "inside the variant the input reads the res output's storage directly") + require.NotContains(t, ir, "alias_match", "no run-time selection remains") + require.NotContains(t, ir, "define void @"+mangled+"(", "the unaliased specialization is not emitted when only the variant is called") } func TestRangedCallDoesNotCopyUnrelatedArrayInput(t *testing.T) { @@ -545,9 +548,9 @@ res` scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) mangled := Mangle(MangleDirPath(moduleName, ""), "Acc", []Type{I64, Range{Iter: I64}}) - require.Contains(t, scriptIR, "define noundef i64 @"+mangled+"(", "range-bearing variant should keep the direct scalar return") - require.Contains(t, scriptIR, "i64 noundef %0, ptr noundef nonnull \"captures\"=\"none\" %1, i32 noundef %2, i64 noundef %3", "range-bearing variant should keep the range indirect but lower scalar input/output directly with param attrs") - require.Contains(t, scriptIR, "call i64 @"+mangled+"(", "expected direct scalar call/return for ranged accumulator case") + require.Contains(t, scriptIR, `define internal noundef i64 @"`+mangled+`$alias$1$0"(`, "the self-aliased range-bearing call lowers to a private variant that keeps the direct scalar return") + require.Contains(t, scriptIR, "i64 noundef %0, ptr noundef nonnull \"captures\"=\"none\" %1, i64 noundef %2", "range-bearing variant should keep the range indirect but lower scalar input/output directly with param attrs") + require.Contains(t, scriptIR, `call i64 @"`+mangled+`$alias$1$0"(`, "expected direct scalar call/return for ranged accumulator case") require.NotContains(t, scriptIR, mangled+"_ret", "single-scalar range variant should not use sret struct") } @@ -626,11 +629,11 @@ res` Range{Iter: I64}, }) - require.Contains(t, scriptIR, "define noundef i64 @"+scalarMangled+"(i64 noundef %0, i64 noundef %1, i32 noundef %2, i32 noundef %3, i64 noundef %4)", + require.Contains(t, scriptIR, "define noundef i64 @"+scalarMangled+"(i64 noundef %0, i64 noundef %1, i64 noundef %2)", "a shared driver must select the ordinary scalar specialization") require.GreaterOrEqual(t, strings.Count(scriptIR, "call i64 @"+scalarMangled+"("), 1, "the shared caller-side loop should invoke the scalar specialization") - require.Contains(t, scriptIR, "call i64 @"+scalarMangled+"(i64 %get, i64 %iter, i32 0, i32 0, i64 %call_seed)", + require.Contains(t, scriptIR, "call i64 @"+scalarMangled+"(i64 %get, i64 %iter, i64 %call_seed)", "the array access and scalar argument should use the same caller-loop iterator") require.NotContains(t, scriptIR, arrayRangeMangled, "arr[i] and i must not become independent callee iterators") diff --git a/docs/Pluto ABI Optimization Plan.md b/docs/Pluto ABI Optimization Plan.md index 12e732a6..fec17df5 100644 --- a/docs/Pluto ABI Optimization Plan.md +++ b/docs/Pluto ABI Optimization Plan.md @@ -95,8 +95,9 @@ Direct lowering for scalar numeric inputs and single scalar outputs. skipped conditional writes and empty-range behavior without making the physical signature depend on the function body - preserve live input/output sharing in both ordinary and range-bearing calls - with one hidden alias selector for every direct scalar input; reads use the - selected output's current value, including writes in the same iteration + by lowering a call whose argument names its own destination to a private + alias variant, in which reads use that output's current value, including + writes in the same iteration; the exported signature is unchanged `MustWrite`/`MayWrite` has limited utility at the public boundary and must not decide whether the seed parameter exists. Adding one conditional output write diff --git a/docs/Pluto C ABI Spec.md b/docs/Pluto C ABI Spec.md index 09bab589..f71ffe70 100644 --- a/docs/Pluto C ABI Spec.md +++ b/docs/Pluto C ABI Spec.md @@ -1,6 +1,6 @@ # Pluto C ABI & Name Mangling Specification -**Version:** 2.1 | **Status:** Draft | **Target:** C11 / C++17 +**Version:** 2.0 | **Status:** Draft | **Target:** C11 / C++17 ## 1. Overview @@ -284,8 +284,7 @@ typedef struct { The descriptor occupies the ordinary source-parameter position. An indirect result carrier, when present, comes first; all source parameters follow in -source order; hidden alias selectors follow them; and a hidden direct-return -seed is last. +source order; and a hidden direct-return seed is last. --- @@ -331,10 +330,8 @@ Module: `github.com/user/math`, RelPath: `stats/integral` The native calling convention is selected from the solved parameter and output types: -- `I64` and `F64` parameters are passed directly, with a hidden `i32` alias - selector for each direct parameter after all source parameters, in source - order. Ranges, internal `ArrayRange` descriptors, and other values are - passed indirectly. +- `I64` and `F64` parameters are passed directly. Ranges, internal + `ArrayRange` descriptors, and other values are passed indirectly. - A function with exactly one `I64` or `F64` output returns that scalar directly and receives one hidden seed value. The seed preserves the caller's staged value when the callee does not write its output, including a failed @@ -360,8 +357,8 @@ unconditionally overwrites its output. Schematically, with mangled names abbreviated: ```c -int64_t Pt_Square_I64(int64_t x, int32_t x_output_alias, int64_t seed); -int64_t Pt_ConditionalSquare_I64(int64_t x, int32_t x_output_alias, int64_t seed); +int64_t Pt_Square_I64(int64_t x, int64_t seed); +int64_t Pt_ConditionalSquare_I64(int64_t x, int64_t seed); int64_t Pt_Acc_I64_Range( int64_t a, const PtRangeI64 *range, @@ -390,28 +387,24 @@ struct Results { bool *wrote1; }; -void Pt_example( - Results *results, - I64 direct_arg, - Other *indirect_arg, - int32_t direct_arg_output_alias -); +void Pt_example(Results *results, I64 direct_arg, Other *indirect_arg); ``` -Every ordinary or range-bearing variant receives one hidden alias selector -for each direct scalar parameter. Zero selects the explicit argument value; -a positive value `k` selects output slot `k - 1`, whose type must match the -parameter. Each input read observes the selected output's current value, so a -write through an output is visible to a later read through an aliased input. -For compatible indirect parameters, the caller instead passes the matching -staged output pointer itself, without another hidden parameter. Both forms -also carry output values into subsequent range iterations. The caller's real -destinations remain unchanged until the surrounding assignment commits. - -Version 2.1 adds these selectors to ordinary variants as well as ranged ones; -C callers must supply zero for inputs that do not alias an output. This changes -the native C signature. Hidden ABI fields and parameters are not part of name -mangling. +A call whose argument and destination are the same binding shares the input +with that output. This is a compile-time fact of the call site, so it never +appears in the exported signature. The compiler lowers such a call to a +private variant of the specialization, an internal symbol named +`$alias$$...` with one entry per parameter: `0` for an +unshared input, `k` for an input sharing output slot `k - 1`, whose type must +match the parameter. Inside the variant a direct scalar input reads the +output's current value, and for a compatible indirect input the caller passes +the matching staged output pointer itself. Each read therefore observes the +selected output's current value, and both forms carry output values into +subsequent range iterations. The caller's real destinations remain unchanged +until the surrounding assignment commits. A native caller cannot request a +variant: passing the same address for a pointer input and an output shares +them naturally, and a register scalar is always a plain value. Hidden ABI +fields and private variants are not part of name mangling. An eligible immediate bare `array[range]` call argument may therefore select an `ArrayRange` specialization and run its loop inside the callee. This diff --git a/docs/Pluto Effects and Follow-up Plan.md b/docs/Pluto Effects and Follow-up Plan.md index 6dbe396c..f185c782 100644 --- a/docs/Pluto Effects and Follow-up Plan.md +++ b/docs/Pluto Effects and Follow-up Plan.md @@ -28,9 +28,9 @@ preserve outputs that are not written. A caller can explicitly connect an input to an output by reusing the same binding: later statements then observe writes through that output, in ordinary and ranged calls alike. Inputs are read-only bindings, not frozen values. No per-iteration input snapshot is -needed. Direct scalar inputs use hidden alias selectors for ordinary as well -as ranged variants, which changes the native calling convention while keeping -its classification independent of body effects. The canonical description is in +needed. Sharing is a compile-time fact of each call site and lowers to a +private alias variant of the specialization, so the native calling convention +is unchanged and stays independent of body effects. The canonical description is in [the memory model](./Pluto%20Memory%20Model.md) under "Parameters and Outputs". The storage mismatch filed as @@ -151,8 +151,8 @@ does not reject read-only parameters. The live-reference update now rejects `%n` writes to input and iterator parameters through `Symbol.ReadOnly`, with ordinary and ranged rejection covered by `TestFormatCountRejectsInputParameter`. The former -`TestPromotedAliasTypeGap` no longer mutates an input; its output-selector -coverage remains in `TestInputAliasSelectsCompatibleOutput`. The `acc_fmt` +`TestPromotedAliasTypeGap` no longer mutates an input; its output-position +coverage remains in `TestAliasedInputReadsOutputInVariant`. The `acc_fmt` fixture now writes a local count. CFG marker handling still records reads, so the formatting write effects below remain unimplemented. From 8c862a22b30eb2971ed922a6c164002b0f2f3804 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 12 Sep 2026 18:54:29 +0530 Subject: [PATCH 09/56] refactor(compiler): mangle lowering variants with marker suffixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The private alias and output-storage variants were named with `$`, which LLVM accepts but which is not a C identifier character and did not follow the mangling scheme. Name them with the scheme's lowercase-marker-plus- count form, alongside `_fN`, `_tN`, and the reserved `_cN`: `_oN_` lists every output slot's storage type for a widened-storage variant and `_aN_...` carries the per-parameter alias pattern, `_oN` before `_aN` when both apply. MangleVariant builds the suffixes, Demangle parses them back into OutputStorage and AliasPattern and renders them as `-> (StrH, StrH)` and `[in1->out1]`, and the C ABI spec gains §5.2 plus grammar rules for VariantSym. Emitted symbols change; behavior does not. Co-Authored-By: Claude Fable 5.1 --- compiler/compiler.go | 33 ++++---------- compiler/compiler_test.go | 6 +-- compiler/mangle.go | 94 ++++++++++++++++++++++++++++++++++++++- compiler/mangle_test.go | 35 +++++++++++++++ docs/Pluto C ABI Spec.md | 56 ++++++++++++++++++----- 5 files changed, 183 insertions(+), 41 deletions(-) diff --git a/compiler/compiler.go b/compiler/compiler.go index 68be9fae..037113e6 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -89,9 +89,11 @@ type callArg struct { // change the emitted body but not its types: wider output storage and which // inputs share a binding with which outputs. type callSignature struct { - FuncName string - Mangled string - StorageName string // private lowering variant when output slots have wider storage + FuncName string + Mangled string + // OutputStorage lists every output slot's storage type when a caller + // destination is wider than the declared output; nil otherwise. + OutputStorage []Type // AliasPattern holds, per parameter, the one-based output it shares a // binding with at this call site, or 0. Nil means no parameter aliases. AliasPattern []int @@ -3269,23 +3271,11 @@ func (c *Compiler) compileIndirectCallIntoStagedOutputs( // loweredName is the symbol of the private variant this call site lowers to, // or the public specialization when no call-site fact changes the body. func (sig *callSignature) loweredName() string { - name := sig.Mangled - if sig.StorageName != "" { - name = sig.StorageName - } - if sig.AliasPattern == nil { - return name - } - - name += "$alias" - for _, output := range sig.AliasPattern { - name += fmt.Sprintf("$%d", output) - } - return name + return MangleVariant(sig.Mangled, sig.OutputStorage, sig.AliasPattern) } func (sig *callSignature) isVariant() bool { - return sig.StorageName != "" || sig.AliasPattern != nil + return sig.OutputStorage != nil || sig.AliasPattern != nil } // specializeOutputStorage keeps a writable output and a compatible input on @@ -3307,13 +3297,8 @@ func (c *Compiler) specializeOutputStorage(sig *callSignature, outputs []*Symbol sig.ABI.Return.OutTypes[i] = storage changed = true } - if !changed { - return - } - - sig.StorageName = sig.Mangled + "$outputs" - for _, output := range sig.ABI.Return.OutTypes { - sig.StorageName += "$" + output.Mangle() + if changed { + sig.OutputStorage = slices.Clone(sig.ABI.Return.OutTypes) } } diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index d5431776..90fe1e8d 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -446,7 +446,7 @@ h, r` ir, _ := compileScriptAndCodeIR(t, "input_alias_variant", code, script) mangled := Mangle(MangleDirPath("input_alias_variant", ""), "Rev", []Type{I64, Range{Iter: I64}}) - require.Contains(t, ir, `define internal void @"`+mangled+`$alias$2$0"(`, + require.Contains(t, ir, "define internal void @"+mangled+"_a2_2_0(", "the aliased call must lower to a private variant naming the second output for the first input") require.Contains(t, ir, "%a_alias_load = load i64, ptr %res_dest", "inside the variant the input reads the res output's storage directly") @@ -548,9 +548,9 @@ res` scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) mangled := Mangle(MangleDirPath(moduleName, ""), "Acc", []Type{I64, Range{Iter: I64}}) - require.Contains(t, scriptIR, `define internal noundef i64 @"`+mangled+`$alias$1$0"(`, "the self-aliased range-bearing call lowers to a private variant that keeps the direct scalar return") + require.Contains(t, scriptIR, "define internal noundef i64 @"+mangled+"_a2_1_0(", "the self-aliased range-bearing call lowers to a private variant that keeps the direct scalar return") require.Contains(t, scriptIR, "i64 noundef %0, ptr noundef nonnull \"captures\"=\"none\" %1, i64 noundef %2", "range-bearing variant should keep the range indirect but lower scalar input/output directly with param attrs") - require.Contains(t, scriptIR, `call i64 @"`+mangled+`$alias$1$0"(`, "expected direct scalar call/return for ranged accumulator case") + require.Contains(t, scriptIR, "call i64 @"+mangled+"_a2_1_0(", "expected direct scalar call/return for ranged accumulator case") require.NotContains(t, scriptIR, mangled+"_ret", "single-scalar range variant should not use sret struct") } diff --git a/compiler/mangle.go b/compiler/mangle.go index 7bf59ae6..108d6c4a 100644 --- a/compiler/mangle.go +++ b/compiler/mangle.go @@ -14,6 +14,8 @@ const ( R = "r" // Relpath end marker (for constants with relpath) F = "f" // Function arity marker T = "t" // Generic type params marker + A = "a" // Alias variant marker: per-parameter output slot pattern + O = "o" // Output storage variant marker: widened output slot types M = "m" // Method separator OP = "op" // Operator prefix N = "n" // Numeric segment prefix @@ -58,6 +60,13 @@ type Demangled struct { Kind SymbolKind // Type of symbol Arity int // Number of arguments (for functions) ArgTypes []string // Argument type names (for functions) + // OutputStorage lists every output slot's storage type for a private + // output-storage variant; nil for the public specialization. + OutputStorage []string + // AliasPattern holds, per parameter, the one-based output slot the + // parameter shares at the call site, 0 for none; nil when no parameter + // aliases. Present only on private alias variants. + AliasPattern []int } // FullPath returns the complete path (ModPath + RelPath). @@ -89,9 +98,32 @@ func (d *Demangled) String() string { result.WriteString(strings.Join(d.ArgTypes, ", ")) result.WriteString(")") } + if d.OutputStorage != nil { + result.WriteString(" -> (") + result.WriteString(strings.Join(d.OutputStorage, ", ")) + result.WriteString(")") + } + if aliases := d.aliasDisplay(); aliases != "" { + result.WriteString(" [") + result.WriteString(aliases) + result.WriteString("]") + } return result.String() } +// aliasDisplay renders the non-zero alias pattern entries as in->out, +// both one-based, in parameter order. +func (d *Demangled) aliasDisplay() string { + var parts []string + for i, slot := range d.AliasPattern { + if slot == 0 { + continue + } + parts = append(parts, fmt.Sprintf("in%d->out%d", i+1, slot)) + } + return strings.Join(parts, ", ") +} + // Mangle generates C ABI-compliant function name per Pluto C ABI Spec. // Format: [MangledPath]_[Name]_f[N]_[Types...] // mangledPath is pre-computed via MangleDirPath. @@ -103,6 +135,29 @@ func Mangle(mangledPath, funcName string, args []Type) string { return strings.Join(parts, SEP) } +// MangleVariant names a private lowering variant of a function specialization +// per Pluto C ABI Spec §5.2. An output-storage suffix _oN_ lists +// every output slot's storage type; an alias suffix _aN_... carries one +// entry per parameter, 0 for a parameter sharing no output and k for one +// sharing output k-1. A nil slice omits its suffix, so two nils return the +// public specialization symbol unchanged. +func MangleVariant(mangled string, outputStorage []Type, aliasPattern []int) string { + parts := []string{mangled} + if outputStorage != nil { + parts = append(parts, O+strconv.Itoa(len(outputStorage))) + for _, storage := range outputStorage { + parts = append(parts, storage.Mangle()) + } + } + if aliasPattern != nil { + parts = append(parts, A+strconv.Itoa(len(aliasPattern))) + for _, slot := range aliasPattern { + parts = append(parts, strconv.Itoa(slot)) + } + } + return strings.Join(parts, SEP) +} + // ManglePath converts a logical path to its mangled form per Pluto C ABI Spec. // Separators: . -> d, / -> s, - -> h // Identifiers are length-prefixed. @@ -390,14 +445,49 @@ func demangleFunc(result *Demangled, rest string) { // Parse argument types for strings.HasPrefix(rest, SEP) { - rest = rest[len(SEP):] - typeName, remaining := demangleType(rest) + typeName, remaining := demangleType(rest[len(SEP):]) if typeName == "" { break } result.ArgTypes = append(result.ArgTypes, typeName) rest = remaining } + + demangleVariant(result, rest) +} + +// demangleVariant parses the optional private-variant suffixes that follow a +// function's argument types: _oN and N storage types, then _aN and N slots. +func demangleVariant(result *Demangled, rest string) { + if after, ok := strings.CutPrefix(rest, SEP+O); ok && startsWithDigit(after) { + count, remaining := parseArity(after) + result.OutputStorage = []string{} + for i := 0; i < count && strings.HasPrefix(remaining, SEP); i++ { + typeName, next := demangleType(remaining[len(SEP):]) + if typeName == "" { + break + } + result.OutputStorage = append(result.OutputStorage, typeName) + remaining = next + } + rest = remaining + } + + after, ok := strings.CutPrefix(rest, SEP+A) + if !ok || !startsWithDigit(after) { + return + } + count, remaining := parseArity(after) + result.AliasPattern = []int{} + for i := 0; i < count && strings.HasPrefix(remaining, SEP) && startsWithDigit(remaining[len(SEP):]); i++ { + slot, next := parseArity(remaining[len(SEP):]) + result.AliasPattern = append(result.AliasPattern, slot) + remaining = next + } +} + +func startsWithDigit(s string) bool { + return len(s) > 0 && s[0] >= '0' && s[0] <= '9' } // parseArity parses arity digits from s. diff --git a/compiler/mangle_test.go b/compiler/mangle_test.go index b04e0611..6103892a 100644 --- a/compiler/mangle_test.go +++ b/compiler/mangle_test.go @@ -821,3 +821,38 @@ func TestMangleScriptUsesPathEncoding(t *testing.T) { assert.Equal(t, "Pt_7example_d_3com_s_4math_s_2v1_d_n2_d_n3_p_7reports_s_5daily_r_n1_d_n2_h_7summary_e", mangled) } + +func TestMangleVariantRoundTrip(t *testing.T) { + base := Mangle(MangleDirPath("math", ""), "Fold", []Type{I64, StrH{}}) + tests := []struct { + name string + storage []Type + pattern []int + mangled string + expected string + }{ + {name: "public specialization", mangled: base, expected: "math.Fold(I64, StrH)"}, + {name: "alias variant", pattern: []int{1, 0}, mangled: base + "_a2_1_0", expected: "math.Fold(I64, StrH) [in1->out1]"}, + {name: "storage variant", storage: []Type{StrH{}, StrH{}}, mangled: base + "_o2_StrH_StrH", expected: "math.Fold(I64, StrH) -> (StrH, StrH)"}, + {name: "storage and alias variant", storage: []Type{StrH{}, I64}, pattern: []int{2, 1}, mangled: base + "_o2_StrH_I64_a2_2_1", expected: "math.Fold(I64, StrH) -> (StrH, I64) [in1->out2, in2->out1]"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mangled := MangleVariant(base, tt.storage, tt.pattern) + assert.Equal(t, tt.mangled, mangled) + assert.Equal(t, tt.expected, Demangle(mangled)) + + parsed, err := DemangleParsed(mangled) + assert.NoError(t, err) + assert.Equal(t, SymbolFunc, parsed.Kind) + assert.Equal(t, []string{"I64", "StrH"}, parsed.ArgTypes) + assert.Equal(t, tt.pattern, parsed.AliasPattern) + if tt.storage == nil { + assert.Nil(t, parsed.OutputStorage) + return + } + assert.Len(t, parsed.OutputStorage, len(tt.storage)) + }) + } +} diff --git a/docs/Pluto C ABI Spec.md b/docs/Pluto C ABI Spec.md index f71ffe70..6e24b209 100644 --- a/docs/Pluto C ABI Spec.md +++ b/docs/Pluto C ABI Spec.md @@ -393,18 +393,15 @@ void Pt_example(Results *results, I64 direct_arg, Other *indirect_arg); A call whose argument and destination are the same binding shares the input with that output. This is a compile-time fact of the call site, so it never appears in the exported signature. The compiler lowers such a call to a -private variant of the specialization, an internal symbol named -`$alias$$...` with one entry per parameter: `0` for an -unshared input, `k` for an input sharing output slot `k - 1`, whose type must -match the parameter. Inside the variant a direct scalar input reads the -output's current value, and for a compatible indirect input the caller passes -the matching staged output pointer itself. Each read therefore observes the -selected output's current value, and both forms carry output values into -subsequent range iterations. The caller's real destinations remain unchanged -until the surrounding assignment commits. A native caller cannot request a -variant: passing the same address for a pointer input and an output shares -them naturally, and a register scalar is always a plain value. Hidden ABI -fields and private variants are not part of name mangling. +private alias variant of the specialization (§5.2). Inside the variant a +direct scalar input reads the output's current value, and for a compatible +indirect input the caller passes the matching staged output pointer itself. +Each read therefore observes the selected output's current value, and both +forms carry output values into subsequent range iterations. The caller's real +destinations remain unchanged until the surrounding assignment commits. A +native caller cannot request a variant: passing the same address for a +pointer input and an output shares them naturally, and a register scalar is +always a plain value. An eligible immediate bare `array[range]` call argument may therefore select an `ArrayRange` specialization and run its loop inside the callee. This @@ -433,6 +430,37 @@ collector for an item type `T` will be passed as `PtArrayT *` in the final native parameter position; this statement reserves the position but does not make it part of the current calling convention. +### 5.2 Private Lowering Variants + +Two facts of a call site change the emitted body of a specialization without +changing its types. Each lowers to a private variant: an internal symbol that +appends a suffix to the ordinary function mangle and is never exported. The +suffixes use the same lowercase-marker-plus-count form as `_fN`, `_tN`, and +the reserved `_cN`, so they parse unambiguously after the argument types. + +``` +_oN_ +_aN__... +``` + +`_oN` is the output-storage variant. It lists the storage type of every +output slot, in declaration order, when a caller destination holds a +compatible wider representation than the declared output (an owned `StrH` +slot receiving a `StrG` output, or a concrete-rank array slot receiving `[]`). +`_aN` is the alias variant. It carries one entry per parameter, in source +order: `0` for a parameter that shares no output, `k` for one that shares +output slot `k - 1`, whose type must match the parameter. When both apply, +`_oN` precedes `_aN`. + +Examples: `Pt_4math_p_4Fold_f2_I64_StrH_a2_1_0` is `Fold(I64, StrH)` with its +first parameter sharing its first output; `..._o2_StrH_StrH` is the same +function writing both outputs into owned string slots. `Demangle` renders +these as `math.Fold(I64, StrH) [in1->out1]` and +`math.Fold(I64, StrH) -> (StrH, StrH)`. + +The public specialization symbol is unchanged by either variant. C callers +never see a variant and cannot request one. + --- ## 6. Grammar @@ -440,6 +468,9 @@ make it part of the current calling convention. ```ebnf FunctionSym := 'Pt' ModPath '_p_' Ident '_f' Arity Types | 'Pt' ModPath '_p_' RelPath '_r_' Ident '_f' Arity Types +VariantSym := FunctionSym OutputStorage? AliasPattern? (* internal linkage only *) +OutputStorage := '_o' Num Types +AliasPattern := '_a' Num ('_' Num)* MethodSym := 'Pt' ModPath '_p_' Ident '_m_' Ident '_f' Arity Types | 'Pt' ModPath '_p_' RelPath '_r_' Ident '_m_' Ident '_f' Arity Types OperatorSym := 'Pt' ModPath '_p_' Ident '_m_op_' Opcode '_' Fixity Types @@ -496,4 +527,5 @@ Generic := (Qualified | Ident) '_t' Num Types * Numeric path segments preserve source digits; `Num` keeps arities, counts, and length prefixes canonical * Operators: Fixity implies arity (in=2, pre/suf=1, cirN=N); Types listed left-to-right * Generics (`_tN`) only in type arguments, not as top-level linkable symbols +* Variant suffixes (`_oN`, `_aN`) name private lowering variants (§5.2); they follow the argument types and never appear on exported symbols * All symbols always have `_p_` after ModPath; symbols with relpath use `_r_`, and script roots end with `_e` From ceb8a891cd3d2cea6c3e64a3ea03a68fe6dd2546 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 12 Sep 2026 19:15:30 +0530 Subject: [PATCH 10/56] fix(compiler): keep alias identity through ranged staging and conditionals Two lowerings replaced the name a shared input was recorded under, so a nested call inside them selected the unshared variant. Ranged staging rebinds an aliased input to the staged output slot, which no longer matched paramAlias.Base; `out, seen = Fold(current, (1:3) + 0)` in a wrapper called with a shared input gave `13 11` instead of `13 13`, and a second ranged call in the same body gave `20 16` instead of `20 20`. Conditional lowering targets synthetic `$c_cond_` destinations that never equal the recorded output name; `item > 0 Fold(current, item)` gave `15 10` instead of `15 15`. A parameter may now carry several alias bases, and ranged staging registers the staged slot as one. Conditional temps record the source destination they commit into, and call-site aliasing resolves through that map before comparing names. Fixtures cover both ranged shapes, arrays, and the conditional call taken and skipped. The C ABI spec is versioned to 2.1: master's range-bearing variants carried hidden alias selectors before the seed, and removing them moves the seed, so those prototypes change. Native pointer sharing is scoped to the called body's own statements; a nested Pluto call stages its outputs and does not extend it. Co-Authored-By: Claude Fable 5.1 --- compiler/compiler.go | 55 ++++++++++++++++++++++++-------- compiler/cond.go | 2 ++ docs/Pluto C ABI Spec.md | 29 ++++++++++------- tests/alias_input/self_alias.exp | 5 +++ tests/alias_input/self_alias.pt | 19 +++++++++++ tests/alias_input/self_alias.spt | 15 +++++++++ 6 files changed, 101 insertions(+), 24 deletions(-) diff --git a/compiler/compiler.go b/compiler/compiler.go index 037113e6..3c4edcec 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -111,7 +111,9 @@ type preparedCall struct { // paramAlias records that a parameter of the active function body shares its // caller binding with the named output. The Base check prevents alias behavior -// from leaking onto a same-name binding introduced later in the scope tree. +// from leaking onto a same-name binding introduced later in the scope tree; a +// lowering that rebinds the parameter on purpose, such as ranged staging, +// registers the new symbol as a further base. type paramAlias struct { Base *Symbol Output string @@ -145,9 +147,12 @@ type Compiler struct { ExprCache map[ExprKey]*ExprInfo FuncNameMangled string // current script root or function specialization key Errors []*token.CompileError - paramAliasStack []map[string]*paramAlias + paramAliasStack []map[string][]*paramAlias outputSlotTypes map[string]Type - stmtCtxStack []stmtCtx + // condTempDest maps a synthetic conditional destination to the source + // destination it stands in for, so call-site aliasing sees through it. + condTempDest map[string]string + stmtCtxStack []stmtCtx } type stmtCtx struct { @@ -190,7 +195,8 @@ func NewCompiler(ctx llvm.Context, mangledPath string, cc *CodeCompiler) *Compil ExprCache: exprCache, FuncNameMangled: "", Errors: []*token.CompileError{}, - paramAliasStack: []map[string]*paramAlias{}, + paramAliasStack: []map[string][]*paramAlias{}, + condTempDest: make(map[string]string), stmtCtxStack: []stmtCtx{}, } } @@ -222,7 +228,7 @@ func (c *Compiler) bindingSlotType(name string, fallback Type) Type { return typ } -func (c *Compiler) currentParamAliases() map[string]*paramAlias { +func (c *Compiler) currentParamAliases() map[string][]*paramAlias { if len(c.paramAliasStack) == 0 { return nil } @@ -230,7 +236,7 @@ func (c *Compiler) currentParamAliases() map[string]*paramAlias { } func (c *Compiler) pushParamAliases() { - c.paramAliasStack = append(c.paramAliasStack, make(map[string]*paramAlias)) + c.paramAliasStack = append(c.paramAliasStack, make(map[string][]*paramAlias)) } func (c *Compiler) popParamAliases() { @@ -249,15 +255,29 @@ func identNames(idents []*ast.Identifier) []string { // value on every input read. The output may remain a value or be replaced in // scope without invalidating the input's reference to it. func (c *Compiler) bindParamAlias(name string, sym *Symbol, output string) { - c.currentParamAliases()[name] = ¶mAlias{Base: sym, Output: output} + aliases := c.currentParamAliases() + aliases[name] = append(aliases[name], ¶mAlias{Base: sym, Output: output}) } func (c *Compiler) paramAliasFor(name string, sym *Symbol) (*paramAlias, bool) { - alias, ok := c.currentParamAliases()[name] - if !ok || alias.Base != sym { - return nil, false + for _, alias := range c.currentParamAliases()[name] { + if alias.Base == sym { + return alias, true + } + } + return nil, false +} + +// destinationBase resolves a synthetic conditional destination to the source +// destination it commits into, following stage temps through commit temps. +func (c *Compiler) destinationBase(name string) string { + for { + base, synthetic := c.condTempDest[name] + if !synthetic { + return name + } + name = base } - return alias, true } func (c *Compiler) resolvedDestTypes(dest []*ast.Identifier, outTypes []Type) []Type { @@ -349,7 +369,8 @@ func (c *Compiler) setCallArgAliases(sig *callSignature, args []callArg, dest [] if !aliasableOutput(sig.ParamTypes[paramIndex], sig.ABI.Return.OutTypes[outputIndex]) { continue } - if output.Value != arg.Name && !c.inputAliasesOutput(arg.Name, output.Value) { + base := c.destinationBase(output.Value) + if base != arg.Name && !c.inputAliasesOutput(arg.Name, base) { continue } if pattern == nil { @@ -2375,7 +2396,9 @@ func (c *Compiler) bindRangedTempOutputs(dest []*ast.Identifier, outputs []*Symb } names := []string{dest[i].Value} + aliased := make(map[string]string) if current, ok := Get(c.Scopes, dest[i].Value); ok && current.Type.Kind() == PtrKind { + base := c.destinationBase(dest[i].Value) seen := make(map[string]struct{}) for scopeIdx := len(c.Scopes) - 1; scopeIdx >= 0; scopeIdx-- { scope := c.Scopes[scopeIdx] @@ -2385,8 +2408,9 @@ func (c *Compiler) bindRangedTempOutputs(dest []*ast.Identifier, outputs []*Symb continue } seen[name] = struct{}{} - if alias, aliased := c.paramAliasFor(name, sym); aliased && alias.Output == dest[i].Value { + if alias, ok := c.paramAliasFor(name, sym); ok && alias.Output == base { names = append(names, name) + aliased[name] = alias.Output continue } if sym.Type.Kind() == PtrKind && sym.Val == current.Val { @@ -2400,6 +2424,11 @@ func (c *Compiler) bindRangedTempOutputs(dest []*ast.Identifier, outputs []*Symb } for _, name := range names { Put(c.Scopes, name, outputs[i]) + // The rebound input keeps its alias, so a nested call inside the + // loop still selects the sharing variant. + if output, ok := aliased[name]; ok { + c.bindParamAlias(name, outputs[i], output) + } } } } diff --git a/compiler/cond.go b/compiler/cond.go index 6e9381e1..d4746439 100644 --- a/compiler/cond.go +++ b/compiler/cond.go @@ -222,6 +222,7 @@ func (c *Compiler) createConditionalTempOutputsFor(dest []*ast.Identifier, outTy // Temporary conditional outputs are borrowed so scope cleanup does not free // values that are transferred to real destinations in the merge block. Put(c.Scopes, tempName, tempSym) + c.condTempDest[tempName] = ident.Value slots[i] = OutputSlot{dest: ident, temp: tempIdent, outType: outTypes[i]} } return slots @@ -362,6 +363,7 @@ func (c *Compiler) createStageTempOutputsFor(commit []OutputSlot) []OutputSlot { stageTempSym.WriteFlag = commitSym.WriteFlag } Put(c.Scopes, tempName, stageTempSym) + c.condTempDest[tempName] = cs.dest.Value stage[i] = OutputSlot{dest: cs.dest, temp: tempIdent, outType: outType} } return stage diff --git a/docs/Pluto C ABI Spec.md b/docs/Pluto C ABI Spec.md index 6e24b209..2f922849 100644 --- a/docs/Pluto C ABI Spec.md +++ b/docs/Pluto C ABI Spec.md @@ -1,6 +1,6 @@ # Pluto C ABI & Name Mangling Specification -**Version:** 2.0 | **Status:** Draft | **Target:** C11 / C++17 +**Version:** 2.1 | **Status:** Draft | **Target:** C11 / C++17 ## 1. Overview @@ -359,12 +359,7 @@ abbreviated: ```c int64_t Pt_Square_I64(int64_t x, int64_t seed); int64_t Pt_ConditionalSquare_I64(int64_t x, int64_t seed); -int64_t Pt_Acc_I64_Range( - int64_t a, - const PtRangeI64 *range, - int32_t a_output_alias, - int64_t seed -); +int64_t Pt_Acc_I64_Range(int64_t a, const PtRangeI64 *range, int64_t seed); ``` A C caller passes the destination's current value to request Pluto's keep-old @@ -398,10 +393,22 @@ direct scalar input reads the output's current value, and for a compatible indirect input the caller passes the matching staged output pointer itself. Each read therefore observes the selected output's current value, and both forms carry output values into subsequent range iterations. The caller's real -destinations remain unchanged until the surrounding assignment commits. A -native caller cannot request a variant: passing the same address for a -pointer input and an output shares them naturally, and a register scalar is -always a plain value. +destinations remain unchanged until the surrounding assignment commits. + +A native caller cannot request a variant. Passing the same address for a +pointer input and an output shares them only within the called body's own +statements: a nested Pluto call inside that body stages its outputs and +commits them afterwards, so it does not extend the sharing. A register scalar +is always a plain value. Sharing across nested calls is guaranteed for Pluto +callers, whose call sites select the variants statically. + +**Changes in 2.1.** Version 2.0 gave range-bearing variants a hidden `i32` +alias selector per direct scalar parameter, placed after the source parameters +and before the seed. Version 2.1 removes those selectors: every variant's +native signature is the source parameters followed by the seed, and aliasing +is lowered as private variants instead. The prototype of a range-bearing +function such as `Acc` therefore changes, and its seed moves one position +earlier. Functions without a `Range` or `ArrayRange` parameter are unchanged. An eligible immediate bare `array[range]` call argument may therefore select an `ArrayRange` specialization and run its loop inside the callee. This diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index 791d5280..544f7b59 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -20,6 +20,11 @@ NestedArrayRange: [10 1 2] [10 1 2] ConditionalTaken: 15 15 ConditionalSkipped: 10 10 RepeatedWrites: 16 +NestedRange: 13 13 +NestedRangeTwice: 20 20 +NestedArrayTwice: [10 1 2 3 4] [10 1 2 3 4] +ConditionalNested: 15 15 +ConditionalNestedSkipped: 10 0 StagedString: helloabc helloabc hello ResetArray: [ ] [1 2] [ diff --git a/tests/alias_input/self_alias.pt b/tests/alias_input/self_alias.pt index 029c5dbc..8a3f5fa5 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -52,6 +52,25 @@ out, seen = ConditionalFold(current, item) out = item > 0 current + item seen = current +# Caller-driven ranges rebind the shared input to the staged output while the +# loop runs; the nested call must still select the sharing variant, on the +# first ranged call and on a later one. +out, seen = NestedRange(current) + out, seen = FoldAfter(current, (1:3) + 0) + +out, seen = NestedRangeTwice(current) + out, seen = FoldAfter(current, (1:3) + 0) + out, seen = FoldAfter(current, (3:5) + 0) + +out, seen = NestedArrayTwice(current) + out, seen = ArrayAfter(current, (1:3) + 0) + out, seen = ArrayAfter(current, (3:5) + 0) + +# A conditional call writes through synthetic destinations that stand in for +# the outputs; sharing follows them to the real output. +out, seen = ConditionalNested(current, item) + out, seen = item > 0 FoldAfter(current, item) + out = BumpTwice(current, item) out = current + item out = current + item diff --git a/tests/alias_input/self_alias.spt b/tests/alias_input/self_alias.spt index aa395078..eebfd63e 100644 --- a/tests/alias_input/self_alias.spt +++ b/tests/alias_input/self_alias.spt @@ -80,6 +80,21 @@ skipped, skippedSeen = ConditionalFold(skipped, -1) repeated = 10 repeated = BumpTwice(repeated, 1:3) "RepeatedWrites:", repeated +nestedRange = 10 +nestedRange, nestedRangeSeen = NestedRange(nestedRange) +"NestedRange:", nestedRange, nestedRangeSeen +nestedTwice = 10 +nestedTwice, nestedTwiceSeen = NestedRangeTwice(nestedTwice) +"NestedRangeTwice:", nestedTwice, nestedTwiceSeen +nestedArrayTwice = [10] +nestedArrayTwice, nestedArrayTwiceSeen = NestedArrayTwice(nestedArrayTwice) +"NestedArrayTwice:", nestedArrayTwice, nestedArrayTwiceSeen +condNested = 10 +condNested, condNestedSeen = ConditionalNested(condNested, 5) +"ConditionalNested:", condNested, condNestedSeen +condNestedSkipped = 10 +condNestedSkipped, condNestedSkippedSeen = ConditionalNested(condNestedSkipped, -1) +"ConditionalNestedSkipped:", condNestedSkipped, condNestedSkippedSeen # Sharing is internal to the call: a sibling RHS still reads the caller's # pre-assignment binding until every RHS finishes. From 03d9970d01f38714745c16886a31cd55a8837969 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 12 Sep 2026 19:36:40 +0530 Subject: [PATCH 11/56] docs(abi): state the 2.1 prototype change precisely across the plans Only direct-return functions end in a seed; indirect returns keep their leading result carrier and have none. The effects plan and the ABI optimization plan no longer claim the calling convention is unchanged: range-bearing prototypes change in 2.1, and the generic pointer entry for native callers with unknown sharing is recorded as outstanding. The alias fixture gains the two-level array wrapper from review. Co-Authored-By: Claude Fable 5.1 --- docs/Pluto ABI Optimization Plan.md | 4 +++- docs/Pluto C ABI Spec.md | 13 ++++++++----- docs/Pluto Effects and Follow-up Plan.md | 11 ++++++++++- tests/alias_input/self_alias.exp | 1 + tests/alias_input/self_alias.pt | 5 +++++ tests/alias_input/self_alias.spt | 3 +++ 6 files changed, 30 insertions(+), 7 deletions(-) diff --git a/docs/Pluto ABI Optimization Plan.md b/docs/Pluto ABI Optimization Plan.md index fec17df5..e986f414 100644 --- a/docs/Pluto ABI Optimization Plan.md +++ b/docs/Pluto ABI Optimization Plan.md @@ -97,7 +97,9 @@ Direct lowering for scalar numeric inputs and single scalar outputs. - preserve live input/output sharing in both ordinary and range-bearing calls by lowering a call whose argument names its own destination to a private alias variant, in which reads use that output's current value, including - writes in the same iteration; the exported signature is unchanged + writes in the same iteration; exported prototypes no longer carry alias + selectors, which changes range-bearing prototypes (ABI 2.1) and leaves + every other function's signature as it was `MustWrite`/`MayWrite` has limited utility at the public boundary and must not decide whether the seed parameter exists. Adding one conditional output write diff --git a/docs/Pluto C ABI Spec.md b/docs/Pluto C ABI Spec.md index 2f922849..62592cea 100644 --- a/docs/Pluto C ABI Spec.md +++ b/docs/Pluto C ABI Spec.md @@ -404,11 +404,14 @@ callers, whose call sites select the variants statically. **Changes in 2.1.** Version 2.0 gave range-bearing variants a hidden `i32` alias selector per direct scalar parameter, placed after the source parameters -and before the seed. Version 2.1 removes those selectors: every variant's -native signature is the source parameters followed by the seed, and aliasing -is lowered as private variants instead. The prototype of a range-bearing -function such as `Acc` therefore changes, and its seed moves one position -earlier. Functions without a `Range` or `ArrayRange` parameter are unchanged. +and, for direct returns, before the seed. Version 2.1 removes those selectors, +and aliasing is lowered as private variants instead. A direct-return +function's native signature is therefore its source parameters followed by +the seed; an indirect-return function keeps its leading result carrier +followed by the source parameters, with no seed. The prototype of a +range-bearing function such as `Acc` changes, and for a direct return its +seed moves one position earlier. Functions without a `Range` or `ArrayRange` +parameter are unchanged. An eligible immediate bare `array[range]` call argument may therefore select an `ArrayRange` specialization and run its loop inside the callee. This diff --git a/docs/Pluto Effects and Follow-up Plan.md b/docs/Pluto Effects and Follow-up Plan.md index f185c782..232c222d 100644 --- a/docs/Pluto Effects and Follow-up Plan.md +++ b/docs/Pluto Effects and Follow-up Plan.md @@ -30,7 +30,16 @@ writes through that output, in ordinary and ranged calls alike. Inputs are read-only bindings, not frozen values. No per-iteration input snapshot is needed. Sharing is a compile-time fact of each call site and lowers to a private alias variant of the specialization, so the native calling convention -is unchanged and stays independent of body effects. The canonical description is in +stays independent of body effects. It is not unchanged: range-bearing +variants on master carried hidden alias selectors, and removing them changes +those prototypes, recorded as ABI 2.1 in +[the C ABI specification](./Pluto%20C%20ABI%20Spec.md). Still outstanding on +that boundary: a native caller that passes one address as both an input and an +output shares them only within the called body, because a nested Pluto call +stages its outputs. A generic pointer entry that resolves unknown sharing at +run time, alongside the private variants, would close that gap; nested +staging would still need alias handling inside it. The canonical description +of the language rule is in [the memory model](./Pluto%20Memory%20Model.md) under "Parameters and Outputs". The storage mismatch filed as diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index 544f7b59..0159b7d7 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -23,6 +23,7 @@ RepeatedWrites: 16 NestedRange: 13 13 NestedRangeTwice: 20 20 NestedArrayTwice: [10 1 2 3 4] [10 1 2 3 4] +NestedArrayDeep: [10 1 2] [10 1 2] ConditionalNested: 15 15 ConditionalNestedSkipped: 10 0 StagedString: helloabc helloabc hello diff --git a/tests/alias_input/self_alias.pt b/tests/alias_input/self_alias.pt index 8a3f5fa5..28fb3188 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -66,6 +66,11 @@ out, seen = NestedArrayTwice(current) out, seen = ArrayAfter(current, (1:3) + 0) out, seen = ArrayAfter(current, (3:5) + 0) +# One more wrapper layer: the alias must survive two levels of forwarding +# before the ranged call rebinds it. +out, seen = NestedArrayDeep(current) + out, seen = NestedArrayRange(current) + # A conditional call writes through synthetic destinations that stand in for # the outputs; sharing follows them to the real output. out, seen = ConditionalNested(current, item) diff --git a/tests/alias_input/self_alias.spt b/tests/alias_input/self_alias.spt index eebfd63e..89ceb3f2 100644 --- a/tests/alias_input/self_alias.spt +++ b/tests/alias_input/self_alias.spt @@ -89,6 +89,9 @@ nestedTwice, nestedTwiceSeen = NestedRangeTwice(nestedTwice) nestedArrayTwice = [10] nestedArrayTwice, nestedArrayTwiceSeen = NestedArrayTwice(nestedArrayTwice) "NestedArrayTwice:", nestedArrayTwice, nestedArrayTwiceSeen +nestedArrayDeep = [10] +nestedArrayDeep, nestedArrayDeepSeen = NestedArrayDeep(nestedArrayDeep) +"NestedArrayDeep:", nestedArrayDeep, nestedArrayDeepSeen condNested = 10 condNested, condNestedSeen = ConditionalNested(condNested, 5) "ConditionalNested:", condNested, condNestedSeen From 3819f656d3de308c60d51c23b1db6110ce4cfd51 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 12 Sep 2026 20:19:28 +0530 Subject: [PATCH 12/56] test(compiler): make the array alias regression bite and retire selector wording NestedArrayRange now stages the ranged call and makes its nested call inside that loop, which is the shape that lost the alias before ceb8a89: it prints `[10 1 2] [10 1]` on 8c862a2 and the expected `[10 1 2] [10 1 2]` on the fix. The previous extra wrapper forwarded the alias before staging and passed on both. The mismatched-sibling IR test now shares the input with its second output so the variant has to skip the incompatible first one, and the unrelated-array test asserts the public specialization is called rather than a retired selector name. A stale comment describing a run-time pointer select is corrected. Co-Authored-By: Claude Fable 5.1 --- compiler/compiler.go | 5 +++-- compiler/compiler_test.go | 20 +++++++++++--------- tests/alias_input/self_alias.exp | 1 - tests/alias_input/self_alias.pt | 12 ++++++------ tests/alias_input/self_alias.spt | 3 --- 5 files changed, 20 insertions(+), 21 deletions(-) diff --git a/compiler/compiler.go b/compiler/compiler.go index 3c4edcec..9e740499 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -2381,8 +2381,9 @@ func (c *Compiler) cleanupSkippedCallOutputAdapters(adapters []callOutputAdapter // bindRangedTempOutputs makes each destination name resolve to its staged slot // while that one ranged expression is compiled. Conditional lowering can make // the real destination and a synthetic conditional write name alias the same -// slot, so bind every visible name for that slot as well. Input references may -// share it at run time and follow the staged slot through a pointer select. +// slot, so bind every visible name for that slot as well. An input that +// shares the destination is rebound to the staged slot too and keeps its +// alias, so a nested call inside the loop still selects the sharing variant. // This preserves loop-carried self-reference without exposing staged values to // sibling right-hand sides in a simultaneous assignment. The caller pops its // BlockScope before compiling the next expression. diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 90fe1e8d..788d630b 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -201,15 +201,16 @@ func verifyCompiledModules(t *testing.T, moduleName, codeSrc, scriptSrc string) compileScriptAndCodeIR(t, moduleName, codeSrc, scriptSrc) } -// The alias selector picks an output by position, so a mistyped output reaching -// it can produce invalid IR or silently select the wrong slot. -func TestAliasSelectorTypeGaps(t *testing.T) { - const accFirst = "s = 1\nq, r = Mixed(s, 0:4)\nq, r" +// A shared input may only alias an output of its own type. With an +// incompatible output declared first, the variant must pass over it and bind +// the input to the compatible sibling, producing valid IR for each kind. +func TestAliasVariantSkipsIncompatibleOutputs(t *testing.T) { + const sharedSecond = "s = 1\nr, s = Mixed(s, 0:4)\nr, s" cases := []struct{ name, code, script string }{ - {"float sibling", "sum, other = Mixed(a, x)\n sum = a + x\n other = x * 0.5", accFirst}, - {"string sibling", "sum, other = Mixed(a, x)\n sum = a + x\n other = \"n\"", accFirst}, - {"array sibling", "sum, other = Mixed(a, x)\n sum = a + x\n other = [x x]", accFirst}, + {"float first", "other, sum = Mixed(a, x)\n other = x * 0.5\n sum = a + x", sharedSecond}, + {"string first", "other, sum = Mixed(a, x)\n other = \"n\"\n sum = a + x", sharedSecond}, + {"array first", "other, sum = Mixed(a, x)\n other = [x x]\n sum = a + x", sharedSecond}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -481,11 +482,12 @@ result = Pick(data, 0:8) result` ir, _ := compileScriptAndCodeIR(t, "matching_array_input", code, script) + mangled := Mangle(MangleDirPath("matching_array_input", ""), "Pick", []Type{Array{ElemType: I64, Rank: 1}, Range{Iter: I64}}) require.NotContains(t, ir, "@arr_i64_copy", "a matching output type must not introduce an input copy on every iteration") - require.NotContains(t, ir, "%data_arg_ref", - "an input with a known-zero alias selector must use its original pointer directly") + require.NotContains(t, ir, mangled+"_a", + "an input that shares no destination calls the public specialization, not an alias variant") } func TestRangeCollectorScalarVariant(t *testing.T) { diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index 0159b7d7..544f7b59 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -23,7 +23,6 @@ RepeatedWrites: 16 NestedRange: 13 13 NestedRangeTwice: 20 20 NestedArrayTwice: [10 1 2 3 4] [10 1 2 3 4] -NestedArrayDeep: [10 1 2] [10 1 2] ConditionalNested: 15 15 ConditionalNestedSkipped: 10 0 StagedString: helloabc helloabc hello diff --git a/tests/alias_input/self_alias.pt b/tests/alias_input/self_alias.pt index 28fb3188..0f2b241a 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -45,8 +45,13 @@ out, seen = NestedFold(current, item) out, seen = NestedConcat(current, item) out, seen = ConcatAfter(current, item) +# The ranged call rebinds the shared input to its staged slot; the nested +# call made inside that loop must still select the sharing variant. out, seen = NestedArrayRange(current) - out, seen = ArrayAfter(current, (1:3) + 0) + out, seen = NestedArrayDeep(current, (1:3) + 0) + +out, seen = NestedArrayDeep(current, item) + out, seen = ArrayAfter(current, item) out, seen = ConditionalFold(current, item) out = item > 0 current + item @@ -66,11 +71,6 @@ out, seen = NestedArrayTwice(current) out, seen = ArrayAfter(current, (1:3) + 0) out, seen = ArrayAfter(current, (3:5) + 0) -# One more wrapper layer: the alias must survive two levels of forwarding -# before the ranged call rebinds it. -out, seen = NestedArrayDeep(current) - out, seen = NestedArrayRange(current) - # A conditional call writes through synthetic destinations that stand in for # the outputs; sharing follows them to the real output. out, seen = ConditionalNested(current, item) diff --git a/tests/alias_input/self_alias.spt b/tests/alias_input/self_alias.spt index 89ceb3f2..eebfd63e 100644 --- a/tests/alias_input/self_alias.spt +++ b/tests/alias_input/self_alias.spt @@ -89,9 +89,6 @@ nestedTwice, nestedTwiceSeen = NestedRangeTwice(nestedTwice) nestedArrayTwice = [10] nestedArrayTwice, nestedArrayTwiceSeen = NestedArrayTwice(nestedArrayTwice) "NestedArrayTwice:", nestedArrayTwice, nestedArrayTwiceSeen -nestedArrayDeep = [10] -nestedArrayDeep, nestedArrayDeepSeen = NestedArrayDeep(nestedArrayDeep) -"NestedArrayDeep:", nestedArrayDeep, nestedArrayDeepSeen condNested = 10 condNested, condNestedSeen = ConditionalNested(condNested, 5) "ConditionalNested:", condNested, condNestedSeen From 30dad1124f69c6b103855c7350a4a873a8d4c6d2 Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 15 Sep 2026 22:47:47 +0530 Subject: [PATCH 13/56] refactor(cfg): declare names instead of storing phantom write events The CFG scope existed only to answer whether a name is defined, yet it stored a VarEvent{Kind: Write} for every declared parameter, output, and target, which read as a write that never happened. The scope now holds name membership, and publishTarget becomes declareName; the events that reach the dataflow passes are the only VarEvents left. Outputs are still declared before the body so a formatting marker naming one is rejected as a read rather than passing as literal text. Co-Authored-By: Claude Fable 5.1 --- compiler/cfg.go | 26 ++++++++++++++------------ compiler/cfg_test.go | 4 ++-- docs/Pluto IR Plan.md | 2 +- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index e6249ad3..281f8295 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -39,7 +39,7 @@ type BasicBlock struct { type CFG struct { CodeCompiler *CodeCompiler Blocks []*BasicBlock - Scopes []Scope[VarEvent] + Scopes []Scope[struct{}] Errors []*token.CompileError } @@ -47,7 +47,7 @@ func NewCFG(cc *CodeCompiler) *CFG { return &CFG{ CodeCompiler: cc, Blocks: make([]*BasicBlock, 0), - Scopes: []Scope[VarEvent]{NewScope[VarEvent](FuncScope)}, + Scopes: []Scope[struct{}]{NewScope[struct{}](FuncScope)}, Errors: make([]*token.CompileError, 0), } } @@ -193,13 +193,13 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { PushScope(&cfg.Scopes, FuncScope) defer PopScope(&cfg.Scopes) - // Outputs are published up front so that a formatting marker naming one + // Outputs are declared up front so that a formatting marker naming one // resolves as a read and is rejected, instead of passing as literal text. for _, param := range fn.Parameters { - cfg.publishTarget(param) + cfg.declareName(param) } for _, output := range fn.Outputs { - cfg.publishTarget(output) + cfg.declareName(output) } parameterNames := make(map[string]struct{}, len(fn.Parameters)) @@ -251,7 +251,7 @@ func (cfg *CFG) validateTemplateBody(statements []ast.Statement, parameterNames, reads := cfg.collectStatementReads(stmt) targets := cfg.validateStatementStructure(stmt, reads, parameterNames, outputNames) if let, ok := stmt.(*ast.LetStatement); ok { - cfg.publishTargets(let.Name) + cfg.declareTargets(let.Name) } body.statementReads = append(body.statementReads, reads) @@ -304,7 +304,7 @@ func (cfg *CFG) AnalyzeSpecialization(template *ast.FuncStatement, info *FuncInf defer PopScope(&cfg.Scopes) for _, param := range template.Parameters { - cfg.publishTarget(param) + cfg.declareName(param) } cfg.typedForwardPass(template, info) @@ -368,7 +368,7 @@ func (cfg *CFG) processTypedStatement(stmt ast.Statement, reads []VarEvent, effe cfg.processDataflowEvents(stmt, events, lastWrites) if let, ok := stmt.(*ast.LetStatement); ok { - cfg.publishTargets(let.Name) + cfg.declareTargets(let.Name) } } @@ -508,16 +508,18 @@ func (cfg *CFG) validateStructuralWrite(target *ast.Identifier, parameters map[s } } -func (cfg *CFG) publishTargets(targets []*ast.Identifier) { +func (cfg *CFG) declareTargets(targets []*ast.Identifier) { for _, target := range targets { if !isDiscard(target) { - cfg.publishTarget(target) + cfg.declareName(target) } } } -func (cfg *CFG) publishTarget(target *ast.Identifier) { - Put(cfg.Scopes, target.Value, VarEvent{Name: target.Value, Kind: Write, Token: target.Tok()}) +// declareName makes a name resolvable in the current scope. It records no +// event: reads and writes reach the dataflow passes only through VarEvents. +func (cfg *CFG) declareName(target *ast.Identifier) { + Put(cfg.Scopes, target.Value, struct{}{}) } func (cfg *CFG) addError(tok token.Token, msg string) { diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index a6b326f4..d901483f 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -472,7 +472,7 @@ func BenchmarkCollectStringReadsManyMarkers(b *testing.B) { require.Empty(b, cc.Compile()) cfg := NewCFG(cc) - Put(cfg.Scopes, "x", VarEvent{Name: "x", Kind: Write}) + Put(cfg.Scopes, "x", struct{}{}) value := strings.Repeat("-x ", 10000) tok := token.Token{FileName: b.Name(), Line: 1, Column: 1} @@ -656,7 +656,7 @@ res = discardBinding(x) template := codeAST.Statements[0].(*ast.FuncStatement) discard := template.Body.Statements[0].(*ast.LetStatement) cfg := NewCFG(cc) - cfg.publishTargets(discard.Name) + cfg.declareTargets(discard.Name) _, exists := Get(cfg.Scopes, "_") assert.False(t, exists) } diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index 45b1b3a2..4105b715 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -982,7 +982,7 @@ cached on `FuncInfo` and replayed when a later script reuses a settled body. `.pt` functions run `AnalyzeFuncs` once before any specialization exists. That pass is structural only: explicit use-before-definition, illegal input/global writes, unused inputs, syntactically unassigned outputs, formatting structure, -and discard behavior. It collects all reads before publishing a statement's +and discard behavior. It collects all reads before declaring a statement's destinations, so a fresh `x = x + 1` cannot define its own RHS. An unknown main format marker remains literal text; malformed specifiers and missing dynamic width/precision variables on a resolved marker remain structural errors. From 62941179b0ee78b835d14a22c99c4a896455a1a7 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 19 Sep 2026 22:06:37 +0530 Subject: [PATCH 14/56] docs(cfg): name the shared-input liveness rule as an over-approximation The helper that widens an input read to every output it could share is renamed possibleInputOutputAliases, and its comment and the IR plan state the policy it implements: one CFG result serves every alias pattern of a type specialization, so a body's unused-write diagnostics do not depend on a particular call's sharing, at the cost of leaving a write observable only under sharing undiagnosed in calls that do not share. Per-pattern warnings remain a possible refinement. No behavior change. Co-Authored-By: Claude Fable 5.1 --- compiler/cfg.go | 15 +++++++++------ docs/Pluto IR Plan.md | 9 +++++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index 281f8295..4cc1ae33 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -316,11 +316,14 @@ func (cfg *CFG) AnalyzeSpecialization(template *ast.FuncStatement, info *FuncInf cfg.backwardPass(live) } -// inputOutputAliases lists outputs that a caller could share with each input. -// Specializations are reused across calls, so liveness must conservatively -// retain writes observable through any compatible input reference. These are -// scalar body types, so this also conservatively includes iterator inputs. -func inputOutputAliases(template *ast.FuncStatement, info *FuncInfo) map[string][]*ast.Identifier { +// possibleInputOutputAliases over-approximates sharing: every output whose +// storage a compatible input could share, whatever any actual call does, and +// iterator inputs as well since these are scalar body types. One CFG result +// serves every alias pattern of a type specialization, so liveness keeps any +// write such an input might observe. This is the chosen diagnostic policy: +// a body's unused-write diagnostics do not depend on a call's alias pattern, +// at the cost of leaving a write undiagnosed in calls that do not share. +func possibleInputOutputAliases(template *ast.FuncStatement, info *FuncInfo) map[string][]*ast.Identifier { aliases := make(map[string][]*ast.Identifier, len(template.Parameters)) for i, paramType := range info.Sig.Params { for j, outputType := range info.Sig.OutTypes { @@ -336,7 +339,7 @@ func inputOutputAliases(template *ast.FuncStatement, info *FuncInfo) map[string] } func (cfg *CFG) typedForwardPass(template *ast.FuncStatement, info *FuncInfo) { - aliases := inputOutputAliases(template, info) + aliases := possibleInputOutputAliases(template, info) lastWrites := make(map[string]VarEvent) for _, stmt := range template.Body.Statements { reads := cfg.collectStatementReads(stmt) diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index 4105b715..b19a9cdf 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -1010,6 +1010,15 @@ The two diagnostics consume effects differently: to silence it. A prior seed overwritten by a proven-`MustWrite` call output without being read is instead a true positive: remove the seed or read it explicitly when its value is semantically required. +- *Shared inputs.* Inside a body, a read of an input counts as a read of every + output whose storage that input could share, because a caller may pass one + binding as both. This is a deliberate over-approximation and a diagnostic + policy: one CFG result serves every alias pattern of a type specialization, + so a body's unused-write diagnostics never depend on a particular call's + sharing. The cost is that a write observable only under sharing, such as + `out = current + 1` written twice, goes undiagnosed in calls that do not + share. Per-pattern warnings computed where the pattern is known remain a + possible future refinement. After a script solve succeeds, CFG first treats the script as a zero-input, zero-output template for structural validation, then runs effect-sensitive From 4fc984774d256752b0d6ad3c69f4c9da28288a56 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 19 Sep 2026 22:41:01 +0530 Subject: [PATCH 15/56] feat(cfg): analyze function liveness exactly per alias context The CFG treated every compatible input as a possible reader of every output, so a dead store observable only under sharing went unreported in calls that did not share. The dataflow now runs per alias context. Settlement analyzes each type specialization's unshared context as before; the script walk derives every call's context from its names, forwards it through nested calls by the rule lowering uses to select a variant, analyzes a shared context on first reach, and caches it on the specialization for later scripts. That rule, including output-storage widening, moves into one function, aliasPattern, that lowering and the CFG both call, so a body is analyzed exactly as it is lowered. Diagnostics become exact per calling context: `out = current + item` written twice is accepted for `value = BumpTwice(value, 5)` and reported as an unused overwrite for `other = BumpTwice(value, 5)`, directly or through a wrapper. The exact analysis immediately found one such dead store in the alias fixture itself, where two nested calls each assigned `seen` and nothing read the first; the fixture discards it. Co-Authored-By: Claude Fable 5.1 --- compiler/alias.go | 66 +++++++++++ compiler/cfg.go | 48 ++++---- compiler/cfg_test.go | 37 +++++- compiler/compiler.go | 80 ++++++------- compiler/scriptcompiler.go | 192 ++++++++++++++++++++++++++++---- compiler/solver.go | 2 +- compiler/types.go | 8 +- docs/Pluto IR Plan.md | 23 ++-- tests/alias_input/self_alias.pt | 4 +- 9 files changed, 345 insertions(+), 115 deletions(-) create mode 100644 compiler/alias.go diff --git a/compiler/alias.go b/compiler/alias.go new file mode 100644 index 00000000..60a6606e --- /dev/null +++ b/compiler/alias.go @@ -0,0 +1,66 @@ +package compiler + +import ( + "strconv" + "strings" +) + +// widenedOutputStorage returns the storage a call's output slot uses: the +// destination's own storage when it is a compatible wider representation of +// the declared output (an owned string slot receiving a static output, a +// concrete-rank array slot receiving an untyped empty one), otherwise the +// declared type. Lowering and the CFG share it so both see the same sharing. +func widenedOutputStorage(declared, storage Type) Type { + if TypeEqual(storage, declared) || !bindingSlotCompatible(storage, declared) { + return declared + } + if !TypeEqual(mergeBindingSlotType(storage, declared), storage) { + return declared + } + return storage +} + +// aliasPattern decides, per callee parameter, the one-based caller destination +// whose binding the argument shares, or 0; nil when no parameter shares one. +// argNames holds one entry per parameter, empty for an argument that is not a +// plain identifier; dests names the destinations of the call's outputs in +// order; outTypes already carry storage widening. enclosing maps a caller-body +// input to the caller output it already shares, so a nested call forwards that +// sharing. A parameter shares at most one destination, the first that matches. +func aliasPattern(argNames, dests []string, paramTypes, outTypes []Type, enclosing map[string]string) []int { + var pattern []int + for i, name := range argNames { + if name == "" { + continue + } + + for j, dest := range dests { + if j >= len(outTypes) { + break + } + if !aliasableOutput(paramTypes[i], outTypes[j]) { + continue + } + if dest != name && enclosing[name] != dest { + continue + } + if pattern == nil { + pattern = make([]int, len(argNames)) + } + pattern[i] = j + 1 + break + } + } + + return pattern +} + +// aliasPatternKey identifies one alias context; the empty key is the +// unshared context in which no parameter shares a destination. +func aliasPatternKey(pattern []int) string { + parts := make([]string, len(pattern)) + for i, slot := range pattern { + parts[i] = strconv.Itoa(slot) + } + return strings.Join(parts, "_") +} diff --git a/compiler/cfg.go b/compiler/cfg.go index 4cc1ae33..0a17b77b 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -295,9 +295,11 @@ func (cfg *CFG) validateScriptTemplate(statements []ast.Statement) [][]VarEvent return cfg.validateTemplateBody(statements, nil, nil).statementReads } -// AnalyzeSpecialization runs only typed dataflow. Structural diagnostics were -// already produced once from the function template. -func (cfg *CFG) AnalyzeSpecialization(template *ast.FuncStatement, info *FuncInfo) { +// AnalyzeSpecialization runs only typed dataflow, in one alias context: +// pattern names, per parameter, the one-based output that parameter shares at +// the call being analyzed, and nil is the unshared context. Structural +// diagnostics were already produced once from the function template. +func (cfg *CFG) AnalyzeSpecialization(template *ast.FuncStatement, info *FuncInfo, pattern []int) { cfg.PushBlock() defer cfg.PopBlock() PushScope(&cfg.Scopes, FuncScope) @@ -307,7 +309,7 @@ func (cfg *CFG) AnalyzeSpecialization(template *ast.FuncStatement, info *FuncInf cfg.declareName(param) } - cfg.typedForwardPass(template, info) + cfg.typedForwardPass(template, info, sharedOutputs(template, pattern)) live := make(map[string]struct{}, len(template.Outputs)) for _, output := range template.Outputs { @@ -316,40 +318,28 @@ func (cfg *CFG) AnalyzeSpecialization(template *ast.FuncStatement, info *FuncInf cfg.backwardPass(live) } -// possibleInputOutputAliases over-approximates sharing: every output whose -// storage a compatible input could share, whatever any actual call does, and -// iterator inputs as well since these are scalar body types. One CFG result -// serves every alias pattern of a type specialization, so liveness keeps any -// write such an input might observe. This is the chosen diagnostic policy: -// a body's unused-write diagnostics do not depend on a call's alias pattern, -// at the cost of leaving a write undiagnosed in calls that do not share. -func possibleInputOutputAliases(template *ast.FuncStatement, info *FuncInfo) map[string][]*ast.Identifier { - aliases := make(map[string][]*ast.Identifier, len(template.Parameters)) - for i, paramType := range info.Sig.Params { - for j, outputType := range info.Sig.OutTypes { - if !bindingSlotCompatible(paramType, outputType) { - continue - } - name := template.Parameters[i].Value - aliases[name] = append(aliases[name], template.Outputs[j]) +// sharedOutputs maps each input that shares an output in this context to that +// output, so a read of the input is also a read of the output's latest write. +func sharedOutputs(template *ast.FuncStatement, pattern []int) map[string]*ast.Identifier { + shared := make(map[string]*ast.Identifier, len(pattern)) + for i, slot := range pattern { + if slot > 0 { + shared[template.Parameters[i].Value] = template.Outputs[slot-1] } } - - return aliases + return shared } -func (cfg *CFG) typedForwardPass(template *ast.FuncStatement, info *FuncInfo) { - aliases := possibleInputOutputAliases(template, info) +func (cfg *CFG) typedForwardPass(template *ast.FuncStatement, info *FuncInfo, shared map[string]*ast.Identifier) { lastWrites := make(map[string]VarEvent) for _, stmt := range template.Body.Statements { reads := cfg.collectStatementReads(stmt) for _, read := range reads { - for _, output := range aliases[read.Name] { - if !cfg.isDefined(output.Value) { - continue - } - reads = append(reads, VarEvent{Name: output.Value, Kind: Read, Token: read.Token}) + output, ok := shared[read.Name] + if !ok || !cfg.isDefined(output.Value) { + continue } + reads = append(reads, VarEvent{Name: output.Value, Kind: Read, Token: read.Token}) } cfg.processTypedStatement(stmt, reads, info.StatementEffects, lastWrites) } diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index d901483f..dcd346c9 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -82,6 +82,37 @@ func TestInputAliasOutputWriteLiveness(t *testing.T) { out = current + item`, input: "value = 10\nvalue = BumpTwice(value, 5)\nvalue", }, + { + // The same body called without sharing: the first write is dead. + name: "Repeated Output Write Unshared", + code: `out = BumpTwice(current, item) + out = current + item + out = current + item`, + input: "value = 10\nother = BumpTwice(value, 5)\nother", + errorContains: `unconditional assignment to "out" overwrites a previous value that was never used`, + }, + { + // Sharing reaches a nested call through the wrapper's own alias. + name: "Repeated Output Write Through Wrapper", + code: `out = BumpTwice(current, item) + out = current + item + out = current + item + +out = Bump(current, item) + out = BumpTwice(current, item)`, + input: "value = 10\nvalue = Bump(value, 5)\nvalue", + }, + { + name: "Repeated Output Write Through Unshared Wrapper", + code: `out = BumpTwice(current, item) + out = current + item + out = current + item + +out = Bump(current, item) + out = BumpTwice(current, item)`, + input: "value = 10\nother = Bump(value, 5)\nother", + errorContains: `unconditional assignment to "out" overwrites a previous value that was never used`, + }, { name: "Incompatible Input Output Storage", code: `out = Replaced(current) @@ -730,7 +761,7 @@ func TestSpecializationReadsSeedBeforeWrite(t *testing.T) { cc := NewCodeCompiler(ctx, "seededSpecialization", "", code) cfg := NewCFG(cc) - cfg.AnalyzeSpecialization(template, info) + cfg.AnalyzeSpecialization(template, info, nil) require.Empty(t, cfg.Errors) } @@ -759,7 +790,7 @@ func TestSpecializationPrintReadKeepsLocalLive(t *testing.T) { cc := NewCodeCompiler(ctx, "printedSpecialization", "", code) cfg := NewCFG(cc) - cfg.AnalyzeSpecialization(template, info) + cfg.AnalyzeSpecialization(template, info, nil) require.Empty(t, cfg.Errors) } @@ -803,7 +834,7 @@ func TestCFGRejectsMissingStatementEffects(t *testing.T) { cfg := NewCFG(cc) require.PanicsWithValue(t, `internal: missing CFG effects for statement "res = x"`, func() { - cfg.AnalyzeSpecialization(template, info) + cfg.AnalyzeSpecialization(template, info, nil) }) } diff --git a/compiler/compiler.go b/compiler/compiler.go index 9e740499..62fa9ca0 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -343,58 +343,47 @@ func (c *Compiler) resolveCallSignature(funcName string, ce *ast.CallExpression, } // setCallArgAliases records on each argument which caller destination it -// shares a binding with, and derives the call's alias pattern from them. A -// direct scalar param then reads the output's current value inside the -// variant; an indirect param receives that output's staged pointer instead of -// its own. Everything is decided from names, so a nested call inside a variant -// forwards its enclosing input's alias without any run-time state. +// shares a binding with, and derives the call's alias pattern from them +// through the rule the CFG also uses. A direct scalar param then reads the +// output's current value inside the variant; an indirect param receives that +// output's staged pointer instead of its own. Everything is decided from +// names, so a nested call inside a variant forwards its enclosing input's +// alias without any run-time state. func (c *Compiler) setCallArgAliases(sig *callSignature, args []callArg, dest []*ast.Identifier) { if dest == nil { return } - var pattern []int - for paramIndex, arg := range args { - if arg.Name == "" { - continue - } - - for outputIndex, output := range dest { - if outputIndex >= len(sig.ABI.Return.OutTypes) { - break - } - // Output storage variants preserve compatible ownership widening. - // A remaining type mismatch cannot share the input's representation - // and must not select that output as its storage. - if !aliasableOutput(sig.ParamTypes[paramIndex], sig.ABI.Return.OutTypes[outputIndex]) { - continue - } - base := c.destinationBase(output.Value) - if base != arg.Name && !c.inputAliasesOutput(arg.Name, base) { - continue - } - if pattern == nil { - pattern = make([]int, len(args)) - } - args[paramIndex].AliasOutput = outputIndex + 1 - pattern[paramIndex] = outputIndex + 1 - break - } + argNames := make([]string, len(args)) + for i, arg := range args { + argNames[i] = arg.Name + } + dests := make([]string, len(dest)) + for i, output := range dest { + dests[i] = c.destinationBase(output.Value) } + pattern := aliasPattern(argNames, dests, sig.ParamTypes, sig.ABI.Return.OutTypes, c.enclosingAliases()) + for i, slot := range pattern { + args[i].AliasOutput = slot + } sig.AliasPattern = pattern } -// inputAliasesOutput reports whether a name read inside a variant is an input -// that already shares the given output's binding, so a nested call targeting -// that output with this input keeps the same storage. -func (c *Compiler) inputAliasesOutput(input, output string) bool { - sym, ok := Get(c.Scopes, input) - if !ok { - return false +// enclosingAliases maps each input of the body being lowered to the output it +// shares under the current variant, for the bindings currently in scope. +func (c *Compiler) enclosingAliases() map[string]string { + aliases := make(map[string]string) + for name := range c.currentParamAliases() { + sym, ok := Get(c.Scopes, name) + if !ok { + continue + } + if alias, ok := c.paramAliasFor(name, sym); ok { + aliases[name] = alias.Output + } } - alias, ok := c.paramAliasFor(input, sym) - return ok && alias.Output == output + return aliases } // directReturnSeedForCall captures the caller's current destination value for a @@ -3316,15 +3305,12 @@ func (sig *callSignature) isVariant() bool { func (c *Compiler) specializeOutputStorage(sig *callSignature, outputs []*Symbol) { changed := false for i, output := range outputs { - storage := output.Type.(Ptr).Elem declared := sig.ABI.Return.OutTypes[i] - if TypeEqual(storage, declared) || !bindingSlotCompatible(storage, declared) { - continue - } - if !TypeEqual(mergeBindingSlotType(storage, declared), storage) { + widened := widenedOutputStorage(declared, output.Type.(Ptr).Elem) + if TypeEqual(widened, declared) { continue } - sig.ABI.Return.OutTypes[i] = storage + sig.ABI.Return.OutTypes[i] = widened changed = true } if changed { diff --git a/compiler/scriptcompiler.go b/compiler/scriptcompiler.go index a664588c..eb94804e 100644 --- a/compiler/scriptcompiler.go +++ b/compiler/scriptcompiler.go @@ -2,6 +2,7 @@ package compiler import ( "fmt" + "slices" "github.com/thiremani/pluto/ast" "github.com/thiremani/pluto/pir" @@ -62,8 +63,7 @@ func (sc *ScriptCompiler) Compile() []*token.CompileError { cfg := NewCFG(sc.Compiler.CodeCompiler) cfg.AnalyzeScript(sc.Program.Statements, sc.Script.Root.StatementEffects) - directCallees, _ := collectSpecializationCallEdges(sc.Compiler, sc.ScriptMangled, sc.Program.Statements) - cfg.Errors = replaySpecializationCFG(sc.Compiler, directCallees, cfg.Errors) + cfg.Errors = replaySpecializationCFG(sc.Compiler, sc.ScriptMangled, sc.Program.Statements, sc.Script.Root.Vars, cfg.Errors) if len(cfg.Errors) > 0 { return cfg.Errors } @@ -103,41 +103,189 @@ func (sc *ScriptCompiler) compileStatements() { } } -func replaySpecializationCFG(compiler *Compiler, roots []string, errors []*token.CompileError) []*token.CompileError { - visited := make(map[string]struct{}) - reported := make(map[cfgDiagnosticKey]struct{}, len(errors)) +// replaySpecializationCFG reports the dataflow diagnostics of every +// specialization the script reaches, in the alias context each call reaches +// it with. A script call site fixes its own context from names; inside a +// callee, each nested call derives its context from the enclosing one by the +// same rule lowering applies, so a body is analyzed exactly as it is lowered. +// Contexts are visited once, root-first and depth-first in source order, and +// diagnostics are deduplicated by location and message. +func replaySpecializationCFG(compiler *Compiler, scriptMangled string, statements []ast.Statement, vars map[string]Type, errors []*token.CompileError) []*token.CompileError { + walk := &cfgWalk{ + compiler: compiler, + visited: make(map[cfgContext]struct{}), + reported: make(map[cfgDiagnosticKey]struct{}, len(errors)), + errors: errors, + } for _, compileError := range errors { - reported[cfgDiagnosticKeyFor(compileError)] = struct{}{} + walk.reported[cfgDiagnosticKeyFor(compileError)] = struct{}{} } - for _, mangled := range roots { - errors = replaySpecializationCFGNode(compiler, mangled, visited, reported, errors) + walk.visitSites(scriptMangled, statements, vars, nil) + return walk.errors +} + +// cfgContext is one specialization in one alias context. +type cfgContext struct { + mangled string + pattern string +} + +type cfgWalk struct { + compiler *Compiler + visited map[cfgContext]struct{} + reported map[cfgDiagnosticKey]struct{} + errors []*token.CompileError +} + +// cfgCallSite is one call with the destinations it writes; a call nested in an +// expression, a condition, or a print writes none. +type cfgCallSite struct { + call *ast.CallExpression + dests []*ast.Identifier +} + +// bodyCallSites lists a body's calls in source order. A multi-valued sibling +// shifts a later call's destinations by its output count, as lowering does. +func bodyCallSites(compiler *Compiler, mangled string, statements []ast.Statement) []cfgCallSite { + var sites []cfgCallSite + for _, statement := range statements { + switch stmt := statement.(type) { + case *ast.LetStatement: + for _, condition := range stmt.Condition { + sites = appendNestedCallSites(sites, condition) + } + target := 0 + for _, value := range stmt.Value { + if call, ok := value.(*ast.CallExpression); ok { + sites = append(sites, cfgCallSite{call: call, dests: stmt.Name[target:]}) + for _, argument := range call.Arguments { + sites = appendNestedCallSites(sites, argument) + } + } else { + sites = appendNestedCallSites(sites, value) + } + target += len(compiler.ExprCache[key(mangled, value)].OutTypes) + } + case *ast.PrintStatement: + for _, argument := range stmt.Expression.Arguments { + sites = appendNestedCallSites(sites, argument) + } + } } + return sites +} - return errors +func appendNestedCallSites(sites []cfgCallSite, expr ast.Expression) []cfgCallSite { + for _, call := range collectExprCalls(expr) { + sites = append(sites, cfgCallSite{call: call}) + } + return sites } -func replaySpecializationCFGNode(compiler *Compiler, mangled string, visited map[string]struct{}, reported map[cfgDiagnosticKey]struct{}, errors []*token.CompileError) []*token.CompileError { - if _, seen := visited[mangled]; seen { - return errors +func (walk *cfgWalk) visitSites(callerMangled string, statements []ast.Statement, vars map[string]Type, enclosing map[string]string) { + for _, site := range bodyCallSites(walk.compiler, callerMangled, statements) { + if _, builtin := Builtins[site.call.Function.Value]; builtin { + continue + } + + info := walk.compiler.ExprCache[key(callerMangled, site.call)] + walk.visitCallee(callerMangled, site, info.CallParamTypes, vars, enclosing) + if info.ScalarCallVariantEnsured { + walk.visitCallee(callerMangled, site, info.ScalarCallParamTypes, vars, enclosing) + } } - visited[mangled] = struct{}{} +} + +func (walk *cfgWalk) visitCallee(callerMangled string, site cfgCallSite, paramTypes []Type, vars map[string]Type, enclosing map[string]string) { + mangled := Mangle(walk.compiler.MangledPath, site.call.Function.Value, paramTypes) + requireSpecializationCallTarget(walk.compiler, callerMangled, mangled) + callee := walk.compiler.FuncCache[mangled] + pattern := walk.sitePattern(callerMangled, site, paramTypes, callee.Sig.OutTypes, vars, enclosing) - info := compiler.FuncCache[mangled] - for _, compileError := range info.CFGResult.Errors { - key := cfgDiagnosticKeyFor(compileError) - if _, seen := reported[key]; seen { + context := cfgContext{mangled: mangled, pattern: aliasPatternKey(pattern)} + if _, seen := walk.visited[context]; seen { + return + } + walk.visited[context] = struct{}{} + + template, ok := walk.compiler.CodeCompiler.lookupFuncTemplate(callee.Sig.Name, len(callee.Sig.Params)) + if !ok { + panic(fmt.Sprintf("internal: settled specialization %s has no template", mangled)) + } + for _, compileError := range walk.contextErrors(template, callee, pattern) { + diagnostic := cfgDiagnosticKeyFor(compileError) + if _, seen := walk.reported[diagnostic]; seen { continue } + walk.reported[diagnostic] = struct{}{} + walk.errors = append(walk.errors, compileError) + } - reported[key] = struct{}{} - errors = append(errors, compileError) + nested := make(map[string]string, len(pattern)) + for i, slot := range pattern { + if slot > 0 { + nested[template.Parameters[i].Value] = template.Outputs[slot-1].Value + } } - for _, callee := range info.CFGResult.DirectCallees { - errors = replaySpecializationCFGNode(compiler, callee, visited, reported, errors) + walk.visitSites(mangled, template.Body.Statements, callee.Vars, nested) +} + +// sitePattern derives a call's alias pattern the way lowering will: one name +// per parameter position for plain identifier arguments, the destinations by +// their source names, and each output widened to its destination's storage. +func (walk *cfgWalk) sitePattern(callerMangled string, site cfgCallSite, paramTypes, outTypes []Type, vars map[string]Type, enclosing map[string]string) []int { + if site.dests == nil { + return nil } - return errors + argNames := make([]string, len(paramTypes)) + position := 0 + for _, argument := range site.call.Arguments { + width := len(walk.compiler.ExprCache[key(callerMangled, argument)].OutTypes) + if ident, ok := argument.(*ast.Identifier); ok && width == 1 && position < len(argNames) { + argNames[position] = ident.Value + } + position += width + } + + dests := make([]string, 0, len(site.dests)) + widened := make([]Type, 0, len(site.dests)) + for j, dest := range site.dests { + if j >= len(outTypes) { + break + } + dests = append(dests, dest.Value) + storage, known := vars[dest.Value] + if !known { + storage = outTypes[j] + } + widened = append(widened, widenedOutputStorage(outTypes[j], storage)) + } + + return aliasPattern(argNames, dests, paramTypes, widened, enclosing) +} + +// contextErrors returns the callee's diagnostics in one alias context, +// analyzing a shared context on first reach and caching it on the +// specialization; the unshared context was analyzed at settlement. +func (walk *cfgWalk) contextErrors(template *ast.FuncStatement, callee *FuncInfo, pattern []int) []*token.CompileError { + if pattern == nil { + return callee.CFGResult.Errors + } + + patternKey := aliasPatternKey(pattern) + if cached, ok := callee.CFGResult.shared[patternKey]; ok { + return cached + } + + cfg := NewCFG(walk.compiler.CodeCompiler) + cfg.AnalyzeSpecialization(template, callee, pattern) + if callee.CFGResult.shared == nil { + callee.CFGResult.shared = make(map[string][]*token.CompileError) + } + callee.CFGResult.shared[patternKey] = slices.Clone(cfg.Errors) + return callee.CFGResult.shared[patternKey] } func cfgDiagnosticKeyFor(compileError *token.CompileError) cfgDiagnosticKey { diff --git a/compiler/solver.go b/compiler/solver.go index 29cd39ab..9ff83c03 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -2619,7 +2619,7 @@ func (ts *TypeSolver) settleSpecializationBatch(graph *specializationCallGraph) for id, node := range graph.nodes { walked := ts.walkedFuncs[node.mangled] cfg := NewCFG(ts.ScriptCompiler.Compiler.CodeCompiler) - cfg.AnalyzeSpecialization(walked.template, walked.info) + cfg.AnalyzeSpecialization(walked.template, walked.info, nil) staged[id] = &SpecializationCFGResult{ DirectCallees: slices.Clone(node.directCallees), Errors: slices.Clone(cfg.Errors), diff --git a/compiler/types.go b/compiler/types.go index b187de81..23a66a07 100644 --- a/compiler/types.go +++ b/compiler/types.go @@ -267,11 +267,15 @@ func (f Func) OutputTypesInferred() bool { return true } -// SpecializationCFGResult is the immutable dataflow result and persistent -// direct-call reachability for one settled function specialization. +// SpecializationCFGResult is the dataflow result and persistent direct-call +// reachability for one settled function specialization. Errors is the +// unshared context, produced at settlement; contexts in which an input shares +// an output are analyzed on first reach by the script walk and kept here so +// later scripts reuse them. type SpecializationCFGResult struct { DirectCallees []string Errors []*token.CompileError + shared map[string][]*token.CompileError } // FuncInfo holds the mutable facts for one function specialization. Settled diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index b19a9cdf..ccc70434 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -1010,15 +1010,20 @@ The two diagnostics consume effects differently: to silence it. A prior seed overwritten by a proven-`MustWrite` call output without being read is instead a true positive: remove the seed or read it explicitly when its value is semantically required. -- *Shared inputs.* Inside a body, a read of an input counts as a read of every - output whose storage that input could share, because a caller may pass one - binding as both. This is a deliberate over-approximation and a diagnostic - policy: one CFG result serves every alias pattern of a type specialization, - so a body's unused-write diagnostics never depend on a particular call's - sharing. The cost is that a write observable only under sharing, such as - `out = current + 1` written twice, goes undiagnosed in calls that do not - share. Per-pattern warnings computed where the pattern is known remain a - possible future refinement. +- *Shared inputs.* Inside a body, a read of an input that shares an output at + the call being analyzed counts as a read of that output's latest write. The + dataflow therefore runs per **alias context**: settlement analyzes the + unshared context once per type specialization, and the script walk analyzes + each shared context on first reach and caches it on the specialization. A + script call site fixes its context from names; inside a callee, each nested + call derives its context from the enclosing one by the same rule lowering + uses to pick a variant, including output-storage widening, so a body is + analyzed exactly as it is lowered. Diagnostics are exact per context and + deduplicated by location and message: `out = current + 1` written twice is + accepted for `x = Twice(x)` and reported for `y = Twice(x)`, because the + first write is dead there. A body may consequently fail to compile because + of an unshared call elsewhere; that is the chosen policy for unused-write + errors, which are errors rather than warnings throughout. After a script solve succeeds, CFG first treats the script as a zero-input, zero-output template for structural validation, then runs effect-sensitive diff --git a/tests/alias_input/self_alias.pt b/tests/alias_input/self_alias.pt index 0f2b241a..e3417241 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -64,11 +64,11 @@ out, seen = NestedRange(current) out, seen = FoldAfter(current, (1:3) + 0) out, seen = NestedRangeTwice(current) - out, seen = FoldAfter(current, (1:3) + 0) + out, _ = FoldAfter(current, (1:3) + 0) out, seen = FoldAfter(current, (3:5) + 0) out, seen = NestedArrayTwice(current) - out, seen = ArrayAfter(current, (1:3) + 0) + out, _ = ArrayAfter(current, (1:3) + 0) out, seen = ArrayAfter(current, (3:5) + 0) # A conditional call writes through synthetic destinations that stand in for From 652678aef14fe55cf2fff9d8f9b8844aae8c9c46 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 19 Sep 2026 22:59:02 +0530 Subject: [PATCH 16/56] fix(cfg): carry widened output storage into nested call contexts The script CFG walk passed a callee's own variable types into its body, so a nested call whose destination is an output lost the storage the enclosing call had widened it to. Lowering keeps that storage through outputSlotTypes, so a wrapper receiving a heap string (or a typed array) was lowered with the nested call shared while the CFG analyzed it unshared and reported a live intermediate write as unused. The walk now computes each site's output storage the way lowering does, binds the body's outputs to it before visiting nested calls, and visits each lowered variant once, keyed by its variant symbol. Diagnostics stay cached per alias pattern, which is all the body analysis depends on. Co-Authored-By: Claude Fable 5.1 --- compiler/cfg_test.go | 26 ++++++++++++ compiler/scriptcompiler.go | 71 ++++++++++++++++++-------------- tests/alias_input/self_alias.exp | 1 + tests/alias_input/self_alias.pt | 8 ++++ tests/alias_input/self_alias.spt | 6 +++ 5 files changed, 82 insertions(+), 30 deletions(-) diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index dcd346c9..81973394 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -122,6 +122,32 @@ out = Bump(current, item) input: "value = Replaced(1)\nvalue", errorContains: `unconditional assignment to "out" overwrites a previous value that was never used`, }, + { + // The wrapper's destination widens its output to a heap string, and + // the nested call shares that storage, as lowering does. + name: "Repeated Output Write Through Widening Wrapper", + code: `out, seen = Reset(current) + out = "first" + seen = current + out = "second" + +out, seen = Wrap(current) + out, seen = Reset(current)`, + input: `value = "hello" ⊕ "!" +value, seen = Wrap(value) +value, seen`, + }, + { + name: "Repeated Empty Array Write Through Widening Wrapper", + code: `out, seen = ResetEmpty(current) + out = [] + seen = current + out = [] + +out, seen = WrapEmpty(current) + out, seen = ResetEmpty(current)`, + input: "value = [1 2]\nvalue, seen = WrapEmpty(value)\nvalue, seen", + }, } for _, tt := range tests { diff --git a/compiler/scriptcompiler.go b/compiler/scriptcompiler.go index eb94804e..e2991a88 100644 --- a/compiler/scriptcompiler.go +++ b/compiler/scriptcompiler.go @@ -2,6 +2,7 @@ package compiler import ( "fmt" + "maps" "slices" "github.com/thiremani/pluto/ast" @@ -108,12 +109,12 @@ func (sc *ScriptCompiler) compileStatements() { // it with. A script call site fixes its own context from names; inside a // callee, each nested call derives its context from the enclosing one by the // same rule lowering applies, so a body is analyzed exactly as it is lowered. -// Contexts are visited once, root-first and depth-first in source order, and -// diagnostics are deduplicated by location and message. +// Each lowered variant is visited once, root-first and depth-first in source +// order, and diagnostics are deduplicated by location and message. func replaySpecializationCFG(compiler *Compiler, scriptMangled string, statements []ast.Statement, vars map[string]Type, errors []*token.CompileError) []*token.CompileError { walk := &cfgWalk{ compiler: compiler, - visited: make(map[cfgContext]struct{}), + visited: make(map[string]struct{}), reported: make(map[cfgDiagnosticKey]struct{}, len(errors)), errors: errors, } @@ -125,15 +126,9 @@ func replaySpecializationCFG(compiler *Compiler, scriptMangled string, statement return walk.errors } -// cfgContext is one specialization in one alias context. -type cfgContext struct { - mangled string - pattern string -} - type cfgWalk struct { compiler *Compiler - visited map[cfgContext]struct{} + visited map[string]struct{} // lowered variant symbols already walked reported map[cfgDiagnosticKey]struct{} errors []*token.CompileError } @@ -201,13 +196,18 @@ func (walk *cfgWalk) visitCallee(callerMangled string, site cfgCallSite, paramTy mangled := Mangle(walk.compiler.MangledPath, site.call.Function.Value, paramTypes) requireSpecializationCallTarget(walk.compiler, callerMangled, mangled) callee := walk.compiler.FuncCache[mangled] - pattern := walk.sitePattern(callerMangled, site, paramTypes, callee.Sig.OutTypes, vars, enclosing) + storage := siteOutputStorage(site, callee.Sig.OutTypes, vars) + pattern := walk.sitePattern(callerMangled, site, paramTypes, storage, enclosing) - context := cfgContext{mangled: mangled, pattern: aliasPatternKey(pattern)} - if _, seen := walk.visited[context]; seen { + var variantStorage []Type + if !slices.EqualFunc(storage, callee.Sig.OutTypes, TypeEqual) { + variantStorage = storage + } + variant := MangleVariant(mangled, variantStorage, pattern) + if _, seen := walk.visited[variant]; seen { return } - walk.visited[context] = struct{}{} + walk.visited[variant] = struct{}{} template, ok := walk.compiler.CodeCompiler.lookupFuncTemplate(callee.Sig.Name, len(callee.Sig.Params)) if !ok { @@ -228,13 +228,34 @@ func (walk *cfgWalk) visitCallee(callerMangled string, site cfgCallSite, paramTy nested[template.Parameters[i].Value] = template.Outputs[slot-1].Value } } - walk.visitSites(mangled, template.Body.Statements, callee.Vars, nested) + // The body's outputs bind to this variant's storage, as lowering's + // outputSlotTypes do, so nested destinations widen the same way. + bodyVars := maps.Clone(callee.Vars) + for j, output := range template.Outputs { + bodyVars[output.Value] = storage[j] + } + walk.visitSites(mangled, template.Body.Statements, bodyVars, nested) +} + +// siteOutputStorage returns each output's storage at one call site: widened +// to its destination's binding, as lowering widens it, else the declared type. +func siteOutputStorage(site cfgCallSite, outTypes []Type, vars map[string]Type) []Type { + storage := slices.Clone(outTypes) + for j, dest := range site.dests { + if j >= len(outTypes) { + break + } + if slot, known := vars[dest.Value]; known { + storage[j] = widenedOutputStorage(outTypes[j], slot) + } + } + return storage } // sitePattern derives a call's alias pattern the way lowering will: one name // per parameter position for plain identifier arguments, the destinations by -// their source names, and each output widened to its destination's storage. -func (walk *cfgWalk) sitePattern(callerMangled string, site cfgCallSite, paramTypes, outTypes []Type, vars map[string]Type, enclosing map[string]string) []int { +// their source names, and the outputs at this site's storage. +func (walk *cfgWalk) sitePattern(callerMangled string, site cfgCallSite, paramTypes, storage []Type, enclosing map[string]string) []int { if site.dests == nil { return nil } @@ -249,21 +270,11 @@ func (walk *cfgWalk) sitePattern(callerMangled string, site cfgCallSite, paramTy position += width } - dests := make([]string, 0, len(site.dests)) - widened := make([]Type, 0, len(site.dests)) + dests := make([]string, len(site.dests)) for j, dest := range site.dests { - if j >= len(outTypes) { - break - } - dests = append(dests, dest.Value) - storage, known := vars[dest.Value] - if !known { - storage = outTypes[j] - } - widened = append(widened, widenedOutputStorage(outTypes[j], storage)) + dests[j] = dest.Value } - - return aliasPattern(argNames, dests, paramTypes, widened, enclosing) + return aliasPattern(argNames, dests, paramTypes, storage, enclosing) } // contextErrors returns the callee's diagnostics in one alias context, diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index 544f7b59..3c9b32db 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -29,3 +29,4 @@ StagedString: helloabc helloabc hello ResetArray: [ ] [1 2] [ ] +WidenedWrapper: second first diff --git a/tests/alias_input/self_alias.pt b/tests/alias_input/self_alias.pt index e3417241..79a14449 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -80,6 +80,14 @@ out = BumpTwice(current, item) out = current + item out = current + item +out, seen = Reset(current) + out = "first" + seen = current + out = "second" + +out, seen = Wrap(current) + out, seen = Reset(current) + out, left, right = ResetPair(first, second) out = [] left = first diff --git a/tests/alias_input/self_alias.spt b/tests/alias_input/self_alias.spt index eebfd63e..ccfb7620 100644 --- a/tests/alias_input/self_alias.spt +++ b/tests/alias_input/self_alias.spt @@ -108,3 +108,9 @@ flat = [1 2] matrix = [[3 4]] matrix, flatSeen, matrixSeen = ResetPair(flat, matrix) "ResetArray:", matrix, flatSeen, matrixSeen + +# The wrapper widens its output to the caller's heap string, and the nested +# call shares that storage, so its intermediate write is observed. +wrapped = "hello" ⊕ "!" +wrapped, wrappedSeen = Wrap(wrapped) +"WidenedWrapper:", wrapped, wrappedSeen From a1e6c35baa182ba7cddfe811e012141c89efcf75 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sun, 20 Sep 2026 17:45:10 +0530 Subject: [PATCH 17/56] docs(abi): state variant suffix normalization and fix the storage example Section 5.2 now says when each private variant suffix is emitted: _aN only when a parameter shares an output, _oN only when an output slot's storage differs from its declared type, and the bare specialization symbol when neither applies. An unshared call can still carry _oN. The storage-variant example reused the alias example's function, whose first output must be I64 to share an I64 parameter, so widening it to StrH was invalid. The example now uses a separate string-output function. Co-Authored-By: Claude Fable 5.1 --- docs/Pluto C ABI Spec.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/Pluto C ABI Spec.md b/docs/Pluto C ABI Spec.md index 62592cea..2963a93f 100644 --- a/docs/Pluto C ABI Spec.md +++ b/docs/Pluto C ABI Spec.md @@ -460,13 +460,18 @@ slot receiving a `StrG` output, or a concrete-rank array slot receiving `[]`). `_aN` is the alias variant. It carries one entry per parameter, in source order: `0` for a parameter that shares no output, `k` for one that shares output slot `k - 1`, whose type must match the parameter. When both apply, -`_oN` precedes `_aN`. +`_oN` precedes `_aN`. A suffix appears only when it carries information: the +compiler omits `_aN` when no parameter shares an output, omits `_oN` when +every output slot uses its declared type, and emits the bare specialization +symbol when both are omitted. An unshared call can still lower to an `_oN` +variant. Examples: `Pt_4math_p_4Fold_f2_I64_StrH_a2_1_0` is `Fold(I64, StrH)` with its -first parameter sharing its first output; `..._o2_StrH_StrH` is the same -function writing both outputs into owned string slots. `Demangle` renders -these as `math.Fold(I64, StrH) [in1->out1]` and -`math.Fold(I64, StrH) -> (StrH, StrH)`. +first parameter sharing its first output, which is therefore an `I64`; +`Pt_4math_p_5Label_f2_I64_StrG_o2_StrH_StrH` is `Label(I64, StrG)` writing +both of its declared `StrG` outputs into owned string slots. `Demangle` +renders these as `math.Fold(I64, StrH) [in1->out1]` and +`math.Label(I64, StrG) -> (StrH, StrH)`. The public specialization symbol is unchanged by either variant. C callers never see a variant and cannot request one. From 5df1e1d95f910e9e6d16ed1f32e63b3659d64a1d Mon Sep 17 00:00:00 2001 From: Tejas Date: Sun, 20 Sep 2026 20:39:10 +0530 Subject: [PATCH 18/56] refactor(compiler): derive shared output storage from the alias pattern A shared output's storage was carried as a second variant dimension, _oN, listing every output slot's storage when a destination held a wider compatible representation. But a shared input and its destination are one binding, so the input's parameter type already fixes that storage and the alias variant symbol implied it. Unshared outputs never needed it: the call-site adapters convert a declared static string or untyped empty array into the destination after the call, committing only when the callee wrote. setCallArgAliases now gives each shared output its input's storage, and the staged-call path makes adapters after the pattern is known, so a shared slot passes through unbridged. The CFG walk no longer tracks destination storage: a nested call's context follows from its own parameter types and pattern, so contexts are keyed by the alias variant symbol alone. Mangling, the demangler, and the C ABI spec drop the _oN suffix; the public specialization symbol is unchanged. Fewer variants are emitted. An unshared call into a wider destination uses the public specialization, which a new IR test asserts, and the alias fixture covers the unshared static-string and empty-array conversions at run time. Co-Authored-By: Claude Fable 5.1 --- compiler/abi.go | 12 ---- compiler/alias.go | 26 ++++---- compiler/compiler.go | 85 +++++++----------------- compiler/compiler_test.go | 20 ++++++ compiler/mangle.go | 43 ++---------- compiler/mangle_test.go | 11 +-- compiler/scriptcompiler.go | 55 ++++----------- docs/Pluto C ABI Spec.md | 70 +++++++++---------- docs/Pluto Effects and Follow-up Plan.md | 2 +- docs/Pluto IR Plan.md | 6 +- docs/Pluto Memory Model.md | 20 +++--- tests/alias_input/self_alias.exp | 4 ++ tests/alias_input/self_alias.pt | 4 ++ tests/alias_input/self_alias.spt | 13 ++++ 14 files changed, 147 insertions(+), 224 deletions(-) diff --git a/compiler/abi.go b/compiler/abi.go index b6c55a3c..af9ddad9 100644 --- a/compiler/abi.go +++ b/compiler/abi.go @@ -47,18 +47,6 @@ func isDirectScalarABIType(t Type) bool { } } -// aliasableOutput reports whether an output can back a parameter's alias slot. -// The alias pattern names an output by position and the callee then reads that -// storage as the parameter's own type, so the two must lower identically. There -// is no numeric conversion anywhere on this path, and a pointer selected across -// mismatched types would be loaded as the wrong type. -func aliasableOutput(paramType, outputType Type) bool { - if ptr, ok := outputType.(Ptr); ok { - outputType = ptr.Elem - } - return TypeEqual(paramType, outputType) -} - func directScalarABIReturnType(outTypes []Type) (Type, bool) { if len(outTypes) != 1 { return nil, false diff --git a/compiler/alias.go b/compiler/alias.go index 60a6606e..50ed9207 100644 --- a/compiler/alias.go +++ b/compiler/alias.go @@ -5,26 +5,26 @@ import ( "strings" ) -// widenedOutputStorage returns the storage a call's output slot uses: the -// destination's own storage when it is a compatible wider representation of -// the declared output (an owned string slot receiving a static output, a -// concrete-rank array slot receiving an untyped empty one), otherwise the -// declared type. Lowering and the CFG share it so both see the same sharing. -func widenedOutputStorage(declared, storage Type) Type { - if TypeEqual(storage, declared) || !bindingSlotCompatible(storage, declared) { - return declared +// sharableOutput reports whether an input of paramType can share an output +// declared as outType: the input's storage must be the declared type or a +// compatible wider representation of it (an owned string for a static +// output, a concrete-rank array for an untyped empty one). The shared output +// then uses the input's storage, so a write lands where the next read looks. +func sharableOutput(paramType, outType Type) bool { + if TypeEqual(paramType, outType) { + return true } - if !TypeEqual(mergeBindingSlotType(storage, declared), storage) { - return declared + if !bindingSlotCompatible(paramType, outType) { + return false } - return storage + return TypeEqual(mergeBindingSlotType(paramType, outType), paramType) } // aliasPattern decides, per callee parameter, the one-based caller destination // whose binding the argument shares, or 0; nil when no parameter shares one. // argNames holds one entry per parameter, empty for an argument that is not a // plain identifier; dests names the destinations of the call's outputs in -// order; outTypes already carry storage widening. enclosing maps a caller-body +// order; outTypes are the declared output types. enclosing maps a caller-body // input to the caller output it already shares, so a nested call forwards that // sharing. A parameter shares at most one destination, the first that matches. func aliasPattern(argNames, dests []string, paramTypes, outTypes []Type, enclosing map[string]string) []int { @@ -38,7 +38,7 @@ func aliasPattern(argNames, dests []string, paramTypes, outTypes []Type, enclosi if j >= len(outTypes) { break } - if !aliasableOutput(paramTypes[i], outTypes[j]) { + if !sharableOutput(paramTypes[i], outTypes[j]) { continue } if dest != name && enclosing[name] != dest { diff --git a/compiler/compiler.go b/compiler/compiler.go index 62fa9ca0..db0115b1 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -85,15 +85,12 @@ type callArg struct { } // callSignature is one call site's view of a specialization. Mangled is the -// solver's key; the lowered symbol additionally encodes call-site facts that -// change the emitted body but not its types: wider output storage and which -// inputs share a binding with which outputs. +// solver's key; the lowered symbol additionally encodes the one call-site +// fact that changes the emitted body but not its types: which inputs share a +// binding with which outputs. type callSignature struct { FuncName string Mangled string - // OutputStorage lists every output slot's storage type when a caller - // destination is wider than the declared output; nil otherwise. - OutputStorage []Type // AliasPattern holds, per parameter, the one-based output it shares a // binding with at this call site, or 0. Nil means no parameter aliases. AliasPattern []int @@ -346,9 +343,9 @@ func (c *Compiler) resolveCallSignature(funcName string, ce *ast.CallExpression, // shares a binding with, and derives the call's alias pattern from them // through the rule the CFG also uses. A direct scalar param then reads the // output's current value inside the variant; an indirect param receives that -// output's staged pointer instead of its own. Everything is decided from -// names, so a nested call inside a variant forwards its enclosing input's -// alias without any run-time state. +// output's staged pointer instead of its own. A shared output takes its +// input's storage, so the variant writes the representation the input reads +// and the caller's staged slot passes through without an adapter. func (c *Compiler) setCallArgAliases(sig *callSignature, args []callArg, dest []*ast.Identifier) { if dest == nil { return @@ -366,6 +363,9 @@ func (c *Compiler) setCallArgAliases(sig *callSignature, args []callArg, dest [] pattern := aliasPattern(argNames, dests, sig.ParamTypes, sig.ABI.Return.OutTypes, c.enclosingAliases()) for i, slot := range pattern { args[i].AliasOutput = slot + if slot > 0 { + sig.ABI.Return.OutTypes[slot-1] = sig.ParamTypes[i] + } } sig.AliasPattern = pattern } @@ -3209,8 +3209,7 @@ func (c *Compiler) compileCallExpression(ce *ast.CallExpression, dest []*ast.Ide // them at independent, destination-seeded slots so a call in one RHS cannot // mutate a real destination before sibling RHS expressions have read the // statement-start values. The outer assignment owns the eventual commit and - // cleanup. Private output-storage variants preserve compatible widening, - // such as an established StrH slot receiving a declared StrG output. + // cleanup. outputs := c.makeSeededTempOutputs(dest, info.OutTypes) c.compileIndirectCallIntoStagedOutputs(sig, ce, dest, outputs) return c.loadOutputValues(outputs, "call_final") @@ -3244,15 +3243,19 @@ func (c *Compiler) compileDirectCallIntoOutput(sig *callSignature, ce *ast.CallE }) } -func (c *Compiler) compileIndirectCallIntoOutputs( +// compileIndirectCallIntoStagedOutputs calls into destination-typed staged +// slots. A shared output passes its slot straight through, since the alias +// pattern gave it the input's storage; an unshared output of another +// representation goes through an adapter committed only when written. +func (c *Compiler) compileIndirectCallIntoStagedOutputs( sig *callSignature, ce *ast.CallExpression, dest []*ast.Identifier, - outputs []*Symbol, - afterCall func([]llvm.Value), - onSkip func(), + staged []*Symbol, ) { c.withPreparedCall(sig, ce, dest, func(call preparedCall) { + adapters := c.makeCallOutputAdapters(staged, sig.ABI.Return.OutTypes) + outputs := callAdapterOutputs(adapters) c.runCallWithBoundsElse(func() { writeFlags := c.makeCallOutputWriteFlags(len(outputs)) c.builder.CreateCall( @@ -3261,61 +3264,19 @@ func (c *Compiler) compileIndirectCallIntoOutputs( c.callArgs(sig, call, call.RetStruct, outputs, writeFlags, nil), "", ) - if afterCall != nil { - afterCall(writeFlags) - } - }, onSkip) + c.commitCallOutputAdapters(staged, adapters, writeFlags) + }, func() { c.cleanupSkippedCallOutputAdapters(adapters) }) }) } -func (c *Compiler) compileIndirectCallIntoStagedOutputs( - sig *callSignature, - ce *ast.CallExpression, - dest []*ast.Identifier, - staged []*Symbol, -) { - c.specializeOutputStorage(sig, staged) - adapters := c.makeCallOutputAdapters(staged, sig.ABI.Return.OutTypes) - callOutputs := callAdapterOutputs(adapters) - c.compileIndirectCallIntoOutputs( - sig, - ce, - dest, - callOutputs, - func(writeFlags []llvm.Value) { c.commitCallOutputAdapters(staged, adapters, writeFlags) }, - func() { c.cleanupSkippedCallOutputAdapters(adapters) }, - ) -} - // loweredName is the symbol of the private variant this call site lowers to, -// or the public specialization when no call-site fact changes the body. +// or the public specialization when no parameter shares an output. func (sig *callSignature) loweredName() string { - return MangleVariant(sig.Mangled, sig.OutputStorage, sig.AliasPattern) + return MangleVariant(sig.Mangled, sig.AliasPattern) } func (sig *callSignature) isVariant() bool { - return sig.OutputStorage != nil || sig.AliasPattern != nil -} - -// specializeOutputStorage keeps a writable output and a compatible input on -// the same representation. In particular an untyped empty array result must -// reset the actual array slot, rather than a separate zero-seeded adapter that -// its input cannot observe. Solver facts remain keyed by the source signature; -// only the private function's output storage and ownership change. -func (c *Compiler) specializeOutputStorage(sig *callSignature, outputs []*Symbol) { - changed := false - for i, output := range outputs { - declared := sig.ABI.Return.OutTypes[i] - widened := widenedOutputStorage(declared, output.Type.(Ptr).Elem) - if TypeEqual(widened, declared) { - continue - } - sig.ABI.Return.OutTypes[i] = widened - changed = true - } - if changed { - sig.OutputStorage = slices.Clone(sig.ABI.Return.OutTypes) - } + return sig.AliasPattern != nil } func (c *Compiler) makeCallOutputWriteFlags(count int) []llvm.Value { diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 788d630b..736a8390 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -455,6 +455,26 @@ h, r` require.NotContains(t, ir, "define void @"+mangled+"(", "the unaliased specialization is not emitted when only the variant is called") } +// An unshared static-string output written into an owned-string destination +// is converted after the call, so the call uses the public specialization; +// only sharing selects a private variant. +func TestUnsharedWidenedDestinationUsesPublicSpecialization(t *testing.T) { + code := `out, seen = Replace(current) + out = "new" + seen = current` + script := `value = "hello" ⊕ "!" +other = "keep" ⊕ "" +other +other, seen = Replace(value) +other, seen` + + ir, _ := compileScriptAndCodeIR(t, "unshared_widened", code, script) + mangled := Mangle(MangleDirPath("unshared_widened", ""), "Replace", []Type{StrH{}}) + + require.Contains(t, ir, "define void @"+mangled+"(", "the unshared call uses the public specialization") + require.NotContains(t, ir, "@"+mangled+"_a", "destination storage alone selects no private variant") +} + func TestRangedCallDoesNotCopyUnrelatedArrayInput(t *testing.T) { // Both outputs are integers, so writing them can never change the array // input even though it is read after the first output write. Copying it diff --git a/compiler/mangle.go b/compiler/mangle.go index 108d6c4a..28daa8cf 100644 --- a/compiler/mangle.go +++ b/compiler/mangle.go @@ -15,7 +15,6 @@ const ( F = "f" // Function arity marker T = "t" // Generic type params marker A = "a" // Alias variant marker: per-parameter output slot pattern - O = "o" // Output storage variant marker: widened output slot types M = "m" // Method separator OP = "op" // Operator prefix N = "n" // Numeric segment prefix @@ -60,9 +59,6 @@ type Demangled struct { Kind SymbolKind // Type of symbol Arity int // Number of arguments (for functions) ArgTypes []string // Argument type names (for functions) - // OutputStorage lists every output slot's storage type for a private - // output-storage variant; nil for the public specialization. - OutputStorage []string // AliasPattern holds, per parameter, the one-based output slot the // parameter shares at the call site, 0 for none; nil when no parameter // aliases. Present only on private alias variants. @@ -98,11 +94,6 @@ func (d *Demangled) String() string { result.WriteString(strings.Join(d.ArgTypes, ", ")) result.WriteString(")") } - if d.OutputStorage != nil { - result.WriteString(" -> (") - result.WriteString(strings.Join(d.OutputStorage, ", ")) - result.WriteString(")") - } if aliases := d.aliasDisplay(); aliases != "" { result.WriteString(" [") result.WriteString(aliases) @@ -136,19 +127,11 @@ func Mangle(mangledPath, funcName string, args []Type) string { } // MangleVariant names a private lowering variant of a function specialization -// per Pluto C ABI Spec §5.2. An output-storage suffix _oN_ lists -// every output slot's storage type; an alias suffix _aN_... carries one -// entry per parameter, 0 for a parameter sharing no output and k for one -// sharing output k-1. A nil slice omits its suffix, so two nils return the -// public specialization symbol unchanged. -func MangleVariant(mangled string, outputStorage []Type, aliasPattern []int) string { +// per Pluto C ABI Spec §5.2. The alias suffix _aN_... carries one entry +// per parameter, 0 for a parameter sharing no output and k for one sharing +// output k-1. A nil pattern returns the public specialization symbol. +func MangleVariant(mangled string, aliasPattern []int) string { parts := []string{mangled} - if outputStorage != nil { - parts = append(parts, O+strconv.Itoa(len(outputStorage))) - for _, storage := range outputStorage { - parts = append(parts, storage.Mangle()) - } - } if aliasPattern != nil { parts = append(parts, A+strconv.Itoa(len(aliasPattern))) for _, slot := range aliasPattern { @@ -456,23 +439,9 @@ func demangleFunc(result *Demangled, rest string) { demangleVariant(result, rest) } -// demangleVariant parses the optional private-variant suffixes that follow a -// function's argument types: _oN and N storage types, then _aN and N slots. +// demangleVariant parses the optional private-variant suffix that follows a +// function's argument types: _aN and N slots. func demangleVariant(result *Demangled, rest string) { - if after, ok := strings.CutPrefix(rest, SEP+O); ok && startsWithDigit(after) { - count, remaining := parseArity(after) - result.OutputStorage = []string{} - for i := 0; i < count && strings.HasPrefix(remaining, SEP); i++ { - typeName, next := demangleType(remaining[len(SEP):]) - if typeName == "" { - break - } - result.OutputStorage = append(result.OutputStorage, typeName) - remaining = next - } - rest = remaining - } - after, ok := strings.CutPrefix(rest, SEP+A) if !ok || !startsWithDigit(after) { return diff --git a/compiler/mangle_test.go b/compiler/mangle_test.go index 6103892a..b58f1ed4 100644 --- a/compiler/mangle_test.go +++ b/compiler/mangle_test.go @@ -826,20 +826,18 @@ func TestMangleVariantRoundTrip(t *testing.T) { base := Mangle(MangleDirPath("math", ""), "Fold", []Type{I64, StrH{}}) tests := []struct { name string - storage []Type pattern []int mangled string expected string }{ {name: "public specialization", mangled: base, expected: "math.Fold(I64, StrH)"}, {name: "alias variant", pattern: []int{1, 0}, mangled: base + "_a2_1_0", expected: "math.Fold(I64, StrH) [in1->out1]"}, - {name: "storage variant", storage: []Type{StrH{}, StrH{}}, mangled: base + "_o2_StrH_StrH", expected: "math.Fold(I64, StrH) -> (StrH, StrH)"}, - {name: "storage and alias variant", storage: []Type{StrH{}, I64}, pattern: []int{2, 1}, mangled: base + "_o2_StrH_I64_a2_2_1", expected: "math.Fold(I64, StrH) -> (StrH, I64) [in1->out2, in2->out1]"}, + {name: "swapped alias variant", pattern: []int{2, 1}, mangled: base + "_a2_2_1", expected: "math.Fold(I64, StrH) [in1->out2, in2->out1]"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - mangled := MangleVariant(base, tt.storage, tt.pattern) + mangled := MangleVariant(base, tt.pattern) assert.Equal(t, tt.mangled, mangled) assert.Equal(t, tt.expected, Demangle(mangled)) @@ -848,11 +846,6 @@ func TestMangleVariantRoundTrip(t *testing.T) { assert.Equal(t, SymbolFunc, parsed.Kind) assert.Equal(t, []string{"I64", "StrH"}, parsed.ArgTypes) assert.Equal(t, tt.pattern, parsed.AliasPattern) - if tt.storage == nil { - assert.Nil(t, parsed.OutputStorage) - return - } - assert.Len(t, parsed.OutputStorage, len(tt.storage)) }) } } diff --git a/compiler/scriptcompiler.go b/compiler/scriptcompiler.go index e2991a88..723a3aa1 100644 --- a/compiler/scriptcompiler.go +++ b/compiler/scriptcompiler.go @@ -2,7 +2,6 @@ package compiler import ( "fmt" - "maps" "slices" "github.com/thiremani/pluto/ast" @@ -64,7 +63,7 @@ func (sc *ScriptCompiler) Compile() []*token.CompileError { cfg := NewCFG(sc.Compiler.CodeCompiler) cfg.AnalyzeScript(sc.Program.Statements, sc.Script.Root.StatementEffects) - cfg.Errors = replaySpecializationCFG(sc.Compiler, sc.ScriptMangled, sc.Program.Statements, sc.Script.Root.Vars, cfg.Errors) + cfg.Errors = replaySpecializationCFG(sc.Compiler, sc.ScriptMangled, sc.Program.Statements, cfg.Errors) if len(cfg.Errors) > 0 { return cfg.Errors } @@ -111,7 +110,7 @@ func (sc *ScriptCompiler) compileStatements() { // same rule lowering applies, so a body is analyzed exactly as it is lowered. // Each lowered variant is visited once, root-first and depth-first in source // order, and diagnostics are deduplicated by location and message. -func replaySpecializationCFG(compiler *Compiler, scriptMangled string, statements []ast.Statement, vars map[string]Type, errors []*token.CompileError) []*token.CompileError { +func replaySpecializationCFG(compiler *Compiler, scriptMangled string, statements []ast.Statement, errors []*token.CompileError) []*token.CompileError { walk := &cfgWalk{ compiler: compiler, visited: make(map[string]struct{}), @@ -122,7 +121,7 @@ func replaySpecializationCFG(compiler *Compiler, scriptMangled string, statement walk.reported[cfgDiagnosticKeyFor(compileError)] = struct{}{} } - walk.visitSites(scriptMangled, statements, vars, nil) + walk.visitSites(scriptMangled, statements, nil) return walk.errors } @@ -178,32 +177,27 @@ func appendNestedCallSites(sites []cfgCallSite, expr ast.Expression) []cfgCallSi return sites } -func (walk *cfgWalk) visitSites(callerMangled string, statements []ast.Statement, vars map[string]Type, enclosing map[string]string) { +func (walk *cfgWalk) visitSites(callerMangled string, statements []ast.Statement, enclosing map[string]string) { for _, site := range bodyCallSites(walk.compiler, callerMangled, statements) { if _, builtin := Builtins[site.call.Function.Value]; builtin { continue } info := walk.compiler.ExprCache[key(callerMangled, site.call)] - walk.visitCallee(callerMangled, site, info.CallParamTypes, vars, enclosing) + walk.visitCallee(callerMangled, site, info.CallParamTypes, enclosing) if info.ScalarCallVariantEnsured { - walk.visitCallee(callerMangled, site, info.ScalarCallParamTypes, vars, enclosing) + walk.visitCallee(callerMangled, site, info.ScalarCallParamTypes, enclosing) } } } -func (walk *cfgWalk) visitCallee(callerMangled string, site cfgCallSite, paramTypes []Type, vars map[string]Type, enclosing map[string]string) { +func (walk *cfgWalk) visitCallee(callerMangled string, site cfgCallSite, paramTypes []Type, enclosing map[string]string) { mangled := Mangle(walk.compiler.MangledPath, site.call.Function.Value, paramTypes) requireSpecializationCallTarget(walk.compiler, callerMangled, mangled) callee := walk.compiler.FuncCache[mangled] - storage := siteOutputStorage(site, callee.Sig.OutTypes, vars) - pattern := walk.sitePattern(callerMangled, site, paramTypes, storage, enclosing) + pattern := walk.sitePattern(callerMangled, site, paramTypes, callee.Sig.OutTypes, enclosing) - var variantStorage []Type - if !slices.EqualFunc(storage, callee.Sig.OutTypes, TypeEqual) { - variantStorage = storage - } - variant := MangleVariant(mangled, variantStorage, pattern) + variant := MangleVariant(mangled, pattern) if _, seen := walk.visited[variant]; seen { return } @@ -228,34 +222,13 @@ func (walk *cfgWalk) visitCallee(callerMangled string, site cfgCallSite, paramTy nested[template.Parameters[i].Value] = template.Outputs[slot-1].Value } } - // The body's outputs bind to this variant's storage, as lowering's - // outputSlotTypes do, so nested destinations widen the same way. - bodyVars := maps.Clone(callee.Vars) - for j, output := range template.Outputs { - bodyVars[output.Value] = storage[j] - } - walk.visitSites(mangled, template.Body.Statements, bodyVars, nested) -} - -// siteOutputStorage returns each output's storage at one call site: widened -// to its destination's binding, as lowering widens it, else the declared type. -func siteOutputStorage(site cfgCallSite, outTypes []Type, vars map[string]Type) []Type { - storage := slices.Clone(outTypes) - for j, dest := range site.dests { - if j >= len(outTypes) { - break - } - if slot, known := vars[dest.Value]; known { - storage[j] = widenedOutputStorage(outTypes[j], slot) - } - } - return storage + walk.visitSites(mangled, template.Body.Statements, nested) } // sitePattern derives a call's alias pattern the way lowering will: one name -// per parameter position for plain identifier arguments, the destinations by -// their source names, and the outputs at this site's storage. -func (walk *cfgWalk) sitePattern(callerMangled string, site cfgCallSite, paramTypes, storage []Type, enclosing map[string]string) []int { +// per parameter position for plain identifier arguments, and the destinations +// by their source names. +func (walk *cfgWalk) sitePattern(callerMangled string, site cfgCallSite, paramTypes, outTypes []Type, enclosing map[string]string) []int { if site.dests == nil { return nil } @@ -274,7 +247,7 @@ func (walk *cfgWalk) sitePattern(callerMangled string, site cfgCallSite, paramTy for j, dest := range site.dests { dests[j] = dest.Value } - return aliasPattern(argNames, dests, paramTypes, storage, enclosing) + return aliasPattern(argNames, dests, paramTypes, outTypes, enclosing) } // contextErrors returns the callee's diagnostics in one alias context, diff --git a/docs/Pluto C ABI Spec.md b/docs/Pluto C ABI Spec.md index 2963a93f..e5dbb158 100644 --- a/docs/Pluto C ABI Spec.md +++ b/docs/Pluto C ABI Spec.md @@ -345,12 +345,14 @@ types: - Output expressions are staged independently at the call site, so one output cannot mutate a destination before a sibling right-hand side reads its statement-start value. -- When a caller destination has a compatible wider ownership or shape - representation than the declared output, a private lowering variant uses - that output storage so an aliased input can observe its writes. It has a - distinct internal symbol; the source specialization and its effect facts - remain unchanged. Other representation changes use a separate ABI output - adapter initialized to zero and committed only if its write marker is set. +- When an input shares an output whose declared representation is narrower + but compatible (an owned string input with a static string output, a + concrete-rank array input with an untyped empty output), the private alias + variant gives that output the input's storage so the input observes its + writes. It has a distinct internal symbol; the source specialization and + its effect facts remain unchanged. Any other representation change between + a declared output and its destination uses a separate ABI output adapter + initialized to zero and committed only if its write marker is set. The direct-return seed is always present, even when the function body unconditionally overwrites its output. Schematically, with mangled names @@ -442,38 +444,33 @@ make it part of the current calling convention. ### 5.2 Private Lowering Variants -Two facts of a call site change the emitted body of a specialization without -changing its types. Each lowers to a private variant: an internal symbol that -appends a suffix to the ordinary function mangle and is never exported. The -suffixes use the same lowercase-marker-plus-count form as `_fN`, `_tN`, and -the reserved `_cN`, so they parse unambiguously after the argument types. +One fact of a call site changes the emitted body of a specialization without +changing its types: which inputs share a binding with which outputs. Such a +call lowers to a private variant: an internal symbol that appends a suffix to +the ordinary function mangle and is never exported. The suffix uses the same +lowercase-marker-plus-count form as `_fN`, `_tN`, and the reserved `_cN`, so +it parses unambiguously after the argument types. ``` -_oN_ _aN__... ``` -`_oN` is the output-storage variant. It lists the storage type of every -output slot, in declaration order, when a caller destination holds a -compatible wider representation than the declared output (an owned `StrH` -slot receiving a `StrG` output, or a concrete-rank array slot receiving `[]`). -`_aN` is the alias variant. It carries one entry per parameter, in source -order: `0` for a parameter that shares no output, `k` for one that shares -output slot `k - 1`, whose type must match the parameter. When both apply, -`_oN` precedes `_aN`. A suffix appears only when it carries information: the -compiler omits `_aN` when no parameter shares an output, omits `_oN` when -every output slot uses its declared type, and emits the bare specialization -symbol when both are omitted. An unshared call can still lower to an `_oN` -variant. - -Examples: `Pt_4math_p_4Fold_f2_I64_StrH_a2_1_0` is `Fold(I64, StrH)` with its -first parameter sharing its first output, which is therefore an `I64`; -`Pt_4math_p_5Label_f2_I64_StrG_o2_StrH_StrH` is `Label(I64, StrG)` writing -both of its declared `StrG` outputs into owned string slots. `Demangle` -renders these as `math.Fold(I64, StrH) [in1->out1]` and -`math.Label(I64, StrG) -> (StrH, StrH)`. - -The public specialization symbol is unchanged by either variant. C callers +`_aN` carries one entry per parameter, in source order: `0` for a parameter +that shares no output, `k` for one that shares output slot `k - 1`. The +parameter's type must be the output's declared type or a compatible wider +representation of it (an owned `StrH` input sharing a declared `StrG` output, +or a concrete-rank array input sharing an untyped `[]` output). Inside the +variant that output uses the parameter's storage, so the argument types fix +every shared output's representation. The compiler emits the suffix only when +at least one parameter shares an output; otherwise the call uses the bare +specialization symbol. An unshared output keeps its declared representation, +and the caller converts it into the destination after the call. + +Example: `Pt_4math_p_4Fold_f2_I64_StrH_a2_1_0` is `Fold(I64, StrH)` with its +first parameter sharing its first output, which is therefore an `I64`. +`Demangle` renders it as `math.Fold(I64, StrH) [in1->out1]`. + +The public specialization symbol is unchanged by the variant. C callers never see a variant and cannot request one. --- @@ -483,9 +480,8 @@ never see a variant and cannot request one. ```ebnf FunctionSym := 'Pt' ModPath '_p_' Ident '_f' Arity Types | 'Pt' ModPath '_p_' RelPath '_r_' Ident '_f' Arity Types -VariantSym := FunctionSym OutputStorage? AliasPattern? (* internal linkage only *) -OutputStorage := '_o' Num Types -AliasPattern := '_a' Num ('_' Num)* +VariantSym := FunctionSym AliasPattern? (* internal linkage only *) +AliasPattern := '_a' Num ('_' Num)* MethodSym := 'Pt' ModPath '_p_' Ident '_m_' Ident '_f' Arity Types | 'Pt' ModPath '_p_' RelPath '_r_' Ident '_m_' Ident '_f' Arity Types OperatorSym := 'Pt' ModPath '_p_' Ident '_m_op_' Opcode '_' Fixity Types @@ -542,5 +538,5 @@ Generic := (Qualified | Ident) '_t' Num Types * Numeric path segments preserve source digits; `Num` keeps arities, counts, and length prefixes canonical * Operators: Fixity implies arity (in=2, pre/suf=1, cirN=N); Types listed left-to-right * Generics (`_tN`) only in type arguments, not as top-level linkable symbols -* Variant suffixes (`_oN`, `_aN`) name private lowering variants (§5.2); they follow the argument types and never appear on exported symbols +* The variant suffix (`_aN`) names a private lowering variant (§5.2); it follows the argument types and never appears on exported symbols * All symbols always have `_p_` after ModPath; symbols with relpath use `_r_`, and script roots end with `_e` diff --git a/docs/Pluto Effects and Follow-up Plan.md b/docs/Pluto Effects and Follow-up Plan.md index 232c222d..485a3665 100644 --- a/docs/Pluto Effects and Follow-up Plan.md +++ b/docs/Pluto Effects and Follow-up Plan.md @@ -48,7 +48,7 @@ specializing binding arguments on their merged storage type and revisiting calls when a later assignment widens that storage. Under live-reference semantics, `s = "a"` followed by `s, prev = FoldStr(s, "b")`, where the body writes `out = current ⊕ item` before `seen = current`, must produce `ab ab`. -Compatible wider output storage is handled by a private lowering variant, +A shared output takes its input's storage inside the private alias variant, preserving sharing without changing unrelated input types. These cases are covered by `tests/alias_input`. diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index ccc70434..5c1529b6 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -1017,9 +1017,9 @@ The two diagnostics consume effects differently: each shared context on first reach and caches it on the specialization. A script call site fixes its context from names; inside a callee, each nested call derives its context from the enclosing one by the same rule lowering - uses to pick a variant, including output-storage widening, so a body is - analyzed exactly as it is lowered. Diagnostics are exact per context and - deduplicated by location and message: `out = current + 1` written twice is + uses to pick a variant, so a body is analyzed exactly as it is lowered. + Diagnostics are exact per context and deduplicated by location and + message: `out = current + 1` written twice is accepted for `x = Twice(x)` and reported for `y = Twice(x)`, because the first write is dead there. A body may consequently fail to compile because of an unshared call elsewhere; that is the chosen policy for unused-write diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index e649fa0e..99a2e61b 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -275,15 +275,17 @@ res = sum(a, b) after every sibling right-hand side has been evaluated. - **No name overlap**: Parameters and outputs must have distinct names -Calls specialize binding arguments on their actual storage type. When a -caller's destination has a compatible wider representation than the declared -output (for example, an owned string slot receiving a static string, or a -concrete-rank array slot receiving `[]`), a private lowering variant uses that -wider output storage. An aliased input and output therefore continue to share -one slot: assigning `[]` makes a later input read observe the empty array. -An unrelated input keeps its own type and value. Other representation changes -use a separate output adapter with a per-output write marker; the caller only -commits its value when the callee actually writes the output. +Calls specialize binding arguments on their actual storage type. When an +input shares an output whose declared representation is narrower but +compatible (for example, an owned string input with a static string output, +or a concrete-rank array input with an untyped `[]` output), the private +alias variant gives that output the input's storage. An aliased input and +output therefore continue to share one slot: assigning `[]` makes a later +input read observe the empty array. An unrelated input keeps its own type and +value. An unshared output keeps its declared representation; the caller +converts it into the destination through a separate output adapter with a +per-output write marker, and only commits its value when the callee actually +writes the output. ### Call Site diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index 3c9b32db..9d64a70d 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -30,3 +30,7 @@ ResetArray: [ ] [1 2] [ ] WidenedWrapper: second first +UnsharedWidenedBefore: keep +UnsharedWidened: tagged hello! hello! +UnsharedResetBefore: [9] +UnsharedReset: [] [1 2] [1 2] diff --git a/tests/alias_input/self_alias.pt b/tests/alias_input/self_alias.pt index 79a14449..8a8ef964 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -88,6 +88,10 @@ out, seen = Reset(current) out, seen = Wrap(current) out, seen = Reset(current) +out, seen = Tag(current) + seen = current + out = "tagged" + out, left, right = ResetPair(first, second) out = [] left = first diff --git a/tests/alias_input/self_alias.spt b/tests/alias_input/self_alias.spt index ccfb7620..f5c7d8b3 100644 --- a/tests/alias_input/self_alias.spt +++ b/tests/alias_input/self_alias.spt @@ -114,3 +114,16 @@ matrix, flatSeen, matrixSeen = ResetPair(flat, matrix) wrapped = "hello" ⊕ "!" wrapped, wrappedSeen = Wrap(wrapped) "WidenedWrapper:", wrapped, wrappedSeen + +# Without sharing, a static output is converted into the owned destination +# after the call, and an untyped empty output resets the typed destination. +wideOther = "keep" ⊕ "" +wideSource = "hello" ⊕ "!" +"UnsharedWidenedBefore:", wideOther +wideOther, wideSeen = Tag(wideSource) +"UnsharedWidened:", wideOther, wideSeen, wideSource +resetTarget = [9] +resetSource = [1 2] +"UnsharedResetBefore:", resetTarget +resetTarget, resetLeft, resetRight = ResetPair(resetSource, resetSource) +"UnsharedReset:", resetTarget, resetLeft, resetRight From 49ce683c0818083160e32ea392032e92695b3732 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sun, 20 Sep 2026 21:47:49 +0530 Subject: [PATCH 19/56] refactor(compiler): drop outcome-neutral guards from the alias machinery The typed CFG pass skipped the synthetic read of a shared output until that output was defined, but both dataflow passes already treat a read with no prior write as a no-op, so the guard could not change a diagnostic. With it gone the typed pass never consults parameters in scope, so it no longer declares them. The same sweep removes state kept twice and conditions that cannot fail: callArgs reads the alias slot from the signature's pattern instead of a per-argument copy; the walk keys its shared-context cache by the variant symbol it already computes instead of a separate pattern key and reuses sharedOutputs for the nested context; an empty destination list yields a nil pattern by itself, so the early returns go; the type test for sharing is the compatibility rule alone, whose equal-type case it already covers; and the fresh CFG's error list is stored without a copy. Co-Authored-By: Claude Fable 5.1 --- compiler/alias.go | 23 +-------------------- compiler/cfg.go | 21 ++++++++----------- compiler/compiler.go | 36 ++++++++++---------------------- compiler/scriptcompiler.go | 42 ++++++++++++-------------------------- 4 files changed, 33 insertions(+), 89 deletions(-) diff --git a/compiler/alias.go b/compiler/alias.go index 50ed9207..1627457b 100644 --- a/compiler/alias.go +++ b/compiler/alias.go @@ -1,23 +1,12 @@ package compiler -import ( - "strconv" - "strings" -) - // sharableOutput reports whether an input of paramType can share an output // declared as outType: the input's storage must be the declared type or a // compatible wider representation of it (an owned string for a static // output, a concrete-rank array for an untyped empty one). The shared output // then uses the input's storage, so a write lands where the next read looks. func sharableOutput(paramType, outType Type) bool { - if TypeEqual(paramType, outType) { - return true - } - if !bindingSlotCompatible(paramType, outType) { - return false - } - return TypeEqual(mergeBindingSlotType(paramType, outType), paramType) + return bindingSlotCompatible(paramType, outType) && TypeEqual(mergeBindingSlotType(paramType, outType), paramType) } // aliasPattern decides, per callee parameter, the one-based caller destination @@ -54,13 +43,3 @@ func aliasPattern(argNames, dests []string, paramTypes, outTypes []Type, enclosi return pattern } - -// aliasPatternKey identifies one alias context; the empty key is the -// unshared context in which no parameter shares a destination. -func aliasPatternKey(pattern []int) string { - parts := make([]string, len(pattern)) - for i, slot := range pattern { - parts[i] = strconv.Itoa(slot) - } - return strings.Join(parts, "_") -} diff --git a/compiler/cfg.go b/compiler/cfg.go index 0a17b77b..542c0365 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -305,10 +305,6 @@ func (cfg *CFG) AnalyzeSpecialization(template *ast.FuncStatement, info *FuncInf PushScope(&cfg.Scopes, FuncScope) defer PopScope(&cfg.Scopes) - for _, param := range template.Parameters { - cfg.declareName(param) - } - cfg.typedForwardPass(template, info, sharedOutputs(template, pattern)) live := make(map[string]struct{}, len(template.Outputs)) @@ -319,27 +315,26 @@ func (cfg *CFG) AnalyzeSpecialization(template *ast.FuncStatement, info *FuncInf } // sharedOutputs maps each input that shares an output in this context to that -// output, so a read of the input is also a read of the output's latest write. -func sharedOutputs(template *ast.FuncStatement, pattern []int) map[string]*ast.Identifier { - shared := make(map[string]*ast.Identifier, len(pattern)) +// output's name, so a read of the input is also a read of the output's latest +// write, and a nested call forwards the sharing. +func sharedOutputs(template *ast.FuncStatement, pattern []int) map[string]string { + shared := make(map[string]string, len(pattern)) for i, slot := range pattern { if slot > 0 { - shared[template.Parameters[i].Value] = template.Outputs[slot-1] + shared[template.Parameters[i].Value] = template.Outputs[slot-1].Value } } return shared } -func (cfg *CFG) typedForwardPass(template *ast.FuncStatement, info *FuncInfo, shared map[string]*ast.Identifier) { +func (cfg *CFG) typedForwardPass(template *ast.FuncStatement, info *FuncInfo, shared map[string]string) { lastWrites := make(map[string]VarEvent) for _, stmt := range template.Body.Statements { reads := cfg.collectStatementReads(stmt) for _, read := range reads { - output, ok := shared[read.Name] - if !ok || !cfg.isDefined(output.Value) { - continue + if output, ok := shared[read.Name]; ok { + reads = append(reads, VarEvent{Name: output, Kind: Read, Token: read.Token}) } - reads = append(reads, VarEvent{Name: output.Value, Kind: Read, Token: read.Token}) } cfg.processTypedStatement(stmt, reads, info.StatementEffects, lastWrites) } diff --git a/compiler/compiler.go b/compiler/compiler.go index db0115b1..6851e466 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -76,12 +76,6 @@ type callArg struct { Name string Symbol *Symbol Lowered *Symbol - // AliasOutput is the one-based caller destination this argument shares a - // binding with: 0 means none, N means output N-1. It is decided at compile - // time from the call's names, so it selects a lowering variant rather than - // travelling as an argument. One-based keeps the zero value correct for - // arguments that alias nothing. - AliasOutput int } // callSignature is one call site's view of a specialization. Mangled is the @@ -339,18 +333,12 @@ func (c *Compiler) resolveCallSignature(funcName string, ce *ast.CallExpression, }, true } -// setCallArgAliases records on each argument which caller destination it -// shares a binding with, and derives the call's alias pattern from them -// through the rule the CFG also uses. A direct scalar param then reads the -// output's current value inside the variant; an indirect param receives that -// output's staged pointer instead of its own. A shared output takes its -// input's storage, so the variant writes the representation the input reads -// and the caller's staged slot passes through without an adapter. -func (c *Compiler) setCallArgAliases(sig *callSignature, args []callArg, dest []*ast.Identifier) { - if dest == nil { - return - } - +// setCallAliasPattern derives which arguments share a caller destination, +// through the rule the CFG also uses, and gives each shared output its +// input's storage. A direct scalar param then reads the output's current +// value inside the variant; an indirect param receives that output's staged +// pointer, which passes through to the destination without an adapter. +func (c *Compiler) setCallAliasPattern(sig *callSignature, args []callArg, dest []*ast.Identifier) { argNames := make([]string, len(args)) for i, arg := range args { argNames[i] = arg.Name @@ -360,14 +348,12 @@ func (c *Compiler) setCallArgAliases(sig *callSignature, args []callArg, dest [] dests[i] = c.destinationBase(output.Value) } - pattern := aliasPattern(argNames, dests, sig.ParamTypes, sig.ABI.Return.OutTypes, c.enclosingAliases()) - for i, slot := range pattern { - args[i].AliasOutput = slot + sig.AliasPattern = aliasPattern(argNames, dests, sig.ParamTypes, sig.ABI.Return.OutTypes, c.enclosingAliases()) + for i, slot := range sig.AliasPattern { if slot > 0 { sig.ABI.Return.OutTypes[slot-1] = sig.ParamTypes[i] } } - sig.AliasPattern = pattern } // enclosingAliases maps each input of the body being lowered to the output it @@ -3084,7 +3070,7 @@ func (c *Compiler) freeCallArgTemps(callArgs []callArg) { func (c *Compiler) prepareCall(sig *callSignature, ce *ast.CallExpression, dest []*ast.Identifier) preparedCall { callArgs := c.compileCallArgs(sig, ce) - c.setCallArgAliases(sig, callArgs, dest) + c.setCallAliasPattern(sig, callArgs, dest) c.lowerCallArgs(sig.FuncName, callArgs, sig) fn, funcType, retStruct := c.getOrCompileCallFunction(sig) return preparedCall{ @@ -3338,8 +3324,8 @@ func (c *Compiler) callArgs( } for i, arg := range call.Args { argVal := arg.Lowered.Val - if sig.ABI.Params[i].Mode == ABIParamIndirect && arg.AliasOutput > 0 && arg.AliasOutput <= len(outputs) { - argVal = outputs[arg.AliasOutput-1].Val + if sig.ABI.Params[i].Mode == ABIParamIndirect && i < len(sig.AliasPattern) && sig.AliasPattern[i] > 0 { + argVal = outputs[sig.AliasPattern[i]-1].Val } llvmArgs = append(llvmArgs, argVal) } diff --git a/compiler/scriptcompiler.go b/compiler/scriptcompiler.go index 723a3aa1..df6d4b27 100644 --- a/compiler/scriptcompiler.go +++ b/compiler/scriptcompiler.go @@ -2,7 +2,6 @@ package compiler import ( "fmt" - "slices" "github.com/thiremani/pluto/ast" "github.com/thiremani/pluto/pir" @@ -207,7 +206,7 @@ func (walk *cfgWalk) visitCallee(callerMangled string, site cfgCallSite, paramTy if !ok { panic(fmt.Sprintf("internal: settled specialization %s has no template", mangled)) } - for _, compileError := range walk.contextErrors(template, callee, pattern) { + for _, compileError := range walk.contextErrors(template, callee, pattern, variant) { diagnostic := cfgDiagnosticKeyFor(compileError) if _, seen := walk.reported[diagnostic]; seen { continue @@ -216,50 +215,35 @@ func (walk *cfgWalk) visitCallee(callerMangled string, site cfgCallSite, paramTy walk.errors = append(walk.errors, compileError) } - nested := make(map[string]string, len(pattern)) - for i, slot := range pattern { - if slot > 0 { - nested[template.Parameters[i].Value] = template.Outputs[slot-1].Value - } - } - walk.visitSites(mangled, template.Body.Statements, nested) + walk.visitSites(mangled, template.Body.Statements, sharedOutputs(template, pattern)) } // sitePattern derives a call's alias pattern the way lowering will: one name // per parameter position for plain identifier arguments, and the destinations // by their source names. func (walk *cfgWalk) sitePattern(callerMangled string, site cfgCallSite, paramTypes, outTypes []Type, enclosing map[string]string) []int { - if site.dests == nil { - return nil - } - argNames := make([]string, len(paramTypes)) position := 0 for _, argument := range site.call.Arguments { - width := len(walk.compiler.ExprCache[key(callerMangled, argument)].OutTypes) - if ident, ok := argument.(*ast.Identifier); ok && width == 1 && position < len(argNames) { + if ident, ok := argument.(*ast.Identifier); ok { argNames[position] = ident.Value + position++ + continue } - position += width - } - - dests := make([]string, len(site.dests)) - for j, dest := range site.dests { - dests[j] = dest.Value + position += len(walk.compiler.ExprCache[key(callerMangled, argument)].OutTypes) } - return aliasPattern(argNames, dests, paramTypes, outTypes, enclosing) + return aliasPattern(argNames, identNames(site.dests), paramTypes, outTypes, enclosing) } // contextErrors returns the callee's diagnostics in one alias context, // analyzing a shared context on first reach and caching it on the -// specialization; the unshared context was analyzed at settlement. -func (walk *cfgWalk) contextErrors(template *ast.FuncStatement, callee *FuncInfo, pattern []int) []*token.CompileError { +// specialization under its variant symbol; the unshared context was +// analyzed at settlement. +func (walk *cfgWalk) contextErrors(template *ast.FuncStatement, callee *FuncInfo, pattern []int, variant string) []*token.CompileError { if pattern == nil { return callee.CFGResult.Errors } - - patternKey := aliasPatternKey(pattern) - if cached, ok := callee.CFGResult.shared[patternKey]; ok { + if cached, ok := callee.CFGResult.shared[variant]; ok { return cached } @@ -268,8 +252,8 @@ func (walk *cfgWalk) contextErrors(template *ast.FuncStatement, callee *FuncInfo if callee.CFGResult.shared == nil { callee.CFGResult.shared = make(map[string][]*token.CompileError) } - callee.CFGResult.shared[patternKey] = slices.Clone(cfg.Errors) - return callee.CFGResult.shared[patternKey] + callee.CFGResult.shared[variant] = cfg.Errors + return cfg.Errors } func cfgDiagnosticKeyFor(compileError *token.CompileError) cfgDiagnosticKey { From f3ed70aad179a28bbc8842cc379bd7f8c3b22048 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sun, 20 Sep 2026 22:42:42 +0530 Subject: [PATCH 20/56] fix(cfg): keep parameters in scope for the typed pass's marker reads The previous sweep dropped the parameter declarations from AnalyzeSpecialization on the claim that the typed pass never consults parameter scope. It does: the marker collector shared with the structural pass treats an unknown main marker as literal text and rejects unknown specifier names. Without the declarations, "-current" between two output writes was no longer a read of the shared output, so the first write was reported unused, and "-local%(-width)d" reported a parameter width as undefined. Two CFG cases pin both paths; they fail without the declarations. Co-Authored-By: Claude Fable 5.1 --- compiler/cfg.go | 6 ++++++ compiler/cfg_test.go | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/compiler/cfg.go b/compiler/cfg.go index 542c0365..4e7c5282 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -305,6 +305,12 @@ func (cfg *CFG) AnalyzeSpecialization(template *ast.FuncStatement, info *FuncInf PushScope(&cfg.Scopes, FuncScope) defer PopScope(&cfg.Scopes) + // Parameters must be in scope: the shared marker collector treats an + // unknown main marker as literal text and rejects unknown specifier names. + for _, param := range template.Parameters { + cfg.declareName(param) + } + cfg.typedForwardPass(template, info, sharedOutputs(template, pattern)) live := make(map[string]struct{}, len(template.Outputs)) diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index 81973394..04867864 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -137,6 +137,16 @@ out, seen = Wrap(current) value, seen = Wrap(value) value, seen`, }, + { + // A format marker naming the shared input reads the output's first + // write, so the second write does not overwrite an unused value. + name: "Marker Read Of Shared Input Between Output Writes", + code: `out = Show(current) + out = 1 + "-current" + out = 2`, + input: "x = 5\nx = Show(x)\nx", + }, { name: "Repeated Empty Array Write Through Widening Wrapper", code: `out, seen = ResetEmpty(current) @@ -192,6 +202,15 @@ func getValidTestCases() []cfgTestCase { input: `x = 42 "Answer: -x"`, // x defined before marker }, + { + // A parameter is in scope for a marker's specifier inside a body. + name: "Parameter In Marker Specifier", + code: `out = Pad(value, width) + local = value + out = value + "-local%(-width)d"`, + input: "y = Pad(7, 4)\ny", + }, { name: "Marker Following Unresolved Marker", input: `width = 5 From 6a2012d1abf055bb16474ffe3c064f2c74a355e5 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sun, 20 Sep 2026 23:48:33 +0530 Subject: [PATCH 21/56] feat(compiler): allow reading an output once it is definitely assigned Outputs were write-only inside their template, which rejected the ordinary `sq = x * x` followed by `cube = sq * x`. The hazard the rule guarded against is narrower: a read that can observe the caller's seed. An output is now readable after a statement that definitely assigns it. The structural pass rejects a read before any assignment, including in the same simultaneous assignment, whose reads precede its writes. The typed pass rejects a read after only conditional or seed-preserving writes, using the same must-write-and-not-seed rule as body output effects, and exempts the synthetic reads of shared inputs, which are meant to observe the seed. Solved types are per parameter-type specialization while storage is per alias context, so a read of an output whose storage a shared caller widened would leak that storage into slots and callees typed at the declared type: `out = "first"`, `kept = Identity(out)`, `out = "second"` on a shared heap string specialized Identity(StrG) against heap storage that the overwrite then freed. An output the body reads is therefore solved at owned storage, static strings becoming heap recursively through struct fields, and must have a concrete type. The solver's storage rewalk propagates the widening to locals copied from the output and to the calls they feed. Unread outputs are unchanged, so every program that compiled before keeps its meaning. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- compiler/cfg.go | 75 +++++++--- compiler/cfg_test.go | 179 +++++++++++++++++------ compiler/codecompiler.go | 11 ++ compiler/compiler_test.go | 24 +++ compiler/effects.go | 21 ++- compiler/solver.go | 6 + compiler/types.go | 48 ++++++ docs/Pluto ABI Optimization Plan.md | 2 +- docs/Pluto Effects and Follow-up Plan.md | 8 +- docs/Pluto IR Plan.md | 8 +- docs/Pluto Memory Model.md | 27 ++-- tests/alias_input/self_alias.exp | 3 + tests/alias_input/self_alias.pt | 14 ++ tests/alias_input/self_alias.spt | 12 ++ 15 files changed, 347 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index b2d901cf..77ef56c4 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ y = Square(x) y = x * x ``` -Inputs are read-only — they flow in. Outputs are write-only inside the template — they flow out; use a local for intermediate values. Read-only means the template cannot assign through the input name; it does not freeze a value shared with an output. A caller may reuse a variable as both argument and destination, `a = Square(a)`. +Inputs are read-only — they flow in. Outputs flow out: the template may read one only after assigning it unconditionally, as in `sq = x * x` followed by `cube = sq * x`; before that, use a local. Read-only means the template cannot assign through the input name; it does not freeze a value shared with an output. A caller may reuse a variable as both argument and destination, `a = Square(a)`. ```python out, seen = Fold(current, item) diff --git a/compiler/cfg.go b/compiler/cfg.go index 4e7c5282..a633e4b9 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -2,6 +2,7 @@ package compiler import ( "fmt" + "maps" "github.com/thiremani/pluto/ast" "github.com/thiremani/pluto/lexer" @@ -194,7 +195,7 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { defer PopScope(&cfg.Scopes) // Outputs are declared up front so that a formatting marker naming one - // resolves as a read and is rejected, instead of passing as literal text. + // resolves as a read, instead of passing as literal text. for _, param := range fn.Parameters { cfg.declareName(param) } @@ -202,18 +203,9 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { cfg.declareName(output) } - parameterNames := make(map[string]struct{}, len(fn.Parameters)) - for _, parameter := range fn.Parameters { - parameterNames[parameter.Value] = struct{}{} - } - - outputNames := make(map[string]struct{}, len(fn.Outputs)) - for _, output := range fn.Outputs { - outputNames[output.Value] = struct{}{} - } - - body := cfg.validateTemplateBody(fn.Body.Statements, parameterNames, outputNames) + body := cfg.validateTemplateBody(fn.Body.Statements, identSet(fn.Parameters), identSet(fn.Outputs)) readInputs, assignedOutputs := body.readInputs, body.assignedOutputs + cfg.CodeCompiler.outputReads[funcKey{name: fn.Token.Literal, arity: len(fn.Parameters)}] = body.readOutputs for _, input := range fn.Parameters { if _, wasRead := readInputs[input.Value]; wasRead { @@ -235,9 +227,18 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { type templateBody struct { statementReads [][]VarEvent readInputs map[string]struct{} + readOutputs map[string]struct{} assignedOutputs map[string]struct{} } +func identSet(idents []*ast.Identifier) map[string]struct{} { + set := make(map[string]struct{}, len(idents)) + for _, ident := range idents { + set[ident.Value] = struct{}{} + } + return set +} + // validateTemplateBody runs structural validation over one template body. A // script is a zero-input, zero-output template: it passes nil name sets and // consumes only the reads. @@ -245,11 +246,12 @@ func (cfg *CFG) validateTemplateBody(statements []ast.Statement, parameterNames, body := templateBody{ statementReads: make([][]VarEvent, 0, len(statements)), readInputs: make(map[string]struct{}, len(parameterNames)), + readOutputs: make(map[string]struct{}, len(outputNames)), assignedOutputs: make(map[string]struct{}, len(outputNames)), } for _, stmt := range statements { reads := cfg.collectStatementReads(stmt) - targets := cfg.validateStatementStructure(stmt, reads, parameterNames, outputNames) + targets := cfg.validateStatementStructure(stmt, reads, parameterNames, outputNames, body.assignedOutputs) if let, ok := stmt.(*ast.LetStatement); ok { cfg.declareTargets(let.Name) } @@ -259,6 +261,9 @@ func (cfg *CFG) validateTemplateBody(statements []ast.Statement, parameterNames, if _, isParameter := parameterNames[event.Name]; isParameter { body.readInputs[event.Name] = struct{}{} } + if _, isOutput := outputNames[event.Name]; isOutput { + body.readOutputs[event.Name] = struct{}{} + } } for _, target := range targets { if _, isOutput := outputNames[target.Value]; isOutput { @@ -311,13 +316,15 @@ func (cfg *CFG) AnalyzeSpecialization(template *ast.FuncStatement, info *FuncInf cfg.declareName(param) } - cfg.typedForwardPass(template, info, sharedOutputs(template, pattern)) - - live := make(map[string]struct{}, len(template.Outputs)) - for _, output := range template.Outputs { - live[output.Value] = struct{}{} + outputs := identSet(template.Outputs) + readOutputs := cfg.CodeCompiler.readOutputs(template.Token.Literal, len(template.Parameters)) + for i, output := range template.Outputs { + if _, isRead := readOutputs[output.Value]; isRead && !concreteStorage(info.Sig.OutTypes[i]) { + cfg.addError(output.Tok(), fmt.Sprintf("output %q is read but its type %s is not concrete", output.Value, info.Sig.OutTypes[i])) + } } - cfg.backwardPass(live) + cfg.typedForwardPass(template, info, outputs, sharedOutputs(template, pattern)) + cfg.backwardPass(maps.Clone(outputs)) } // sharedOutputs maps each input that shares an output in this context to that @@ -333,16 +340,31 @@ func sharedOutputs(template *ast.FuncStatement, pattern []int) map[string]string return shared } -func (cfg *CFG) typedForwardPass(template *ast.FuncStatement, info *FuncInfo, shared map[string]string) { +// typedForwardPass runs the forward dataflow over a body. An output is +// readable once a statement has definitely assigned it, and a read of a +// shared input is also a read of its output's latest write. +func (cfg *CFG) typedForwardPass(template *ast.FuncStatement, info *FuncInfo, outputs map[string]struct{}, shared map[string]string) { + assigned := make(map[string]struct{}, len(outputs)) lastWrites := make(map[string]VarEvent) for _, stmt := range template.Body.Statements { reads := cfg.collectStatementReads(stmt) for _, read := range reads { + if _, isOutput := outputs[read.Name]; isOutput { + if _, isAssigned := assigned[read.Name]; !isAssigned { + cfg.addError(read.Token, fmt.Sprintf("output %q is read where it may still be unassigned; assign it unconditionally first or use a local", read.Name)) + } + continue + } if output, ok := shared[read.Name]; ok { reads = append(reads, VarEvent{Name: output, Kind: Read, Token: read.Token}) } } cfg.processTypedStatement(stmt, reads, info.StatementEffects, lastWrites) + if let, ok := stmt.(*ast.LetStatement); ok { + for _, name := range definiteTargets(let, info.StatementEffects[let]) { + assigned[name] = struct{}{} + } + } } } @@ -369,9 +391,9 @@ func (cfg *CFG) processTypedStatement(stmt ast.Statement, reads []VarEvent, effe // validateStatementStructure reports template-stable read and write errors and // returns named targets for caller-specific bookkeeping. The caller publishes // them only after all statement reads have been checked. -func (cfg *CFG) validateStatementStructure(stmt ast.Statement, reads []VarEvent, parameters, outputs map[string]struct{}) []*ast.Identifier { +func (cfg *CFG) validateStatementStructure(stmt ast.Statement, reads []VarEvent, parameters, outputs, assigned map[string]struct{}) []*ast.Identifier { for _, event := range reads { - cfg.validateStructuralRead(event, outputs) + cfg.validateStructuralRead(event, outputs, assigned) } let, ok := stmt.(*ast.LetStatement) @@ -483,9 +505,14 @@ func (cfg *CFG) backwardPass(live map[string]struct{}) { // validateStructuralRead enforces that a declared output is write-only inside // its template. A body may observe output writes through an explicitly passed // input that shares the output's binding, but never through the output name. -func (cfg *CFG) validateStructuralRead(event VarEvent, outputs map[string]struct{}) { +// An output is readable once an earlier statement has assigned it; a +// statement's reads precede its own writes. The typed pass narrows this per +// specialization to writes that definitely assign. +func (cfg *CFG) validateStructuralRead(event VarEvent, outputs, assigned map[string]struct{}) { if _, isOutput := outputs[event.Name]; isOutput { - cfg.addError(event.Token, fmt.Sprintf("output %q is read inside its function; outputs are write-only, use a local", event.Name)) + if _, isAssigned := assigned[event.Name]; !isAssigned { + cfg.addError(event.Token, fmt.Sprintf("output %q is read before it is assigned", event.Name)) + } return } if !cfg.isDefined(event.Name) { diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index 04867864..6290562b 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -201,6 +201,103 @@ func getValidTestCases() []cfgTestCase { name: "FormatMarker After Def", input: `x = 42 "Answer: -x"`, // x defined before marker + }, + { + // An output is readable once definitely assigned, as a value, a + // condition, a call argument, a print, or a marker. + name: "Output Read After Definite Write", + code: `res = overwrite(x) + res = x + res = res + 1`, + input: "x = overwrite(3)\nx", + }, + { + name: "Output Read In Condition", + code: `res = gated(x) + res = x + res = res > 5 x * x`, + input: "x = gated(3)\nx", + }, + { + name: "Output Read As Call Argument", + code: `res = id(x) + res = x + +res = forwarded(x) + res = x + res = id(res)`, + input: "x = forwarded(3)\nx", + }, + { + name: "Output Read By Print", + code: `res = printed(x) + res = x + res`, + input: "x = printed(3)\nx", + }, + { + name: "Output Read By Format Marker After Assignment", + code: `res = marked(x) + res = x + "value -res"`, + input: "x = marked(3)\nx", + }, + { + name: "Output Read By Dynamic Width", + code: `res = widened(x) + res = x + "-x%(-res)d"`, + input: "x = widened(3)\nx", + }, + { + name: "Output Feeds Sibling Output", + code: `sq, cube = powers(x) + sq = x * x + cube = sq * x`, + input: "p, q = powers(3)\np, q", + }, + { + // A later conditional write does not undo the assignment. + name: "Output Read After Later Conditional Write", + code: `res = refined(x) + res = x + res = x > 5 x * x + res = res + 1`, + input: "x = refined(3)\nx", + }, + { + // Once assigned, the simultaneous form reads the previous value. + name: "Simultaneous Output Read After Assignment", + code: `sq, cube = powers(x) + sq = 1 + sq, cube = x * x, sq * x`, + input: "a, b = powers(3)\na, b", + }, + { + // Empty data with an established element type is readable. + name: "Concrete Empty Array Output Read", + code: `out, n = shrink(x) + out = [] + n = out + out = [x]`, + input: "a, b = shrink(1)\na, b", + }, + { + // A read string output is solved as owned, so the local copied + // from it and the call it feeds use heap storage. + name: "Output Read Through Local Into Call", + code: `seen = Identity(current) + seen = current + +out, kept, echo = ReadTwice(current) + out = "first" + saved = out + kept = Identity(saved) + out = "second" + echo = current`, + input: `value = "hello" ⊕ "!" +value, kept, echo = ReadTwice(value) +value, kept, echo`, }, { // A parameter is in scope for a marker's specifier inside a body. @@ -369,42 +466,33 @@ func getErrorTestCases() []cfgTestCase { res = x > 0 x res = res + 1`, input: "x = maybeIncrement(-1)\nx", - errorContains: `output "res" is read inside its function; outputs are write-only, use a local`, + errorContains: `output "res" is read where it may still be unassigned`, }, { - name: "Output Read After Definite Write", - code: `res = overwrite(x) - res = x + // A call that may leave its output unwritten does not assign it. + name: "Output Read After Skippable Call", + code: `res = maybe(x) + res = x > 0 x + +res = chained(x) + res = maybe(x) res = res + 1`, - input: "x = overwrite(3)\nx", - errorContains: `output "res" is read inside its function; outputs are write-only, use a local`, - }, - { - name: "Output Read In Condition", - code: `res = gated(x) - res = x - res = res > 5 x * x`, - input: "x = gated(3)\nx", - errorContains: `output "res" is read inside its function; outputs are write-only, use a local`, + input: "x = chained(-1)\nx", + errorContains: `output "res" is read where it may still be unassigned`, }, { - name: "Output Read As Call Argument", - code: `res = id(x) - res = x + // Two seed-preserving calls in a row still leave the caller's seed + // in place, so the read after them is rejected. + name: "Output Read After Two Seed Preserving Calls", + code: `res = maybe(x) + res = x > 0 x -res = forwarded(x) - res = x - res = id(res)`, - input: "x = forwarded(3)\nx", - errorContains: `output "res" is read inside its function; outputs are write-only, use a local`, - }, - { - name: "Output Read By Print", - code: `res = printed(x) - res = x - res`, - input: "x = printed(3)\nx", - errorContains: `output "res" is read inside its function; outputs are write-only, use a local`, +res = twice(x) + res = maybe(x) + res = maybe(x) + res = res + 1`, + input: "x = twice(-1)\nx", + errorContains: `output "res" is read where it may still be unassigned`, }, { // A marker naming an output is a read even before any assignment, @@ -414,23 +502,26 @@ res = forwarded(x) "seed -res" res = x`, input: "x = marked(3)\nx", - errorContains: `output "res" is read inside its function; outputs are write-only, use a local`, + errorContains: `output "res" is read before it is assigned`, }, { - name: "Output Read By Dynamic Width", - code: `res = widened(x) - res = x - "-x%(-res)d"`, - input: "x = widened(3)\nx", - errorContains: `output "res" is read inside its function; outputs are write-only, use a local`, + // Reads in a simultaneous assignment precede its writes. + name: "Simultaneous Output Read Before Assignment", + code: `sq, cube = powers(x) + sq, cube = x * x, sq * x`, + input: "a, b = powers(3)\na, b", + errorContains: `output "sq" is read before it is assigned`, }, { - name: "Sibling Output Read", - code: `a, b = cross(x) - a = x - b = a + 1`, - input: "p, q = cross(3)\np, q", - errorContains: `output "a" is read inside its function; outputs are write-only, use a local`, + // A caller's destination could still refine an untyped empty + // array, so its storage is not fixed when the body reads it. + name: "Untyped Empty Array Output Read", + code: `out, n = emptied(x) + out = [] + n = x + out`, + input: "a, b = emptied(1)\na, b", + errorContains: `output "out" is read but its type`, }, { name: "Unresolved Dynamic Specifier", @@ -960,7 +1051,7 @@ res = readFirst(x) res = x * 2 `, wantMsgs: []string{ - `output "res" is read inside its function; outputs are write-only, use a local`, + `output "res" is read before it is assigned`, }, }, { diff --git a/compiler/codecompiler.go b/compiler/codecompiler.go index cd859dec..c90cbd9c 100644 --- a/compiler/codecompiler.go +++ b/compiler/codecompiler.go @@ -13,6 +13,9 @@ type CodeCompiler struct { Code *ast.Code globalBindings map[string]token.Token funcTemplates map[funcKey]*ast.FuncStatement + // outputReads names the outputs each template reads in its own body, + // recorded by the structural CFG pass for the solver. + outputReads map[funcKey]map[string]struct{} } type funcKey struct { @@ -47,6 +50,7 @@ func (cc *CodeCompiler) indexDeclarations() []*token.CompileError { var errs []*token.CompileError cc.globalBindings = make(map[string]token.Token) cc.funcTemplates = make(map[funcKey]*ast.FuncStatement) + cc.outputReads = make(map[funcKey]map[string]struct{}) for _, stmt := range cc.Code.Statements { switch s := stmt.(type) { @@ -76,6 +80,13 @@ func (cc *CodeCompiler) indexDeclarations() []*token.CompileError { return errs } +// readOutputs names the outputs a template reads in its body. The solver +// solves such an output at owned storage, so every read and every nested call +// it feeds see the representation lowering stores. +func (cc *CodeCompiler) readOutputs(name string, arity int) map[string]struct{} { + return cc.outputReads[funcKey{name: name, arity: arity}] +} + func (cc *CodeCompiler) lookupFuncTemplate(name string, arity int) (*ast.FuncStatement, bool) { template, ok := cc.funcTemplates[funcKey{name: name, arity: arity}] return template, ok diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 736a8390..adc263b4 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -475,6 +475,30 @@ other, seen` require.NotContains(t, ir, "@"+mangled+"_a", "destination storage alone selects no private variant") } +// A read string output is solved as owned, so a nested call fed from it +// specializes on the storage the shared caller's slot actually holds. +func TestReadOutputFeedsNestedCallAtOwnedStorage(t *testing.T) { + code := `seen = Identity(current) + seen = current + +out, kept, echo = ReadTwice(current) + out = "first" + saved = out + kept = Identity(saved) + out = "second" + echo = current` + script := `value = "hello" ⊕ "!" +value, kept, echo = ReadTwice(value) +value, kept, echo` + + ir, _ := compileScriptAndCodeIR(t, "read_output_owned", code, script) + heap := Mangle(MangleDirPath("read_output_owned", ""), "Identity", []Type{StrH{}}) + static := Mangle(MangleDirPath("read_output_owned", ""), "Identity", []Type{StrG{}}) + + require.Contains(t, ir, "@"+heap+"(", "the local copied from the read output is owned") + require.NotContains(t, ir, "@"+static+"(", "no static specialization borrows the output's heap storage") +} + func TestRangedCallDoesNotCopyUnrelatedArrayInput(t *testing.T) { // Both outputs are integers, so writing them can never change the array // input even though it is read after the first output write. Copying it diff --git a/compiler/effects.go b/compiler/effects.go index fad03c9f..e9001db5 100644 --- a/compiler/effects.go +++ b/compiler/effects.go @@ -525,12 +525,8 @@ func deriveBodyOutputEffects(template *ast.FuncStatement, statements map[*ast.Le return slices.Repeat([]WriteEffect{WriteInvalid}, len(template.Outputs)) } - for _, write := range statementEffect.Writes { - if write.Effect != MustWrite || slices.Contains(statementEffect.ReadsSeed, write.TargetIndex) { - continue - } - index, isOutput := outputIndex[stmt.Name[write.TargetIndex].Value] - if isOutput { + for _, name := range definiteTargets(stmt, statementEffect) { + if index, isOutput := outputIndex[name]; isOutput { effects[index] = MustWrite } } @@ -539,6 +535,19 @@ func deriveBodyOutputEffects(template *ast.FuncStatement, statements map[*ast.Le return effects } +// definiteTargets lists the targets a statement leaves holding its own value +// on every path: unconditional writes that do not merely preserve the +// target's seed. +func definiteTargets(stmt *ast.LetStatement, effect StatementEffect) []string { + var targets []string + for _, write := range effect.Writes { + if write.Effect == MustWrite && !slices.Contains(effect.ReadsSeed, write.TargetIndex) { + targets = append(targets, stmt.Name[write.TargetIndex].Value) + } + } + return targets +} + type specializationNodeID int type specializationNode struct { diff --git a/compiler/solver.go b/compiler/solver.go index 9ff83c03..fc7dfb22 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -2703,6 +2703,7 @@ func (ts *TypeSolver) TypeBlock(template *ast.FuncStatement, f *FuncInfo) { } } + readOutputs := ts.ScriptCompiler.Compiler.CodeCompiler.readOutputs(f.Sig.Name, len(template.Parameters)) for i, id := range template.Outputs { outArg, ok := Get(ts.Scopes, id.Value) if !ok { @@ -2726,6 +2727,11 @@ func (ts *TypeSolver) TypeBlock(template *ast.FuncStatement, f *FuncInfo) { )) } nextOutArg := mergeBindingSlotType(oldOutArg, outArg) + // A read output is solved at owned storage; the widening rewalks the + // body so its reads and the calls they feed follow. + if _, isRead := readOutputs[id.Value]; isRead { + nextOutArg = ownedStorage(nextOutArg) + } ts.recordBindingSlotType(id.Value, nextOutArg) if TypeEqual(oldOutArg, nextOutArg) { continue diff --git a/compiler/types.go b/compiler/types.go index 23a66a07..b869c8c4 100644 --- a/compiler/types.go +++ b/compiler/types.go @@ -690,6 +690,54 @@ func bindingSlotCompatible(oldType, newType Type) bool { return CanRefineType(oldType, newType) } +// ownedStorage is the widest storage a shared caller could give a value of +// type t: a static string becomes owned, recursively through struct fields. +// An output its body reads is solved at this storage, so its reads and the +// nested calls they feed see the representation lowering stores. +func ownedStorage(t Type) Type { + switch tt := t.(type) { + case StrG: + return StrH{} + case Struct: + owned := tt + owned.Fields = make([]StructField, len(tt.Fields)) + for i, field := range tt.Fields { + owned.Fields[i] = StructField{Name: field.Name, Type: ownedStorage(field.Type)} + } + return owned + default: + return t + } +} + +// concreteStorage reports whether t fixes its storage in every calling +// context: an untyped empty array, a column without an element type, or an +// unresolved leaf could still be refined by a caller's destination. +func concreteStorage(t Type) bool { + switch tt := t.(type) { + case Empty, Unresolved: + return false + case Array: + return tt.Rank > 0 && tt.ElemType != nil && concreteStorage(tt.ElemType) + case Table: + for _, column := range tt.Columns { + if column.ElemType == nil || !concreteStorage(column.ElemType) { + return false + } + } + return true + case Struct: + for _, field := range tt.Fields { + if !concreteStorage(field.Type) { + return false + } + } + return true + default: + return IsFullyResolvedType(t) + } +} + // mergeBindingSlotType joins compatible observations without narrowing storage. func mergeBindingSlotType(oldType, newType Type) Type { if oldType.Kind() == StrKind && newType.Kind() == StrKind { diff --git a/docs/Pluto ABI Optimization Plan.md b/docs/Pluto ABI Optimization Plan.md index e986f414..955b5db2 100644 --- a/docs/Pluto ABI Optimization Plan.md +++ b/docs/Pluto ABI Optimization Plan.md @@ -29,7 +29,7 @@ Pluto's source-level semantics stay unchanged: - assignments copy - input names are read-only, but can observe writes through a shared output -- output names are write-only, and results reach the caller at assignment commit +- output names are readable only once definitely assigned, and results reach the caller at assignment commit These are **language semantics**. How values physically move across a call boundary is the **lowered calling convention** — a separate concern. An `I64` diff --git a/docs/Pluto Effects and Follow-up Plan.md b/docs/Pluto Effects and Follow-up Plan.md index 485a3665..94aef272 100644 --- a/docs/Pluto Effects and Follow-up Plan.md +++ b/docs/Pluto Effects and Follow-up Plan.md @@ -21,9 +21,9 @@ type, and stored type separately, as the corrected code comment already does. Resolved by a language rule instead of an analysis ([PR #104](https://github.com/thiremani/pluto/pull/104), superseding the closed -[PR #102](https://github.com/thiremani/pluto/pull/102)): declared outputs are -write-only inside their template, so the reproducer below is rejected at -`y = y + 1`. The hidden seed and destination-seeded staging slots continue to +[PR #102](https://github.com/thiremani/pluto/pull/102)): a declared output is +readable inside its template only after it is definitely assigned, so the +reproducer below is rejected at `y = y + 1`. The hidden seed and destination-seeded staging slots continue to preserve outputs that are not written. A caller can explicitly connect an input to an output by reusing the same binding: later statements then observe writes through that output, in ordinary and ranged calls alike. Inputs are @@ -236,7 +236,7 @@ and [ABI stability plan](./Pluto%20ABI%20Optimization%20Plan.md). | Work | Completion criterion / existing reference | | --- | --- | -| Seed/effect correctness | Section 1; resolved by the write-only-outputs rule in [PR #104](https://github.com/thiremani/pluto/pull/104); flow-versus-slot call specialization is [#103](https://github.com/thiremani/pluto/issues/103) | +| Seed/effect correctness | Section 1; resolved by the definite-assignment rule for output reads in [PR #104](https://github.com/thiremani/pluto/pull/104); flow-versus-slot call specialization is [#103](https://github.com/thiremani/pluto/issues/103) | | `%n` effect contract | Section 2; separate bounded change with formatting semantics updated | | Output path protection | [Issue #80](https://github.com/thiremani/pluto/issues/80): compilation cannot overwrite source/configuration through name collisions or unsafe path resolution | | Numeric edge behavior | Define and guard integer divide/remainder faults and invalid shift counts; audit range/count/allocation arithmetic | diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index 5c1529b6..33a57908 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -854,9 +854,11 @@ Boundary resolution implies an **implicit read of the destination seed**, and only where the dependency is real: after a successful invocation, at an *existing* target whose direct callee output is `MayWrite`, resolved at `=`. A fresh destination, a discard, a nested or targetless call, or an -all-`MustWrite` callee introduces no implicit seed read. Declared outputs are -write-only inside their template (the structural CFG rejects every read, -including formatting markers), so the body cannot read the hidden seed through +all-`MustWrite` callee introduces no implicit seed read. A declared output is +readable inside its template only after a statement that definitely assigns +it (the structural CFG rejects a read before any assignment, including a +formatting marker, and the typed pass rejects a read after only conditional +or seed-preserving writes), so the body cannot read the hidden seed through an output name. An input explicitly shared with an output can observe the staged value and later writes; that dependency is already an explicit argument read at the call site. Step 2A diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index 99a2e61b..ba347347 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -263,16 +263,23 @@ res = sum(a, b) observes that slot's current value, including writes from earlier statements in the body. This rule applies to both ordinary and ranged calls and is independent of whether the implementation passes the value or a pointer. -- **Outputs**: Write-only inside their template. A body may assign an output - any number of times, conditionally or not, and a nested call may target it, - but reading it anywhere — a value, a condition, a call argument, a print, or - a formatting marker — is a compile error. Intermediate values live in - locals. Outputs are independently staged result slots: an existing - destination supplies the initial value and a fresh destination starts at - its type's zero value, so a body that writes nothing preserves the caller's - value. The body may observe that value through an explicitly aliased input; - it cannot read the output name itself. The real destinations are committed only - after every sibling right-hand side has been evaluated. +- **Outputs**: Readable once definitely assigned. A body may assign an output + any number of times, conditionally or not, and a nested call may target it. + It may read an output — as a value, a condition, a call argument, a print, + or a formatting marker — only after a statement that assigns it + unconditionally with a value that cannot be skipped. A read before that is + a compile error: before any assignment, in the same simultaneous + assignment, or after only conditional or seed-preserving writes. A later + conditional write does not revoke the assignment. Outputs are independently + staged result slots: an existing destination supplies the initial value and + a fresh destination starts at its type's zero value, so a body that writes + nothing preserves the caller's value. The body may observe that value + through an explicitly aliased input; it can never read it through the + output name. An output the body reads is solved at owned storage (a static + string output becomes a heap string, recursively through struct fields) and + must have a concrete type, so every read and every nested call it feeds use + the representation the caller's shared slot holds. The real destinations + are committed only after every sibling right-hand side has been evaluated. - **No name overlap**: Parameters and outputs must have distinct names Calls specialize binding arguments on their actual storage type. When an diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index 9d64a70d..7e7ef760 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -34,3 +34,6 @@ UnsharedWidenedBefore: keep UnsharedWidened: tagged hello! hello! UnsharedResetBefore: [9] UnsharedReset: [] [1 2] [1 2] +Powers: 9 27 +PowersShared: 9 81 +ReadTwice: second first second diff --git a/tests/alias_input/self_alias.pt b/tests/alias_input/self_alias.pt index 8a8ef964..263466bc 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -92,6 +92,20 @@ out, seen = Tag(current) seen = current out = "tagged" +sq, cube = Powers(x) + sq = x * x + cube = sq * x + +seen = Identity(current) + seen = current + +out, kept, echo = ReadTwice(current) + out = "first" + saved = out + kept = Identity(saved) + out = "second" + echo = current + out, left, right = ResetPair(first, second) out = [] left = first diff --git a/tests/alias_input/self_alias.spt b/tests/alias_input/self_alias.spt index f5c7d8b3..febdeca8 100644 --- a/tests/alias_input/self_alias.spt +++ b/tests/alias_input/self_alias.spt @@ -127,3 +127,15 @@ resetSource = [1 2] "UnsharedResetBefore:", resetTarget resetTarget, resetLeft, resetRight = ResetPair(resetSource, resetSource) "UnsharedReset:", resetTarget, resetLeft, resetRight + +# An output is readable once definitely assigned: a sibling output can build +# on it, and a shared input observes the write. A read string output is solved +# as owned, so the nested call sees the storage the shared caller holds. +sq, cube = Powers(3) +"Powers:", sq, cube +pw = 3 +pw, pwCube = Powers(pw) +"PowersShared:", pw, pwCube +readTwice = "hello" ⊕ "!" +readTwice, readKept, readEcho = ReadTwice(readTwice) +"ReadTwice:", readTwice, readKept, readEcho From 1c19da04bc9ecc2d9fdbebead43699186b6377b9 Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 21 Sep 2026 00:05:10 +0530 Subject: [PATCH 22/56] fix(compiler): promote only string outputs; structs share at exact type Promoting a read output to owned storage rewrote struct fields from static to heap strings, but nothing converts a struct's fields on store, so a struct passed in through a parameter and read through its output was returned with a static pointer in a slot typed as owned, and the caller freed it. Only the string flavor has a store-time conversion, so only a static-string output is promoted. For the same reason a struct now shares an output only at its exact type: widening its fields under sharing would produce the same mismatch. Struct rows are constants today, so no heap-field struct can be built and the restriction changes no reachable program; it keeps the invariant that a read output's storage equals its solved type by construction rather than by accident. The struct fixture passes a constant struct through a function that reads its output; it aborted before this fix. A unit test pins which representations sharing may widen. Co-Authored-By: Claude Fable 5.1 --- compiler/alias.go | 11 ++++++++--- compiler/cfg.go | 3 --- compiler/compiler_test.go | 11 +++++++++++ compiler/solver.go | 9 +++++---- compiler/types.go | 27 --------------------------- docs/Pluto C ABI Spec.md | 3 ++- docs/Pluto Memory Model.md | 9 +++++---- tests/struct/struct.exp | 1 + tests/struct/struct.pt | 4 ++++ tests/struct/struct.spt | 5 +++++ 10 files changed, 41 insertions(+), 42 deletions(-) diff --git a/compiler/alias.go b/compiler/alias.go index 1627457b..f808428b 100644 --- a/compiler/alias.go +++ b/compiler/alias.go @@ -2,10 +2,15 @@ package compiler // sharableOutput reports whether an input of paramType can share an output // declared as outType: the input's storage must be the declared type or a -// compatible wider representation of it (an owned string for a static -// output, a concrete-rank array for an untyped empty one). The shared output -// then uses the input's storage, so a write lands where the next read looks. +// compatible wider representation that a store converts (an owned string for +// a static output, a concrete-rank array for an untyped empty one, a schema +// for a header-only table). A struct shares only at its exact type, since +// nothing converts its fields. The shared output then uses the input's +// storage, so a write lands where the next read looks. func sharableOutput(paramType, outType Type) bool { + if _, isStruct := outType.(Struct); isStruct { + return TypeEqual(paramType, outType) + } return bindingSlotCompatible(paramType, outType) && TypeEqual(mergeBindingSlotType(paramType, outType), paramType) } diff --git a/compiler/cfg.go b/compiler/cfg.go index a633e4b9..12e5784e 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -502,9 +502,6 @@ func (cfg *CFG) backwardPass(live map[string]struct{}) { } } -// validateStructuralRead enforces that a declared output is write-only inside -// its template. A body may observe output writes through an explicitly passed -// input that shares the output's binding, but never through the output name. // An output is readable once an earlier statement has assigned it; a // statement's reads precede its own writes. The typed pass narrows this per // specialization to writes that definitely assign. diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index adc263b4..6170778d 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -499,6 +499,17 @@ value, kept, echo` require.NotContains(t, ir, "@"+static+"(", "no static specialization borrows the output's heap storage") } +// Sharing widens an output only into a representation a store converts. +func TestSharableOutputWidensOnlyConvertedRepresentations(t *testing.T) { + static := Struct{Name: "Person", Fields: []StructField{{Name: "name", Type: StrG{}}}} + owned := Struct{Name: "Person", Fields: []StructField{{Name: "name", Type: StrH{}}}} + + require.True(t, sharableOutput(StrH{}, StrG{}), "an owned string input widens a static output") + require.False(t, sharableOutput(StrG{}, StrH{}), "a static input cannot share an owned output") + require.True(t, sharableOutput(static, static), "a struct shares at its exact type") + require.False(t, sharableOutput(owned, static), "nothing converts a struct's fields, so a struct never widens one") +} + func TestRangedCallDoesNotCopyUnrelatedArrayInput(t *testing.T) { // Both outputs are integers, so writing them can never change the array // input even though it is read after the first output write. Copying it diff --git a/compiler/solver.go b/compiler/solver.go index fc7dfb22..e4ddcc99 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -2727,10 +2727,11 @@ func (ts *TypeSolver) TypeBlock(template *ast.FuncStatement, f *FuncInfo) { )) } nextOutArg := mergeBindingSlotType(oldOutArg, outArg) - // A read output is solved at owned storage; the widening rewalks the - // body so its reads and the calls they feed follow. - if _, isRead := readOutputs[id.Value]; isRead { - nextOutArg = ownedStorage(nextOutArg) + // A read static-string output is solved as owned, the one widening + // a shared caller can give it that a store converts; the rewalk then + // retypes its reads and the calls they feed to match. + if _, isRead := readOutputs[id.Value]; isRead && IsStrG(nextOutArg) { + nextOutArg = StrH{} } ts.recordBindingSlotType(id.Value, nextOutArg) if TypeEqual(oldOutArg, nextOutArg) { diff --git a/compiler/types.go b/compiler/types.go index b869c8c4..efc4c7b4 100644 --- a/compiler/types.go +++ b/compiler/types.go @@ -690,26 +690,6 @@ func bindingSlotCompatible(oldType, newType Type) bool { return CanRefineType(oldType, newType) } -// ownedStorage is the widest storage a shared caller could give a value of -// type t: a static string becomes owned, recursively through struct fields. -// An output its body reads is solved at this storage, so its reads and the -// nested calls they feed see the representation lowering stores. -func ownedStorage(t Type) Type { - switch tt := t.(type) { - case StrG: - return StrH{} - case Struct: - owned := tt - owned.Fields = make([]StructField, len(tt.Fields)) - for i, field := range tt.Fields { - owned.Fields[i] = StructField{Name: field.Name, Type: ownedStorage(field.Type)} - } - return owned - default: - return t - } -} - // concreteStorage reports whether t fixes its storage in every calling // context: an untyped empty array, a column without an element type, or an // unresolved leaf could still be refined by a caller's destination. @@ -726,13 +706,6 @@ func concreteStorage(t Type) bool { } } return true - case Struct: - for _, field := range tt.Fields { - if !concreteStorage(field.Type) { - return false - } - } - return true default: return IsFullyResolvedType(t) } diff --git a/docs/Pluto C ABI Spec.md b/docs/Pluto C ABI Spec.md index e5dbb158..ca055a22 100644 --- a/docs/Pluto C ABI Spec.md +++ b/docs/Pluto C ABI Spec.md @@ -459,7 +459,8 @@ _aN__... that shares no output, `k` for one that shares output slot `k - 1`. The parameter's type must be the output's declared type or a compatible wider representation of it (an owned `StrH` input sharing a declared `StrG` output, -or a concrete-rank array input sharing an untyped `[]` output). Inside the +or a concrete-rank array input sharing an untyped `[]` output); a struct +input shares only at its exact type. Inside the variant that output uses the parameter's storage, so the argument types fix every shared output's representation. The compiler emits the suffix only when at least one parameter shares an output; otherwise the call uses the bare diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index ba347347..4d64118a 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -276,9 +276,9 @@ res = sum(a, b) nothing preserves the caller's value. The body may observe that value through an explicitly aliased input; it can never read it through the output name. An output the body reads is solved at owned storage (a static - string output becomes a heap string, recursively through struct fields) and - must have a concrete type, so every read and every nested call it feeds use - the representation the caller's shared slot holds. The real destinations + string output becomes a heap string) and must have a concrete type, so + every read and every nested call it feeds use the representation the + caller's shared slot holds. The real destinations are committed only after every sibling right-hand side has been evaluated. - **No name overlap**: Parameters and outputs must have distinct names @@ -286,7 +286,8 @@ Calls specialize binding arguments on their actual storage type. When an input shares an output whose declared representation is narrower but compatible (for example, an owned string input with a static string output, or a concrete-rank array input with an untyped `[]` output), the private -alias variant gives that output the input's storage. An aliased input and +alias variant gives that output the input's storage. A struct input shares an +output only at its exact type. An aliased input and output therefore continue to share one slot: assigning `[]` makes a later input read observe the empty array. An unrelated input keeps its own type and value. An unshared output keeps its declared representation; the caller diff --git a/tests/struct/struct.exp b/tests/struct/struct.exp index c774faaa..b30f4e96 100644 --- a/tests/struct/struct.exp +++ b/tests/struct/struct.exp @@ -19,3 +19,4 @@ x=Person : name age height Tejas 35 184.5 y=99 +Tejas Tejas diff --git a/tests/struct/struct.pt b/tests/struct/struct.pt index 3319a740..c454fe12 100644 --- a/tests/struct/struct.pt +++ b/tests/struct/struct.pt @@ -7,3 +7,7 @@ q = Person 28 "Ada" r = Person + +out, tag = Copy(current) + out = current + tag = out.name diff --git a/tests/struct/struct.spt b/tests/struct/struct.spt index 5e92ad28..4c8aaa17 100644 --- a/tests/struct/struct.spt +++ b/tests/struct/struct.spt @@ -9,3 +9,8 @@ copiedName p p, 99 "x=-p y=99" + +# A struct read through its output keeps its constant fields static; the +# caller must not free them. +copied, copiedTag = Copy(p) +copied.name, copiedTag From 8e13419e5b57b1a4d5dbf9013f857fd17cffe21f Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 21 Sep 2026 00:26:05 +0530 Subject: [PATCH 23/56] refactor(cfg): name the two read steps of the typed forward pass The loop interleaved the definite-assignment check on explicit output reads with the synthetic reads added for shared inputs, under a `continue` that only obscured their independence: parameter and output names are distinct, so an output read never matches the shared map. Each step is now a named helper, and the loop states the order the rule depends on: validate the original reads, add the shared reads, run the events, then record the statement's definite targets. No behavior change; the existing definite-assignment, marker, and alias-liveness tests cover it. Co-Authored-By: Claude Fable 5.1 --- compiler/cfg.go | 43 +++++++++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index 12e5784e..956f3f62 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -340,25 +340,18 @@ func sharedOutputs(template *ast.FuncStatement, pattern []int) map[string]string return shared } -// typedForwardPass runs the forward dataflow over a body. An output is -// readable once a statement has definitely assigned it, and a read of a -// shared input is also a read of its output's latest write. +// typedForwardPass runs the forward dataflow over a body. Per statement, in +// order: an explicit read of an output needs an earlier definite assignment; +// a read of a shared input also reads its output's latest write, which may +// be the caller's seed; the statement's events run; its definite targets +// become assigned for the statements after it. func (cfg *CFG) typedForwardPass(template *ast.FuncStatement, info *FuncInfo, outputs map[string]struct{}, shared map[string]string) { assigned := make(map[string]struct{}, len(outputs)) lastWrites := make(map[string]VarEvent) for _, stmt := range template.Body.Statements { reads := cfg.collectStatementReads(stmt) - for _, read := range reads { - if _, isOutput := outputs[read.Name]; isOutput { - if _, isAssigned := assigned[read.Name]; !isAssigned { - cfg.addError(read.Token, fmt.Sprintf("output %q is read where it may still be unassigned; assign it unconditionally first or use a local", read.Name)) - } - continue - } - if output, ok := shared[read.Name]; ok { - reads = append(reads, VarEvent{Name: output, Kind: Read, Token: read.Token}) - } - } + cfg.rejectUnassignedOutputReads(reads, outputs, assigned) + reads = withSharedOutputReads(reads, shared) cfg.processTypedStatement(stmt, reads, info.StatementEffects, lastWrites) if let, ok := stmt.(*ast.LetStatement); ok { for _, name := range definiteTargets(let, info.StatementEffects[let]) { @@ -368,6 +361,28 @@ func (cfg *CFG) typedForwardPass(template *ast.FuncStatement, info *FuncInfo, ou } } +func (cfg *CFG) rejectUnassignedOutputReads(reads []VarEvent, outputs, assigned map[string]struct{}) { + for _, read := range reads { + if _, isOutput := outputs[read.Name]; !isOutput { + continue + } + if _, isAssigned := assigned[read.Name]; !isAssigned { + cfg.addError(read.Token, fmt.Sprintf("output %q is read where it may still be unassigned; assign it unconditionally first or use a local", read.Name)) + } + } +} + +// withSharedOutputReads adds, for each read of a shared input, a read of the +// output it shares at the same location. +func withSharedOutputReads(reads []VarEvent, shared map[string]string) []VarEvent { + for _, read := range reads { + if output, ok := shared[read.Name]; ok { + reads = append(reads, VarEvent{Name: output, Kind: Read, Token: read.Token}) + } + } + return reads +} + func (cfg *CFG) typedScriptForwardPass(statements []ast.Statement, effects map[*ast.LetStatement]StatementEffect, statementReads [][]VarEvent) { if len(statementReads) != len(statements) { panic("internal: script CFG read count does not match statement count") From 2dd5b7c25e654eebd2a563b08fc5ca407ab26222 Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 21 Sep 2026 20:34:52 +0530 Subject: [PATCH 24/56] fix(cfg): name the input-initialization repair in the output-read error Moving `res = x > 0 x` and `res = res + 1` from a script into a function and calling `res = maybeIncrement(x)` is rejected by design: the body would read the caller's previous value without receiving it. The message said only to assign first or use a local, which does not describe the repair for that case. It now also says to pass the previous value as an input and initialize from it, which works for a shared and an unshared call alike; a valid CFG case pins that idiom. The typed forward pass renames its set to definitelyAssigned and separates its phases with blank lines. No behavior change. Co-Authored-By: Claude Fable 5.1 --- compiler/cfg.go | 14 ++++++++------ compiler/cfg_test.go | 10 ++++++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index 956f3f62..1d63f8ac 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -346,28 +346,30 @@ func sharedOutputs(template *ast.FuncStatement, pattern []int) map[string]string // be the caller's seed; the statement's events run; its definite targets // become assigned for the statements after it. func (cfg *CFG) typedForwardPass(template *ast.FuncStatement, info *FuncInfo, outputs map[string]struct{}, shared map[string]string) { - assigned := make(map[string]struct{}, len(outputs)) + definitelyAssigned := make(map[string]struct{}, len(outputs)) lastWrites := make(map[string]VarEvent) for _, stmt := range template.Body.Statements { reads := cfg.collectStatementReads(stmt) - cfg.rejectUnassignedOutputReads(reads, outputs, assigned) + cfg.rejectUnassignedOutputReads(reads, outputs, definitelyAssigned) + reads = withSharedOutputReads(reads, shared) cfg.processTypedStatement(stmt, reads, info.StatementEffects, lastWrites) + if let, ok := stmt.(*ast.LetStatement); ok { for _, name := range definiteTargets(let, info.StatementEffects[let]) { - assigned[name] = struct{}{} + definitelyAssigned[name] = struct{}{} } } } } -func (cfg *CFG) rejectUnassignedOutputReads(reads []VarEvent, outputs, assigned map[string]struct{}) { +func (cfg *CFG) rejectUnassignedOutputReads(reads []VarEvent, outputs, definitelyAssigned map[string]struct{}) { for _, read := range reads { if _, isOutput := outputs[read.Name]; !isOutput { continue } - if _, isAssigned := assigned[read.Name]; !isAssigned { - cfg.addError(read.Token, fmt.Sprintf("output %q is read where it may still be unassigned; assign it unconditionally first or use a local", read.Name)) + if _, ok := definitelyAssigned[read.Name]; !ok { + cfg.addError(read.Token, fmt.Sprintf("output %q is read where it may still be unassigned; assign it unconditionally first, or pass the previous value as an input and initialize from it", read.Name)) } } } diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index 6290562b..8affcc95 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -256,6 +256,16 @@ res = forwarded(x) cube = sq * x`, input: "p, q = powers(3)\np, q", }, + { + // The repair the diagnostic names: the previous value arrives as + // an input and initializes the output, for either call shape. + name: "Output Initialized From Input Before Conditional Write", + code: `res = maybeIncrement(current, x) + res = current + res = x > 0 x + res = res + 1`, + input: "a = 5\na = maybeIncrement(a, -1)\nb = maybeIncrement(5, 3)\na, b", + }, { // A later conditional write does not undo the assignment. name: "Output Read After Later Conditional Write", From 6056c0aac95a313fff3bf88cdaa86b061d6874ed Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 21 Sep 2026 20:44:12 +0530 Subject: [PATCH 25/56] refactor(compiler): fold the sharing rule into abi.go and file a stray test alias.go held two functions, the sharing compatibility test and the per-call-site pattern, split out so lowering and the CFG walk shared one rule. abi.go's header already describes that rule as a compile-time property of the call site that selects a private variant, and it held the compatibility test before, so the functions live there now. live_alias_format_test.go was named after a feature branch rather than the unit it tests. Its one test, a count marker naming an input parameter, now sits in format_test.go beside the constant case, written in that file's style. No behavior change. Co-Authored-By: Claude Fable 5.1 --- compiler/abi.go | 49 +++++++++++++++++++++++++++++ compiler/alias.go | 50 ------------------------------ compiler/format_test.go | 35 +++++++++++++++++++++ compiler/live_alias_format_test.go | 38 ----------------------- 4 files changed, 84 insertions(+), 88 deletions(-) delete mode 100644 compiler/alias.go delete mode 100644 compiler/live_alias_format_test.go diff --git a/compiler/abi.go b/compiler/abi.go index af9ddad9..3dc8fc20 100644 --- a/compiler/abi.go +++ b/compiler/abi.go @@ -111,3 +111,52 @@ func (abi FuncABI) DirectReturnSeedParamIndex() int { } return abi.sourceParamBaseIndex() + len(abi.Params) } + +// sharableOutput reports whether an input of paramType can share an output +// declared as outType: the input's storage must be the declared type or a +// compatible wider representation that a store converts (an owned string for +// a static output, a concrete-rank array for an untyped empty one, a schema +// for a header-only table). A struct shares only at its exact type, since +// nothing converts its fields. The shared output then uses the input's +// storage, so a write lands where the next read looks. +func sharableOutput(paramType, outType Type) bool { + if _, isStruct := outType.(Struct); isStruct { + return TypeEqual(paramType, outType) + } + return bindingSlotCompatible(paramType, outType) && TypeEqual(mergeBindingSlotType(paramType, outType), paramType) +} + +// aliasPattern decides, per callee parameter, the one-based caller destination +// whose binding the argument shares, or 0; nil when no parameter shares one. +// argNames holds one entry per parameter, empty for an argument that is not a +// plain identifier; dests names the destinations of the call's outputs in +// order; outTypes are the declared output types. enclosing maps a caller-body +// input to the caller output it already shares, so a nested call forwards that +// sharing. A parameter shares at most one destination, the first that matches. +func aliasPattern(argNames, dests []string, paramTypes, outTypes []Type, enclosing map[string]string) []int { + var pattern []int + for i, name := range argNames { + if name == "" { + continue + } + + for j, dest := range dests { + if j >= len(outTypes) { + break + } + if !sharableOutput(paramTypes[i], outTypes[j]) { + continue + } + if dest != name && enclosing[name] != dest { + continue + } + if pattern == nil { + pattern = make([]int, len(argNames)) + } + pattern[i] = j + 1 + break + } + } + + return pattern +} diff --git a/compiler/alias.go b/compiler/alias.go deleted file mode 100644 index f808428b..00000000 --- a/compiler/alias.go +++ /dev/null @@ -1,50 +0,0 @@ -package compiler - -// sharableOutput reports whether an input of paramType can share an output -// declared as outType: the input's storage must be the declared type or a -// compatible wider representation that a store converts (an owned string for -// a static output, a concrete-rank array for an untyped empty one, a schema -// for a header-only table). A struct shares only at its exact type, since -// nothing converts its fields. The shared output then uses the input's -// storage, so a write lands where the next read looks. -func sharableOutput(paramType, outType Type) bool { - if _, isStruct := outType.(Struct); isStruct { - return TypeEqual(paramType, outType) - } - return bindingSlotCompatible(paramType, outType) && TypeEqual(mergeBindingSlotType(paramType, outType), paramType) -} - -// aliasPattern decides, per callee parameter, the one-based caller destination -// whose binding the argument shares, or 0; nil when no parameter shares one. -// argNames holds one entry per parameter, empty for an argument that is not a -// plain identifier; dests names the destinations of the call's outputs in -// order; outTypes are the declared output types. enclosing maps a caller-body -// input to the caller output it already shares, so a nested call forwards that -// sharing. A parameter shares at most one destination, the first that matches. -func aliasPattern(argNames, dests []string, paramTypes, outTypes []Type, enclosing map[string]string) []int { - var pattern []int - for i, name := range argNames { - if name == "" { - continue - } - - for j, dest := range dests { - if j >= len(outTypes) { - break - } - if !sharableOutput(paramTypes[i], outTypes[j]) { - continue - } - if dest != name && enclosing[name] != dest { - continue - } - if pattern == nil { - pattern = make([]int, len(argNames)) - } - pattern[i] = j + 1 - break - } - } - - return pattern -} diff --git a/compiler/format_test.go b/compiler/format_test.go index d516904d..7a05c9d1 100644 --- a/compiler/format_test.go +++ b/compiler/format_test.go @@ -250,6 +250,41 @@ func TestFormatCountRejectsCodeConstant(t *testing.T) { } } +func TestFormatCountRejectsInputParameter(t *testing.T) { + tests := []struct { + name string + script string + }{ + {name: "plain", script: "value = 10\nvalue = Count(value)\nvalue"}, + {name: "range", script: "value = Count(1:3)\nvalue"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + code := mustParseCode(t, `out = Count(current) + "count-current%n" + out = current`) + cc := NewCodeCompiler(ctx, "format_input_parameter", "", code) + if errs := cc.Compile(); len(errs) != 0 { + t.Fatalf("unexpected code compile errors: %v", errs) + } + + sc := NewScriptCompiler(ctx, t.Name(), mustParseScript(t, tt.script), cc) + linkCodeModuleForTest(t, ctx, sc.Compiler.Module, cc.Compiler.Module) + errs := sc.Compile() + if len(errs) != 1 { + t.Fatalf("expected one compile error, got %d: %v", len(errs), errs) + } + if got, want := errs[0].Msg, `cannot write to input parameter "current"`; got != want { + t.Fatalf("compile error = %q, want %q", got, want) + } + }) + } +} + func TestValidFormatString(t *testing.T) { tests := []struct { name string diff --git a/compiler/live_alias_format_test.go b/compiler/live_alias_format_test.go deleted file mode 100644 index b5b4e297..00000000 --- a/compiler/live_alias_format_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package compiler - -import ( - "testing" - - "github.com/stretchr/testify/require" - "tinygo.org/x/go-llvm" -) - -func TestFormatCountRejectsInputParameter(t *testing.T) { - tests := []struct { - name string - script string - }{ - {name: "plain", script: "value = 10\nvalue = Count(value)\nvalue"}, - {name: "range", script: "value = Count(1:3)\nvalue"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx := llvm.NewContext() - defer ctx.Dispose() - - code := mustParseCode(t, `out = Count(current) - "count-current%n" - out = current`) - cc := NewCodeCompiler(ctx, "format_input_parameter", "", code) - require.Empty(t, cc.Compile()) - - sc := NewScriptCompiler(ctx, t.Name(), mustParseScript(t, tt.script), cc) - linkCodeModuleForTest(t, ctx, sc.Compiler.Module, cc.Compiler.Module) - errs := sc.Compile() - - require.Len(t, errs, 1) - require.Equal(t, `cannot write to input parameter "current"`, errs[0].Msg) - }) - } -} From fbd6ab74cb31c133bc536bad6cadd916df77819b Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 21 Sep 2026 20:50:55 +0530 Subject: [PATCH 26/56] refactor(cfg): inline the shared-input read loop in the typed pass The helper had one caller and five lines, and the pass's comment already states the rule it implemented, so the loop now sits inline beside the definite-targets loop it mirrors. No behavior change. Co-Authored-By: Claude Fable 5.1 --- compiler/cfg.go | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index 1d63f8ac..b1279c7e 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -352,7 +352,11 @@ func (cfg *CFG) typedForwardPass(template *ast.FuncStatement, info *FuncInfo, ou reads := cfg.collectStatementReads(stmt) cfg.rejectUnassignedOutputReads(reads, outputs, definitelyAssigned) - reads = withSharedOutputReads(reads, shared) + for _, read := range reads { + if output, ok := shared[read.Name]; ok { + reads = append(reads, VarEvent{Name: output, Kind: Read, Token: read.Token}) + } + } cfg.processTypedStatement(stmt, reads, info.StatementEffects, lastWrites) if let, ok := stmt.(*ast.LetStatement); ok { @@ -374,17 +378,6 @@ func (cfg *CFG) rejectUnassignedOutputReads(reads []VarEvent, outputs, definitel } } -// withSharedOutputReads adds, for each read of a shared input, a read of the -// output it shares at the same location. -func withSharedOutputReads(reads []VarEvent, shared map[string]string) []VarEvent { - for _, read := range reads { - if output, ok := shared[read.Name]; ok { - reads = append(reads, VarEvent{Name: output, Kind: Read, Token: read.Token}) - } - } - return reads -} - func (cfg *CFG) typedScriptForwardPass(statements []ast.Statement, effects map[*ast.LetStatement]StatementEffect, statementReads [][]VarEvent) { if len(statementReads) != len(statements) { panic("internal: script CFG read count does not match statement count") From f1774df4a1bfa1c11fba044e176212a651ccc308 Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 21 Sep 2026 23:38:38 +0530 Subject: [PATCH 27/56] docs(effects): point the archived seed analysis at the follow-up issue The plan below the #104 resolution is the specification for seed-readable outputs, now tracked as issue #105, rather than a record of abandoned work. Co-Authored-By: Claude Fable 5.1 --- docs/Pluto Effects and Follow-up Plan.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/Pluto Effects and Follow-up Plan.md b/docs/Pluto Effects and Follow-up Plan.md index 94aef272..bc839b9c 100644 --- a/docs/Pluto Effects and Follow-up Plan.md +++ b/docs/Pluto Effects and Follow-up Plan.md @@ -52,7 +52,11 @@ A shared output takes its input's storage inside the private alias variant, preserving sharing without changing unrelated input types. These cases are covered by `tests/alias_input`. -The original analysis plan is kept below for the record. +The original analysis plan is kept below as the specification for the planned +follow-up, seed-readable outputs +([issue #105](https://github.com/thiremani/pluto/issues/105)): a body may read +an output before assigning it and observes the destination's previous value, +with output types still inferred from inputs and the body. ### Confirmed failure From 08c7777717e68bc9f2cc0bd6b8409fedb6de4466 Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 22 Sep 2026 12:12:56 +0530 Subject: [PATCH 28/56] refactor(effects): return definite targets as a set A statement's definite targets have no order and no duplicates, and both consumers only test membership, so the set is the natural type. The typed pass merges it in one call instead of looping, and the body effect fold ranges over the keys. No behavior change. Co-Authored-By: Claude Fable 5.1 --- compiler/cfg.go | 4 +--- compiler/effects.go | 12 ++++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index b1279c7e..624f5cf2 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -360,9 +360,7 @@ func (cfg *CFG) typedForwardPass(template *ast.FuncStatement, info *FuncInfo, ou cfg.processTypedStatement(stmt, reads, info.StatementEffects, lastWrites) if let, ok := stmt.(*ast.LetStatement); ok { - for _, name := range definiteTargets(let, info.StatementEffects[let]) { - definitelyAssigned[name] = struct{}{} - } + maps.Copy(definitelyAssigned, definiteTargets(let, info.StatementEffects[let])) } } } diff --git a/compiler/effects.go b/compiler/effects.go index e9001db5..aac2754b 100644 --- a/compiler/effects.go +++ b/compiler/effects.go @@ -525,7 +525,7 @@ func deriveBodyOutputEffects(template *ast.FuncStatement, statements map[*ast.Le return slices.Repeat([]WriteEffect{WriteInvalid}, len(template.Outputs)) } - for _, name := range definiteTargets(stmt, statementEffect) { + for name := range definiteTargets(stmt, statementEffect) { if index, isOutput := outputIndex[name]; isOutput { effects[index] = MustWrite } @@ -535,14 +535,14 @@ func deriveBodyOutputEffects(template *ast.FuncStatement, statements map[*ast.Le return effects } -// definiteTargets lists the targets a statement leaves holding its own value -// on every path: unconditional writes that do not merely preserve the +// definiteTargets is the set of targets a statement leaves holding its own +// value on every path: unconditional writes that do not merely preserve the // target's seed. -func definiteTargets(stmt *ast.LetStatement, effect StatementEffect) []string { - var targets []string +func definiteTargets(stmt *ast.LetStatement, effect StatementEffect) map[string]struct{} { + targets := make(map[string]struct{}, len(effect.Writes)) for _, write := range effect.Writes { if write.Effect == MustWrite && !slices.Contains(effect.ReadsSeed, write.TargetIndex) { - targets = append(targets, stmt.Name[write.TargetIndex].Value) + targets[stmt.Name[write.TargetIndex].Value] = struct{}{} } } return targets From 3973f1a2db7ea2cd82c49acb1b2619cfd445ef89 Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 22 Sep 2026 12:51:52 +0530 Subject: [PATCH 29/56] test(cfg): restore the per-type CFG test's direct input reads The body was rewritten to read through a local when settlement analyzed every specialization under a conservative rule that treated any read of a type-compatible input as a read of the output. The exact per-context walk replaced that rule, and this test's scripts pass literals, so the original body is analyzed unshared and the local no longer earns its place. Co-Authored-By: Claude Fable 5.1 --- compiler/cfg_replay_test.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/compiler/cfg_replay_test.go b/compiler/cfg_replay_test.go index eace49a9..e0ab280e 100644 --- a/compiler/cfg_replay_test.go +++ b/compiler/cfg_replay_test.go @@ -285,9 +285,8 @@ result = Diamond(x) func TestCFGResultsAreIndependentPerType(t *testing.T) { code := mustParseCode(t, `result = MaskOrKeep(x) - local = x - result = local - result = local > 0 + result = x + result = x > 0 `) ctx := llvm.NewContext() From e1585ded22a7a81673ee9c0dfe1ae32aa94c0e49 Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 22 Sep 2026 18:04:43 +0530 Subject: [PATCH 30/56] feat(cfg): make output-write liveness independent of callers A body is now analyzed once per type specialization, with every input taken as its own value, and every script that reaches the specialization replays its diagnostics. Sharing an input with an output at a call only adds reads, so it can never make a body invalid; a body must be valid without it. `out = current + item` written twice is therefore a dead write for every caller, because the body never reads `out`; a body that builds on its own write says so by name, `out = out + item`, which is readable once definitely assigned. This replaces the per-alias-context analysis: the script walk that derived each call's pattern, the per-pattern error cache, and the synthetic reads of shared inputs in the typed pass are gone, and the replay is again a reachability walk over settled results. This is a language restriction, not a behavior-preserving cleanup: bodies that were valid only when a call shared them are rejected, and each of the four fixture bodies affected was rewritten to read its output by name with its intended results unchanged. Run-time sharing semantics are untouched. Co-Authored-By: Claude Fable 5.1 --- compiler/cfg.go | 39 ++------ compiler/cfg_test.go | 73 ++++++-------- compiler/scriptcompiler.go | 166 ++++++------------------------- compiler/solver.go | 2 +- compiler/types.go | 6 +- docs/Pluto IR Plan.md | 24 ++--- docs/Pluto Memory Model.md | 8 +- tests/alias_input/self_alias.exp | 2 +- tests/alias_input/self_alias.pt | 10 +- tests/alias_input/self_alias.spt | 5 +- 10 files changed, 99 insertions(+), 236 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index 624f5cf2..7d045252 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -300,11 +300,12 @@ func (cfg *CFG) validateScriptTemplate(statements []ast.Statement) [][]VarEvent return cfg.validateTemplateBody(statements, nil, nil).statementReads } -// AnalyzeSpecialization runs only typed dataflow, in one alias context: -// pattern names, per parameter, the one-based output that parameter shares at -// the call being analyzed, and nil is the unshared context. Structural -// diagnostics were already produced once from the function template. -func (cfg *CFG) AnalyzeSpecialization(template *ast.FuncStatement, info *FuncInfo, pattern []int) { +// AnalyzeSpecialization runs only typed dataflow over one type +// specialization, with every input treated as its own value: a call that +// shares an input with an output only adds reads, so a body valid here is +// valid in every call. Structural diagnostics were already produced once +// from the function template. +func (cfg *CFG) AnalyzeSpecialization(template *ast.FuncStatement, info *FuncInfo) { cfg.PushBlock() defer cfg.PopBlock() PushScope(&cfg.Scopes, FuncScope) @@ -323,40 +324,20 @@ func (cfg *CFG) AnalyzeSpecialization(template *ast.FuncStatement, info *FuncInf cfg.addError(output.Tok(), fmt.Sprintf("output %q is read but its type %s is not concrete", output.Value, info.Sig.OutTypes[i])) } } - cfg.typedForwardPass(template, info, outputs, sharedOutputs(template, pattern)) + cfg.typedForwardPass(template, info, outputs) cfg.backwardPass(maps.Clone(outputs)) } -// sharedOutputs maps each input that shares an output in this context to that -// output's name, so a read of the input is also a read of the output's latest -// write, and a nested call forwards the sharing. -func sharedOutputs(template *ast.FuncStatement, pattern []int) map[string]string { - shared := make(map[string]string, len(pattern)) - for i, slot := range pattern { - if slot > 0 { - shared[template.Parameters[i].Value] = template.Outputs[slot-1].Value - } - } - return shared -} - // typedForwardPass runs the forward dataflow over a body. Per statement, in // order: an explicit read of an output needs an earlier definite assignment; -// a read of a shared input also reads its output's latest write, which may -// be the caller's seed; the statement's events run; its definite targets -// become assigned for the statements after it. -func (cfg *CFG) typedForwardPass(template *ast.FuncStatement, info *FuncInfo, outputs map[string]struct{}, shared map[string]string) { +// the statement's events run; its definite targets become assigned for the +// statements after it. +func (cfg *CFG) typedForwardPass(template *ast.FuncStatement, info *FuncInfo, outputs map[string]struct{}) { definitelyAssigned := make(map[string]struct{}, len(outputs)) lastWrites := make(map[string]VarEvent) for _, stmt := range template.Body.Statements { reads := cfg.collectStatementReads(stmt) cfg.rejectUnassignedOutputReads(reads, outputs, definitelyAssigned) - - for _, read := range reads { - if output, ok := shared[read.Name]; ok { - reads = append(reads, VarEvent{Name: output, Kind: Read, Token: read.Token}) - } - } cfg.processTypedStatement(stmt, reads, info.StatementEffects, lastWrites) if let, ok := stmt.(*ast.LetStatement); ok { diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index 8affcc95..02dd9a7d 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -73,17 +73,20 @@ func TestFunctionDataflowWaitsForSpecialization(t *testing.T) { require.Equal(t, 2, deadStores) } -func TestInputAliasOutputWriteLiveness(t *testing.T) { +// A body must be valid on its own: sharing an input with an output at a call +// only adds reads, so it never makes a dead write live. The body says which +// value it reads by naming the output. +func TestOutputWriteLivenessIgnoresSharing(t *testing.T) { tests := []cfgTestCase{ { - name: "Repeated Output Write", + name: "Repeated Output Write Shared", code: `out = BumpTwice(current, item) out = current + item out = current + item`, - input: "value = 10\nvalue = BumpTwice(value, 5)\nvalue", + input: "value = 10\nvalue = BumpTwice(value, 5)\nvalue", + errorContains: `unconditional assignment to "out" overwrites a previous value that was never used`, }, { - // The same body called without sharing: the first write is dead. name: "Repeated Output Write Unshared", code: `out = BumpTwice(current, item) out = current + item @@ -92,7 +95,6 @@ func TestInputAliasOutputWriteLiveness(t *testing.T) { errorContains: `unconditional assignment to "out" overwrites a previous value that was never used`, }, { - // Sharing reaches a nested call through the wrapper's own alias. name: "Repeated Output Write Through Wrapper", code: `out = BumpTwice(current, item) out = current + item @@ -100,17 +102,7 @@ func TestInputAliasOutputWriteLiveness(t *testing.T) { out = Bump(current, item) out = BumpTwice(current, item)`, - input: "value = 10\nvalue = Bump(value, 5)\nvalue", - }, - { - name: "Repeated Output Write Through Unshared Wrapper", - code: `out = BumpTwice(current, item) - out = current + item - out = current + item - -out = Bump(current, item) - out = BumpTwice(current, item)`, - input: "value = 10\nother = Bump(value, 5)\nother", + input: "value = 10\nvalue = Bump(value, 5)\nvalue", errorContains: `unconditional assignment to "out" overwrites a previous value that was never used`, }, { @@ -123,12 +115,26 @@ out = Bump(current, item) errorContains: `unconditional assignment to "out" overwrites a previous value that was never used`, }, { - // The wrapper's destination widens its output to a heap string, and - // the nested call shares that storage, as lowering does. - name: "Repeated Output Write Through Widening Wrapper", + // The second write reads the first through the output name, so + // the body is valid for every call shape. + name: "Second Write Reads Output", + code: `out = BumpTwice(current, item) + out = current + item + out = out + item`, + input: "value = 10\nvalue = BumpTwice(value, 5)\nother = BumpTwice(value, 5)\nvalue, other", + }, + { + name: "Second Write Reads Output And Input", + code: `out = BumpTwice(current, item) + out = current + item + out = current + out`, + input: "value = 10\nvalue = BumpTwice(10, value)\nvalue", + }, + { + name: "Output Read Between Writes Through Nested Call", code: `out, seen = Reset(current) - out = "first" - seen = current + out = current + seen = out out = "second" out, seen = Wrap(current) @@ -138,26 +144,13 @@ value, seen = Wrap(value) value, seen`, }, { - // A format marker naming the shared input reads the output's first - // write, so the second write does not overwrite an unused value. - name: "Marker Read Of Shared Input Between Output Writes", + name: "Marker Read Of Output Between Writes", code: `out = Show(current) - out = 1 - "-current" + out = current + "-out" out = 2`, input: "x = 5\nx = Show(x)\nx", }, - { - name: "Repeated Empty Array Write Through Widening Wrapper", - code: `out, seen = ResetEmpty(current) - out = [] - seen = current - out = [] - -out, seen = WrapEmpty(current) - out, seen = ResetEmpty(current)`, - input: "value = [1 2]\nvalue, seen = WrapEmpty(value)\nvalue, seen", - }, } for _, tt := range tests { @@ -907,7 +900,7 @@ func TestSpecializationReadsSeedBeforeWrite(t *testing.T) { cc := NewCodeCompiler(ctx, "seededSpecialization", "", code) cfg := NewCFG(cc) - cfg.AnalyzeSpecialization(template, info, nil) + cfg.AnalyzeSpecialization(template, info) require.Empty(t, cfg.Errors) } @@ -936,7 +929,7 @@ func TestSpecializationPrintReadKeepsLocalLive(t *testing.T) { cc := NewCodeCompiler(ctx, "printedSpecialization", "", code) cfg := NewCFG(cc) - cfg.AnalyzeSpecialization(template, info, nil) + cfg.AnalyzeSpecialization(template, info) require.Empty(t, cfg.Errors) } @@ -980,7 +973,7 @@ func TestCFGRejectsMissingStatementEffects(t *testing.T) { cfg := NewCFG(cc) require.PanicsWithValue(t, `internal: missing CFG effects for statement "res = x"`, func() { - cfg.AnalyzeSpecialization(template, info, nil) + cfg.AnalyzeSpecialization(template, info) }) } diff --git a/compiler/scriptcompiler.go b/compiler/scriptcompiler.go index df6d4b27..dd7960f3 100644 --- a/compiler/scriptcompiler.go +++ b/compiler/scriptcompiler.go @@ -62,7 +62,8 @@ func (sc *ScriptCompiler) Compile() []*token.CompileError { cfg := NewCFG(sc.Compiler.CodeCompiler) cfg.AnalyzeScript(sc.Program.Statements, sc.Script.Root.StatementEffects) - cfg.Errors = replaySpecializationCFG(sc.Compiler, sc.ScriptMangled, sc.Program.Statements, cfg.Errors) + directCallees, _ := collectSpecializationCallEdges(sc.Compiler, sc.ScriptMangled, sc.Program.Statements) + cfg.Errors = replaySpecializationCFG(sc.Compiler, directCallees, cfg.Errors) if len(cfg.Errors) > 0 { return cfg.Errors } @@ -102,158 +103,47 @@ func (sc *ScriptCompiler) compileStatements() { } } -// replaySpecializationCFG reports the dataflow diagnostics of every -// specialization the script reaches, in the alias context each call reaches -// it with. A script call site fixes its own context from names; inside a -// callee, each nested call derives its context from the enclosing one by the -// same rule lowering applies, so a body is analyzed exactly as it is lowered. -// Each lowered variant is visited once, root-first and depth-first in source -// order, and diagnostics are deduplicated by location and message. -func replaySpecializationCFG(compiler *Compiler, scriptMangled string, statements []ast.Statement, errors []*token.CompileError) []*token.CompileError { - walk := &cfgWalk{ - compiler: compiler, - visited: make(map[string]struct{}), - reported: make(map[cfgDiagnosticKey]struct{}, len(errors)), - errors: errors, - } +// replaySpecializationCFG reports the settled dataflow diagnostics of every +// specialization the script reaches, root-first and depth-first in source +// order, deduplicated by location and message. A body is analyzed once per +// type specialization at settlement, independent of its callers: sharing an +// input with an output only adds reads, so it can never make a body invalid, +// and a body must be valid without it. +func replaySpecializationCFG(compiler *Compiler, roots []string, errors []*token.CompileError) []*token.CompileError { + visited := make(map[string]struct{}) + reported := make(map[cfgDiagnosticKey]struct{}, len(errors)) for _, compileError := range errors { - walk.reported[cfgDiagnosticKeyFor(compileError)] = struct{}{} - } - - walk.visitSites(scriptMangled, statements, nil) - return walk.errors -} - -type cfgWalk struct { - compiler *Compiler - visited map[string]struct{} // lowered variant symbols already walked - reported map[cfgDiagnosticKey]struct{} - errors []*token.CompileError -} - -// cfgCallSite is one call with the destinations it writes; a call nested in an -// expression, a condition, or a print writes none. -type cfgCallSite struct { - call *ast.CallExpression - dests []*ast.Identifier -} - -// bodyCallSites lists a body's calls in source order. A multi-valued sibling -// shifts a later call's destinations by its output count, as lowering does. -func bodyCallSites(compiler *Compiler, mangled string, statements []ast.Statement) []cfgCallSite { - var sites []cfgCallSite - for _, statement := range statements { - switch stmt := statement.(type) { - case *ast.LetStatement: - for _, condition := range stmt.Condition { - sites = appendNestedCallSites(sites, condition) - } - target := 0 - for _, value := range stmt.Value { - if call, ok := value.(*ast.CallExpression); ok { - sites = append(sites, cfgCallSite{call: call, dests: stmt.Name[target:]}) - for _, argument := range call.Arguments { - sites = appendNestedCallSites(sites, argument) - } - } else { - sites = appendNestedCallSites(sites, value) - } - target += len(compiler.ExprCache[key(mangled, value)].OutTypes) - } - case *ast.PrintStatement: - for _, argument := range stmt.Expression.Arguments { - sites = appendNestedCallSites(sites, argument) - } - } + reported[cfgDiagnosticKeyFor(compileError)] = struct{}{} } - return sites -} -func appendNestedCallSites(sites []cfgCallSite, expr ast.Expression) []cfgCallSite { - for _, call := range collectExprCalls(expr) { - sites = append(sites, cfgCallSite{call: call}) + for _, mangled := range roots { + errors = replaySpecializationCFGNode(compiler, mangled, visited, reported, errors) } - return sites -} -func (walk *cfgWalk) visitSites(callerMangled string, statements []ast.Statement, enclosing map[string]string) { - for _, site := range bodyCallSites(walk.compiler, callerMangled, statements) { - if _, builtin := Builtins[site.call.Function.Value]; builtin { - continue - } - - info := walk.compiler.ExprCache[key(callerMangled, site.call)] - walk.visitCallee(callerMangled, site, info.CallParamTypes, enclosing) - if info.ScalarCallVariantEnsured { - walk.visitCallee(callerMangled, site, info.ScalarCallParamTypes, enclosing) - } - } + return errors } -func (walk *cfgWalk) visitCallee(callerMangled string, site cfgCallSite, paramTypes []Type, enclosing map[string]string) { - mangled := Mangle(walk.compiler.MangledPath, site.call.Function.Value, paramTypes) - requireSpecializationCallTarget(walk.compiler, callerMangled, mangled) - callee := walk.compiler.FuncCache[mangled] - pattern := walk.sitePattern(callerMangled, site, paramTypes, callee.Sig.OutTypes, enclosing) - - variant := MangleVariant(mangled, pattern) - if _, seen := walk.visited[variant]; seen { - return - } - walk.visited[variant] = struct{}{} - - template, ok := walk.compiler.CodeCompiler.lookupFuncTemplate(callee.Sig.Name, len(callee.Sig.Params)) - if !ok { - panic(fmt.Sprintf("internal: settled specialization %s has no template", mangled)) +func replaySpecializationCFGNode(compiler *Compiler, mangled string, visited map[string]struct{}, reported map[cfgDiagnosticKey]struct{}, errors []*token.CompileError) []*token.CompileError { + if _, seen := visited[mangled]; seen { + return errors } - for _, compileError := range walk.contextErrors(template, callee, pattern, variant) { - diagnostic := cfgDiagnosticKeyFor(compileError) - if _, seen := walk.reported[diagnostic]; seen { - continue - } - walk.reported[diagnostic] = struct{}{} - walk.errors = append(walk.errors, compileError) - } - - walk.visitSites(mangled, template.Body.Statements, sharedOutputs(template, pattern)) -} + visited[mangled] = struct{}{} -// sitePattern derives a call's alias pattern the way lowering will: one name -// per parameter position for plain identifier arguments, and the destinations -// by their source names. -func (walk *cfgWalk) sitePattern(callerMangled string, site cfgCallSite, paramTypes, outTypes []Type, enclosing map[string]string) []int { - argNames := make([]string, len(paramTypes)) - position := 0 - for _, argument := range site.call.Arguments { - if ident, ok := argument.(*ast.Identifier); ok { - argNames[position] = ident.Value - position++ + info := compiler.FuncCache[mangled] + for _, compileError := range info.CFGResult.Errors { + key := cfgDiagnosticKeyFor(compileError) + if _, seen := reported[key]; seen { continue } - position += len(walk.compiler.ExprCache[key(callerMangled, argument)].OutTypes) - } - return aliasPattern(argNames, identNames(site.dests), paramTypes, outTypes, enclosing) -} -// contextErrors returns the callee's diagnostics in one alias context, -// analyzing a shared context on first reach and caching it on the -// specialization under its variant symbol; the unshared context was -// analyzed at settlement. -func (walk *cfgWalk) contextErrors(template *ast.FuncStatement, callee *FuncInfo, pattern []int, variant string) []*token.CompileError { - if pattern == nil { - return callee.CFGResult.Errors + reported[key] = struct{}{} + errors = append(errors, compileError) } - if cached, ok := callee.CFGResult.shared[variant]; ok { - return cached + for _, callee := range info.CFGResult.DirectCallees { + errors = replaySpecializationCFGNode(compiler, callee, visited, reported, errors) } - cfg := NewCFG(walk.compiler.CodeCompiler) - cfg.AnalyzeSpecialization(template, callee, pattern) - if callee.CFGResult.shared == nil { - callee.CFGResult.shared = make(map[string][]*token.CompileError) - } - callee.CFGResult.shared[variant] = cfg.Errors - return cfg.Errors + return errors } func cfgDiagnosticKeyFor(compileError *token.CompileError) cfgDiagnosticKey { diff --git a/compiler/solver.go b/compiler/solver.go index e4ddcc99..cbc7e122 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -2619,7 +2619,7 @@ func (ts *TypeSolver) settleSpecializationBatch(graph *specializationCallGraph) for id, node := range graph.nodes { walked := ts.walkedFuncs[node.mangled] cfg := NewCFG(ts.ScriptCompiler.Compiler.CodeCompiler) - cfg.AnalyzeSpecialization(walked.template, walked.info, nil) + cfg.AnalyzeSpecialization(walked.template, walked.info) staged[id] = &SpecializationCFGResult{ DirectCallees: slices.Clone(node.directCallees), Errors: slices.Clone(cfg.Errors), diff --git a/compiler/types.go b/compiler/types.go index efc4c7b4..94ca53eb 100644 --- a/compiler/types.go +++ b/compiler/types.go @@ -269,13 +269,11 @@ func (f Func) OutputTypesInferred() bool { // SpecializationCFGResult is the dataflow result and persistent direct-call // reachability for one settled function specialization. Errors is the -// unshared context, produced at settlement; contexts in which an input shares -// an output are analyzed on first reach by the script walk and kept here so -// later scripts reuse them. +// specialization's diagnostics, produced once at settlement and replayed by +// every script that reaches it. type SpecializationCFGResult struct { DirectCallees []string Errors []*token.CompileError - shared map[string][]*token.CompileError } // FuncInfo holds the mutable facts for one function specialization. Settled diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index 33a57908..f2f7c0b3 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -1012,20 +1012,16 @@ The two diagnostics consume effects differently: to silence it. A prior seed overwritten by a proven-`MustWrite` call output without being read is instead a true positive: remove the seed or read it explicitly when its value is semantically required. -- *Shared inputs.* Inside a body, a read of an input that shares an output at - the call being analyzed counts as a read of that output's latest write. The - dataflow therefore runs per **alias context**: settlement analyzes the - unshared context once per type specialization, and the script walk analyzes - each shared context on first reach and caches it on the specialization. A - script call site fixes its context from names; inside a callee, each nested - call derives its context from the enclosing one by the same rule lowering - uses to pick a variant, so a body is analyzed exactly as it is lowered. - Diagnostics are exact per context and deduplicated by location and - message: `out = current + 1` written twice is - accepted for `x = Twice(x)` and reported for `y = Twice(x)`, because the - first write is dead there. A body may consequently fail to compile because - of an unshared call elsewhere; that is the chosen policy for unused-write - errors, which are errors rather than warnings throughout. +- *Shared inputs.* A body is analyzed once per type specialization at + settlement, with every input treated as its own value, and every script + that reaches the specialization replays its diagnostics. Sharing an input + with an output at a call only adds reads, so it can never make a body + invalid, and a body must be valid without it: `out = current + 1` written + twice is reported for `x = Twice(x)` as well as for `y = Twice(x)`, because + the body never reads `out`. A body that means to build on its own write + says so by naming the output, `out = out + 1`, which is readable once + definitely assigned. Diagnostics are deduplicated by location and message, + and unused-write errors are errors rather than warnings throughout. After a script solve succeeds, CFG first treats the script as a zero-input, zero-output template for structural validation, then runs effect-sensitive diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index 4d64118a..586b2a4f 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -307,8 +307,12 @@ res = sum(res, 5) ``` Reusing a variable as both an argument and a destination is how a caller -connects an input to a call's staged output. The template itself reads through -its declared inputs; `res = res + 1` inside `sum` would be rejected. +connects an input to a call's staged output. The template reads the staged +value through its declared input `a`, and may read `res` itself once it has +assigned it. Sharing never changes which of a body's writes are used: a body +must be valid with every input taken as its own value, so `res = a + b` +written twice is a dead write for every caller, while `res = res + b` after +`res = a + b` reads the first write by name. ```python out, before = FoldBefore(current, item) diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index 7e7ef760..8afb8433 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -29,7 +29,7 @@ StagedString: helloabc helloabc hello ResetArray: [ ] [1 2] [ ] -WidenedWrapper: second first +WidenedWrapper: second firstfirst UnsharedWidenedBefore: keep UnsharedWidened: tagged hello! hello! UnsharedResetBefore: [9] diff --git a/tests/alias_input/self_alias.pt b/tests/alias_input/self_alias.pt index 263466bc..4f17f4b3 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -59,17 +59,17 @@ out, seen = ConditionalFold(current, item) # Caller-driven ranges rebind the shared input to the staged output while the # loop runs; the nested call must still select the sharing variant, on the -# first ranged call and on a later one. +# first ranged call and on a later one that continues from the output. out, seen = NestedRange(current) out, seen = FoldAfter(current, (1:3) + 0) out, seen = NestedRangeTwice(current) out, _ = FoldAfter(current, (1:3) + 0) - out, seen = FoldAfter(current, (3:5) + 0) + out, seen = FoldAfter(out, (3:5) + 0) out, seen = NestedArrayTwice(current) out, _ = ArrayAfter(current, (1:3) + 0) - out, seen = ArrayAfter(current, (3:5) + 0) + out, seen = ArrayAfter(out, (3:5) + 0) # A conditional call writes through synthetic destinations that stand in for # the outputs; sharing follows them to the real output. @@ -78,11 +78,11 @@ out, seen = ConditionalNested(current, item) out = BumpTwice(current, item) out = current + item - out = current + item + out = out + item out, seen = Reset(current) out = "first" - seen = current + seen = out ⊕ current out = "second" out, seen = Wrap(current) diff --git a/tests/alias_input/self_alias.spt b/tests/alias_input/self_alias.spt index febdeca8..6870f3c6 100644 --- a/tests/alias_input/self_alias.spt +++ b/tests/alias_input/self_alias.spt @@ -109,8 +109,9 @@ matrix = [[3 4]] matrix, flatSeen, matrixSeen = ResetPair(flat, matrix) "ResetArray:", matrix, flatSeen, matrixSeen -# The wrapper widens its output to the caller's heap string, and the nested -# call shares that storage, so its intermediate write is observed. +# The wrapper widens its output to the caller's heap string and the nested +# call shares that storage: seen reads the first write by name and through +# the shared input, which observes it. wrapped = "hello" ⊕ "!" wrapped, wrappedSeen = Wrap(wrapped) "WidenedWrapper:", wrapped, wrappedSeen From 79ff74b41dfb4689441206371274ae80d5369425 Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 22 Sep 2026 18:19:19 +0530 Subject: [PATCH 31/56] docs(cfg): state the liveness policy precisely and keep the widening fixture Sharing does make a body's write used at run time; the policy ignores that when validating. The memory model and the test comment now say so: every specialization must pass liveness with inputs and outputs treated as unshared, and caller sharing cannot make an otherwise rejected body acceptable. The rewritten Reset body read its output, which promoted the output to heap storage and lost the fixture's purpose, widening a static output into the caller's heap slot through the wrapper. It now writes once and lets the shared input observe that write. Co-Authored-By: Claude Fable 5.1 --- compiler/cfg_test.go | 6 +++--- docs/Pluto Memory Model.md | 9 +++++---- tests/alias_input/self_alias.exp | 2 +- tests/alias_input/self_alias.pt | 3 +-- tests/alias_input/self_alias.spt | 5 ++--- 5 files changed, 12 insertions(+), 13 deletions(-) diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index 02dd9a7d..3e216a26 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -73,9 +73,9 @@ func TestFunctionDataflowWaitsForSpecialization(t *testing.T) { require.Equal(t, 2, deadStores) } -// A body must be valid on its own: sharing an input with an output at a call -// only adds reads, so it never makes a dead write live. The body says which -// value it reads by naming the output. +// Every specialization must pass liveness with its inputs and outputs +// treated as unshared; sharing at a call cannot make an otherwise rejected +// body acceptable. A body that builds on its own write names the output. func TestOutputWriteLivenessIgnoresSharing(t *testing.T) { tests := []cfgTestCase{ { diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index 586b2a4f..51642163 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -309,10 +309,11 @@ res = sum(res, 5) Reusing a variable as both an argument and a destination is how a caller connects an input to a call's staged output. The template reads the staged value through its declared input `a`, and may read `res` itself once it has -assigned it. Sharing never changes which of a body's writes are used: a body -must be valid with every input taken as its own value, so `res = a + b` -written twice is a dead write for every caller, while `res = res + b` after -`res = a + b` reads the first write by name. +assigned it. Every specialization must pass liveness analysis with its inputs +and outputs treated as unshared; caller sharing cannot make an otherwise +rejected body acceptable. So `res = a + b` written twice is a dead write for +every caller, while `res = res + b` after `res = a + b` reads the first write +by name. ```python out, before = FoldBefore(current, item) diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index 8afb8433..e41ed1e4 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -29,7 +29,7 @@ StagedString: helloabc helloabc hello ResetArray: [ ] [1 2] [ ] -WidenedWrapper: second firstfirst +WidenedWrapper: first first UnsharedWidenedBefore: keep UnsharedWidened: tagged hello! hello! UnsharedResetBefore: [9] diff --git a/tests/alias_input/self_alias.pt b/tests/alias_input/self_alias.pt index 4f17f4b3..673f6c38 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -82,8 +82,7 @@ out = BumpTwice(current, item) out, seen = Reset(current) out = "first" - seen = out ⊕ current - out = "second" + seen = current out, seen = Wrap(current) out, seen = Reset(current) diff --git a/tests/alias_input/self_alias.spt b/tests/alias_input/self_alias.spt index 6870f3c6..a4e4a9f0 100644 --- a/tests/alias_input/self_alias.spt +++ b/tests/alias_input/self_alias.spt @@ -109,9 +109,8 @@ matrix = [[3 4]] matrix, flatSeen, matrixSeen = ResetPair(flat, matrix) "ResetArray:", matrix, flatSeen, matrixSeen -# The wrapper widens its output to the caller's heap string and the nested -# call shares that storage: seen reads the first write by name and through -# the shared input, which observes it. +# The wrapper widens the static output to the caller's heap string, and the +# nested call shares that storage, so the input observes the write. wrapped = "hello" ⊕ "!" wrapped, wrappedSeen = Wrap(wrapped) "WidenedWrapper:", wrapped, wrappedSeen From 83548b9c8b2ce3fce375ea940102cc7f4b0658bc Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 22 Sep 2026 18:22:42 +0530 Subject: [PATCH 32/56] docs(memory): say the double write is reported as dead, not that it is dead Co-Authored-By: Claude Fable 5.1 --- docs/Pluto Memory Model.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index 51642163..556119cf 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -311,8 +311,8 @@ connects an input to a call's staged output. The template reads the staged value through its declared input `a`, and may read `res` itself once it has assigned it. Every specialization must pass liveness analysis with its inputs and outputs treated as unshared; caller sharing cannot make an otherwise -rejected body acceptable. So `res = a + b` written twice is a dead write for -every caller, while `res = res + b` after `res = a + b` reads the first write +rejected body acceptable. So `res = a + b` written twice is reported as a dead +write for every caller, while `res = res + b` after `res = a + b` reads the first write by name. ```python From 1366a0c966a958d9154d31ae3c432a1787a1ac75 Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 22 Sep 2026 19:00:58 +0530 Subject: [PATCH 33/56] refactor(compiler): inline the read-outputs lookup The accessor wrapped one map lookup for two callers; its rationale now sits on the field. No behavior change. Co-Authored-By: Claude Fable 5.1 --- compiler/cfg.go | 2 +- compiler/codecompiler.go | 11 +++-------- compiler/solver.go | 2 +- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index 7d045252..0031f3c3 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -318,7 +318,7 @@ func (cfg *CFG) AnalyzeSpecialization(template *ast.FuncStatement, info *FuncInf } outputs := identSet(template.Outputs) - readOutputs := cfg.CodeCompiler.readOutputs(template.Token.Literal, len(template.Parameters)) + readOutputs := cfg.CodeCompiler.outputReads[funcKey{name: template.Token.Literal, arity: len(template.Parameters)}] for i, output := range template.Outputs { if _, isRead := readOutputs[output.Value]; isRead && !concreteStorage(info.Sig.OutTypes[i]) { cfg.addError(output.Tok(), fmt.Sprintf("output %q is read but its type %s is not concrete", output.Value, info.Sig.OutTypes[i])) diff --git a/compiler/codecompiler.go b/compiler/codecompiler.go index c90cbd9c..7fa50411 100644 --- a/compiler/codecompiler.go +++ b/compiler/codecompiler.go @@ -14,7 +14,9 @@ type CodeCompiler struct { globalBindings map[string]token.Token funcTemplates map[funcKey]*ast.FuncStatement // outputReads names the outputs each template reads in its own body, - // recorded by the structural CFG pass for the solver. + // recorded by the structural CFG pass. The solver solves such an output + // at owned storage, so every read and every nested call it feeds see + // the representation lowering stores. outputReads map[funcKey]map[string]struct{} } @@ -80,13 +82,6 @@ func (cc *CodeCompiler) indexDeclarations() []*token.CompileError { return errs } -// readOutputs names the outputs a template reads in its body. The solver -// solves such an output at owned storage, so every read and every nested call -// it feeds see the representation lowering stores. -func (cc *CodeCompiler) readOutputs(name string, arity int) map[string]struct{} { - return cc.outputReads[funcKey{name: name, arity: arity}] -} - func (cc *CodeCompiler) lookupFuncTemplate(name string, arity int) (*ast.FuncStatement, bool) { template, ok := cc.funcTemplates[funcKey{name: name, arity: arity}] return template, ok diff --git a/compiler/solver.go b/compiler/solver.go index cbc7e122..33507c78 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -2703,7 +2703,7 @@ func (ts *TypeSolver) TypeBlock(template *ast.FuncStatement, f *FuncInfo) { } } - readOutputs := ts.ScriptCompiler.Compiler.CodeCompiler.readOutputs(f.Sig.Name, len(template.Parameters)) + readOutputs := ts.ScriptCompiler.Compiler.CodeCompiler.outputReads[funcKey{name: f.Sig.Name, arity: len(template.Parameters)}] for i, id := range template.Outputs { outArg, ok := Get(ts.Scopes, id.Value) if !ok { From ccff9232a6af9e0335de7c195f0b7acf92a3fce4 Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 22 Sep 2026 19:07:33 +0530 Subject: [PATCH 34/56] docs(compiler): drop the stale CFG reference from the alias pattern comment Co-Authored-By: Claude Fable 5.1 --- compiler/compiler.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compiler/compiler.go b/compiler/compiler.go index 6851e466..dc59e2f9 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -333,9 +333,9 @@ func (c *Compiler) resolveCallSignature(funcName string, ce *ast.CallExpression, }, true } -// setCallAliasPattern derives which arguments share a caller destination, -// through the rule the CFG also uses, and gives each shared output its -// input's storage. A direct scalar param then reads the output's current +// setCallAliasPattern derives which arguments share a caller destination +// and gives each shared output its input's storage. A direct scalar param +// then reads the output's current // value inside the variant; an indirect param receives that output's staged // pointer, which passes through to the destination without an adapter. func (c *Compiler) setCallAliasPattern(sig *callSignature, args []callArg, dest []*ast.Identifier) { From 22212955186fdc8fd060e05d35c69bbe43a5074b Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 22 Sep 2026 19:07:50 +0530 Subject: [PATCH 35/56] docs(compiler): reflow the alias pattern comment Co-Authored-By: Claude Fable 5.1 --- compiler/compiler.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compiler/compiler.go b/compiler/compiler.go index dc59e2f9..c0e6bed7 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -335,9 +335,9 @@ func (c *Compiler) resolveCallSignature(funcName string, ce *ast.CallExpression, // setCallAliasPattern derives which arguments share a caller destination // and gives each shared output its input's storage. A direct scalar param -// then reads the output's current -// value inside the variant; an indirect param receives that output's staged -// pointer, which passes through to the destination without an adapter. +// then reads the output's current value inside the variant; an indirect +// param receives that output's staged pointer, which passes through to the +// destination without an adapter. func (c *Compiler) setCallAliasPattern(sig *callSignature, args []callArg, dest []*ast.Identifier) { argNames := make([]string, len(args)) for i, arg := range args { From f1a34ac6771d5fa48e7523b0b7e5b5bf09d12d80 Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 22 Sep 2026 19:18:12 +0530 Subject: [PATCH 36/56] refactor(compiler): scope synthetic conditional destinations to the statement The map from a synthetic conditional destination to the source destination it stands in for lived on the Compiler for the module's lifetime and only grew, although each entry is meaningful only while the statement that created it is being lowered. It now lives in the per-statement context beside the other state with that lifetime, so it is dropped with the statement. No behavior change. Co-Authored-By: Claude Fable 5.1 --- compiler/compiler.go | 23 ++++++++++++++++------- compiler/cond.go | 4 ++-- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/compiler/compiler.go b/compiler/compiler.go index c0e6bed7..d314a94f 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -140,10 +140,7 @@ type Compiler struct { Errors []*token.CompileError paramAliasStack []map[string][]*paramAlias outputSlotTypes map[string]Type - // condTempDest maps a synthetic conditional destination to the source - // destination it stands in for, so call-site aliasing sees through it. - condTempDest map[string]string - stmtCtxStack []stmtCtx + stmtCtxStack []stmtCtx } type stmtCtx struct { @@ -151,6 +148,7 @@ type stmtCtx struct { boundsStack []boundsGuardFrame // Nested bounds guards active within this statement loopBoundsStack []loopBoundsFrame // Loop bounds mode stack active within this statement arrayLitCellDepth int // Nested array-literal cell compilation frames active within this statement + condTempDest map[string]string // Synthetic conditional destination -> the source destination it stands in for } func NewCompiler(ctx llvm.Context, mangledPath string, cc *CodeCompiler) *Compiler { @@ -187,7 +185,6 @@ func NewCompiler(ctx llvm.Context, mangledPath string, cc *CodeCompiler) *Compil FuncNameMangled: "", Errors: []*token.CompileError{}, paramAliasStack: []map[string][]*paramAlias{}, - condTempDest: make(map[string]string), stmtCtxStack: []stmtCtx{}, } } @@ -259,12 +256,24 @@ func (c *Compiler) paramAliasFor(name string, sym *Symbol) (*paramAlias, bool) { return nil, false } +// bindSyntheticDestination records, for the statement being lowered, that a +// synthetic conditional destination stands in for a source destination, so +// call-site aliasing sees through it. +func (c *Compiler) bindSyntheticDestination(temp, dest string) { + ctx := c.currentStmtCtx() + if ctx.condTempDest == nil { + ctx.condTempDest = make(map[string]string) + } + ctx.condTempDest[temp] = dest +} + // destinationBase resolves a synthetic conditional destination to the source // destination it commits into, following stage temps through commit temps. func (c *Compiler) destinationBase(name string) string { + synthetic := c.currentStmtCtx().condTempDest for { - base, synthetic := c.condTempDest[name] - if !synthetic { + base, ok := synthetic[name] + if !ok { return name } name = base diff --git a/compiler/cond.go b/compiler/cond.go index d4746439..dd0c1c6d 100644 --- a/compiler/cond.go +++ b/compiler/cond.go @@ -222,7 +222,7 @@ func (c *Compiler) createConditionalTempOutputsFor(dest []*ast.Identifier, outTy // Temporary conditional outputs are borrowed so scope cleanup does not free // values that are transferred to real destinations in the merge block. Put(c.Scopes, tempName, tempSym) - c.condTempDest[tempName] = ident.Value + c.bindSyntheticDestination(tempName, ident.Value) slots[i] = OutputSlot{dest: ident, temp: tempIdent, outType: outTypes[i]} } return slots @@ -363,7 +363,7 @@ func (c *Compiler) createStageTempOutputsFor(commit []OutputSlot) []OutputSlot { stageTempSym.WriteFlag = commitSym.WriteFlag } Put(c.Scopes, tempName, stageTempSym) - c.condTempDest[tempName] = cs.dest.Value + c.bindSyntheticDestination(tempName, cs.dest.Value) stage[i] = OutputSlot{dest: cs.dest, temp: tempIdent, outType: outType} } return stage From a76082061088edce1afa7f241afc63412dcc6785 Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 22 Sep 2026 19:46:09 +0530 Subject: [PATCH 37/56] refactor(compiler): key parameter aliases by name and symbol Aliases were kept per parameter name as a slice of (symbol, output) records, because ranged staging rebinds a shared input's name to the staged slot and the original symbol's record must survive underneath. A map keyed by the (name, symbol) pair states that directly: binding is one insert and lookup one index, and identity still lives in the symbol so a copied value is a new key that is simply absent. The key keeps the name because several parameters can bind one symbol when a call passes the same binding twice; a symbol-only key would drop one of them. The alias fixture gains that case: two inputs sharing one output are rebound to one staged slot inside the body, and the nested call must still treat both as shared. No behavior change. Co-Authored-By: Claude Fable 5.1 --- compiler/compiler.go | 62 ++++++++++++++------------------ tests/alias_input/self_alias.exp | 1 + tests/alias_input/self_alias.pt | 10 ++++++ tests/alias_input/self_alias.spt | 6 ++++ 4 files changed, 44 insertions(+), 35 deletions(-) diff --git a/compiler/compiler.go b/compiler/compiler.go index d314a94f..1b003b96 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -100,14 +100,15 @@ type preparedCall struct { RetStruct llvm.Type } -// paramAlias records that a parameter of the active function body shares its -// caller binding with the named output. The Base check prevents alias behavior -// from leaking onto a same-name binding introduced later in the scope tree; a -// lowering that rebinds the parameter on purpose, such as ranged staging, -// registers the new symbol as a further base. -type paramAlias struct { - Base *Symbol - Output string +// paramAliasKey identifies one binding of a parameter name to a symbol whose +// caller binding is shared with an output. Keying on the symbol keeps alias +// behavior off a same-name binding introduced later in the scope tree, while +// a lowering that rebinds the parameter on purpose, such as ranged staging, +// registers the new symbol under the same name. Several names may bind one +// symbol when a call passes the same binding twice. +type paramAliasKey struct { + name string + base *Symbol } type symbolSource int @@ -138,7 +139,7 @@ type Compiler struct { ExprCache map[ExprKey]*ExprInfo FuncNameMangled string // current script root or function specialization key Errors []*token.CompileError - paramAliasStack []map[string][]*paramAlias + paramAliasStack []map[paramAliasKey]string // per function body: shared parameter binding -> output name outputSlotTypes map[string]Type stmtCtxStack []stmtCtx } @@ -184,7 +185,7 @@ func NewCompiler(ctx llvm.Context, mangledPath string, cc *CodeCompiler) *Compil ExprCache: exprCache, FuncNameMangled: "", Errors: []*token.CompileError{}, - paramAliasStack: []map[string][]*paramAlias{}, + paramAliasStack: []map[paramAliasKey]string{}, stmtCtxStack: []stmtCtx{}, } } @@ -216,7 +217,7 @@ func (c *Compiler) bindingSlotType(name string, fallback Type) Type { return typ } -func (c *Compiler) currentParamAliases() map[string][]*paramAlias { +func (c *Compiler) currentParamAliases() map[paramAliasKey]string { if len(c.paramAliasStack) == 0 { return nil } @@ -224,7 +225,7 @@ func (c *Compiler) currentParamAliases() map[string][]*paramAlias { } func (c *Compiler) pushParamAliases() { - c.paramAliasStack = append(c.paramAliasStack, make(map[string][]*paramAlias)) + c.paramAliasStack = append(c.paramAliasStack, make(map[paramAliasKey]string)) } func (c *Compiler) popParamAliases() { @@ -243,17 +244,12 @@ func identNames(idents []*ast.Identifier) []string { // value on every input read. The output may remain a value or be replaced in // scope without invalidating the input's reference to it. func (c *Compiler) bindParamAlias(name string, sym *Symbol, output string) { - aliases := c.currentParamAliases() - aliases[name] = append(aliases[name], ¶mAlias{Base: sym, Output: output}) + c.currentParamAliases()[paramAliasKey{name: name, base: sym}] = output } -func (c *Compiler) paramAliasFor(name string, sym *Symbol) (*paramAlias, bool) { - for _, alias := range c.currentParamAliases()[name] { - if alias.Base == sym { - return alias, true - } - } - return nil, false +func (c *Compiler) paramAliasFor(name string, sym *Symbol) (string, bool) { + output, ok := c.currentParamAliases()[paramAliasKey{name: name, base: sym}] + return output, ok } // bindSyntheticDestination records, for the statement being lowered, that a @@ -369,13 +365,9 @@ func (c *Compiler) setCallAliasPattern(sig *callSignature, args []callArg, dest // shares under the current variant, for the bindings currently in scope. func (c *Compiler) enclosingAliases() map[string]string { aliases := make(map[string]string) - for name := range c.currentParamAliases() { - sym, ok := Get(c.Scopes, name) - if !ok { - continue - } - if alias, ok := c.paramAliasFor(name, sym); ok { - aliases[name] = alias.Output + for binding, output := range c.currentParamAliases() { + if sym, ok := Get(c.Scopes, binding.name); ok && sym == binding.base { + aliases[binding.name] = output } } return aliases @@ -436,10 +428,10 @@ func (c *Compiler) putGlobal(name, mangledName string, sym *Symbol) { // directParamValue reads a direct scalar input that shares its binding with // an output: the output's current value is the input's value. -func (c *Compiler) directParamValue(name string, sym *Symbol, alias *paramAlias) *Symbol { - output, ok := c.localValSymbol(alias.Output, name+"_alias_load") +func (c *Compiler) directParamValue(name string, sym *Symbol, outputName string) *Symbol { + output, ok := c.localValSymbol(outputName, name+"_alias_load") if !ok { - panic(fmt.Sprintf("internal: input %s aliases unbound output %s", name, alias.Output)) + panic(fmt.Sprintf("internal: input %s aliases unbound output %s", name, outputName)) } resolved := GetCopy(sym) @@ -451,8 +443,8 @@ func (c *Compiler) directParamValue(name string, sym *Symbol, alias *paramAlias) // output's current value; an aliased indirect input already points at that // output's storage, so it reads through its own pointer like any other. func (c *Compiler) valueSymbol(name string, sym *Symbol, loadName string) *Symbol { - if alias, ok := c.paramAliasFor(name, sym); ok && sym.Type.Kind() != PtrKind { - return c.directParamValue(name, sym, alias) + if output, ok := c.paramAliasFor(name, sym); ok && sym.Type.Kind() != PtrKind { + return c.directParamValue(name, sym, output) } return c.derefIfPointer(sym, loadName) } @@ -2393,9 +2385,9 @@ func (c *Compiler) bindRangedTempOutputs(dest []*ast.Identifier, outputs []*Symb continue } seen[name] = struct{}{} - if alias, ok := c.paramAliasFor(name, sym); ok && alias.Output == base { + if output, ok := c.paramAliasFor(name, sym); ok && output == base { names = append(names, name) - aliased[name] = alias.Output + aliased[name] = output continue } if sym.Type.Kind() == PtrKind && sym.Val == current.Val { diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index e41ed1e4..be9d66c2 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -37,3 +37,4 @@ UnsharedReset: [] [1 2] [1 2] Powers: 9 27 PowersShared: 9 81 ReadTwice: second first second +SharedPair: 48 diff --git a/tests/alias_input/self_alias.pt b/tests/alias_input/self_alias.pt index 673f6c38..5026f8e9 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -105,6 +105,16 @@ out, kept, echo = ReadTwice(current) out = "second" echo = current +# Two inputs may share one output. Ranged staging inside the body rebinds +# both names to the same staged slot, and the nested call must still see +# both as shared: its second write reads the first through b. +out = AddBoth(a, b, item) + out = a + item + out = out + b + +out = SharedPair(a, b) + out = AddBoth(a, b, (1:3) + 0) + out, left, right = ResetPair(first, second) out = [] left = first diff --git a/tests/alias_input/self_alias.spt b/tests/alias_input/self_alias.spt index a4e4a9f0..46f4d391 100644 --- a/tests/alias_input/self_alias.spt +++ b/tests/alias_input/self_alias.spt @@ -139,3 +139,9 @@ pw, pwCube = Powers(pw) readTwice = "hello" ⊕ "!" readTwice, readKept, readEcho = ReadTwice(readTwice) "ReadTwice:", readTwice, readKept, readEcho + +# Both arguments share the destination; each iteration doubles the running +# value plus the item through the nested call. +pair = 10 +pair = SharedPair(pair, pair) +"SharedPair:", pair From 1dc6b14876843a22290e4b1b5f0d26921b69c889 Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 22 Sep 2026 20:14:22 +0530 Subject: [PATCH 38/56] test(alias): make the two-inputs-one-output fixture bite The nested body read its first input only before writing the output, so losing that input's alias changed nothing, and a single integer output kept the wrapper on the direct-return path, so the pointer-backed rebinding never ran. The nested body now reads both inputs after its first write, and the wrapper gains a second output so its result is pointer-backed. A compiler keeping one alias per symbol prints 100 10 where the correct result is 105 10. Co-Authored-By: Claude Fable 5.1 --- tests/alias_input/self_alias.exp | 2 +- tests/alias_input/self_alias.pt | 9 +++++---- tests/alias_input/self_alias.spt | 8 ++++---- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index be9d66c2..234e6d99 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -37,4 +37,4 @@ UnsharedReset: [] [1 2] [1 2] Powers: 9 27 PowersShared: 9 81 ReadTwice: second first second -SharedPair: 48 +SharedPair: 105 10 diff --git a/tests/alias_input/self_alias.pt b/tests/alias_input/self_alias.pt index 5026f8e9..925a7b6a 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -106,13 +106,14 @@ out, kept, echo = ReadTwice(current) echo = current # Two inputs may share one output. Ranged staging inside the body rebinds -# both names to the same staged slot, and the nested call must still see -# both as shared: its second write reads the first through b. +# both names to the same pointer-backed staged slot, and the nested call must +# still see both as shared: its second write reads the first through a and b. out = AddBoth(a, b, item) out = a + item - out = out + b + out = out + a + b -out = SharedPair(a, b) +out, before = SharedPair(a, b) + before = b out = AddBoth(a, b, (1:3) + 0) out, left, right = ResetPair(first, second) diff --git a/tests/alias_input/self_alias.spt b/tests/alias_input/self_alias.spt index 46f4d391..0cae3cd4 100644 --- a/tests/alias_input/self_alias.spt +++ b/tests/alias_input/self_alias.spt @@ -140,8 +140,8 @@ readTwice = "hello" ⊕ "!" readTwice, readKept, readEcho = ReadTwice(readTwice) "ReadTwice:", readTwice, readKept, readEcho -# Both arguments share the destination; each iteration doubles the running -# value plus the item through the nested call. +# Both arguments share the destination; each iteration triples the running +# value plus the item through the nested call, and before keeps the seed. pair = 10 -pair = SharedPair(pair, pair) -"SharedPair:", pair +pair, before = SharedPair(pair, pair) +"SharedPair:", pair, before From 64606607daafaaccf9d84b5010fd71e8378b25a7 Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 22 Sep 2026 21:46:34 +0530 Subject: [PATCH 39/56] test(alias): pin the shared range parameter's scalar iterator A shared range parameter's alias is registered on the range symbol, and the loop binds the same name to a fresh integer symbol that must not inherit it; a name-keyed alias map would load the shared range where an integer is expected. The fixture now covers that case, and the key's comment names it as the reason the symbol is part of the key. Co-Authored-By: Claude Fable 5.1 --- compiler/compiler.go | 10 +++++----- tests/alias_input/self_alias.exp | 1 + tests/alias_input/self_alias.pt | 5 +++++ tests/alias_input/self_alias.spt | 3 +++ 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/compiler/compiler.go b/compiler/compiler.go index 1b003b96..207992ab 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -101,11 +101,11 @@ type preparedCall struct { } // paramAliasKey identifies one binding of a parameter name to a symbol whose -// caller binding is shared with an output. Keying on the symbol keeps alias -// behavior off a same-name binding introduced later in the scope tree, while -// a lowering that rebinds the parameter on purpose, such as ranged staging, -// registers the new symbol under the same name. Several names may bind one -// symbol when a call passes the same binding twice. +// caller binding is shared with an output. A Symbol is a lowering-time +// descriptor, so keying on it keeps the alias off a later binding of the same +// name, such as the scalar iterator of a shared range parameter, while ranged +// staging registers its staged symbol under the same name on purpose. Several +// names may bind one symbol when a call passes the same binding twice. type paramAliasKey struct { name string base *Symbol diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index 234e6d99..b17780cb 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -38,3 +38,4 @@ Powers: 9 27 PowersShared: 9 81 ReadTwice: second first second SharedPair: 105 10 +NextRange: 2:3 diff --git a/tests/alias_input/self_alias.pt b/tests/alias_input/self_alias.pt index 925a7b6a..609d7617 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -116,6 +116,11 @@ out, before = SharedPair(a, b) before = b out = AddBoth(a, b, (1:3) + 0) +# A shared range parameter is iterated by a scalar bound to the same name; +# that scalar must not inherit the range's alias. +out = NextRange(i) + out = i:(i+1) + out, left, right = ResetPair(first, second) out = [] left = first diff --git a/tests/alias_input/self_alias.spt b/tests/alias_input/self_alias.spt index 0cae3cd4..2b7a9b6a 100644 --- a/tests/alias_input/self_alias.spt +++ b/tests/alias_input/self_alias.spt @@ -145,3 +145,6 @@ readTwice, readKept, readEcho = ReadTwice(readTwice) pair = 10 pair, before = SharedPair(pair, pair) "SharedPair:", pair, before +nextRange = 1:3 +nextRange = NextRange(nextRange) +"NextRange:", nextRange From 53540d3098c10d10f91dfa474223b94da1c67038 Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 22 Sep 2026 23:06:00 +0530 Subject: [PATCH 40/56] fix(compiler): index conditional-call parameters by expanded position Before branching, a conditional call promotes its indirect identifier arguments to memory so codegen does not promote on one path only. The loop looked up each argument's parameter by argument index, ignoring arguments that produce several values. A scalar following a two-valued argument was checked against the wrong parameter, judged indirect, promoted into private storage, and lost its live alias: `x, seen = Wrap(x)` printed `11 10` where the shared input should observe the write and print `11 11`. The lookup now advances by each argument's output count, and the alias fixture covers the case. Co-Authored-By: Claude Fable 5.1 --- compiler/cond.go | 15 +++++++-------- tests/alias_input/self_alias.exp | 1 + tests/alias_input/self_alias.pt | 15 +++++++++++++++ tests/alias_input/self_alias.spt | 3 +++ 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/compiler/cond.go b/compiler/cond.go index dd0c1c6d..1dfb4cce 100644 --- a/compiler/cond.go +++ b/compiler/cond.go @@ -109,16 +109,15 @@ func (c *Compiler) addPromotableArgs(ce *ast.CallExpression, out map[string]stru return } + // A multi-valued argument fills several parameter positions, so the + // parameter an identifier binds to is found by expanded position. abi := classifyFuncABI(paramTypes, fnInfo.Sig.OutTypes) - for i, arg := range ce.Arguments { - if abi.Params[i].Mode != ABIParamIndirect { - continue - } - ident, ok := arg.(*ast.Identifier) - if !ok { - continue + position := 0 + for _, arg := range ce.Arguments { + if ident, ok := arg.(*ast.Identifier); ok && abi.Params[position].Mode == ABIParamIndirect { + out[ident.Value] = struct{}{} } - out[ident.Value] = struct{}{} + position += len(c.ExprCache[key(c.FuncNameMangled, arg)].OutTypes) } } diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index b17780cb..8ca2aca2 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -39,3 +39,4 @@ PowersShared: 9 81 ReadTwice: second first second SharedPair: 105 10 NextRange: 2:3 +CondCount: 11 11 lr diff --git a/tests/alias_input/self_alias.pt b/tests/alias_input/self_alias.pt index 609d7617..14e734c5 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -121,6 +121,21 @@ out, before = SharedPair(a, b) out = NextRange(i) out = i:(i+1) +# A conditional call promotes its indirect arguments before branching. The +# scalar after a two-valued argument binds a later parameter and must keep +# its live alias. +left, right = Tags(n) + left = "l" + right = n > 0 "r" + +out, tag = Count(left, right, n) + out = n + 1 + tag = left ⊕ right + +out, seen, tag = CondCount(current) + out, tag = 1 > 0 Count(Tags(current), current) + seen = current + out, left, right = ResetPair(first, second) out = [] left = first diff --git a/tests/alias_input/self_alias.spt b/tests/alias_input/self_alias.spt index 2b7a9b6a..58373b10 100644 --- a/tests/alias_input/self_alias.spt +++ b/tests/alias_input/self_alias.spt @@ -148,3 +148,6 @@ pair, before = SharedPair(pair, pair) nextRange = 1:3 nextRange = NextRange(nextRange) "NextRange:", nextRange +condCount = 10 +condCount, condCountSeen, condTag = CondCount(condCount) +"CondCount:", condCount, condCountSeen, condTag From 628a9edfbe55d681c4ea94767f7d470db2b81956 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 23 Sep 2026 13:07:32 +0530 Subject: [PATCH 41/56] fix(compiler): keep shared inputs read-only and loop-safe across staging Ranged calls and conditional statements lower a destination into a staged slot while their loop or branch runs. Only the ranged path rebound the inputs sharing that destination, and it installed the writable staged symbol under the input's name. Three defects followed: - In a range-gated statement, shared scalars read each iteration's write, but shared strings and arrays kept reading the caller slot (`x.` where the inlined statement gives `x...`). - %n into a shared input was accepted inside a caller-driven ranged call and wrote the input. - The affine bounds fast path checked an array's length once before a loop that shrinks it, then read past the end (garbage, or a crash under Guard Malloc). The script-level form of this also fails on master. stagedBindings now serves both staging paths. It rebinds the destination, gives every sharing input a read-only view of the staged slot that keeps its alias, and records the slot so the affine guard skips it. Both callers gather all bindings before installing any, since the aliases they look up are keyed by the current bindings. Tests: tests/alias_input/staging_{gate,bounds}; a gated nested-range case in TestFormatCountRejectsInputParameter; TestAliasVariantSkipsIncompatibleOutputs now asserts the variant that shares the second output. Co-Authored-By: Claude Opus 5.5 --- compiler/bounds.go | 5 ++ compiler/compiler.go | 110 +++++++++++++++++---------- compiler/compiler_test.go | 4 +- compiler/cond.go | 21 +++-- compiler/format_test.go | 25 ++++-- tests/alias_input/staging.pt | 29 +++++++ tests/alias_input/staging_bounds.exp | 3 + tests/alias_input/staging_bounds.spt | 11 +++ tests/alias_input/staging_gate.exp | 5 ++ tests/alias_input/staging_gate.spt | 27 +++++++ 10 files changed, 185 insertions(+), 55 deletions(-) create mode 100644 tests/alias_input/staging.pt create mode 100644 tests/alias_input/staging_bounds.exp create mode 100644 tests/alias_input/staging_bounds.spt create mode 100644 tests/alias_input/staging_gate.exp create mode 100644 tests/alias_input/staging_gate.spt diff --git a/compiler/bounds.go b/compiler/bounds.go index 4ee21fd4..f7dc1d22 100644 --- a/compiler/bounds.go +++ b/compiler/bounds.go @@ -498,6 +498,11 @@ func (c *Compiler) arraySymbolForAffineGuard(arrayExpr ast.Expression) (*Symbol, if !ok { return nil, nil, false } + // A staged slot can change length inside the loop, so a bound checked + // once before it does not hold for every iteration. + if _, staged := c.currentStmtCtx().stagedSlots[raw.Val]; staged { + return nil, nil, false + } arraySym := c.derefIfPointer(raw, ident.Value+"_affine_arr") arrType, ok := arraySym.Type.(Array) if !ok || arrType.ElemType == nil { diff --git a/compiler/compiler.go b/compiler/compiler.go index 207992ab..e6e1e4e2 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -2,6 +2,7 @@ package compiler import ( "fmt" + "maps" "slices" "strings" @@ -150,6 +151,7 @@ type stmtCtx struct { loopBoundsStack []loopBoundsFrame // Loop bounds mode stack active within this statement arrayLitCellDepth int // Nested array-literal cell compilation frames active within this statement condTempDest map[string]string // Synthetic conditional destination -> the source destination it stands in for + stagedSlots map[llvm.Value]struct{} // Slots standing in for destinations; a loop over the statement may write them } func NewCompiler(ctx llvm.Context, mangledPath string, cc *CodeCompiler) *Compiler { @@ -276,6 +278,35 @@ func (c *Compiler) destinationBase(name string) string { } } +// stagedBindings returns the bindings that make dest resolve to staged, a slot +// standing in for it while a statement or a ranged expression is lowered: +// dest itself, and each input sharing its binding as a view of staged that +// keeps the input's permissions and alias, so the input stays read-only and +// nested calls keep sharing. A loop may write staged on every iteration, so it +// is recorded for the affine fast path to skip. +func (c *Compiler) stagedBindings(dest string, staged *Symbol) map[string]*Symbol { + ctx := c.currentStmtCtx() + if ctx.stagedSlots == nil { + ctx.stagedSlots = make(map[llvm.Value]struct{}) + } + ctx.stagedSlots[staged.Val] = struct{}{} + + bindings := map[string]*Symbol{dest: staged} + base := c.destinationBase(dest) + for name, output := range c.enclosingAliases() { + if output != base { + continue + } + input, _ := Get(c.Scopes, name) + view := GetCopy(staged) + view.FuncArg = input.FuncArg + view.ReadOnly = input.ReadOnly + c.bindParamAlias(name, view, output) + bindings[name] = view + } + return bindings +} + func (c *Compiler) resolvedDestTypes(dest []*ast.Identifier, outTypes []Type) []Type { resolved := make([]Type, len(outTypes)) for i, outType := range outTypes { @@ -2354,16 +2385,14 @@ func (c *Compiler) cleanupSkippedCallOutputAdapters(adapters []callOutputAdapter } } -// bindRangedTempOutputs makes each destination name resolve to its staged slot -// while that one ranged expression is compiled. Conditional lowering can make -// the real destination and a synthetic conditional write name alias the same -// slot, so bind every visible name for that slot as well. An input that -// shares the destination is rebound to the staged slot too and keeps its -// alias, so a nested call inside the loop still selects the sharing variant. -// This preserves loop-carried self-reference without exposing staged values to -// sibling right-hand sides in a simultaneous assignment. The caller pops its -// BlockScope before compiling the next expression. +// bindRangedTempOutputs makes each destination, every input sharing it (see +// stagedBindings) and every name bound to the same slot resolve to its staged +// slot while one ranged expression compiles, so loop-carried reads see it and +// sibling right-hand sides do not. Bindings are gathered before any is +// replaced, since stagedBindings finds inputs by their current bindings. The +// caller pops its BlockScope before compiling the next expression. func (c *Compiler) bindRangedTempOutputs(dest []*ast.Identifier, outputs []*Symbol) { + bindings := make(map[string]*Symbol) for i := 0; i < len(dest) && i < len(outputs); i++ { // A blank binds nothing and nothing can read it back, so it has no // self-reference to preserve — binding it would only expose `_` as a @@ -2371,43 +2400,42 @@ func (c *Compiler) bindRangedTempOutputs(dest []*ast.Identifier, outputs []*Symb if isDiscard(dest[i]) { continue } + for _, name := range c.sameSlotNames(dest[i].Value) { + bindings[name] = outputs[i] + } + maps.Copy(bindings, c.stagedBindings(dest[i].Value, outputs[i])) + } + for _, name := range slices.Sorted(maps.Keys(bindings)) { + Put(c.Scopes, name, bindings[name]) + } +} - names := []string{dest[i].Value} - aliased := make(map[string]string) - if current, ok := Get(c.Scopes, dest[i].Value); ok && current.Type.Kind() == PtrKind { - base := c.destinationBase(dest[i].Value) - seen := make(map[string]struct{}) - for scopeIdx := len(c.Scopes) - 1; scopeIdx >= 0; scopeIdx-- { - scope := c.Scopes[scopeIdx] - for _, name := range scope.BindingOrder { - sym := scope.Elems[name] - if _, visited := seen[name]; visited { - continue - } - seen[name] = struct{}{} - if output, ok := c.paramAliasFor(name, sym); ok && output == base { - names = append(names, name) - aliased[name] = output - continue - } - if sym.Type.Kind() == PtrKind && sym.Val == current.Val { - names = append(names, name) - } - } - if scope.ScopeKind == FuncScope { - break - } +// sameSlotNames lists the visible names bound to the pointer slot that dest is +// bound to. Conditional lowering binds the real destination and a synthetic +// conditional write name to the same slot. +func (c *Compiler) sameSlotNames(dest string) []string { + current, ok := Get(c.Scopes, dest) + if !ok || current.Type.Kind() != PtrKind { + return nil + } + var names []string + seen := make(map[string]struct{}) + for scopeIdx := len(c.Scopes) - 1; scopeIdx >= 0; scopeIdx-- { + scope := c.Scopes[scopeIdx] + for _, name := range scope.BindingOrder { + if _, visited := seen[name]; visited { + continue } - } - for _, name := range names { - Put(c.Scopes, name, outputs[i]) - // The rebound input keeps its alias, so a nested call inside the - // loop still selects the sharing variant. - if output, ok := aliased[name]; ok { - c.bindParamAlias(name, outputs[i], output) + seen[name] = struct{}{} + if sym := scope.Elems[name]; sym.Type.Kind() == PtrKind && sym.Val == current.Val { + names = append(names, name) } } + if scope.ScopeKind == FuncScope { + break + } } + return names } // Destination-aware prefix compilation, diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 6170778d..b444dc17 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -206,6 +206,7 @@ func verifyCompiledModules(t *testing.T, moduleName, codeSrc, scriptSrc string) // the input to the compatible sibling, producing valid IR for each kind. func TestAliasVariantSkipsIncompatibleOutputs(t *testing.T) { const sharedSecond = "s = 1\nr, s = Mixed(s, 0:4)\nr, s" + mangled := Mangle(MangleDirPath("alias_mismatch", ""), "Mixed", []Type{I64, Range{Iter: I64}}) cases := []struct{ name, code, script string }{ {"float first", "other, sum = Mixed(a, x)\n other = x * 0.5\n sum = a + x", sharedSecond}, @@ -214,7 +215,8 @@ func TestAliasVariantSkipsIncompatibleOutputs(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - verifyCompiledModules(t, "alias_mismatch", tc.code, tc.script) + ir, _ := compileScriptAndCodeIR(t, "alias_mismatch", tc.code, tc.script) + require.Contains(t, ir, "@"+mangled+"_a2_2_0(", "the input must share the second output") }) } } diff --git a/compiler/cond.go b/compiler/cond.go index 1dfb4cce..ab2c5e5e 100644 --- a/compiler/cond.go +++ b/compiler/cond.go @@ -2,6 +2,7 @@ package compiler import ( "fmt" + "maps" "slices" "github.com/thiremani/pluto/ast" @@ -278,24 +279,28 @@ func (c *Compiler) commitConditionalOutputs(slots []OutputSlot) { } } -// aliasCondDests maps existing destination names to conditional temp slots so -// RHS reads during IF-branch assignment see the latest temp writes. +// aliasCondDests maps existing destination names, and the inputs sharing them +// (see stagedBindings), to conditional temp slots so RHS reads during IF-branch +// assignment see the latest temp writes. All bindings are gathered before any +// is replaced, since stagedBindings finds inputs by their current bindings. func (c *Compiler) aliasCondDests(slots []OutputSlot) map[string]*Symbol { - aliases := make(map[string]*Symbol, len(slots)) - + bindings := make(map[string]*Symbol, len(slots)) for _, s := range slots { - oldSym, exists := Get(c.Scopes, s.dest.Value) - if !exists { + if _, exists := Get(c.Scopes, s.dest.Value); !exists { continue } tempSym, ok := Get(c.Scopes, s.temp.Value) if !ok { continue } - aliases[s.dest.Value] = oldSym - SetExisting(c.Scopes, s.dest.Value, tempSym) + maps.Copy(bindings, c.stagedBindings(s.dest.Value, tempSym)) } + aliases := make(map[string]*Symbol, len(bindings)) + for _, name := range slices.Sorted(maps.Keys(bindings)) { + aliases[name], _ = Get(c.Scopes, name) + SetExisting(c.Scopes, name, bindings[name]) + } return aliases } diff --git a/compiler/format_test.go b/compiler/format_test.go index 7a05c9d1..dc9ccad8 100644 --- a/compiler/format_test.go +++ b/compiler/format_test.go @@ -251,12 +251,29 @@ func TestFormatCountRejectsCodeConstant(t *testing.T) { } func TestFormatCountRejectsInputParameter(t *testing.T) { + const count = `out = Count(current) + "count-current%n" + out = current` + // A caller-driven ranged call stages its destination and rebinds the input + // that shares it, which must stay read-only, also under a condition. + const step = `out, tag = Step(current, label, item) + out = current + item + tag = label + +` + const nestedRange = step + `out, tag = Count(current) + out, tag = Step(current, "count-current%n", (1:3) + 0)` + const gatedNestedRange = step + `out, tag = Count(current) + out, tag = 1 > 0 Step(current, "count-current%n", (1:3) + 0)` tests := []struct { name string + code string script string }{ - {name: "plain", script: "value = 10\nvalue = Count(value)\nvalue"}, - {name: "range", script: "value = Count(1:3)\nvalue"}, + {name: "plain", code: count, script: "value = 10\nvalue = Count(value)\nvalue"}, + {name: "range", code: count, script: "value = Count(1:3)\nvalue"}, + {name: "shared nested range", code: nestedRange, script: "value = 10\nvalue, tag = Count(value)\nvalue, tag"}, + {name: "shared gated nested range", code: gatedNestedRange, script: "value = 10\nvalue, tag = Count(value)\nvalue, tag"}, } for _, tt := range tests { @@ -264,9 +281,7 @@ func TestFormatCountRejectsInputParameter(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() - code := mustParseCode(t, `out = Count(current) - "count-current%n" - out = current`) + code := mustParseCode(t, tt.code) cc := NewCodeCompiler(ctx, "format_input_parameter", "", code) if errs := cc.Compile(); len(errs) != 0 { t.Fatalf("unexpected code compile errors: %v", errs) diff --git a/tests/alias_input/staging.pt b/tests/alias_input/staging.pt new file mode 100644 index 00000000..61557fb3 --- /dev/null +++ b/tests/alias_input/staging.pt @@ -0,0 +1,29 @@ +# A range-gated statement stages its output, and an input sharing that output +# follows the staged value whether it is passed by value or by pointer. +out = GateInt(current) + out = (1:4) > 0 current + 1 + +out = GateStr(current) + out = (1:4) > 0 current ⊕ "." + +out = GateArr(current) + out = (1:4) > 0 current ⊕ current + +out, seen = GateSibInt(current) + out, seen = (1:4) > 0 current + 1, current + +out, seen = GateSibStr(current) + out, seen = (1:4) > 0 current ⊕ ".", current + +out = Shrink(arr, v) + out = [v + arr[0]] + +# The loop replaces the shared array with a shorter one. +out, seen = ShrinkShared(current) + out = Shrink(current, current[(0:8) + 0]) + seen = current + +# The conditional stages the output, then the ranged call inside it stages it +# again; the nested call must keep sharing through both. +out, after = GatedFold(current) + out, after = 1 > 0 FoldAfter(current, (1:3) + 0) diff --git a/tests/alias_input/staging_bounds.exp b/tests/alias_input/staging_bounds.exp new file mode 100644 index 00000000..b93d0d88 --- /dev/null +++ b/tests/alias_input/staging_bounds.exp @@ -0,0 +1,3 @@ +Shared: [20] [20] +Unshared: [90] [10 20 30 40 50 60 70 80] +Script: [20] diff --git a/tests/alias_input/staging_bounds.spt b/tests/alias_input/staging_bounds.spt new file mode 100644 index 00000000..d639d696 --- /dev/null +++ b/tests/alias_input/staging_bounds.spt @@ -0,0 +1,11 @@ +# The loop shrinks the array it indexes, so a bounds check hoisted before the +# loop must not cover later iterations: their out-of-bounds reads skip. +a = [10 20 30 40 50 60 70 80] +a, seen = ShrinkShared(a) +"Shared:", a, seen +b = [10 20 30 40 50 60 70 80] +c, cs = ShrinkShared(b) +"Unshared:", c, cs +d = [10 20 30 40 50 60 70 80] +d = Shrink(d, d[(0:8) + 0]) +"Script:", d diff --git a/tests/alias_input/staging_gate.exp b/tests/alias_input/staging_gate.exp new file mode 100644 index 00000000..06330eb9 --- /dev/null +++ b/tests/alias_input/staging_gate.exp @@ -0,0 +1,5 @@ +Shared: 13 x... [9 9 9 9 9 9 9 9] +Inlined: 13 x... [9 9 9 9 9 9 9 9] +Sibling: 13 12 x... x.. +Unshared: 11 x. x +Gated: 13 13 diff --git a/tests/alias_input/staging_gate.spt b/tests/alias_input/staging_gate.spt new file mode 100644 index 00000000..0073c8fc --- /dev/null +++ b/tests/alias_input/staging_gate.spt @@ -0,0 +1,27 @@ +# A shared input observes each iteration's staged write, as the inlined +# statement does, for scalars, strings and arrays alike. +i = 10 +i = GateInt(i) +s = "x" +s = GateStr(s) +a = [9] +a = GateArr(a) +"Shared:", i, s, a +si = 10 +si = (1:4) > 0 si + 1 +ss = "x" +ss = (1:4) > 0 ss ⊕ "." +sa = [9] +sa = (1:4) > 0 sa ⊕ sa +"Inlined:", si, ss, sa +j = 10 +j, js = GateSibInt(j) +t = "x" +t, ts = GateSibStr(t) +"Sibling:", j, js, t, ts +u = GateInt(10) +v, vs = GateSibStr("x") +"Unshared:", u, v, vs +g = 10 +g, gs = GatedFold(g) +"Gated:", g, gs From 8cb24528a90ce9b696a4b20335d668d8f9c81a71 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 23 Sep 2026 13:07:40 +0530 Subject: [PATCH 42/56] fix(solver): type untyped call arguments by their own value unless shared Specializing every identifier argument on its binding's merged slot type let a later assignment retype an earlier call. With `x = []`, `kept = Keep(x)`, then `x = [1 2]`, Keep was specialized on [I64]. That rejected programs master accepts and changed results (`[12345678]` printed as `[1.23457e+07]`). An argument now takes the slot type in two cases. First, when its value type is concrete: the two then differ only in ownership, such as a static string widened to heap storage, which #103 needs. Second, when it names one of the call's own destinations, the only case where the input must share the output's storage. Destinations are recorded per statement value call, so a sibling's destination or a caller's variable name never reaches another call or a function body. A nested or sibling call reading an untyped binding that a loop rewrites still sees the pre-loop type, as on master. #106 tracks that for PIR's carries. Tests: tests/call_arg_types. Co-Authored-By: Claude Opus 5.5 --- compiler/solver.go | 37 ++++++++++++++++++++--- tests/call_arg_types/call_arg_types.pt | 37 +++++++++++++++++++++++ tests/call_arg_types/flow_types.exp | 19 ++++++++++++ tests/call_arg_types/flow_types.spt | 41 ++++++++++++++++++++++++++ tests/call_arg_types/slot_storage.exp | 3 ++ tests/call_arg_types/slot_storage.spt | 15 ++++++++++ 6 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 tests/call_arg_types/call_arg_types.pt create mode 100644 tests/call_arg_types/flow_types.exp create mode 100644 tests/call_arg_types/flow_types.spt create mode 100644 tests/call_arg_types/slot_storage.exp create mode 100644 tests/call_arg_types/slot_storage.spt diff --git a/compiler/solver.go b/compiler/solver.go index 33507c78..4094f26e 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -149,6 +149,9 @@ type TypeSolver struct { storageRevision uint64 // increments when a previously observed binding slot widens previousSlotTypes map[string]Type // prior walk's slots for the body being inferred + // A statement's value call -> the statement's names from the call's first output on. + callDests map[*ast.CallExpression][]*ast.Identifier + recLimit recursionLimit } @@ -163,6 +166,7 @@ func NewTypeSolver(sc *ScriptCompiler) *TypeSolver { TmpCounter: 0, PendingAssignments: make(map[pendingAssignment]struct{}), walkedFuncs: make(map[string]walkedSpecialization), + callDests: make(map[*ast.CallExpression][]*ast.Identifier), recLimit: newRecursionLimit(maxActiveRecursiveSpecializations), } } @@ -955,6 +959,9 @@ func (ts *TypeSolver) TypeLetStatement(stmt *ast.LetStatement) { exprRefs := make([]ast.Expression, 0, len(stmt.Name)) exprIdxs := make([]int, 0, len(stmt.Name)) for _, expr := range stmt.Value { + if ce, ok := expr.(*ast.CallExpression); ok { + ts.callDests[ce] = stmt.Name[min(len(types), len(stmt.Name)):] + } exprTypes := ts.TypeExpression(expr, true) ts.resolveBareRangeAssignment(expr, exprTypes, condRanges) ts.mergeCondRangesIntoValue(expr, condRanges) @@ -2421,16 +2428,19 @@ func (ts *TypeSolver) callScopedArrayRangeType(expr ast.Expression) (ArrayRange, func (ts *TypeSolver) collectCallArgs(ce *ast.CallExpression, isRoot bool) (args []Type, innerArgs []Type, loopInside bool) { outerTypesPerArg, loopInside, _ := ts.TypeExprsForIter(ce.Arguments, isRoot) _, builtin := Builtins[ce.Function.Value] + shared := ts.sharedDestinations(ce, outerTypesPerArg) // Build args and innerArgs from outer types // If loopInside=false, ALL range args become their inner type (loop outside) for argIndex, outerTypes := range outerTypesPerArg { if ident, ok := ce.Arguments[argIndex].(*ast.Identifier); ok && !builtin { - // Calls receive the binding's actual slot, including ownership - // widening learned from later writes. Other expressions retain - // their flow type (an empty value can still reset another array). + // A concrete flow type differs from its binding's slot only in + // ownership, which lowering must see. An untyped value keeps its + // flow type unless it shares one of this call's destinations, + // whose input then carries every value the call writes back. body := ts.ScriptCompiler.Compiler.FuncCache[ts.FuncNameMangled] - if slotType, exists := body.Vars[ident.Value]; exists { + slotType, exists := body.Vars[ident.Value] + if exists && (concreteStorage(outerTypes[0]) || slices.Contains(shared, ident.Value)) { outerTypes = []Type{slotType} } } @@ -2460,6 +2470,25 @@ func (ts *TypeSolver) collectCallArgs(ce *ast.CallExpression, isRoot bool) (args return } +// sharedDestinations names the destinations a statement's value call assigns, +// the only bindings its arguments can share. Lowering passes the call the +// statement's names from its first output on and binds one per callee output. +func (ts *TypeSolver) sharedDestinations(ce *ast.CallExpression, outerTypesPerArg [][]Type) []string { + dests, ok := ts.callDests[ce] + if !ok { + return nil + } + arity := 0 + for _, types := range outerTypesPerArg { + arity += len(types) + } + template, ok := ts.ScriptCompiler.Compiler.CodeCompiler.lookupFuncTemplate(ce.Function.Value, arity) + if !ok { + return nil + } + return identNames(dests[:min(len(dests), len(template.Outputs))]) +} + func (ts *TypeSolver) expectSingleArray(source ast.Expression, tok token.Token, context string) (Array, bool) { arrayTypes := ts.TypeExpression(source, false) // nested expression if len(arrayTypes) != 1 { diff --git a/tests/call_arg_types/call_arg_types.pt b/tests/call_arg_types/call_arg_types.pt new file mode 100644 index 00000000..3015fb6b --- /dev/null +++ b/tests/call_arg_types/call_arg_types.pt @@ -0,0 +1,37 @@ +out = Keep(v) + out = v + +out = Tag(v) + out = v ⊕ ["s"] + +out = Append(v, item) + out = v ⊕ [item] + +out = Reset(v) + v + out = [] + +out = Scores(t) + out = t.Score ⊕ ["x"] + +out, tags = Build(n) + acc = [] + tags = Tag(acc) + acc = [n] + out = acc + +# Its local shares a name with a caller's destination, which must not change +# how the body types its own calls. +out = Work(n) + w = [] + Append(w, n) + w = [9.5] + out = w + +a, b = Pair(n) + a = n + b = n + 1 + +first, out = Fold(x, y, current, item) + first = x + y + out = current ⊕ [item] diff --git a/tests/call_arg_types/flow_types.exp b/tests/call_arg_types/flow_types.exp new file mode 100644 index 00000000..703ee381 --- /dev/null +++ b/tests/call_arg_types/flow_types.exp @@ -0,0 +1,19 @@ +kept: [] +kept: [5.5] +x: [1 2] +tags: ["s"] +e: [1.5] +first: [12345678] +prices: [9.5] +n: ["x"] +t: [ + : Name Score + "Ada" 10 +] +built: [3] ["s"] +o: [12345678] y: [9.5] +[12345678] +w: [9.5] +[12345678] +r: [] +r: [9.5] diff --git a/tests/call_arg_types/flow_types.spt b/tests/call_arg_types/flow_types.spt new file mode 100644 index 00000000..55eb17dd --- /dev/null +++ b/tests/call_arg_types/flow_types.spt @@ -0,0 +1,41 @@ +# A call made while a binding holds [] specializes on [], not on an element +# type that a later or sibling assignment gives the binding. +x = [] +kept = Keep(x) +"kept:", kept +kept = [5.5] +"kept:", kept +x = [1 2] +"x:", x +e = [] +tags = Tag(e) +"tags:", tags +e = [1.5] +"e:", e +prices = [] +first = Append(prices, 12345678) +"first:", first +prices = [9.5] +"prices:", prices +t = [ + : Name Score +] +n = Scores(t) +"n:", n +t = [ + : Name Score + "Ada" 10 +] +"t:", t +built, btags = Build(3) +"built:", built, btags +y = [] +o, y = Append(y, 12345678), [9.5] +"o:", o, "y:", y +w = Work(12345678) +"w:", w +r = [] +r = Reset(Append(r, 12345678)) +"r:", r +r = [9.5] +"r:", r diff --git a/tests/call_arg_types/slot_storage.exp b/tests/call_arg_types/slot_storage.exp new file mode 100644 index 00000000..a37d4caf --- /dev/null +++ b/tests/call_arg_types/slot_storage.exp @@ -0,0 +1,3 @@ +kept: a s: abc +acc: [1 2 3] +prefix: -3 [1 2 3] diff --git a/tests/call_arg_types/slot_storage.spt b/tests/call_arg_types/slot_storage.spt new file mode 100644 index 00000000..f7b99719 --- /dev/null +++ b/tests/call_arg_types/slot_storage.spt @@ -0,0 +1,15 @@ +# A string a later write widens to heap storage reaches the call as heap +# storage, so its result stays valid after that storage is replaced. +s = "a" +kept = Keep(s) +s = s ⊕ "b" +s = s ⊕ "c" +"kept:", kept, "s:", s +# An accumulator that starts as [] and shares the call's destination +# carries every element across the range, also after a multi-valued argument. +acc = [] +acc = Append(acc, 1:4) +"acc:", acc +f = [] +total, f = Fold(-Pair(1), f, 1:4) +"prefix:", total, f From a99bca7fe3b5a81495518a9b90151faa48e9ff60 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 23 Sep 2026 13:07:47 +0530 Subject: [PATCH 43/56] fix(compiler): stage indirect-call outputs after evaluating arguments A plain indirect-return call seeded its staged outputs before evaluating its arguments, while a direct-return call takes its seed afterwards. So an argument that writes the destination (%n) was seen by a shared input only for direct returns. With `labeled = 10`, `labeled, tag = Label(labeled, "ab-labeled%n")` gave 10, where every other call shape gives 2. Seed after the arguments for non-ranged indirect calls too, matching the strict argument evaluation that PIR Step 6 plans. Fixes #110 Co-Authored-By: Claude Opus 5.5 --- compiler/compiler.go | 40 +++++++++++++++++++------------- tests/alias_input/self_alias.exp | 1 + tests/alias_input/self_alias.pt | 5 ++++ tests/alias_input/self_alias.spt | 3 +++ 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/compiler/compiler.go b/compiler/compiler.go index e6e1e4e2..e512ca91 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -3223,10 +3223,14 @@ func (c *Compiler) compileCallExpression(ce *ast.CallExpression, dest []*ast.Ide // Indirect-return callees write through their output pointers. Always point // them at independent, destination-seeded slots so a call in one RHS cannot // mutate a real destination before sibling RHS expressions have read the - // statement-start values. The outer assignment owns the eventual commit and - // cleanup. - outputs := c.makeSeededTempOutputs(dest, info.OutTypes) - c.compileIndirectCallIntoStagedOutputs(sig, ce, dest, outputs) + // statement-start values. Seeding after the arguments, as a direct return + // does, lets a shared input see an argument's %n write. The outer + // assignment owns the eventual commit and cleanup. + var outputs []*Symbol + c.withPreparedCall(sig, ce, dest, func(call preparedCall) { + outputs = c.makeSeededTempOutputs(dest, info.OutTypes) + c.callIntoStagedOutputs(sig, call, outputs) + }) return c.loadOutputValues(outputs, "call_final") } @@ -3269,21 +3273,25 @@ func (c *Compiler) compileIndirectCallIntoStagedOutputs( staged []*Symbol, ) { c.withPreparedCall(sig, ce, dest, func(call preparedCall) { - adapters := c.makeCallOutputAdapters(staged, sig.ABI.Return.OutTypes) - outputs := callAdapterOutputs(adapters) - c.runCallWithBoundsElse(func() { - writeFlags := c.makeCallOutputWriteFlags(len(outputs)) - c.builder.CreateCall( - call.FuncType, - call.Function, - c.callArgs(sig, call, call.RetStruct, outputs, writeFlags, nil), - "", - ) - c.commitCallOutputAdapters(staged, adapters, writeFlags) - }, func() { c.cleanupSkippedCallOutputAdapters(adapters) }) + c.callIntoStagedOutputs(sig, call, staged) }) } +func (c *Compiler) callIntoStagedOutputs(sig *callSignature, call preparedCall, staged []*Symbol) { + adapters := c.makeCallOutputAdapters(staged, sig.ABI.Return.OutTypes) + outputs := callAdapterOutputs(adapters) + c.runCallWithBoundsElse(func() { + writeFlags := c.makeCallOutputWriteFlags(len(outputs)) + c.builder.CreateCall( + call.FuncType, + call.Function, + c.callArgs(sig, call, call.RetStruct, outputs, writeFlags, nil), + "", + ) + c.commitCallOutputAdapters(staged, adapters, writeFlags) + }, func() { c.cleanupSkippedCallOutputAdapters(adapters) }) +} + // loweredName is the symbol of the private variant this call site lowers to, // or the public specialization when no parameter shares an output. func (sig *callSignature) loweredName() string { diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index 8ca2aca2..548f548b 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -40,3 +40,4 @@ ReadTwice: second first second SharedPair: 105 10 NextRange: 2:3 CondCount: 11 11 lr +Label: 2 ab diff --git a/tests/alias_input/self_alias.pt b/tests/alias_input/self_alias.pt index 14e734c5..eed88d33 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -140,3 +140,8 @@ out, left, right = ResetPair(first, second) out = [] left = first right = second + +# The label argument's %n writes the shared destination before the call. +out, tag = Label(current, label) + out = current + 0 + tag = label diff --git a/tests/alias_input/self_alias.spt b/tests/alias_input/self_alias.spt index 58373b10..40b99d87 100644 --- a/tests/alias_input/self_alias.spt +++ b/tests/alias_input/self_alias.spt @@ -151,3 +151,6 @@ nextRange = NextRange(nextRange) condCount = 10 condCount, condCountSeen, condTag = CondCount(condCount) "CondCount:", condCount, condCountSeen, condTag +labeled = 10 +labeled, labelTag = Label(labeled, "ab-labeled%n") +"Label:", labeled, labelTag From af3085bab4d0739bdb1e0fb6e77a4e81b5087711 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 23 Sep 2026 13:07:54 +0530 Subject: [PATCH 44/56] test: restore fixtures that read outputs after definite assignment These templates were rewritten for the earlier write-only rule. Outputs are readable once definitely assigned, so master's bodies are valid again and keep their original expectations; array_func's Rebuild gives [26] and [2] again. Restoring them brings back coverage for output reads in conditions and prints. acc_fmt (%n into an input) and mem_str (a read after a conditional write, #105) keep their rewrites. Co-Authored-By: Claude Opus 5.5 --- tests/array/array_func.exp | 4 ++-- tests/array/array_func.pt | 3 ++- tests/array/array_scalar_assign.pt | 3 ++- tests/math/math.pt | 5 ++--- tests/math/range.pt | 10 ++++------ 5 files changed, 12 insertions(+), 13 deletions(-) diff --git a/tests/array/array_func.exp b/tests/array/array_func.exp index f8d2f09f..9e6b2292 100644 --- a/tests/array/array_func.exp +++ b/tests/array/array_func.exp @@ -82,9 +82,9 @@ SquareVecRange: [0 1 4 9 16] SquareInline: [0 1 4 9 16] SquareSameDriver: [4 5 8 13 20] NestedCallCollector: [5 6 9 14 21] -RebuildAssign: [37] +RebuildAssign: [26] RebuildNoAlias: [14] -RebuildAssign2: [6] +RebuildAssign2: [2] RebuildNoAlias2: [6] PairSumRange: [3 4 4 5] ArrayRangeLastNamedRow: [3 4] diff --git a/tests/array/array_func.pt b/tests/array/array_func.pt index c968d9af..1b075a48 100644 --- a/tests/array/array_func.pt +++ b/tests/array/array_func.pt @@ -10,7 +10,8 @@ res = BiasAndScale(x, bias, scale) res = tmp * scale res = Rebuild(vec, i) - res = [i + 1] + vec + res = [i + 1] + res = res + vec res = PairSum(i, j) res = i + j diff --git a/tests/array/array_scalar_assign.pt b/tests/array/array_scalar_assign.pt index aa1e95ce..38a3f494 100644 --- a/tests/array/array_scalar_assign.pt +++ b/tests/array/array_scalar_assign.pt @@ -5,4 +5,5 @@ res = ArrayScalarAdd(i) res = [0:6] + i res = ArraySetAdd(i) - res = [0:i] + 4 + res = [0:i] + res = res + 4 diff --git a/tests/math/math.pt b/tests/math/math.pt index fcbff42e..f9166b25 100644 --- a/tests/math/math.pt +++ b/tests/math/math.pt @@ -21,9 +21,8 @@ quo, rem = Div(dividend, divisor) rem = dividend % divisor mod, res = IsDiv(x, y) - remainder = x % y - mod, res = remainder, "no" - res = remainder == 0 "yes" + mod, res = x % y, "no" + res = mod == 0 "yes" x, y = F(i) x, y = 2 + i, 3 + i diff --git a/tests/math/range.pt b/tests/math/range.pt index 0c986055..9c06c576 100644 --- a/tests/math/range.pt +++ b/tests/math/range.pt @@ -1,12 +1,10 @@ y = Triple(x) - tripled = 3x - tripled - y = tripled + y = 3x + y res = Sum(curr, x) - total = curr + x - total - res = total + res = curr + x + res yes = Divides(in, y, x) yes = in * (y % x) From 0f373350e161d7d04eefbfa186b36fe05bc6d622 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 23 Sep 2026 13:08:00 +0530 Subject: [PATCH 45/56] docs: correct stale statements about arguments, ABI 2.1 and %n - Memory Model: inputs are read-only bindings that may share an output. The summary and comparison table still said "by value". State the call argument typing rule and the %n-into-output restriction (#109). - C ABI Spec: in 2.1 the seed moves one position per direct scalar parameter, not one in total. - ABI Optimization Plan: the private alias variant redirects reads, not metadata. - IR Plan: narrow the alias_input coverage claim to what it pins. Co-Authored-By: Claude Opus 5.5 --- docs/Pluto ABI Optimization Plan.md | 4 ++-- docs/Pluto C ABI Spec.md | 5 +++-- docs/Pluto IR Plan.md | 5 +++-- docs/Pluto Memory Model.md | 18 +++++++++++++----- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/docs/Pluto ABI Optimization Plan.md b/docs/Pluto ABI Optimization Plan.md index 955b5db2..41eaf6b6 100644 --- a/docs/Pluto ABI Optimization Plan.md +++ b/docs/Pluto ABI Optimization Plan.md @@ -33,8 +33,8 @@ Pluto's source-level semantics stay unchanged: These are **language semantics**. How values physically move across a call boundary is the **lowered calling convention** — a separate concern. An `I64` -input can be passed by value provided alias metadata redirects each read to -its shared output when required. A single `I64` output can be returned in a +input can be passed by value provided the call site's alias pattern (the +private `_aN` variant) redirects each read to its shared output when required. A single `I64` output can be returned in a register while still behaving like a Pluto output. ## 3. Architecture diff --git a/docs/Pluto C ABI Spec.md b/docs/Pluto C ABI Spec.md index ca055a22..893a8be4 100644 --- a/docs/Pluto C ABI Spec.md +++ b/docs/Pluto C ABI Spec.md @@ -411,8 +411,9 @@ and aliasing is lowered as private variants instead. A direct-return function's native signature is therefore its source parameters followed by the seed; an indirect-return function keeps its leading result carrier followed by the source parameters, with no seed. The prototype of a -range-bearing function such as `Acc` changes, and for a direct return its -seed moves one position earlier. Functions without a `Range` or `ArrayRange` +range-bearing function with direct scalar parameters, such as `Acc`, changes: +for a direct return, its seed moves earlier by one position per direct scalar +(`I64`/`F64`) parameter, one for `Acc`. Functions without a `Range` or `ArrayRange` parameter are unchanged. An eligible immediate bare `array[range]` call argument may therefore select diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index f2f7c0b3..576e1ccf 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -290,8 +290,9 @@ same destination-seeded staging slot. The input name is read-only, but each read observes earlier output writes to that slot. Reads within one assignment still precede its writes. The real `a` changes only at the outer assignment's commit, so sibling RHS expressions continue to read the pre-commit binding. -`tests/alias_input` pins both statement orders for ordinary and ranged calls, -with direct scalars, static and heap strings, and arrays: starting at 10, +`tests/alias_input` pins both statement orders for ordinary and ranged calls +with direct scalars, and for ranged calls with heap strings and arrays: +starting at 10, `out = current + item` before `seen = current` yields `15 15` for item 5; reversing those body statements yields `15 10`. Step 4's call lowering must preserve this distinction between internal sharing and external commit. diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index 556119cf..6049265f 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -19,8 +19,9 @@ This document describes Pluto's semantic model and compares it with other major 6. **Driver Identity Determines Looping:** Repeated use of one Range binding shares a loop; distinct bindings form a cartesian domain even when their descriptors have equal bounds. -7. **Function Arguments by Value:** Scalar parameters are passed by value; - outputs write into caller destination slots. +7. **Read-Only Function Arguments:** Inputs are read-only. An input the caller + also passes as a destination observes that output's writes; scalars still + travel by value at the ABI level. Outputs write into caller destination slots. 8. **Function Locking:** Input arguments hold read locks, outputs hold write locks (automatic concurrency safety). 9. **Memory Management:** Automatic scope-based deallocation (no GC pauses). @@ -32,7 +33,7 @@ This document describes Pluto's semantic model and compares it with other major |---------|-------|--------|------|-----|-------|-----| | **Assignment (`a=b`)** | **Copy** | Reference | Move / Copy | Copy | Reference | Copy | | **Array Assign** | **Copy** (COW) | Reference | Move | Reference (Slice) | Reference | Copy | -| **Function Args** | **Value** (Scalars) | Reference | Move / Borrow | Copy (Slice Ref) | Reference | Copy | +| **Function Args** | **Read-only binding** (scalars lowered by value) | Reference | Move / Borrow | Copy (Slice Ref) | Reference | Copy | | **Range selection (`a[range]`)** | **Value stream** (final value or explicit collection) | Copy (List) / View (NumPy) | View (Slice) | View (Slice) | Copy (default) / View (`@view`) | View (Slice) | | **Range Usage** | **Copyable descriptor; operations iterate** | Reference (Generator) | Reference (Iterator) | N/A | Reference (Iterator) | N/A | | **Mutability** | **In-Place Only** | Mutable Objects | Mutable (if `mut`) | Mutable | Mutable | Mutable | @@ -269,7 +270,9 @@ res = sum(a, b) or a formatting marker — only after a statement that assigns it unconditionally with a value that cannot be skipped. A read before that is a compile error: before any assignment, in the same simultaneous - assignment, or after only conditional or seed-preserving writes. A later + assignment, or after only conditional or seed-preserving writes. A `%n` + marker naming an output counts as such a read, although it writes the + output; modeling it as a write is tracked in #109. A later conditional write does not revoke the assignment. Outputs are independently staged result slots: an existing destination supplies the initial value and a fresh destination starts at its type's zero value, so a body that writes @@ -282,7 +285,12 @@ res = sum(a, b) are committed only after every sibling right-hand side has been evaluated. - **No name overlap**: Parameters and outputs must have distinct names -Calls specialize binding arguments on their actual storage type. When an +A call specializes a binding argument on the value's own type, with the +ownership of the binding's storage: a static string that a later write widens +to heap storage is passed as a heap string. An untyped `[]` or header-only +table takes the storage's element types only when the argument shares one of +the call's own destinations; a call that runs in a loop rewriting such a +binding still sees its type from before the loop (#106). When an input shares an output whose declared representation is narrower but compatible (for example, an owned string input with a static string output, or a concrete-rank array input with an untyped `[]` output), the private From a54a48afbba22b30de53f14e2e4e6ed3f9e03ad4 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 23 Sep 2026 13:09:15 +0530 Subject: [PATCH 46/56] revert(llvm): move loop metadata preservation to its own PR 92bddd8 was needed while run-time alias selectors changed the optimized Fib tail loop. Compile-time variants restored the original prototypes, and master's unroll annotation passes the Fib regression test again, so the change moves to #112 as an independent robustness fix. Co-Authored-By: Claude Opus 5.5 --- llvm_metadata_byollvm.go | 53 --------------------------------- llvm_pipeline.go | 38 +++--------------------- llvm_pipeline_test.go | 64 +++------------------------------------- 3 files changed, 8 insertions(+), 147 deletions(-) delete mode 100644 llvm_metadata_byollvm.go diff --git a/llvm_metadata_byollvm.go b/llvm_metadata_byollvm.go deleted file mode 100644 index e5e09c78..00000000 --- a/llvm_metadata_byollvm.go +++ /dev/null @@ -1,53 +0,0 @@ -//go:build byollvm - -package main - -/* -#include "llvm-c/Core.h" -*/ -import "C" - -import ( - "unsafe" - - "tinygo.org/x/go-llvm" -) - -// go-LLVM exposes metadata handles but not these operand inspection APIs. -// The returned nodes are borrowed from the module's LLVM context. -func llvmMetadataOperands(node llvm.Value) []llvm.Value { - if node.IsNil() { - return nil - } - count := int(C.LLVMGetMDNodeNumOperands(C.LLVMValueRef(unsafe.Pointer(node.C)))) - if count == 0 { - return nil - } - // Use a C pointer buffer without depending on llvm.Value's struct layout. - refs := make([]C.LLVMValueRef, count) - C.LLVMGetMDNodeOperands(C.LLVMValueRef(unsafe.Pointer(node.C)), &refs[0]) - - operands := make([]llvm.Value, count) - for i, ref := range refs { - *(*unsafe.Pointer)(unsafe.Pointer(&operands[i].C)) = unsafe.Pointer(ref) - } - - return operands -} - -func llvmMetadataString(value llvm.Value) string { - if value.IsNil() { - return "" - } - var length C.unsigned - str := C.LLVMGetMDString(C.LLVMValueRef(unsafe.Pointer(value.C)), &length) - - return C.GoStringN(str, C.int(length)) -} - -func llvmValueAsMetadata(value llvm.Value) llvm.Metadata { - var metadata llvm.Metadata - *(*unsafe.Pointer)(unsafe.Pointer(&metadata.C)) = unsafe.Pointer(C.LLVMValueAsMetadata(C.LLVMValueRef(unsafe.Pointer(value.C)))) - - return metadata -} diff --git a/llvm_pipeline.go b/llvm_pipeline.go index 200baf3d..f5f0f6af 100644 --- a/llvm_pipeline.go +++ b/llvm_pipeline.go @@ -152,11 +152,10 @@ func annotateScalarUnrollLoops(module llvm.Module) int { for fn := module.FirstFunction(); !fn.IsNil(); fn = llvm.NextFunction(fn) { loops := scalarUnrollCandidates(fn) for _, loop := range loops { - metadata := loop.term.Metadata(loopMDKind) - if llvmLoopHasUnrollDirective(metadata) { + if !loop.term.Metadata(loopMDKind).IsNil() { continue } - loop.term.SetMetadata(loopMDKind, llvmUnrollCountMetadata(ctx, metadata, llvmScalarUnrollCount)) + loop.term.SetMetadata(loopMDKind, llvmUnrollCountMetadata(ctx, llvmScalarUnrollCount)) annotated++ } } @@ -451,43 +450,14 @@ func valueUsesVectorType(v llvm.Value) bool { return false } -func llvmLoopHasUnrollDirective(loopID llvm.Value) bool { - for i, property := range llvmMetadataOperands(loopID) { - if i == 0 { - continue - } - operands := llvmMetadataOperands(property) - if len(operands) == 0 { - continue - } - name := llvmMetadataString(operands[0]) - if strings.HasPrefix(name, "llvm.loop.unroll.") || - strings.HasPrefix(name, "llvm.loop.unroll_and_jam.") || - name == "llvm.loop.disable_nonforced" { - return true - } - } - - return false -} - -func llvmUnrollCountMetadata(ctx llvm.Context, previous llvm.Value, count int) llvm.Metadata { +func llvmUnrollCountMetadata(ctx llvm.Context, count int) llvm.Metadata { temp := ctx.TemporaryMDNode(nil) - properties := []llvm.Metadata{temp} - for i, property := range llvmMetadataOperands(previous) { - if i > 0 { - properties = append(properties, llvmValueAsMetadata(property)) - } - } - countMD := llvm.ConstInt(ctx.Int32Type(), uint64(count), false).ConstantAsMetadata() countNode := ctx.MDNode([]llvm.Metadata{ ctx.MDString("llvm.loop.unroll.count"), countMD, }) - loopID := ctx.MDNode(append(properties, countNode)) - // The C API replacement also disposes the temporary node. + loopID := ctx.MDNode([]llvm.Metadata{temp, countNode}) temp.ReplaceAllUsesWith(loopID) - return loopID } diff --git a/llvm_pipeline_test.go b/llvm_pipeline_test.go index 654fe81a..029a3519 100644 --- a/llvm_pipeline_test.go +++ b/llvm_pipeline_test.go @@ -95,62 +95,6 @@ exit: } } -func TestAnnotateScalarUnrollLoopsPreservesMetadata(t *testing.T) { - for _, tt := range []struct { - name string - property string - want int - }{ - {"peeled", `!{!"llvm.loop.peeled.count", i32 1}`, 1}, - {"unroll disabled", `!{!"llvm.loop.unroll.disable"}`, 0}, - {"unroll and jam", `!{!"llvm.loop.unroll_and_jam.disable"}`, 0}, - {"nonforced disabled", `!{!"llvm.loop.disable_nonforced"}`, 0}, - } { - t.Run(tt.name, func(t *testing.T) { - mod := parseTestIR(t, ` -define i64 @fib_like(i64 %n) { -entry: - br label %loop - -loop: - %a = phi i64 [ 0, %entry ], [ %b, %loop ] - %b = phi i64 [ 1, %entry ], [ %sum, %loop ] - %i = phi i64 [ %n, %entry ], [ %dec, %loop ] - %dec = add i64 %i, -1 - %sum = add i64 %a, %b - %done = icmp eq i64 %dec, 0 - br i1 %done, label %exit, label %loop, !llvm.loop !0 - -exit: - ret i64 %b -} - -!0 = distinct !{!0, !1} -!1 = `+tt.property) - before := mod.String() - if got := annotateScalarUnrollLoops(mod); got != tt.want { - t.Fatalf("annotateScalarUnrollLoops() = %d, want %d", got, tt.want) - } - if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { - t.Fatalf("invalid loop metadata after annotation: %v", err) - } - after := mod.String() - if !strings.Contains(after, tt.property) { - t.Fatalf("existing metadata was lost:\n%s", after) - } - if tt.want == 0 && after != before { - t.Fatalf("existing unroll policy was changed:\n%s", after) - } - if tt.want == 1 && !strings.Contains(after, `!{!"llvm.loop.unroll.count", i32 4}`) { - t.Fatalf("peeled loop did not receive an unroll count:\n%s", after) - } - if got := annotateScalarUnrollLoops(mod); got != 0 { - t.Fatalf("second annotation added %d duplicate hints", got) - } - }) - } -} - func TestAnnotateScalarUnrollLoopsSkipsCallHeavyLoops(t *testing.T) { mod := parseTestIR(t, ` declare void @side_effect() @@ -393,7 +337,7 @@ res t.Fatalf("run O3 pipeline: %v", err) } loopMDKind := scriptModule.Context().MDKindID("llvm.loop") - // Loops the O3 run already marked for unrolling contribute no add chains; that + // Loops the O3 run already marked must contribute no add chains; that // makes any post-unroll chain growth attributable to the loops annotated // below rather than to pre-existing loop metadata. if got := maxChainedAddsInMarkedLatch(scriptModule); got != 0 { @@ -405,7 +349,7 @@ res preUnrollChain := 0 for fn := scriptModule.FirstFunction(); !fn.IsNil(); fn = llvm.NextFunction(fn) { for _, loop := range scalarUnrollCandidates(fn) { - if llvmLoopHasUnrollDirective(loop.term.Metadata(loopMDKind)) { + if !loop.term.Metadata(loopMDKind).IsNil() { continue } candidates++ @@ -442,7 +386,7 @@ res } // maxChainedAddsInMarkedLatch returns, across all loop latches that carry -// unroll metadata, the largest number of add instructions that consume +// llvm.loop metadata, the largest number of add instructions that consume // another add from the same block. An unrolled scalar recurrence leaves its // replicated adds chained together inside the marked latch, so this grows // when the annotated loop is actually unrolled. @@ -452,7 +396,7 @@ func maxChainedAddsInMarkedLatch(module llvm.Module) int { for fn := module.FirstFunction(); !fn.IsNil(); fn = llvm.NextFunction(fn) { for bb := fn.FirstBasicBlock(); !bb.IsNil(); bb = llvm.NextBasicBlock(bb) { term := bb.LastInstruction() - if term.IsNil() || !llvmLoopHasUnrollDirective(term.Metadata(loopMDKind)) { + if term.IsNil() || term.Metadata(loopMDKind).IsNil() { continue } if chained := chainedAddsInBlock(bb); chained > best { From e7dbf7917c8981ccb3cc6d288f7bacdb8465d73b Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 23 Sep 2026 15:52:00 +0530 Subject: [PATCH 47/56] docs(compiler): say aliasPattern's destinations arrive resolved setCallAliasPattern resolves synthetic staging names such as $c_cond_x to the bindings they stand in for before calling aliasPattern, which compares names only. State that contract in aliasPattern's comment. Co-Authored-By: Claude Opus 5.5 --- compiler/abi.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/compiler/abi.go b/compiler/abi.go index 3dc8fc20..ef443056 100644 --- a/compiler/abi.go +++ b/compiler/abi.go @@ -129,10 +129,11 @@ func sharableOutput(paramType, outType Type) bool { // aliasPattern decides, per callee parameter, the one-based caller destination // whose binding the argument shares, or 0; nil when no parameter shares one. // argNames holds one entry per parameter, empty for an argument that is not a -// plain identifier; dests names the destinations of the call's outputs in -// order; outTypes are the declared output types. enclosing maps a caller-body -// input to the caller output it already shares, so a nested call forwards that -// sharing. A parameter shares at most one destination, the first that matches. +// plain identifier. dests contains output destination names in order, with +// synthetic staging names already resolved to the bindings they represent. +// outTypes are the declared output types. enclosing maps a caller-body input to +// the caller output it already shares, so a nested call forwards that sharing. +// A parameter shares at most one destination, the first that matches. func aliasPattern(argNames, dests []string, paramTypes, outTypes []Type, enclosing map[string]string) []int { var pattern []int for i, name := range argNames { From cba83c17d587f15800d95b4fb98cec9a79546324 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 23 Sep 2026 17:01:45 +0530 Subject: [PATCH 48/56] refactor(compiler): show an alias variant's sharing inside its signature Demangle rendered a variant as a trailing bracket group, such as math.Fold(I64, StrH) [in1->out2, in2->out1], unlike every other demangled name. Show each shared parameter's output inside the signature instead, one-based like the mangled suffix: math.Fold(I64 -> 2, StrH -> 1). Unshared parameters and public specializations print as before. Co-Authored-By: Claude Opus 5.5 --- compiler/mangle.go | 22 ++++++++-------------- compiler/mangle_test.go | 4 ++-- docs/Pluto C ABI Spec.md | 2 +- 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/compiler/mangle.go b/compiler/mangle.go index 28daa8cf..273cde74 100644 --- a/compiler/mangle.go +++ b/compiler/mangle.go @@ -91,28 +91,22 @@ func (d *Demangled) String() string { if d.Kind == SymbolFunc { result.WriteString("(") - result.WriteString(strings.Join(d.ArgTypes, ", ")) + result.WriteString(strings.Join(d.argDisplay(), ", ")) result.WriteString(")") } - if aliases := d.aliasDisplay(); aliases != "" { - result.WriteString(" [") - result.WriteString(aliases) - result.WriteString("]") - } return result.String() } -// aliasDisplay renders the non-zero alias pattern entries as in->out, -// both one-based, in parameter order. -func (d *Demangled) aliasDisplay() string { - var parts []string +// argDisplay renders the argument types. In an alias variant, a parameter +// that shares an output shows that output's one-based index: I64 -> 1. +func (d *Demangled) argDisplay() []string { + args := append([]string(nil), d.ArgTypes...) for i, slot := range d.AliasPattern { - if slot == 0 { - continue + if slot > 0 && i < len(args) { + args[i] = fmt.Sprintf("%s -> %d", args[i], slot) } - parts = append(parts, fmt.Sprintf("in%d->out%d", i+1, slot)) } - return strings.Join(parts, ", ") + return args } // Mangle generates C ABI-compliant function name per Pluto C ABI Spec. diff --git a/compiler/mangle_test.go b/compiler/mangle_test.go index b58f1ed4..5a0f4acd 100644 --- a/compiler/mangle_test.go +++ b/compiler/mangle_test.go @@ -831,8 +831,8 @@ func TestMangleVariantRoundTrip(t *testing.T) { expected string }{ {name: "public specialization", mangled: base, expected: "math.Fold(I64, StrH)"}, - {name: "alias variant", pattern: []int{1, 0}, mangled: base + "_a2_1_0", expected: "math.Fold(I64, StrH) [in1->out1]"}, - {name: "swapped alias variant", pattern: []int{2, 1}, mangled: base + "_a2_2_1", expected: "math.Fold(I64, StrH) [in1->out2, in2->out1]"}, + {name: "alias variant", pattern: []int{1, 0}, mangled: base + "_a2_1_0", expected: "math.Fold(I64 -> 1, StrH)"}, + {name: "swapped alias variant", pattern: []int{2, 1}, mangled: base + "_a2_2_1", expected: "math.Fold(I64 -> 2, StrH -> 1)"}, } for _, tt := range tests { diff --git a/docs/Pluto C ABI Spec.md b/docs/Pluto C ABI Spec.md index 893a8be4..72388588 100644 --- a/docs/Pluto C ABI Spec.md +++ b/docs/Pluto C ABI Spec.md @@ -470,7 +470,7 @@ and the caller converts it into the destination after the call. Example: `Pt_4math_p_4Fold_f2_I64_StrH_a2_1_0` is `Fold(I64, StrH)` with its first parameter sharing its first output, which is therefore an `I64`. -`Demangle` renders it as `math.Fold(I64, StrH) [in1->out1]`. +`Demangle` renders it as `math.Fold(I64 -> 1, StrH)`. The public specialization symbol is unchanged by the variant. C callers never see a variant and cannot request one. From 2a8ebded230397fceb000fbc2f1bffd4f3a8e171 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 23 Sep 2026 20:52:54 +0530 Subject: [PATCH 49/56] fix(solver): carry storage ownership through comparison arguments A comparison in value position yields its left operand's stored value. When that operand's storage had widened to a heap string, the argument was still typed as a static string. `Keep(s > "")` then specialized Keep(StrG), which returned the heap pointer as static, and reassigning s freed it: `kept` printed `abc` and the program crashed under Guard Malloc. The same failure exists on master; 8cb2452 only covered a variable passed directly. yieldedBinding follows scalar comparisons, including chains, to the binding they pass on, using the solver's CondScalar classification, and the argument takes that binding's storage ownership. Sharing stays limited to plain identifiers. The !builtin guard goes, since Print, the only builtin, lowers from the compiled argument symbols. Tests: tests/call_arg_types/slot_storage (a chained comparison). Co-Authored-By: Claude Opus 5.5 --- compiler/solver.go | 27 +++++++++++++++++++++++---- tests/call_arg_types/slot_storage.exp | 1 + tests/call_arg_types/slot_storage.spt | 7 +++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/compiler/solver.go b/compiler/solver.go index 4094f26e..5c1bd5ae 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -2427,20 +2427,20 @@ func (ts *TypeSolver) callScopedArrayRangeType(expr ast.Expression) (ArrayRange, // Uses the shared TypeExprsForIter for the core logic. func (ts *TypeSolver) collectCallArgs(ce *ast.CallExpression, isRoot bool) (args []Type, innerArgs []Type, loopInside bool) { outerTypesPerArg, loopInside, _ := ts.TypeExprsForIter(ce.Arguments, isRoot) - _, builtin := Builtins[ce.Function.Value] shared := ts.sharedDestinations(ce, outerTypesPerArg) // Build args and innerArgs from outer types // If loopInside=false, ALL range args become their inner type (loop outside) for argIndex, outerTypes := range outerTypesPerArg { - if ident, ok := ce.Arguments[argIndex].(*ast.Identifier); ok && !builtin { + if binding, ok := ts.yieldedBinding(ce.Arguments[argIndex]); ok { // A concrete flow type differs from its binding's slot only in // ownership, which lowering must see. An untyped value keeps its // flow type unless it shares one of this call's destinations, // whose input then carries every value the call writes back. body := ts.ScriptCompiler.Compiler.FuncCache[ts.FuncNameMangled] - slotType, exists := body.Vars[ident.Value] - if exists && (concreteStorage(outerTypes[0]) || slices.Contains(shared, ident.Value)) { + slotType, exists := body.Vars[binding.Value] + shares := ce.Arguments[argIndex] == ast.Expression(binding) && slices.Contains(shared, binding.Value) + if exists && (concreteStorage(outerTypes[0]) || shares) { outerTypes = []Type{slotType} } } @@ -2470,6 +2470,25 @@ func (ts *TypeSolver) collectCallArgs(ce *ast.CallExpression, isRoot bool) (args return } +// yieldedBinding returns the binding whose stored value expr passes on: the +// identifier itself, or the left operand of a scalar comparison in value +// position, which yields its LHS. +func (ts *TypeSolver) yieldedBinding(expr ast.Expression) (*ast.Identifier, bool) { + for { + switch e := expr.(type) { + case *ast.Identifier: + return e, true + case *ast.InfixExpression: + if !ts.ExprCache[key(ts.FuncNameMangled, e)].HasCondScalar() { + return nil, false + } + expr = e.Left + default: + return nil, false + } + } +} + // sharedDestinations names the destinations a statement's value call assigns, // the only bindings its arguments can share. Lowering passes the call the // statement's names from its first output on and binds one per callee output. diff --git a/tests/call_arg_types/slot_storage.exp b/tests/call_arg_types/slot_storage.exp index a37d4caf..1ef07034 100644 --- a/tests/call_arg_types/slot_storage.exp +++ b/tests/call_arg_types/slot_storage.exp @@ -1,3 +1,4 @@ kept: a s: abc +yielded: a c: abc acc: [1 2 3] prefix: -3 [1 2 3] diff --git a/tests/call_arg_types/slot_storage.spt b/tests/call_arg_types/slot_storage.spt index f7b99719..f6af32c8 100644 --- a/tests/call_arg_types/slot_storage.spt +++ b/tests/call_arg_types/slot_storage.spt @@ -5,6 +5,13 @@ kept = Keep(s) s = s ⊕ "b" s = s ⊕ "c" "kept:", kept, "s:", s +# A comparison in value position passes on its left operand's stored value, +# through every link of a chain. +c = "a" +yielded = Keep(c > "" < "zz") +c = c ⊕ "b" +c = c ⊕ "c" +"yielded:", yielded, "c:", c # An accumulator that starts as [] and shares the call's destination # carries every element across the range, also after a multi-valued argument. acc = [] From 3fa2a74419a91821b7aaa7fe8399f328b3c27189 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 23 Sep 2026 20:59:12 +0530 Subject: [PATCH 50/56] fix(compiler): reject heap arguments to static parameters at lowering A specialization may return an input without copying. If a call passes a heap string where the specialization expects a static one, the result aliases storage that the caller later frees. The solver must choose the parameter types so this cannot happen. The comparison-argument bug (2a8ebde) showed that nothing checked it, so a missed argument form compiled into a silent use after free. lowerCallArgs now raises an internal compiler error when an indirect argument holds a heap string, struct field or array element where the specialization declares a static one. It never fires on the suite or on 648 stress and reproduction programs, and it fires for every comparison form that 2a8ebde fixes when that fix is absent. Co-Authored-By: Claude Opus 5.5 --- compiler/compiler.go | 10 ++++++++++ compiler/solver_test.go | 25 +++++++++++++++++++++++++ compiler/types.go | 19 +++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/compiler/compiler.go b/compiler/compiler.go index e512ca91..73bc60af 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -3074,6 +3074,16 @@ func (c *Compiler) lowerCallArgs(funcName string, args []callArg, sig *callSigna } args[i].Lowered = sym } + // A specialization may hand an input back without copying, so a heap value + // passed where it expects a static string would be freed under the result. + for i, arg := range args { + if sig.ABI.Params[i].Mode != ABIParamIndirect { + continue + } + if held := arg.Lowered.Type.(Ptr).Elem; heapWhereStatic(held, sig.ParamTypes[i]) { + panic(fmt.Sprintf("internal: %s argument %d holds %s where the specialization expects %s", funcName, i, held.Mangle(), sig.ParamTypes[i].Mangle())) + } + } } func (c *Compiler) freeCallArgTemps(callArgs []callArg) { diff --git a/compiler/solver_test.go b/compiler/solver_test.go index e068ac65..2174970f 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -721,6 +721,31 @@ out, before = Fold(current, item) } } +func TestHeapWhereStatic(t *testing.T) { + heapPerson := Struct{Name: "Person", Fields: []StructField{{Name: "name", Type: StrH{}}, {Name: "age", Type: I64}}} + staticPerson := Struct{Name: "Person", Fields: []StructField{{Name: "name", Type: StrG{}}, {Name: "age", Type: I64}}} + + for _, tt := range []struct { + name string + held Type + param Type + want bool + }{ + {"heap string into static", StrH{}, StrG{}, true}, + {"static string into heap", StrG{}, StrH{}, false}, + {"matching strings", StrH{}, StrH{}, false}, + {"heap field into static field", heapPerson, staticPerson, true}, + {"static field into heap field", staticPerson, heapPerson, false}, + {"heap elements into static elements", Array{ElemType: StrH{}, Rank: 1}, Array{ElemType: StrG{}, Rank: 1}, true}, + {"concrete array into untyped", Array{ElemType: I64, Rank: 1}, Array{ElemType: Empty{}, Rank: 1}, false}, + {"scalar", I64, I64, false}, + } { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, heapWhereStatic(tt.held, tt.param)) + }) + } +} + func TestMergeBindingSlotTypeIsMonotonic(t *testing.T) { headerOnly := Table{Columns: []TableColumn{ {Name: "Name", ElemType: Empty{}}, diff --git a/compiler/types.go b/compiler/types.go index 94ca53eb..babcfa5a 100644 --- a/compiler/types.go +++ b/compiler/types.go @@ -688,6 +688,25 @@ func bindingSlotCompatible(oldType, newType Type) bool { return CanRefineType(oldType, newType) } +// heapWhereStatic reports whether a value of type held owns heap strings +// where param declares static ones, fieldwise and elementwise. +func heapWhereStatic(held, param Type) bool { + switch p := param.(type) { + case StrG: + return IsStrH(held) + case Struct: + h := held.(Struct) + for i, field := range p.Fields { + if heapWhereStatic(h.Fields[i].Type, field.Type) { + return true + } + } + case Array: + return heapWhereStatic(held.(Array).ElemType, p.ElemType) + } + return false +} + // concreteStorage reports whether t fixes its storage in every calling // context: an untyped empty array, a column without an element type, or an // unresolved leaf could still be refined by a caller's destination. From 61b249e6ce8e631a41d7364a67fd44dea7e02b8d Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 23 Sep 2026 21:26:23 +0530 Subject: [PATCH 51/56] refactor(solver): name the call-argument storage rules Move the decision of when a call argument is specialized on its binding's slot type out of collectCallArgs into argumentStorage, one guard per rule: fresh values, parameters and constants keep their own type; a concrete type takes its slot's ownership; an untyped value takes its slot only when the call writes back into it. No behavior change. Co-Authored-By: Claude Opus 5.5 --- compiler/solver.go | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/compiler/solver.go b/compiler/solver.go index 5c1bd5ae..d932a0cd 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -2432,17 +2432,8 @@ func (ts *TypeSolver) collectCallArgs(ce *ast.CallExpression, isRoot bool) (args // Build args and innerArgs from outer types // If loopInside=false, ALL range args become their inner type (loop outside) for argIndex, outerTypes := range outerTypesPerArg { - if binding, ok := ts.yieldedBinding(ce.Arguments[argIndex]); ok { - // A concrete flow type differs from its binding's slot only in - // ownership, which lowering must see. An untyped value keeps its - // flow type unless it shares one of this call's destinations, - // whose input then carries every value the call writes back. - body := ts.ScriptCompiler.Compiler.FuncCache[ts.FuncNameMangled] - slotType, exists := body.Vars[binding.Value] - shares := ce.Arguments[argIndex] == ast.Expression(binding) && slices.Contains(shared, binding.Value) - if exists && (concreteStorage(outerTypes[0]) || shares) { - outerTypes = []Type{slotType} - } + if slotType, ok := ts.argumentStorage(ce.Arguments[argIndex], outerTypes[0], shared); ok { + outerTypes = []Type{slotType} } if loopInside { @@ -2470,6 +2461,28 @@ func (ts *TypeSolver) collectCallArgs(ce *ast.CallExpression, isRoot bool) (args return } +// argumentStorage returns the storage type a call argument must be +// specialized on when it differs from the argument's own type. +func (ts *TypeSolver) argumentStorage(arg ast.Expression, own Type, shared []string) (Type, bool) { + // A fresh value's type is its storage. + binding, ok := ts.yieldedBinding(arg) + if !ok { + return nil, false + } + // Parameters and code constants are already typed by their storage. + slot, exists := ts.ScriptCompiler.Compiler.FuncCache[ts.FuncNameMangled].Vars[binding.Value] + if !exists { + return nil, false + } + // A concrete type differs from its storage only in ownership. + if concreteStorage(own) { + return slot, true + } + // An untyped value keeps its own type unless the call writes back into it. + _, plain := arg.(*ast.Identifier) + return slot, plain && slices.Contains(shared, binding.Value) +} + // yieldedBinding returns the binding whose stored value expr passes on: the // identifier itself, or the left operand of a scalar comparison in value // position, which yields its LHS. From 0983a465686ae8ca5b523007665831c7a570a0cc Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 23 Sep 2026 21:51:20 +0530 Subject: [PATCH 52/56] docs(solver): describe argumentStorage by what it returns The helper can return a type identical to the argument's own, and its first guard only rejects arguments that do not pass on a binding's value; a fresh value's type is not its storage for every expression (value-position && and || results, #116). Separate the guards with blank lines. No code change. Co-Authored-By: Claude Opus 5.5 --- compiler/solver.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/compiler/solver.go b/compiler/solver.go index d932a0cd..af510972 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -2461,23 +2461,26 @@ func (ts *TypeSolver) collectCallArgs(ce *ast.CallExpression, isRoot bool) (args return } -// argumentStorage returns the storage type a call argument must be -// specialized on when it differs from the argument's own type. +// argumentStorage returns the binding storage type to specialize a call +// argument on, and false when the argument keeps its own type. func (ts *TypeSolver) argumentStorage(arg ast.Expression, own Type, shared []string) (Type, bool) { - // A fresh value's type is its storage. + // An argument that does not pass on a binding's value keeps its own type. binding, ok := ts.yieldedBinding(arg) if !ok { return nil, false } + // Parameters and code constants are already typed by their storage. slot, exists := ts.ScriptCompiler.Compiler.FuncCache[ts.FuncNameMangled].Vars[binding.Value] if !exists { return nil, false } - // A concrete type differs from its storage only in ownership. + + // A concrete type can differ from its storage only in ownership. if concreteStorage(own) { return slot, true } + // An untyped value keeps its own type unless the call writes back into it. _, plain := arg.(*ast.Identifier) return slot, plain && slices.Contains(shared, binding.Value) From 6c358e075e5ef668775ff3788b9d69041096744f Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 23 Sep 2026 22:17:04 +0530 Subject: [PATCH 53/56] test(solver): restore master's recursion and refinement tests The write-only revision of this branch rewrote the recursive test bodies to accumulate into a local instead of reading `res`, and deleted TestRefinedOutputSeedsStableBody because its body reads `res`. Outputs are now readable once definitely assigned, so master's versions pass unchanged, and solver_test.go only adds tests relative to master. Co-Authored-By: Claude Opus 5.5 --- compiler/solver_test.go | 60 ++++++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/compiler/solver_test.go b/compiler/solver_test.go index 2174970f..d959a6ab 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -1718,10 +1718,9 @@ func TestSpecializationTraceCapsIndividualFrames(t *testing.T) { const fixedRankRecursionSource = `res = FixedRank(x) "-x" - total = 0 + res = 0 nested = FixedRank([[1]]) - total = total + nested - res = total + res = res + nested ` func TestRecursiveGrowthReachesFixedClosure(t *testing.T) { @@ -1778,16 +1777,14 @@ func TestRecursiveLimitCountsColdDiscovery(t *testing.T) { func TestFinitePolymorphicRecursionIsAccepted(t *testing.T) { code := mustParseCode(t, `res = Outer(x) - total = 0 + res = 0 inner = Inner([x]) - total = total + inner - res = total + res = res + inner res = Inner(xs) - total = 0 + res = 0 outer = Outer(xs[0]) - total = total + outer - res = total + res = res + outer `) ctx := llvm.NewContext() @@ -2235,6 +2232,51 @@ res = Relay(k) } } +// Consume precedes Root's StrH refinement and must be remangled on the stable sweep. +func TestRefinedOutputSeedsStableBody(t *testing.T) { + code := mustParseCode(t, `res = Root(k) + res = "lit" + tmp = Consume(res) + "-tmp" + res = k > 0 Relay(k) + +res = Relay(k) + res = Root(k - 1) ⊕ "x" + +res = Consume(x) + res = x +`) + ctx := llvm.NewContext() + defer ctx.Dispose() + cc := NewCodeCompiler(ctx, "seedRefinedOutput", "", code) + require.Empty(t, cc.Compile()) + + sl := lexer.New("TestSeedRefinedOutputScript", "v = Root(3)\nv") + sp := parser.NewScriptParser(sl) + program := sp.Parse() + require.Empty(t, sp.Errors()) + + sc := NewScriptCompiler(ctx, t.Name(), program, cc) + ts := NewTypeSolver(sc) + ts.Solve() + + require.Empty(t, ts.Errors) + heapConsumer := cc.Compiler.FuncCache[Mangle(cc.Compiler.MangledPath, "Consume", []Type{StrH{}})] + require.NotNil(t, heapConsumer, "the stable body sweep must remangle Consume with Root's StrH output slot") + require.True(t, heapConsumer.AllTypesInferred()) + + root := code.Statements[0].(*ast.FuncStatement) + consumeStmt := root.Body.Statements[1].(*ast.LetStatement) + consumeCall := consumeStmt.Value[0].(*ast.CallExpression) + rootMangled := Mangle(cc.Compiler.MangledPath, "Root", []Type{I64}) + callInfo := ts.ExprCache[key(rootMangled, consumeCall)] + require.NotNil(t, callInfo) + require.Len(t, callInfo.CallParamTypes, 1) + require.Len(t, callInfo.ScalarCallParamTypes, 1) + require.True(t, TypeEqual(StrH{}, callInfo.CallParamTypes[0]), "final call metadata must use the output slot's StrH storage type") + require.True(t, TypeEqual(StrH{}, callInfo.ScalarCallParamTypes[0]), "final scalar-call metadata must use the output slot's StrH storage type") +} + func TestFunctionOutputTableJoinMatchesStorage(t *testing.T) { code := mustParseCode(t, `res = RefineTable(k) "-k" From dca7db79f3cc2576941d5b4c3dc81445224831d4 Mon Sep 17 00:00:00 2001 From: Tejas Date: Thu, 24 Sep 2026 00:05:29 +0530 Subject: [PATCH 54/56] test: separate fixture cases with blank lines The call_arg_types, staging and self_alias scripts ran their cases together, which made them hard to read. Put each case in its own block, keeping a comment with the cases it describes. Whitespace only; the expected output is unchanged. Co-Authored-By: Claude Opus 5.5 --- tests/alias_input/self_alias.spt | 3 +++ tests/alias_input/staging_bounds.spt | 3 +++ tests/alias_input/staging_gate.spt | 3 +++ tests/call_arg_types/flow_types.spt | 8 ++++++++ tests/call_arg_types/slot_storage.spt | 2 ++ 5 files changed, 19 insertions(+) diff --git a/tests/alias_input/self_alias.spt b/tests/alias_input/self_alias.spt index 40b99d87..a33873c8 100644 --- a/tests/alias_input/self_alias.spt +++ b/tests/alias_input/self_alias.spt @@ -145,12 +145,15 @@ readTwice, readKept, readEcho = ReadTwice(readTwice) pair = 10 pair, before = SharedPair(pair, pair) "SharedPair:", pair, before + nextRange = 1:3 nextRange = NextRange(nextRange) "NextRange:", nextRange + condCount = 10 condCount, condCountSeen, condTag = CondCount(condCount) "CondCount:", condCount, condCountSeen, condTag + labeled = 10 labeled, labelTag = Label(labeled, "ab-labeled%n") "Label:", labeled, labelTag diff --git a/tests/alias_input/staging_bounds.spt b/tests/alias_input/staging_bounds.spt index d639d696..3905b460 100644 --- a/tests/alias_input/staging_bounds.spt +++ b/tests/alias_input/staging_bounds.spt @@ -1,11 +1,14 @@ # The loop shrinks the array it indexes, so a bounds check hoisted before the # loop must not cover later iterations: their out-of-bounds reads skip. + a = [10 20 30 40 50 60 70 80] a, seen = ShrinkShared(a) "Shared:", a, seen + b = [10 20 30 40 50 60 70 80] c, cs = ShrinkShared(b) "Unshared:", c, cs + d = [10 20 30 40 50 60 70 80] d = Shrink(d, d[(0:8) + 0]) "Script:", d diff --git a/tests/alias_input/staging_gate.spt b/tests/alias_input/staging_gate.spt index 0073c8fc..3b28464a 100644 --- a/tests/alias_input/staging_gate.spt +++ b/tests/alias_input/staging_gate.spt @@ -14,14 +14,17 @@ ss = (1:4) > 0 ss ⊕ "." sa = [9] sa = (1:4) > 0 sa ⊕ sa "Inlined:", si, ss, sa + j = 10 j, js = GateSibInt(j) t = "x" t, ts = GateSibStr(t) "Sibling:", j, js, t, ts + u = GateInt(10) v, vs = GateSibStr("x") "Unshared:", u, v, vs + g = 10 g, gs = GatedFold(g) "Gated:", g, gs diff --git a/tests/call_arg_types/flow_types.spt b/tests/call_arg_types/flow_types.spt index 55eb17dd..eff8a9b3 100644 --- a/tests/call_arg_types/flow_types.spt +++ b/tests/call_arg_types/flow_types.spt @@ -1,5 +1,6 @@ # A call made while a binding holds [] specializes on [], not on an element # type that a later or sibling assignment gives the binding. + x = [] kept = Keep(x) "kept:", kept @@ -7,16 +8,19 @@ kept = [5.5] "kept:", kept x = [1 2] "x:", x + e = [] tags = Tag(e) "tags:", tags e = [1.5] "e:", e + prices = [] first = Append(prices, 12345678) "first:", first prices = [9.5] "prices:", prices + t = [ : Name Score ] @@ -27,13 +31,17 @@ t = [ "Ada" 10 ] "t:", t + built, btags = Build(3) "built:", built, btags + y = [] o, y = Append(y, 12345678), [9.5] "o:", o, "y:", y + w = Work(12345678) "w:", w + r = [] r = Reset(Append(r, 12345678)) "r:", r diff --git a/tests/call_arg_types/slot_storage.spt b/tests/call_arg_types/slot_storage.spt index f6af32c8..488189aa 100644 --- a/tests/call_arg_types/slot_storage.spt +++ b/tests/call_arg_types/slot_storage.spt @@ -5,6 +5,7 @@ kept = Keep(s) s = s ⊕ "b" s = s ⊕ "c" "kept:", kept, "s:", s + # A comparison in value position passes on its left operand's stored value, # through every link of a chain. c = "a" @@ -12,6 +13,7 @@ yielded = Keep(c > "" < "zz") c = c ⊕ "b" c = c ⊕ "c" "yielded:", yielded, "c:", c + # An accumulator that starts as [] and shares the call's destination # carries every element across the range, also after a multi-valued argument. acc = [] From bda75c7fad7591d965497faefe47fe9af80bdb25 Mon Sep 17 00:00:00 2001 From: Tejas Date: Thu, 24 Sep 2026 10:52:53 +0530 Subject: [PATCH 55/56] test(call_arg_types): cover comparison arguments whose chain fails The comparison-argument case only covered a chain whose links all pass. Add a chain that fails at its first link into a fresh destination, which stays empty, and one that fails at its second link into an existing destination, which keeps its value. Both leave the widened binding's storage untouched. Co-Authored-By: Claude Opus 5.5 --- tests/call_arg_types/slot_storage.exp | 1 + tests/call_arg_types/slot_storage.spt | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/call_arg_types/slot_storage.exp b/tests/call_arg_types/slot_storage.exp index 1ef07034..232ea60f 100644 --- a/tests/call_arg_types/slot_storage.exp +++ b/tests/call_arg_types/slot_storage.exp @@ -1,4 +1,5 @@ kept: a s: abc yielded: a c: abc +failed: < > old acc: [1 2 3] prefix: -3 [1 2 3] diff --git a/tests/call_arg_types/slot_storage.spt b/tests/call_arg_types/slot_storage.spt index 488189aa..b2dfcbf4 100644 --- a/tests/call_arg_types/slot_storage.spt +++ b/tests/call_arg_types/slot_storage.spt @@ -7,12 +7,17 @@ s = s ⊕ "c" "kept:", kept, "s:", s # A comparison in value position passes on its left operand's stored value, -# through every link of a chain. +# through every link of a chain. When a link fails, the call is skipped: a +# fresh destination stays empty and an existing one keeps its value. c = "a" yielded = Keep(c > "" < "zz") +first = Keep(c > "dd" < "zz") +second = "old" +second = Keep(c > "" < "a") c = c ⊕ "b" c = c ⊕ "c" "yielded:", yielded, "c:", c +"failed: < -first > -second" # An accumulator that starts as [] and shares the call's destination # carries every element across the range, also after a multi-valued argument. From aa467cd348234c266b5d9c9ba08d52f8f94d431b Mon Sep 17 00:00:00 2001 From: Tejas Date: Thu, 24 Sep 2026 11:09:21 +0530 Subject: [PATCH 56/56] test(struct): put the text after an interpolated struct on its own line A struct's printed layout ends with a newline, so the space in "x=-p y=99" started the next line as " y=99". Write the newline explicitly instead; the output now shows the struct, a blank line and y=99. No compiler change. Co-Authored-By: Claude Opus 5.5 --- tests/struct/struct.exp | 3 ++- tests/struct/struct.spt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/struct/struct.exp b/tests/struct/struct.exp index b30f4e96..e7247073 100644 --- a/tests/struct/struct.exp +++ b/tests/struct/struct.exp @@ -18,5 +18,6 @@ Person x=Person : name age height Tejas 35 184.5 - y=99 + +y=99 Tejas Tejas diff --git a/tests/struct/struct.spt b/tests/struct/struct.spt index 4c8aaa17..3cdfe082 100644 --- a/tests/struct/struct.spt +++ b/tests/struct/struct.spt @@ -8,7 +8,7 @@ copiedName "-p" p p, 99 -"x=-p y=99" +"x=-p\ny=99" # A struct read through its output keeps its constant fields static; the # caller must not free them.