diff --git a/README.md b/README.md index 5c3e2964..77ef56c4 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 writable — they flow out. Every function is a transformation. +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) + 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. 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/compiler/abi.go b/compiler/abi.go index 48e022f3..ef443056 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,12 +28,12 @@ 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. 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 - HasRangeParams bool + Params []ABIParam + Return ABIReturn } func isDirectScalarABIType(t Type) bool { @@ -48,18 +47,6 @@ 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 -// 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 @@ -79,28 +66,15 @@ 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{ - 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 - if abi.HasRangeParams { - paramABI.AliasSlot = aliasSlot - aliasSlot++ - } } abi.Params[i] = paramABI } @@ -120,16 +94,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 @@ -141,21 +105,59 @@ func (abi FuncABI) SourceFunctionParamIndex(paramIndex int) int { return abi.sourceParamBaseIndex() + paramIndex } -func (abi FuncABI) AliasParamBaseIndex() int { +func (abi FuncABI) DirectReturnSeedParamIndex() int { + if abi.Return.Mode != ABIReturnDirect { + return -1 + } return abi.sourceParamBaseIndex() + len(abi.Params) } -func (abi FuncABI) AliasFunctionParamIndex(paramIndex int) int { - slot := abi.Params[paramIndex].AliasSlot - if slot < 0 { - return -1 +// 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 abi.AliasParamBaseIndex() + slot + return bindingSlotCompatible(paramType, outType) && TypeEqual(mergeBindingSlotType(paramType, outType), paramType) } -func (abi FuncABI) DirectReturnSeedParamIndex() int { - if abi.Return.Mode != ABIReturnDirect { - return -1 +// 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 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 { + 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 abi.AliasParamBaseIndex() + abi.NumAliasSlots() + + return pattern } 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/cfg.go b/compiler/cfg.go index 8e8178d7..0031f3c3 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" @@ -39,7 +40,7 @@ type BasicBlock struct { type CFG struct { CodeCompiler *CodeCompiler Blocks []*BasicBlock - Scopes []Scope[VarEvent] + Scopes []Scope[struct{}] Errors []*token.CompileError } @@ -47,7 +48,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,21 +194,18 @@ func (cfg *CFG) validateFuncTemplate(fn *ast.FuncStatement) { PushScope(&cfg.Scopes, FuncScope) defer PopScope(&cfg.Scopes) + // Outputs are declared up front so that a formatting marker naming one + // resolves as a read, instead of passing as literal text. for _, param := range fn.Parameters { - cfg.publishTarget(param) + cfg.declareName(param) } - - 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{}{} + cfg.declareName(output) } - _, readInputs, assignedOutputs := 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 { @@ -225,35 +223,56 @@ 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. +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. +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)), + 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) + targets := cfg.validateStatementStructure(stmt, reads, parameterNames, outputNames, body.assignedOutputs) if let, ok := stmt.(*ast.LetStatement); ok { - cfg.publishTargets(let.Name) + cfg.declareTargets(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{}{} + 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 { - 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,36 +297,63 @@ 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 -// already produced once from the function template. +// AnalyzeSpecialization runs only typed dataflow over one type +// specialization, with every input treated as its own value: a call that +// shares an input with an output only adds reads, so a body valid here is +// valid in every call. Structural diagnostics were already produced once +// from the function template. func (cfg *CFG) AnalyzeSpecialization(template *ast.FuncStatement, info *FuncInfo) { cfg.PushBlock() defer cfg.PopBlock() PushScope(&cfg.Scopes, FuncScope) defer PopScope(&cfg.Scopes) + // Parameters must be in scope: the shared marker collector treats an + // unknown main marker as literal text and rejects unknown specifier names. for _, param := range template.Parameters { - cfg.publishTarget(param) + cfg.declareName(param) } - cfg.typedForwardPass(template.Body.Statements, info.StatementEffects) - - live := make(map[string]struct{}, len(template.Outputs)) - for _, output := range template.Outputs { - live[output.Value] = struct{}{} + outputs := identSet(template.Outputs) + 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])) + } } - cfg.backwardPass(live) + cfg.typedForwardPass(template, info, outputs) + cfg.backwardPass(maps.Clone(outputs)) } -func (cfg *CFG) typedForwardPass(statements []ast.Statement, effects map[*ast.LetStatement]StatementEffect) { +// typedForwardPass runs the forward dataflow over a body. Per statement, in +// order: an explicit read of an output needs an earlier definite assignment; +// the statement's events run; its definite targets become assigned for the +// statements after it. +func (cfg *CFG) typedForwardPass(template *ast.FuncStatement, info *FuncInfo, outputs map[string]struct{}) { + definitelyAssigned := make(map[string]struct{}, len(outputs)) lastWrites := make(map[string]VarEvent) - for _, stmt := range statements { + for _, stmt := range template.Body.Statements { reads := cfg.collectStatementReads(stmt) - cfg.processTypedStatement(stmt, reads, effects, lastWrites) + cfg.rejectUnassignedOutputReads(reads, outputs, definitelyAssigned) + cfg.processTypedStatement(stmt, reads, info.StatementEffects, lastWrites) + + if let, ok := stmt.(*ast.LetStatement); ok { + maps.Copy(definitelyAssigned, definiteTargets(let, info.StatementEffects[let])) + } + } +} + +func (cfg *CFG) rejectUnassignedOutputReads(reads []VarEvent, outputs, definitelyAssigned map[string]struct{}) { + for _, read := range reads { + if _, isOutput := outputs[read.Name]; !isOutput { + continue + } + if _, ok := definitelyAssigned[read.Name]; !ok { + cfg.addError(read.Token, fmt.Sprintf("output %q is read where it may still be unassigned; assign it unconditionally first, or pass the previous value as an input and initialize from it", read.Name)) + } } } @@ -327,16 +373,16 @@ 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) } } // 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, assigned map[string]struct{}) []*ast.Identifier { for _, event := range reads { - cfg.validateStructuralRead(event) + cfg.validateStructuralRead(event, outputs, assigned) } let, ok := stmt.(*ast.LetStatement) @@ -445,7 +491,16 @@ func (cfg *CFG) backwardPass(live map[string]struct{}) { } } -func (cfg *CFG) validateStructuralRead(event VarEvent) { +// 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 { + 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) { cfg.addError(event.Token, fmt.Sprintf("variable %q has not been defined", event.Name)) } @@ -460,16 +515,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 bafab129..3e216a26 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -73,6 +73,93 @@ func TestFunctionDataflowWaitsForSpecialization(t *testing.T) { require.Equal(t, 2, deadStores) } +// 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{ + { + name: "Repeated Output Write Shared", + code: `out = BumpTwice(current, item) + out = current + item + out = current + item`, + input: "value = 10\nvalue = BumpTwice(value, 5)\nvalue", + errorContains: `unconditional assignment to "out" overwrites a previous value that was never used`, + }, + { + 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`, + }, + { + 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", + errorContains: `unconditional assignment to "out" overwrites a previous value that was never used`, + }, + { + 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`, + }, + { + // 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 = current + seen = out + out = "second" + +out, seen = Wrap(current) + out, seen = Reset(current)`, + input: `value = "hello" ⊕ "!" +value, seen = Wrap(value) +value, seen`, + }, + { + name: "Marker Read Of Output Between Writes", + code: `out = Show(current) + out = current + "-out" + out = 2`, + input: "x = 5\nx = Show(x)\nx", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runCFGTest(t, tt, tt.errorContains != "") + }) + } +} + func getValidTestCases() []cfgTestCase { return []cfgTestCase{ { @@ -108,6 +195,122 @@ func getValidTestCases() []cfgTestCase { 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", + }, + { + // 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", + 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. + 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 @@ -142,6 +345,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 +461,71 @@ 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 where it may still be unassigned`, + }, + { + // 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 = chained(-1)\nx", + errorContains: `output "res" is read where it may still be unassigned`, + }, + { + // Two seed-preserving calls in a row still leave the caller's seed + // in place, so the read after them is rejected. + name: "Output Read After Two Seed Preserving Calls", + code: `res = maybe(x) + res = x > 0 x + +res = twice(x) + res = maybe(x) + res = maybe(x) + res = res + 1`, + input: "x = twice(-1)\nx", + errorContains: `output "res" is read where it may still be unassigned`, + }, + { + // 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 before it is assigned`, + }, + { + // 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`, + }, + { + // 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", input: `x = 42 @@ -352,7 +642,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} @@ -536,7 +826,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) } @@ -764,7 +1054,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 before it is assigned`, }, }, { diff --git a/compiler/codecompiler.go b/compiler/codecompiler.go index cd859dec..7fa50411 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 + // outputReads names the outputs each template reads in its own body, + // 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{} } type funcKey struct { @@ -47,6 +52,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) { diff --git a/compiler/compiler.go b/compiler/compiler.go index c0d19ebc..73bc60af 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -2,6 +2,7 @@ package compiler import ( "fmt" + "maps" "slices" "strings" @@ -33,7 +34,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 // @@ -76,21 +77,21 @@ 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. - AliasSelector int } +// callSignature is one call site's view of a specialization. Mangled is the +// 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 - ParamTypes []Type - FnInfo *FuncInfo - ABI FuncABI + FuncName string + Mangled string + // 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 { @@ -100,13 +101,15 @@ 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. -type paramAlias struct { - Base *Symbol - AliasIndex llvm.Value - OutputNames []string +// paramAliasKey identifies one binding of a parameter name to a symbol whose +// 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 } type symbolSource int @@ -137,7 +140,8 @@ 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 } @@ -146,6 +150,8 @@ 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 + 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 { @@ -181,7 +187,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{}, } } @@ -202,6 +208,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 { @@ -210,7 +219,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 } @@ -218,7 +227,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() { @@ -233,28 +242,69 @@ func identNames(idents []*ast.Identifier) []string { return names } -// 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. -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()[paramAliasKey{name: name, base: sym}] = output +} + +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 +// 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 } -func (c *Compiler) clearParamAlias(name string) { - delete(c.currentParamAliases(), name) +// 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, ok := synthetic[name] + if !ok { + return name + } + name = base + } } -func (c *Compiler) paramAliasFor(name string, sym *Symbol) (*paramAlias, bool) { - alias, ok := c.currentParamAliases()[name] - if !ok || alias.Base != sym { - return nil, false +// 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 alias, true + return bindings } func (c *Compiler) resolvedDestTypes(dest []*ast.Identifier, outTypes []Type) []Type { @@ -319,40 +369,39 @@ func (c *Compiler) resolveCallSignature(funcName string, ce *ast.CallExpression, }, true } -// setCallArgAliasSelectors records on each argument which caller destination it -// aliases for range-bearing variants. 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 { - return +// 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) { + 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) } - for paramIndex, arg := range args { - if arg.Name == "" { - continue + 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] } + } +} - for outputIndex, output := range dest { - 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. - if !aliasableOutput(sig.ParamTypes[paramIndex], sig.ABI.Return.OutTypes[outputIndex]) { - continue - } - args[paramIndex].AliasSelector = outputIndex + 1 - break +// 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 binding, output := range c.currentParamAliases() { + if sym, ok := Get(c.Scopes, binding.name); ok && sym == binding.base { + aliases[binding.name] = output } } + return aliases } // directReturnSeedForCall captures the caller's current destination value for a @@ -369,23 +418,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 { @@ -425,38 +457,25 @@ func (c *Compiler) putGlobal(name, mangledName string, sym *Symbol) { c.MangledNames[name] = mangledName } -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)) +// 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, outputName string) *Symbol { + output, ok := c.localValSymbol(outputName, name+"_alias_load") + if !ok { + panic(fmt.Sprintf("internal: input %s aliases unbound output %s", name, outputName)) } 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 { - 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) } @@ -1040,7 +1059,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 } @@ -1635,10 +1659,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 +1670,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 @@ -2402,14 +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. 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. +// 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 @@ -2417,30 +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} - if current, ok := Get(c.Scopes, dest[i].Value); ok && current.Type.Kind() == PtrKind { - seen := make(map[string]struct{}) - for scopeIdx := len(c.Scopes) - 1; scopeIdx >= 0; scopeIdx-- { - scope := c.Scopes[scopeIdx] - for name, sym := range scope.Elems { - if _, visited := seen[name]; visited { - continue - } - seen[name] = struct{}{} - 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 + } + seen[name] = struct{}{} + if sym := scope.Elems[name]; sym.Type.Kind() == PtrKind && sym.Val == current.Val { + names = append(names, name) } } - for _, name := range names { - Put(c.Scopes, name, outputs[i]) + if scope.ScopeKind == FuncScope { + break } } + return names } // Destination-aware prefix compilation, @@ -2572,9 +2567,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)) } @@ -2603,7 +2595,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.isVariant() { + function.SetLinkage(llvm.InternalLinkage) + } if sig.ABI.UsesIndirectReturn() { sretAttr := c.Context.CreateTypeAttribute(llvm.AttributeKindID("sret"), retStruct) @@ -2622,9 +2617,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) } @@ -2637,9 +2629,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 { @@ -2730,9 +2728,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, @@ -2742,6 +2737,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) @@ -2771,7 +2772,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) } @@ -2909,11 +2910,17 @@ 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.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] @@ -2926,7 +2933,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) @@ -2960,16 +2967,6 @@ 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) - - output, _ := c.localValSymbol(fn.Outputs[0].Value, fn.Outputs[0].Value+"_iter_out") - return output -} - func (c *Compiler) compileFuncBody(fn *ast.FuncStatement) { for _, stmt := range fn.Body.Statements { c.compileStatement(stmt) @@ -3077,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) { @@ -3102,7 +3109,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.setCallAliasPattern(sig, callArgs, dest) c.lowerCallArgs(sig.FuncName, callArgs, sig) fn, funcType, retStruct := c.getOrCompileCallFunction(sig) return preparedCall{ @@ -3226,17 +3233,20 @@ 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. ABI-flavor adapters handle established slots such as StrH when a - // callee declares StrG. - 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") } 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 } @@ -3262,46 +3272,44 @@ 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) { - c.runCallWithBoundsElse(func() { - writeFlags := c.makeCallOutputWriteFlags(len(outputs)) - c.builder.CreateCall( - call.FuncType, - call.Function, - c.callArgs(sig, call, call.RetStruct, outputs, writeFlags, nil), - "", - ) - if afterCall != nil { - afterCall(writeFlags) - } - }, onSkip) + c.callIntoStagedOutputs(sig, call, staged) }) } -func (c *Compiler) compileIndirectCallIntoStagedOutputs( - sig *callSignature, - ce *ast.CallExpression, - dest []*ast.Identifier, - staged []*Symbol, -) { +func (c *Compiler) callIntoStagedOutputs(sig *callSignature, call preparedCall, staged []*Symbol) { 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) }, - ) + 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 { + return MangleVariant(sig.Mangled, sig.AliasPattern) +} + +func (sig *callSignature) isVariant() bool { + return sig.AliasPattern != nil } func (c *Compiler) makeCallOutputWriteFlags(count int) []llvm.Value { @@ -3363,22 +3371,11 @@ 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 + 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) } - aliasIndices := make([]int, 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 { - llvmArgs = append(llvmArgs, llvm.ConstInt(c.Context.Int32Type(), uint64(aliasIndex), false)) - } 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 5127824b..b444dc17 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -201,19 +201,22 @@ 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" + mangled := Mangle(MangleDirPath("alias_mismatch", ""), "Mixed", []Type{I64, Range{Iter: I64}}) 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) { - 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") }) } } @@ -431,27 +434,117 @@ 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 TestPromotedAliasTypeGap(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) - "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, "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+"_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") + 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") +} + +// 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") +} + +// 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") +} + +// 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 + // 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, "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 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, "matching_array_input", code, script) + mangled := Mangle(MangleDirPath("matching_array_input", ""), "Pick", []Type{Array{ElemType: I64, Rank: 1}, Range{Iter: I64}}) - require.Regexp(t, `%a_alias_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.NotContains(t, ir, "@arr_i64_copy", + "a matching output type must not introduce an input copy on every iteration") + 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) { @@ -514,9 +607,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+"_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+"_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/cond.go b/compiler/cond.go index 6e9381e1..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" @@ -109,16 +110,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) } } @@ -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.bindSyntheticDestination(tempName, ident.Value) slots[i] = OutputSlot{dest: ident, temp: tempIdent, outType: outTypes[i]} } return slots @@ -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 } @@ -362,6 +367,7 @@ func (c *Compiler) createStageTempOutputsFor(commit []OutputSlot) []OutputSlot { stageTempSym.WriteFlag = commitSym.WriteFlag } Put(c.Scopes, tempName, stageTempSym) + c.bindSyntheticDestination(tempName, cs.dest.Value) stage[i] = OutputSlot{dest: cs.dest, temp: tempIdent, outType: outType} } return stage diff --git a/compiler/effects.go b/compiler/effects.go index fad03c9f..aac2754b 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 is the set of targets a statement leaves holding its own +// value on every path: unconditional writes that do not merely preserve the +// target's seed. +func definiteTargets(stmt *ast.LetStatement, effect StatementEffect) map[string]struct{} { + targets := make(map[string]struct{}, len(effect.Writes)) + for _, write := range effect.Writes { + if write.Effect == MustWrite && !slices.Contains(effect.ReadsSeed, write.TargetIndex) { + targets[stmt.Name[write.TargetIndex].Value] = struct{}{} + } + } + return targets +} + type specializationNodeID int type specializationNode struct { 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/format_test.go b/compiler/format_test.go index d516904d..dc9ccad8 100644 --- a/compiler/format_test.go +++ b/compiler/format_test.go @@ -250,6 +250,56 @@ 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", 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 { + t.Run(tt.name, func(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + 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) + } + + 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/mangle.go b/compiler/mangle.go index 7bf59ae6..273cde74 100644 --- a/compiler/mangle.go +++ b/compiler/mangle.go @@ -14,6 +14,7 @@ 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 M = "m" // Method separator OP = "op" // Operator prefix N = "n" // Numeric segment prefix @@ -58,6 +59,10 @@ type Demangled struct { Kind SymbolKind // Type of symbol Arity int // Number of arguments (for functions) ArgTypes []string // Argument type names (for functions) + // 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). @@ -86,12 +91,24 @@ 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(")") } return result.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 && i < len(args) { + args[i] = fmt.Sprintf("%s -> %d", args[i], slot) + } + } + return args +} + // 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 +120,21 @@ 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. 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 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 +422,35 @@ 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 suffix that follows a +// function's argument types: _aN and N slots. +func demangleVariant(result *Demangled, rest string) { + 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..5a0f4acd 100644 --- a/compiler/mangle_test.go +++ b/compiler/mangle_test.go @@ -821,3 +821,31 @@ 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 + 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 -> 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 { + t.Run(tt.name, func(t *testing.T) { + mangled := MangleVariant(base, 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) + }) + } +} diff --git a/compiler/scriptcompiler.go b/compiler/scriptcompiler.go index a664588c..dd7960f3 100644 --- a/compiler/scriptcompiler.go +++ b/compiler/scriptcompiler.go @@ -103,6 +103,12 @@ func (sc *ScriptCompiler) compileStatements() { } } +// 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)) diff --git a/compiler/solver.go b/compiler/solver.go index 10176250..af510972 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,11 @@ 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 + + // A statement's value call -> the statement's names from the call's first output on. + callDests map[*ast.CallExpression][]*ast.Identifier recLimit recursionLimit } @@ -160,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), } } @@ -172,6 +179,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 +735,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 } } @@ -922,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) @@ -2387,10 +2427,15 @@ 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) + 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 slotType, ok := ts.argumentStorage(ce.Arguments[argIndex], outerTypes[0], shared); ok { + outerTypes = []Type{slotType} + } + if loopInside { if arrayRangeType, yieldedType, ok := ts.callScopedArrayRangeType(ce.Arguments[argIndex]); ok { args = append(args, arrayRangeType) @@ -2416,6 +2461,69 @@ func (ts *TypeSolver) collectCallArgs(ce *ast.CallExpression, isRoot bool) (args return } +// 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) { + // 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 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) +} + +// 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. +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 { @@ -2610,7 +2718,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 +2736,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() } @@ -2650,6 +2767,7 @@ func (ts *TypeSolver) TypeBlock(template *ast.FuncStatement, f *FuncInfo) { } } + readOutputs := ts.ScriptCompiler.Compiler.CodeCompiler.outputReads[funcKey{name: f.Sig.Name, arity: len(template.Parameters)}] for i, id := range template.Outputs { outArg, ok := Get(ts.Scopes, id.Value) if !ok { @@ -2673,6 +2791,12 @@ func (ts *TypeSolver) TypeBlock(template *ast.FuncStatement, f *FuncInfo) { )) } nextOutArg := mergeBindingSlotType(oldOutArg, outArg) + // 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) { continue diff --git a/compiler/solver_test.go b/compiler/solver_test.go index aea3b290..d959a6ab 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -643,6 +643,109 @@ 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 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 b187de81..babcfa5a 100644 --- a/compiler/types.go +++ b/compiler/types.go @@ -267,8 +267,10 @@ 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 +// specialization's diagnostics, produced once at settlement and replayed by +// every script that reaches it. type SpecializationCFGResult struct { DirectCallees []string Errors []*token.CompileError @@ -686,6 +688,46 @@ 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. +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 + 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 0fb2d699..41eaf6b6 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 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. 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 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 @@ -90,8 +94,12 @@ 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 + 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; 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 0bc933e0..72388588 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 @@ -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. --- @@ -346,9 +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 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 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 @@ -357,12 +361,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 @@ -388,14 +387,34 @@ struct Results { void Pt_example(Results *results, I64 direct_arg, Other *indirect_arg); ``` -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. -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. +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 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 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, 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 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 an `ArrayRange` specialization and run its loop inside the callee. This @@ -424,6 +443,38 @@ 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 + +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. + +``` +_aN__... +``` + +`_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); 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 +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 -> 1, StrH)`. + +The public specialization symbol is unchanged by the variant. C callers +never see a variant and cannot request one. + --- ## 6. Grammar @@ -431,6 +482,8 @@ 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 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 @@ -487,4 +540,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 +* 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 c4a7070b..bc839b9c 100644 --- a/docs/Pluto Effects and Follow-up Plan.md +++ b/docs/Pluto Effects and Follow-up Plan.md @@ -19,9 +19,44 @@ 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 +([PR #104](https://github.com/thiremani/pluto/pull/104), superseding the closed +[PR #102](https://github.com/thiremani/pluto/pull/102)): a declared output is +readable inside its template only after it is definitely assigned, so 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. 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 +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 +[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`. +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 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 @@ -121,18 +156,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-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. -- 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 @@ -149,8 +190,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. @@ -198,7 +240,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 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 c5f35aff..576e1ccf 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -285,12 +285,17 @@ 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, 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. ## 7. Loop-Carried State @@ -850,10 +855,18 @@ 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 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 +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 @@ -972,7 +985,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. @@ -1000,6 +1013,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.* 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 4c267e46..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 | @@ -257,32 +258,113 @@ 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**: 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**: 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 `%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 + 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) 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 -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. +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 +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 +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 ```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 # - 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 +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 reported as 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) + 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 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 +`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 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 ```python diff --git a/tests/alias_input/self_alias.exp b/tests/alias_input/self_alias.exp index 71afb345..548f548b 100644 --- a/tests/alias_input/self_alias.exp +++ b/tests/alias_input/self_alias.exp @@ -1,3 +1,43 @@ 15 hi!hi [1 2 9 1 2] +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 +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] [ +] +WidenedWrapper: first first +UnsharedWidenedBefore: keep +UnsharedWidened: tagged hello! hello! +UnsharedResetBefore: [9] +UnsharedReset: [] [1 2] [1 2] +Powers: 9 27 +PowersShared: 9 81 +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 891a8b64..eed88d33 100644 --- a/tests/alias_input/self_alias.pt +++ b/tests/alias_input/self_alias.pt @@ -1,11 +1,147 @@ 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 + +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 + +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) + +# 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 = 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 + 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 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(out, (3:5) + 0) + +out, seen = NestedArrayTwice(current) + out, _ = ArrayAfter(current, (1:3) + 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. +out, seen = ConditionalNested(current, item) + out, seen = item > 0 FoldAfter(current, item) + +out = BumpTwice(current, item) + out = current + item + out = out + item + +out, seen = Reset(current) + out = "first" + seen = current + +out, seen = Wrap(current) + out, seen = Reset(current) + +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 + +# Two inputs may share one output. Ranged staging inside the body rebinds +# 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 + a + b + +out, before = SharedPair(a, b) + before = b + out = AddBoth(a, b, (1:3) + 0) + +# A shared range parameter is iterated by a scalar bound to the same name; +# that scalar must not inherit the range's alias. +out = NextRange(i) + out = i:(i+1) + +# A conditional call promotes its indirect arguments before branching. The +# scalar after a two-valued argument binds a later parameter and must keep +# its live alias. +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 + 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 9c98ec69..a33873c8 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,3 +8,152 @@ w v = [1 2] v = Grow(v) v + +# 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 = 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"] +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 +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. +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 + +# 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 + +# 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 + +# 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 + +# 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, 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.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..3905b460 --- /dev/null +++ b/tests/alias_input/staging_bounds.spt @@ -0,0 +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.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..3b28464a --- /dev/null +++ b/tests/alias_input/staging_gate.spt @@ -0,0 +1,30 @@ +# 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 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..eff8a9b3 --- /dev/null +++ b/tests/call_arg_types/flow_types.spt @@ -0,0 +1,49 @@ +# 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..232ea60f --- /dev/null +++ b/tests/call_arg_types/slot_storage.exp @@ -0,0 +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 new file mode 100644 index 00000000..b2dfcbf4 --- /dev/null +++ b/tests/call_arg_types/slot_storage.spt @@ -0,0 +1,29 @@ +# 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 + +# A comparison in value position passes on its left operand's stored value, +# 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. +acc = [] +acc = Append(acc, 1:4) +"acc:", acc +f = [] +total, f = Fold(-Pair(1), f, 1:4) +"prefix:", total, f 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 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) diff --git a/tests/struct/struct.exp b/tests/struct/struct.exp index c774faaa..e7247073 100644 --- a/tests/struct/struct.exp +++ b/tests/struct/struct.exp @@ -18,4 +18,6 @@ Person x=Person : name age height Tejas 35 184.5 - y=99 + +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..3cdfe082 100644 --- a/tests/struct/struct.spt +++ b/tests/struct/struct.spt @@ -8,4 +8,9 @@ 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. +copied, copiedTag = Copy(p) +copied.name, copiedTag