From 290222c10973926a0d641fda9eeb1f33b86d30cd Mon Sep 17 00:00:00 2001 From: Tejas Date: Thu, 23 Jul 2026 15:37:46 +0530 Subject: [PATCH 01/60] feat(parser): standardize struct literal layout Require a spaced field marker and an aligned nested value row for struct declarations, and emit the same table-style layout when formatting values. Update parser/compiler coverage, clarify formatted-string ownership, and correct the release package maintainer metadata. --- .goreleaser.yaml | 2 +- ast/ast.go | 8 +++--- compiler/compiler_test.go | 36 ++++++++++++------------- compiler/format.go | 4 +-- compiler/solver_test.go | 8 +++--- parser/codeparser_test.go | 57 +++++++++++++++++++++++++++++++-------- parser/parser.go | 27 +++++++++++++++++++ runtime/runtime.c | 8 +++--- tests/struct/struct.exp | 8 +++--- tests/struct/struct.pt | 4 +-- 10 files changed, 111 insertions(+), 51 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 849bae06..205096ea 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -251,7 +251,7 @@ nfpms: - apk file_name_template: '{{ .ProjectName }}_{{ .Version }}_linux_{{ .Arch }}' homepage: 'https://github.com/{{ .Env.GITHUB_REPOSITORY }}' - maintainer: '{{ or .Env.NFPM_MAINTAINER "Pluto Contributors " }}' + maintainer: '{{ or .Env.NFPM_MAINTAINER "Tejas " }}' description: Pluto programming language compiler (requires LLVM 22 toolchain on target host). license: 'Apache-2.0 WITH LLVM-exception' bindir: /usr/bin diff --git a/ast/ast.go b/ast/ast.go index 5afd37c6..cf9f1d7c 100644 --- a/ast/ast.go +++ b/ast/ast.go @@ -97,7 +97,7 @@ type Func struct { // For example: // // p = Person -// :name age +// : name age // "Tejas" 35 type StructStatement struct { Token token.Token // The token.ASSIGN token @@ -360,8 +360,8 @@ func writeArrayRow(out *bytes.Buffer, row []Expression) { // Example: // p = Person // -// :name age -// "Tejas" 35 +// : name age +// "Tejas" 35 type StructLiteral struct { Token token.Token // the type name token (e.g. "Person") Headers []token.Token @@ -375,7 +375,7 @@ func (sl *StructLiteral) String() string { out.WriteString(sl.Token.Literal) if len(sl.Headers) > 0 { - out.WriteString("\n :") + out.WriteString("\n : ") for i, header := range sl.Headers { if i > 0 { out.WriteString(" ") diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 04342293..a6ad5d97 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -313,7 +313,7 @@ greeting = "hello\n\x41"` func TestStructStringConstantDecodesEscapes(t *testing.T) { code := mustParseCode(t, `p = Person - :name + : name "\x41da\n"`) ctx := llvm.NewContext() @@ -346,10 +346,10 @@ func TestCompilerModuleTargetMetadata(t *testing.T) { func TestStructRepeatedDefs(t *testing.T) { codeA := mustParseCode(t, `p = Person - :name age + : name age "Tejas" 35`) codeB := mustParseCode(t, `q = Person - :name age + : name age "Ada" 28`) merged := ast.NewCode() @@ -366,10 +366,10 @@ func TestStructRepeatedDefs(t *testing.T) { func TestStructAmbiguousFieldOrder(t *testing.T) { codeA := mustParseCode(t, `p = Person - :name age + : name age "Tejas" 35`) codeB := mustParseCode(t, `q = Person - :age name + : age name 28 "Ada"`) merged := ast.NewCode() @@ -395,10 +395,10 @@ func TestStructAmbiguousFieldOrder(t *testing.T) { func TestStructUnknownField(t *testing.T) { codeA := mustParseCode(t, `p = Person - :name age score + : name age score "Tejas" 35 100`) codeB := mustParseCode(t, `q = Person - :name height + : name height "Ada" 170`) merged := ast.NewCode() @@ -424,10 +424,10 @@ func TestStructUnknownField(t *testing.T) { func TestStructFieldTypeMismatch(t *testing.T) { codeA := mustParseCode(t, `p = Person - :name age height + : name age height "Tejas" 35 184.5`) codeB := mustParseCode(t, `q = Person - :name age + : name age "Ada" 28.5`) merged := ast.NewCode() @@ -453,10 +453,10 @@ func TestStructFieldTypeMismatch(t *testing.T) { func TestStructSameArityConflictingTypes(t *testing.T) { codeA := mustParseCode(t, `p = Person - :name age + : name age "Tejas" 35`) codeB := mustParseCode(t, `q = Person - :name age + : name age 35 "Ada"`) merged := ast.NewCode() @@ -482,10 +482,10 @@ func TestStructSameArityConflictingTypes(t *testing.T) { func TestStructSubsetDefs(t *testing.T) { codeA := mustParseCode(t, `p = Person - :name age height + : name age height "Tejas" 35 184.5`) codeB := mustParseCode(t, `q = Person - :age name + : age name 28 "Ada"`) merged := ast.NewCode() @@ -503,10 +503,10 @@ func TestStructSubsetDefs(t *testing.T) { func TestStructMaxHeaderDef(t *testing.T) { // Smaller statement first, larger definition second — larger wins. codeA := mustParseCode(t, `q = Person - :age name + : age name 28 "Ada"`) codeB := mustParseCode(t, `p = Person - :name age height + : name age height "Tejas" 35 184.5`) merged := ast.NewCode() @@ -527,7 +527,7 @@ func TestStructMaxHeaderDef(t *testing.T) { func TestStructEmptyInit(t *testing.T) { code := mustParseCode(t, `p = Person - :name age + : name age "Tejas" 35 q = Person`) @@ -666,10 +666,10 @@ func TestReservedScriptVariableName(t *testing.T) { func TestStructUnknownFieldNoSpuriousError(t *testing.T) { codeA := mustParseCode(t, `p = Person - :name age + : name age "Tejas" 35`) codeB := mustParseCode(t, `q = Person - :height age + : height age 170 28`) merged := ast.NewCode() diff --git a/compiler/format.go b/compiler/format.go index 337025b6..9e49e29f 100644 --- a/compiler/format.go +++ b/compiler/format.go @@ -857,7 +857,7 @@ func maybeMarker(runes []rune, i int) bool { // Output format: // // Point -// :x y +// : x y // 1 2 func (c *Compiler) structFormatArgs(s *Symbol) (fmtStr string, args []llvm.Value, toFree []llvm.Value) { st := s.Type.(Struct) @@ -881,7 +881,7 @@ func (c *Compiler) structFormatArgs(s *Symbol) (fmtStr string, args []llvm.Value } valueParts = append(valueParts, spec) } - fmtStr = st.Name + "\n :" + strings.Join(headerParts, " ") + "\n " + strings.Join(valueParts, " ") + "\n" + fmtStr = st.Name + "\n : " + strings.Join(headerParts, " ") + "\n " + strings.Join(valueParts, " ") + "\n" return } diff --git a/compiler/solver_test.go b/compiler/solver_test.go index bf910e1c..c57979cd 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -179,10 +179,10 @@ y` func TestTypeStructLiteralCanonicalizesToSchema(t *testing.T) { code := mustParseCode(t, `p = Person - :name age height + : name age height "Tejas" 35 184.5 q = Person - :age + : age 28 r = Person`) @@ -212,7 +212,7 @@ r = Person`) func TestTypeStructLiteralValidatesAgainstCanonicalSchema(t *testing.T) { code := mustParseCode(t, `p = Person - :name age + : name age "Tejas" 35`) ctx := llvm.NewContext() @@ -241,7 +241,7 @@ func TestTypeStructLiteralValidatesAgainstCanonicalSchema(t *testing.T) { func TestTypeStructLiteralWidensStringFieldsFromValues(t *testing.T) { code := mustParseCode(t, `p = Person - :name age + : name age "Tejas" 35`) ctx := llvm.NewContext() diff --git a/parser/codeparser_test.go b/parser/codeparser_test.go index 0878213c..f15bb343 100644 --- a/parser/codeparser_test.go +++ b/parser/codeparser_test.go @@ -369,7 +369,7 @@ func TestFunctionParameterParsing(t *testing.T) { func TestParseStructDefinition(t *testing.T) { input := `p = Person - :name age height + : name age height "Tejas" 35 184.5` cp := NewCodeParser(lexer.New("TestParseStructDefinition", input)) @@ -401,7 +401,21 @@ func TestParseStructDefinition(t *testing.T) { _, constExists := code.ConstNames["p"] require.True(t, constExists) - require.Equal(t, "p = Person\n :name age height\n \"Tejas\" 35 184.5", stmt.String()) + require.Equal(t, "p = Person\n : name age height\n \"Tejas\" 35 184.5", stmt.String()) +} + +func TestStructDefNextStatement(t *testing.T) { + input := `p = Person + : name age + "Tejas" 35 +answer = 42` + + cp := NewCodeParser(lexer.New("TestStructDefNextStatement", input)) + code := cp.Parse() + require.Empty(t, cp.Errors()) + require.Len(t, code.Struct.Statements, 1) + require.Len(t, code.Const.Statements, 1) + require.Equal(t, "answer", code.Const.Statements[0].Name[0].Value) } func TestStructDefErrors(t *testing.T) { @@ -413,24 +427,45 @@ func TestStructDefErrors(t *testing.T) { { name: "duplicate struct field header", input: `p = Person - :name age age + : name age age "Tejas" 35 184.5`, errMsg: "duplicate struct field header: age", }, { name: "multiple lhs bindings not allowed", input: `p, q = Person - :name age + : name age "Tejas" 35`, errMsg: "struct definition must bind exactly one constant name", }, { name: "comma-separated struct row not allowed", input: `p = Person - :name age + : name age "Tejas", 35`, errMsg: "struct value row values must be separated by spaces, not commas", }, + { + name: "struct header requires space after colon", + input: `p = Person + :name age + "Tejas" 35`, + errMsg: "expected a space after ':' in struct field header", + }, + { + name: "struct row requires nested indent", + input: `p = Person + : name age + "Tejas" 35`, + errMsg: "struct value row must be indented beneath its field header", + }, + { + name: "struct row must align with header", + input: `p = Person + : name age + "Tejas" 35`, + errMsg: "struct value row must align with the first field header", + }, } for _, tt := range tests { @@ -452,10 +487,10 @@ func TestStructDefErrors(t *testing.T) { func TestStructDefRepeat(t *testing.T) { input := `p = Person - :name age + : name age "Tejas" 35 q = Person - :name age + : name age "Ada" 28` cp := NewCodeParser(lexer.New("TestStructDefRepeat", input)) @@ -466,10 +501,10 @@ q = Person func TestStructDefSubset(t *testing.T) { input := `p = Person - :name age height + : name age height "Tejas" 35 184.5 q = Person - :age name + : age name 28 "Ada"` cp := NewCodeParser(lexer.New("TestStructDefSubset", input)) @@ -480,7 +515,7 @@ q = Person func TestStructDefZeroInit(t *testing.T) { input := `p = Person - :name age + : name age "Tejas" 35 q = Person` @@ -496,7 +531,7 @@ q = Person` func TestStructDefZeroInitBeforeDef(t *testing.T) { input := `q = Person p = Person - :name age + : name age "Tejas" 35` cp := NewCodeParser(lexer.New("TestStructDefZeroInitBeforeDef", input)) diff --git a/parser/parser.go b/parser/parser.go index 0dff96b2..a139375b 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -684,6 +684,13 @@ func (p *StmtParser) parseStructLiteralStatement(assignTok token.Token, idents [ } p.nextToken() + if p.curTokenIs(token.IDENT) && !p.curToken.HadSpace { + p.errors = append(p.errors, &token.CompileError{ + Token: p.curToken, + Msg: "expected a space after ':' in struct field header", + }) + return nil + } headers, ok := p.parseStructHeaders() if !ok { return nil @@ -698,6 +705,21 @@ func (p *StmtParser) parseStructLiteralStatement(assignTok token.Token, idents [ } p.nextToken() + if !p.curTokenIs(token.INDENT) { + p.errors = append(p.errors, &token.CompileError{ + Token: p.curToken, + Msg: "struct value row must be indented beneath its field header", + }) + return nil + } + p.nextToken() + if p.curToken.Column != headers[0].Column { + p.errors = append(p.errors, &token.CompileError{ + Token: p.curToken, + Msg: "struct value row must align with the first field header", + }) + return nil + } row, ok := p.parseStructRowConstants() if !ok { return nil @@ -714,6 +736,11 @@ func (p *StmtParser) parseStructLiteralStatement(assignTok token.Token, idents [ if p.curTokenIs(token.NEWLINE) { p.nextToken() } + if p.curTokenIs(token.DEINDENT) { + // Consume the value-row indentation. The surrounding struct-body + // DEINDENT remains current for CodeParser to consume. + p.nextToken() + } if !p.curTokenIs(token.EOF) && !p.curTokenIs(token.DEINDENT) { p.errors = append(p.errors, &token.CompileError{ diff --git a/runtime/runtime.c b/runtime/runtime.c index 5bdd6260..d0e8d28d 100644 --- a/runtime/runtime.c +++ b/runtime/runtime.c @@ -248,11 +248,9 @@ char *f32_str(float xf) { // Format a string using snprintf with variadic arguments. // Uses snprintf to determine size, then allocates exact buffer needed. -// Returns a newly allocated string that the caller must free(). -// NOTE: Currently, formatted strings are not automatically freed and will -// leak unless explicitly freed by the caller or at program exit. -// TODO: Implement proper string lifetime management with reference counting -// or scope-based cleanup. +// Returns a newly allocated string. Ownership transfers to generated code, +// which represents it as StrH and releases it through normal temporary, +// assignment, argument, and scope cleanup. char *sprintf_alloc(const char *fmt, ...) { va_list args1, args2; va_start(args1, fmt); diff --git a/tests/struct/struct.exp b/tests/struct/struct.exp index 4ac97b36..ed356933 100644 --- a/tests/struct/struct.exp +++ b/tests/struct/struct.exp @@ -2,18 +2,18 @@ Tejas 35 184.5 Ada 28 0 0 0 Person - :name age height + : name age height Tejas 35 184.5 Person - :name age height + : name age height Tejas 35 184.5 Person - :name age height + : name age height Tejas 35 184.5 99 x=Person - :name age height + : name age height Tejas 35 184.5 y=99 diff --git a/tests/struct/struct.pt b/tests/struct/struct.pt index 2812ac81..3319a740 100644 --- a/tests/struct/struct.pt +++ b/tests/struct/struct.pt @@ -1,9 +1,9 @@ p = Person - :name age height + : name age height "Tejas" 35 184.5 q = Person - :age name + : age name 28 "Ada" r = Person From 2f9765c2126066ba4afbb19974c55dcf0763a3e3 Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 24 Jul 2026 00:59:48 +0530 Subject: [PATCH 02/60] feat(compiler)!: finalize range streams at value roots Treat bare ranges and range-indexed selections as streams whose value roots retain the final yield, while explicit array literals collect all yields. Keep ArrayRange internal to immediate call specialization, use collision-free structural mangling, preserve shared-driver identity, and support callee-side Range/ArrayRange iteration across direct and indirect returns. Stage indirect outputs with write flags and preserve compatible input/output aliases across iterations. Update ownership and C ABI documentation and expand solver, IR, E2E, and leak coverage. BREAKING CHANGE: Bare Range and array-range values no longer print or bind descriptor views. Root use iterates and retains the final yielded value. --- README.md | 49 +- compiler/abi.go | 2 + compiler/array.go | 114 +-- compiler/cfg.go | 12 +- compiler/cfuncs.go | 9 +- compiler/compiler.go | 824 +++++++++++------- compiler/compiler_test.go | 141 ++- compiler/cond.go | 58 +- compiler/format.go | 53 +- compiler/loop.go | 41 +- compiler/mangle_test.go | 99 +++ compiler/solver.go | 293 ++++--- compiler/solver_test.go | 169 +++- compiler/types.go | 25 +- docs/Pluto Array Semantics.md | 30 +- docs/Pluto C ABI Spec.md | 127 ++- docs/Pluto Conditional Value Semantics.md | 5 +- docs/Pluto Memory Model.md | 144 +-- docs/Pluto Range Semantics.md | 112 ++- docs/Pluto String and Formatting Semantics.md | 11 + parser/parser.go | 2 +- parser/scriptparser_test.go | 4 +- runtime/runtime.c | 20 - tests/array/array.exp | 3 + tests/array/array.spt | 10 + tests/array/array_capture.exp | 4 +- tests/array/array_capture.spt | 10 +- tests/array/array_func.exp | 42 + tests/array/array_func.pt | 10 + tests/array/array_func.spt | 82 ++ tests/array/array_range.exp | 28 +- tests/array/array_range.pt | 12 + tests/array/array_range.spt | 53 +- tests/array/cond_accum.exp | 8 +- tests/array/cond_accum.spt | 44 +- tests/cond/expr_forms.exp | 14 +- tests/cond/expr_forms.spt | 16 +- tests/math/func.spt | 2 +- tests/math/func_array_range.exp | 8 +- tests/math/func_array_range.spt | 16 +- tests/math/func_nested_range.spt | 8 +- tests/math/func_range.exp | 8 +- tests/math/func_range.spt | 16 +- tests/math/print_func.exp | 9 +- tests/math/print_func.spt | 2 +- tests/mem/mem_str.exp | 17 + tests/mem/mem_str.pt | 18 + tests/mem/mem_str.spt | 81 ++ tests/range.exp | 8 +- tests/range.spt | 9 +- tests/range_finalize.exp | 21 + tests/range_finalize.spt | 63 ++ token/token.go | 2 +- 53 files changed, 2144 insertions(+), 824 deletions(-) create mode 100644 tests/range_finalize.exp create mode 100644 tests/range_finalize.spt diff --git a/README.md b/README.md index c2f6628a..c05603b2 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Intended for performance-sensitive scripting, numerical work, simulation, and sy Range-driven auto-vectorization and safe arrays. -Scope-based memory (no nulls, no out-of-bounds, no GC), and concurrency by construction. +Scope-based memory (no null values, no unsafe out-of-bounds access, no GC), and concurrency by construction. --- @@ -43,8 +43,8 @@ Scope-based memory (no nulls, no out-of-bounds, no GC), and concurrency by const - Template functions in `.pt`: specialized per argument types (generics by use) - Range literals with auto-vectorized execution - First-class rectangular arrays of any rank, columnar tables, and link semantics -- Scope-based memory: no nulls, no out-of-bounds, no garbage collector -- printf-style formatting; arrays/ranges printable +- Scope-based memory: no null values, no unsafe out-of-bounds access, no garbage collector +- printf-style formatting; arrays printable and range streams iterable - Cross-platform (Linux/macOS/Windows) ## Command name @@ -143,18 +143,18 @@ b = Square(2.2) # float specialization c = Square(arr) # squares each array element a, b, c -# Range, mask, and filter (non-accumulated) +# Range, mask, and filter (non-collected) d = Square(1:3) # range: final iteration result -e = Square(arr[1:3]) # array-range: final iteration result +e = Square(arr[1:3]) # range-indexed array: final iteration result f = Square(arr > 3) # element-wise mask: each element kept where > 3, else 0 -g = Square(arr[1:3] > 3) # array-range filter: final iteration result +g = Square(arr[1:3] > 3) # range-indexed filter: final iteration result d, e, f, g # Accumulation forms -h = [Square(1:3)] # range accumulation -i = [Square(arr[1:3])] # array-range accumulation -j = [Square((1:3) > 1)] # conditional range accumulation -k = [Square(arr[1:4] > 2)] # conditional array-range accumulation +h = [Square(1:3)] # range collection +i = [Square(arr[1:3])] # range-indexed collection +j = [Square((1:3) > 1)] # conditional range collection +k = [Square(arr[1:4] > 2)] # conditional range-indexed collection h, i, j, k ``` @@ -174,13 +174,28 @@ Generated code is equivalent to handwritten specialized code. There is no runtim ## Ranges as the execution primitive -Ranges are first-class values: +A range literal binds an execution domain: ```python -Square(1:5) +i = 0:5 +last = i # 4 +values = [i] # [0 1 2 3 4] +lastSquare = Square(i) # 16 ``` -Passing a range to a template executes it across all values. The compiler can map these operations to SIMD instructions — this is range-driven auto-vectorization. +A bare range at an assignment root keeps its final yield. Brackets +materialize all yields into an array. Passing a range to a template executes +the call once for each value. The compiler can map these +operations to SIMD instructions — this is range-driven auto-vectorization. + +Range-indexed array access follows the same rule: + +```python +arr = [10 20 30 40] +i = 1:4 +last = arr[i] # 40 +selected = [arr[i]] # [20 30 40] +``` Data-parallel execution without explicit loop syntax. @@ -195,7 +210,11 @@ x = [1 2 3 4 5] y = [1.1 2.2 3.3] ``` -Arrays are safe by construction — out-of-bounds access is not possible. Comparisons like `arr > 2` produce element-wise masks (each element kept where it holds, else 0) that work anywhere an array does. +Arrays are bounds-checked: an invalid index never reads outside array storage. +In a scalar range stream, invalid selection points yield nothing; inside `[]`, +they zero-fill to preserve shape. Comparisons like `arr > 2` produce +element-wise masks (each element kept where it holds, else 0) that work +anywhere an array does. The same bracket syntax infers higher-rank arrays and tables without a type keyword: @@ -292,7 +311,7 @@ Pluto uses deterministic, scope-based memory: - No garbage collector - No null values -- No out-of-bounds access +- No unsafe out-of-bounds memory access - Memory freed when scope ends Predictable performance with minimal runtime overhead. diff --git a/compiler/abi.go b/compiler/abi.go index cc905c5a..796cd85a 100644 --- a/compiler/abi.go +++ b/compiler/abi.go @@ -97,6 +97,8 @@ func classifyFuncABI(paramTypes []Type, outTypes []Type) FuncABI { if directType, ok := directScalarABIReturnType(outTypes); ok { abi.Return.Mode = ABIReturnDirect abi.Return.DirectType = directType + // Range-bearing variants need a seed so an empty range preserves the + // caller's destination. Ordinary scalar variants return directly. abi.Return.HasSeedParam = abi.HasRangeParams } diff --git a/compiler/array.go b/compiler/array.go index bee4c878..62eadc60 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -953,19 +953,6 @@ func (c *Compiler) arrayFormatArg(s *Symbol, info ArrayInfo, elementFormat strin return c.builder.CreateCall(fnTy, fn, formatArgs, "arr_format") } -func (c *Compiler) arrayRangeStrArgs(s *Symbol) (arrayStr llvm.Value, rangeStr llvm.Value) { - arrRange := s.Type.(ArrayRange) - agg := s.Val - arrPtr := c.builder.CreateExtractValue(agg, 0, "array_range_arr") - arrSym := &Symbol{Val: arrPtr, Type: arrRange.Array} - arrayStr = c.arrayStrArg(arrSym) - - rangeVal := c.builder.CreateExtractValue(agg, 1, "array_range_rng") - rangeSym := &Symbol{Val: rangeVal, Type: arrRange.Range} - rangeStr = c.rangeStrArg(rangeSym) - return -} - func (c *Compiler) compileArrayRangeOperands(expr *ast.ArrayRangeExpression) (*Symbol, *Symbol, Array) { arrayLoadName := "" if arrayIdent, ok := expr.Array.(*ast.Identifier); ok { @@ -982,6 +969,45 @@ func (c *Compiler) compileArrayRangeOperands(expr *ast.ArrayRangeExpression) (*S return arraySym, idxSym, arrType } +// compileArrayRangeCallArg builds the internal descriptor used only at an +// immediate call boundary. Ordinary array indexing still lowers to the final +// selected element/subarray and therefore cannot expose or retain ArrayRange. +func (c *Compiler) compileArrayRangeCallArg(expr *ast.ArrayRangeExpression, typ ArrayRange) *Symbol { + arrayLoadName := "" + if arrayIdent, ok := expr.Array.(*ast.Identifier); ok { + arrayLoadName = arrayIdent.Value + "_load" + } + arrayValues := c.compileExpression(expr.Array, nil) + if len(arrayValues) != 1 { + panic("internal: ArrayRange call argument must have one array source") + } + arraySym := c.derefIfPointer(arrayValues[0], arrayLoadName) + if !TypeEqual(arraySym.Type, typ.Array) { + panic(fmt.Sprintf("internal: ArrayRange source type mismatch: got %s, want %s", arraySym.Type, typ.Array)) + } + + var rangeSym *Symbol + switch rangeExpr := expr.Range.(type) { + case *ast.Identifier: + rangeSym = c.compileIdentifier(rangeExpr) + case *ast.RangeLiteral: + rangeSym = c.compileRangeExpression(rangeExpr)[0] + default: + panic(fmt.Sprintf("internal: unsupported ArrayRange call index %T", expr.Range)) + } + rangeSym = c.derefIfPointer(rangeSym, "array_range_index") + if !TypeEqual(rangeSym.Type, typ.Range) { + panic(fmt.Sprintf("internal: ArrayRange index type mismatch: got %s, want %s", rangeSym.Type, typ.Range)) + } + + _, arrayIsIdent := expr.Array.(*ast.Identifier) + return &Symbol{ + Val: c.CreateArrayRange(arraySym.Val, rangeSym.Val, typ), + Type: typ, + Borrowed: arrayIsIdent || arraySym.Borrowed, + } +} + func (c *Compiler) normalizeArrayIndex(idxSym *Symbol) llvm.Value { idxVal := idxSym.Val intType, ok := idxSym.Type.(Int) @@ -994,9 +1020,14 @@ func (c *Compiler) normalizeArrayIndex(idxSym *Symbol) llvm.Value { return c.builder.CreateIntCast(idxVal, c.Context.Int64Type(), "arr_idx_cast") } -func (c *Compiler) storeArrayRangeOutput(output *Symbol, value llvm.Value, valueType Type) { +func (c *Compiler) storeRangedOutput(output *Symbol, value llvm.Value, valueType Type) { c.freeSymbolValue(output, "old_output") - c.createStore(value, output.Val, valueType) + c.storeSymbolToSlot( + output, + &Symbol{Val: value, Type: valueType}, + output.Type.(Ptr).Elem, + "range_output_store", + ) } func (c *Compiler) compileArraySubarray(array *Symbol, index llvm.Value) *Symbol { @@ -1049,19 +1080,12 @@ func (c *Compiler) checkedArraySubarray(array *Symbol, index llvm.Value, inBound return c.createLoad(resultSlot, resultType, "array_subarray_checked") } -// compileArrayRangeExpression compiles an array indexing expression. -// If the index is a range (e.g., arr[0:10]), returns an ArrayRange symbol. -// If the index is a scalar (e.g., arr[4]), returns the element. -// We check the actual compiled index type, not cached OutTypes, because -// inside a loop the index may be bound to a scalar even if originally a range. +// compileArrayRangeExpression compiles an array indexing expression. A ranged +// index is always finalized or collected through the surrounding loop; only a +// scalar element/subarray value reaches compileArrayRangeBasic. func (c *Compiler) compileArrayRangeExpression(expr *ast.ArrayRangeExpression, dest []*ast.Identifier) []*Symbol { info := c.ExprCache[key(c.FuncNameMangled, expr)] - pending := c.pendingLoopRanges(info.Ranges) - if len(pending) > 0 { - // Bare range indices (arr[i] where i is a range) remain ArrayRange views. - if len(info.OutTypes) > 0 && info.OutTypes[0].Kind() == ArrayRangeKind { - return c.compileArrayRangeBasic(expr) - } + if len(c.pendingLoopRanges(info.Ranges)) > 0 { return c.compileArrayRangeRanges(info, dest) } return c.compileArrayRangeBasic(expr) @@ -1070,21 +1094,8 @@ func (c *Compiler) compileArrayRangeExpression(expr *ast.ArrayRangeExpression, d func (c *Compiler) compileArrayRangeBasic(expr *ast.ArrayRangeExpression) []*Symbol { arraySym, idxSym, arrType := c.compileArrayRangeOperands(expr) - // Check actual compiled index type to determine ArrayRange vs element access if idxSym.Type.Kind() == RangeKind { - // ArrayRange from an identifier is a borrowed view into existing storage. - _, arrayIsIdent := expr.Array.(*ast.Identifier) - borrowed := arrayIsIdent || arraySym.Borrowed - - arrRange := ArrayRange{ - Array: arrType, - Range: idxSym.Type.(Range), - } - return []*Symbol{{ - Val: c.CreateArrayRange(arraySym.Val, idxSym.Val, arrRange), - Type: arrRange, - Borrowed: borrowed, - }} + panic("internal: ranged array access reached scalar lowering without an iterator loop") } // Scalar element access does not retain the source array pointer. // Release temporary array sources on all scalar return paths; a consumed @@ -1124,7 +1135,8 @@ func (c *Compiler) compileArrayRangeRanges(info *ExprInfo, dest []*ast.Identifie PushScope(&c.Scopes, BlockScope) defer c.popScope() - outputs := c.makeOutputs(dest, info.OutTypes, true) + outputs := c.makeSeededTempOutputs(dest, info.OutTypes) + c.bindRangedTempOutputs(dest, outputs) output := outputs[0] withCollectorPreparedLoopNest(c, info.Rewrite.(*ast.ArrayRangeExpression), info.Ranges, nil, nil, func(rew *ast.ArrayRangeExpression) { @@ -1145,11 +1157,11 @@ func (c *Compiler) compileArrayRangeRanges(info *ExprInfo, dest []*ast.Identifie if c.currentLoopBoundsMode() == loopBoundsModeAffineFast && c.isFastAffineAccess(rew) { if arrType.Rank > 1 { subarray := c.compileArraySubarray(arraySym, idxVal) - c.storeArrayRangeOutput(output, subarray.Val, resultType) + c.storeRangedOutput(output, subarray.Val, resultType) return } elemVal := c.ArrayGet(arraySym, arrElemType, idxVal) - c.storeArrayRangeOutput(output, elemVal, resultType) + c.storeRangedOutput(output, elemVal, resultType) return } @@ -1160,10 +1172,10 @@ func (c *Compiler) compileArrayRangeRanges(info *ExprInfo, dest []*ast.Identifie c.builder.SetInsertPointAtEnd(storeBlock) if arrType.Rank > 1 { subarray := c.compileArraySubarray(arraySym, idxVal) - c.storeArrayRangeOutput(output, subarray.Val, resultType) + c.storeRangedOutput(output, subarray.Val, resultType) } else { elemVal := c.ArrayGet(arraySym, arrElemType, idxVal) - c.storeArrayRangeOutput(output, elemVal, resultType) + c.storeRangedOutput(output, elemVal, resultType) } c.builder.CreateBr(contBlock) @@ -1172,7 +1184,7 @@ func (c *Compiler) compileArrayRangeRanges(info *ExprInfo, dest []*ast.Identifie if arrType.Rank > 1 { zeroVal = c.zeroArraySubarray(arraySym) } - c.storeArrayRangeOutput(output, zeroVal.Val, zeroVal.Type) + c.storeRangedOutput(output, zeroVal.Val, zeroVal.Type) c.builder.CreateBr(contBlock) c.builder.SetInsertPointAtEnd(contBlock) @@ -1184,10 +1196,10 @@ func (c *Compiler) compileArrayRangeRanges(info *ExprInfo, dest []*ast.Identifie c.builder.SetInsertPointAtEnd(storeBlock) if arrType.Rank > 1 { subarray := c.compileArraySubarray(arraySym, idxVal) - c.storeArrayRangeOutput(output, subarray.Val, resultType) + c.storeRangedOutput(output, subarray.Val, resultType) } else { elemVal := c.ArrayGet(arraySym, arrElemType, idxVal) - c.storeArrayRangeOutput(output, elemVal, resultType) + c.storeRangedOutput(output, elemVal, resultType) } c.builder.CreateBr(contBlock) @@ -1195,9 +1207,5 @@ func (c *Compiler) compileArrayRangeRanges(info *ExprInfo, dest []*ast.Identifie }) }) - elemType := output.Type.(Ptr).Elem - return []*Symbol{{ - Val: c.createLoad(output.Val, elemType, "final"), - Type: elemType, - }} + return c.loadOutputValues(outputs, "final") } diff --git a/compiler/cfg.go b/compiler/cfg.go index b322fd18..aa75205b 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -223,6 +223,15 @@ func (cfg *CFG) hasRangeExpr(e ast.Expression) bool { c := cfg.ScriptCompiler.Compiler switch t := e.(type) { + case *ast.Identifier: + // A bare named Range is an iterated scalar-finalization root. Unlike a + // range literal constructor, an empty driver may leave an existing + // destination unchanged, so its write is conditional. + return len(c.ExprCache[key(c.FuncNameMangled, t)].Ranges) > 0 + case *ast.StringLiteral: + // Formatting markers can reference named Range drivers even though the + // dependency is not represented as an AST child. + return len(c.ExprCache[key(c.FuncNameMangled, t)].Ranges) > 0 case *ast.InfixExpression, *ast.PrefixExpression: return len(c.ExprCache[key(c.FuncNameMangled, t)].Ranges) > 0 case *ast.ArrayRangeExpression: @@ -257,7 +266,8 @@ func (cfg *CFG) hasRangeExpr(e ast.Expression) bool { case *ast.DotExpression: return cfg.hasRangeExpr(t.Left) default: - // Identifiers, literals, etc. are not conditional at root level + // Literals and other scalar roots are unconditional. A bare range + // literal is a driver constructor rather than an iterated use. return false } } diff --git a/compiler/cfuncs.go b/compiler/cfuncs.go index 20c19aca..059bb19e 100644 --- a/compiler/cfuncs.go +++ b/compiler/cfuncs.go @@ -9,9 +9,6 @@ const ( FREE = "free" STRDUP = "strdup" - // Range functions - RANGE_I64_STR = "range_i64_str" - // Scalar string functions F64_STR = "f64_str" F32_STR = "f32_str" @@ -63,7 +60,7 @@ const ( ) // GetFnType returns the LLVM FunctionType for a Pluto runtime helper -// name, like "printf", "free", or "range_i64_str". +// name, like "printf", "free", or "array_nd_str". func (c *Compiler) GetFnType(name string) llvm.Type { // Short helpers to reduce duplication charPtr := llvm.PointerType(c.Context.Int8Type(), 0) @@ -82,10 +79,6 @@ func (c *Compiler) GetFnType(name string) llvm.Type { case STRDUP: return llvm.FunctionType(charPtr, []llvm.Type{charPtr}, false) - // Range functions - case RANGE_I64_STR: - return llvm.FunctionType(charPtr, []llvm.Type{i64, i64, i64}, false) - // Scalar string functions case F64_STR: return llvm.FunctionType(charPtr, []llvm.Type{f64}, false) diff --git a/compiler/compiler.go b/compiler/compiler.go index e70690a4..74f948a8 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -12,11 +12,12 @@ import ( ) type Symbol struct { - Val llvm.Value - Type Type - FuncArg bool // Symbol originates from function input/output argument context. - Borrowed bool // Value/storage is borrowed from another owner (scope cleanup must skip). - ReadOnly bool // Input parameter (cannot be written to). + Val llvm.Value + Type Type + FuncArg bool // Symbol originates from function input/output argument context. + Borrowed bool // Value/storage is borrowed from another owner (scope cleanup must skip). + ReadOnly bool // Input parameter (cannot be written to). + WriteFlag llvm.Value // Optional i1* set when this logical output is actually written. } // Borrowed-value ownership model: @@ -39,9 +40,10 @@ type Symbol struct { // Memory management: // // For function calls (x = f(y)): -// - Function produces a new value and writes it to output slot -// - This value is MOVED to the destination (ownership transferred) -// - Old value in destination is NOT freed (see freeExprOldValues) +// - Function writes into an independent, destination-seeded staging slot +// - The staged result is MOVED to the real destination after all RHS values +// have been evaluated +// - The real destination's old value is then freed by freeExprOldValues // - Temps passed as inputs are freed by caller after call returns // - Function cleanup skips borrowed params/slots (caller owns them) // @@ -70,10 +72,11 @@ type FuncArgs struct { } type callArg struct { - Expr ast.Expression - Name string - Symbol *Symbol - Lowered *Symbol + Expr ast.Expression + Name string + Symbol *Symbol + Lowered *Symbol + OutputAlias int } type callSignature struct { @@ -123,6 +126,7 @@ func GetCopy(s *Symbol) (newSym *Symbol) { newSym.FuncArg = s.FuncArg newSym.Borrowed = s.Borrowed newSym.ReadOnly = s.ReadOnly + newSym.WriteFlag = s.WriteFlag return newSym } @@ -255,28 +259,24 @@ func (c *Compiler) resolvedDestTypes(dest []*ast.Identifier, outTypes []Type) [] if dest == nil || i >= len(dest) { continue } - resolved[i] = c.bindingSlotType(dest[i].Value, outType) - } - return resolved -} - -func outputTypesDiffer(a, b []Type) bool { - if len(a) != len(b) { - return true - } - for i := range a { - if !TypeEqual(a[i], b[i]) { - return true + bindingType, ok := c.BindingTypes[BindingKey{ + FuncNameMangled: c.FuncNameMangled, + Name: dest[i].Value, + }] + if ok { + resolved[i] = bindingType + continue + } + // Conditional lowering writes through synthetic condtmp_* identifiers. + // They have no solver binding entry, but their pointer element is the + // authoritative slot flavor selected for the real destination. + if sym, exists := Get(c.Scopes, dest[i].Value); exists { + if ptrType, isPtr := sym.Type.(Ptr); isPtr { + resolved[i] = ptrType.Elem + } } } - return false -} - -func (c *Compiler) callNeedsTempOutputs(info *ExprInfo, dest []*ast.Identifier) bool { - if len(info.Ranges) != 0 || dest == nil { - return false - } - return outputTypesDiffer(info.OutTypes, c.resolvedDestTypes(dest, info.OutTypes)) + return resolved } func (c *Compiler) addCallTypeError(tok token.Token, msg string) bool { @@ -485,9 +485,13 @@ func (c *Compiler) mapToLLVMType(t Type) llvm.Type { ) case ArrayRangeKind: arrRange := t.(ArrayRange) - arrayPtr := llvm.PointerType(c.Context.Int8Type(), 0) - rangeTy := c.mapToLLVMType(arrRange.Range) - return llvm.StructType([]llvm.Type{arrayPtr, rangeTy}, false) + return llvm.StructType( + []llvm.Type{ + c.mapToLLVMType(arrRange.Array), + c.mapToLLVMType(arrRange.Range), + }, + false, + ) case PtrKind: ptrType := t.(Ptr) elemLLVM := c.mapToLLVMType(ptrType.Elem) @@ -817,13 +821,6 @@ func (c *Compiler) makeZeroValue(symType Type) *Symbol { s.Val = c.createTableValue(c.ConstI64(0), columns, tableType) case RangeKind: s.Val = c.CreateRange(c.ConstI64(0), c.ConstI64(0), c.ConstI64(1), symType) - case ArrayRangeKind: - arrRangeType := symType.(ArrayRange) - // Create zero value for the array part - arraySym := c.makeZeroValue(arrRangeType.Array) - // Create zero value for the range part - rangeSym := c.makeZeroValue(arrRangeType.Range) - s.Val = c.CreateArrayRange(arraySym.Val, rangeSym.Val, arrRangeType) case StructKind: structType := symType.(Struct) fieldVals := make([]llvm.Value, len(structType.Fields)) @@ -949,11 +946,25 @@ func (c *Compiler) coerceSymbolForType(sym *Symbol, target Type, loadName string return derefed } +func (c *Compiler) markOutputSlotWritten(dst *Symbol, mergedFrom llvm.Value) { + if dst.WriteFlag.IsNil() { + return + } + // Moving a value produced by a stage that tracks the same logical output + // is a merge, not a new write. The stage already set the flag only on the + // runtime paths that yielded a value. + if !mergedFrom.IsNil() && mergedFrom == dst.WriteFlag { + return + } + c.builder.CreateStore(llvm.ConstInt(c.Context.Int1Type(), 1, false), dst.WriteFlag) +} + func (c *Compiler) storeSymbolToSlot(dst *Symbol, src *Symbol, target Type, loadName string) *Symbol { ptrType, ok := dst.Type.(Ptr) if !ok { panic("internal: storeSymbolToSlot requires pointer destination") } + sourceWriteFlag := src.WriteFlag if target.Kind() != ptrType.Elem.Kind() { target = ptrType.Elem } @@ -977,6 +988,7 @@ func (c *Compiler) storeSymbolToSlot(dst *Symbol, src *Symbol, target Type, load coerced := c.coerceSymbolForType(source, target, "") c.createStore(coerced.Val, dst.Val, coerced.Type) + c.markOutputSlotWritten(dst, sourceWriteFlag) return coerced } @@ -1028,9 +1040,10 @@ func (c *Compiler) freeValue(val llvm.Value, typ Type) { } } case ArrayRange: - // Release the backing array payload. Borrowed views are skipped by callers. - arrVal := c.builder.CreateExtractValue(val, 0, "arr_range_arr") - c.freeValue(arrVal, t.Array) + // Call-only ArrayRange descriptors own a temporary backing array when + // Borrowed is false. The range metadata itself has no heap storage. + arrayVal := c.builder.CreateExtractValue(val, 0, "array_range_arr") + c.freeValue(arrayVal, t.Array) } } @@ -1062,46 +1075,6 @@ func (c *Compiler) freeSymbolValue(sym *Symbol, loadName string) { c.freeValue(derefed.Val, derefed.Type) } -// shouldSkipOldValueFree returns true when an expression delegates destination -// old-value cleanup to inner assignment logic, avoiding caller-side double-free. -// -// Cases: -// -// - CallExpression writing directly through destination pointers: -// The caller passes output pointers to the callee. The callee then applies -// normal assignment cleanup when writing to those output params, so caller -// freeExprOldValues must skip. -// -// - InfixExpression/PrefixExpression/ArrayRangeExpression with pending ranges: -// Range-lowered paths free previous output values per iteration inside the -// loop body before storing the next value. -// -// All other expressions return false so freeExprOldValues handles cleanup with full -// assignment context (moved sources and borrowed/non-owning guards). -func (c *Compiler) shouldSkipOldValueFree(expr ast.Expression, dest []*ast.Identifier) bool { - if ce, isCall := expr.(*ast.CallExpression); isCall { - info := c.ExprCache[key(c.FuncNameMangled, ce)] - if _, ok := directScalarABIReturnType(info.OutTypes); ok { - return false - } - return !c.callNeedsTempOutputs(info, dest) - } - - switch e := expr.(type) { - case *ast.InfixExpression: - info := c.ExprCache[key(c.FuncNameMangled, e)] - return len(c.pendingLoopRanges(info.Ranges)) > 0 - case *ast.PrefixExpression: - info := c.ExprCache[key(c.FuncNameMangled, e)] - return len(c.pendingLoopRanges(info.Ranges)) > 0 - case *ast.ArrayRangeExpression: - info := c.ExprCache[key(c.FuncNameMangled, e)] - return len(c.pendingLoopRanges(info.Ranges)) > 0 - default: - return false - } -} - // slotAssign is one destination slot of an assignment: where the value is // written, which identifier owns move/copy decisions (the real destination, // even when writing through a temp), the compiled value and the RHS variable @@ -1186,6 +1159,7 @@ func (c *Compiler) newExprAssign(expr ast.Expression, bit llvm.Value, res []*Sym owner: owners[j], value: sym, rhsName: rhsName, + needsCopy: sym.Borrowed, oldValue: olds[j], destBacked: c.aliasesDestSlot(dests[j], sym), } @@ -1338,17 +1312,10 @@ func (c *Compiler) commitAssignments(assigns []exprAssign) { } // freeExprOldValues frees the destination values one expression's commit -// replaced, skipping fresh destinations, moved sources, borrowed storage, and -// expressions that manage destination cleanup themselves (calls writing -// through destination pointers; ranged lowerings free per iteration). +// replaced, skipping fresh destinations, moved sources, and borrowed storage. +// Function calls and ranged expressions return independent staged values, so +// their real destination cleanup also happens here. func (c *Compiler) freeExprOldValues(e exprAssign, movedSources map[string]struct{}) { - owners := make([]*ast.Identifier, len(e.slots)) - for j, slot := range e.slots { - owners[j] = slot.owner - } - if c.shouldSkipOldValueFree(e.expr, owners) { - return - } for _, slot := range e.slots { if slot.oldValue == nil || c.skipBorrowedOldValueFree(slot.oldValue) { continue @@ -1454,8 +1421,7 @@ func (c *Compiler) skipBorrowedOldValueFree(sym *Symbol) bool { return true } - // Borrowed array ranges are non-owning views into another array payload. - return sym.Type.Kind() == ArrayRangeKind + return false } // captureOldValues captures the current values of destination variables before RHS compilation. @@ -1513,7 +1479,7 @@ func (c *Compiler) compileExpression(expr ast.Expression, dest []*ast.Identifier s.Val = c.ConstF64(e.Value) res = []*Symbol{s} case *ast.StringLiteral: - res = []*Symbol{c.compileStringLiteral(e.Token)} + res = c.compileStringLiteralExpression(e, dest) case *ast.RangeLiteral: info := c.ExprCache[key(c.FuncNameMangled, e)] // Root bare range literals can be scalarized by an outer ranged context. @@ -1531,7 +1497,7 @@ func (c *Compiler) compileExpression(expr ast.Expression, dest []*ast.Identifier case *ast.DotExpression: return c.compileDotExpression(e) case *ast.Identifier: - res = []*Symbol{c.compileIdentifier(e)} + res = c.compileIdentifierExpression(e, dest) case *ast.InfixExpression: res = c.compileInfixExpression(e, dest) case *ast.PrefixExpression: @@ -1565,10 +1531,7 @@ func setInstAlignment(inst llvm.Value, t Type) { inst.SetAlignment(8) case Range: setInstAlignment(inst, typ.Iter) - case Array, Table: - inst.SetAlignment(8) - case ArrayRange: - // ArrayRange is a struct of { i8*, Range }, so align to the largest member, which is i8* + case Array, Table, ArrayRange: inst.SetAlignment(8) case Struct: // Struct alignment follows max-field ABI alignment; Phase 1 fields are scalar/ptr. @@ -1590,11 +1553,12 @@ func (c *Compiler) makePtr(name string, s *Symbol) (ptr *Symbol, alreadyPtr bool // Create the new symbol that represents the pointer to this memory. ptr = &Symbol{ - Val: alloca, - Type: Ptr{Elem: s.Type}, - FuncArg: s.FuncArg, - Borrowed: s.Borrowed, - ReadOnly: s.ReadOnly, + Val: alloca, + Type: Ptr{Elem: s.Type}, + FuncArg: s.FuncArg, + Borrowed: s.Borrowed, + ReadOnly: s.ReadOnly, + WriteFlag: s.WriteFlag, } return ptr, false @@ -1644,11 +1608,12 @@ func (c *Compiler) promoteAlias(name string, sym *Symbol, alias *paramAlias) *Sy } ptr := &Symbol{ - Val: slotPtr, - Type: Ptr{Elem: sym.Type}, - FuncArg: sym.FuncArg, - Borrowed: sym.Borrowed, - ReadOnly: sym.ReadOnly, + 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) @@ -1696,6 +1661,8 @@ func (c *Compiler) derefIfPointer(s *Symbol, loadName string) *Symbol { newS := GetCopy(s) newS.Val = loadedVal newS.Type = ptrType.Elem + // A write flag belongs to the storage slot, not to an ordinary value read. + newS.WriteFlag = llvm.Value{} return newS } @@ -1751,6 +1718,51 @@ func (c *Compiler) compileIdentifier(ident *ast.Identifier) *Symbol { return c.derefIfPointer(s, ident.Value+"_load") } +// compileStringLiteralExpression lowers formatting markers that reference +// named Range drivers once per yield and retains the final formatted string. +func (c *Compiler) compileStringLiteralExpression(lit *ast.StringLiteral, dest []*ast.Identifier) []*Symbol { + info := c.ExprCache[key(c.FuncNameMangled, lit)] + if info == nil || len(c.pendingLoopRanges(info.Ranges)) == 0 { + return []*Symbol{c.compileStringLiteral(lit.Token)} + } + + PushScope(&c.Scopes, BlockScope) + defer c.popScope() + + outputs := c.makeSeededTempOutputs(dest, info.OutTypes) + c.bindRangedTempOutputs(dest, outputs) + output := outputs[0] + c.withLoopNest(info.Ranges, func() { + value := c.compileStringLiteral(lit.Token) + c.storeRangedOutput(output, value.Val, value.Type) + }) + + return c.loadOutputValues(outputs, "format_range_final") +} + +// compileIdentifierExpression closes a bare named Range driver to its final +// yielded iterator value. When an outer loop has already shadowed the Range +// with a scalar, the identifier compiles directly. +func (c *Compiler) compileIdentifierExpression(ident *ast.Identifier, dest []*ast.Identifier) []*Symbol { + info := c.ExprCache[key(c.FuncNameMangled, ident)] + if info == nil || len(c.pendingLoopRanges(info.Ranges)) == 0 { + return []*Symbol{c.compileIdentifier(ident)} + } + + PushScope(&c.Scopes, BlockScope) + defer c.popScope() + + outputs := c.makeSeededTempOutputs(dest, info.OutTypes) + c.bindRangedTempOutputs(dest, outputs) + output := outputs[0] + c.withLoopNest(info.Ranges, func() { + value := c.compileIdentifier(ident) + c.storeRangedOutput(output, value.Val, value.Type) + }) + + return c.loadOutputValues(outputs, "range_final") +} + func (c *Compiler) compileDotExpression(expr *ast.DotExpression) []*Symbol { leftSym := c.compileExpression(expr.Left, nil)[0] leftSym = c.derefIfPointer(leftSym, "dot_left") @@ -2070,24 +2082,24 @@ func (c *Compiler) CreateRange(start, stop, step llvm.Value, typ Type) llvm.Valu return agg } -func (c *Compiler) CreateArrayRange(arrayVal llvm.Value, rangeVal llvm.Value, arrRange ArrayRange) llvm.Value { - llvmTy := c.mapToLLVMType(arrRange) - agg := llvm.Undef(llvmTy) +func (c *Compiler) CreateArrayRange(arrayVal, rangeVal llvm.Value, typ ArrayRange) llvm.Value { + llvmType := c.mapToLLVMType(typ) + agg := llvm.Undef(llvmType) agg = c.builder.CreateInsertValue(agg, arrayVal, 0, "array_range_arr") agg = c.builder.CreateInsertValue(agg, rangeVal, 1, "array_range_rng") return agg } -// Modified compileInfixRanges - cleaner with destinations +// compileInfixRanges evaluates an infix expression once per driver yield and +// retains one loop-carried result per destination. func (c *Compiler) compileInfixRanges(expr *ast.InfixExpression, info *ExprInfo, dest []*ast.Identifier) (res []*Symbol) { - // Infix expressions never accumulate (Accumulates is always false for infix) - // They loop and store the final value PushScope(&c.Scopes, BlockScope) defer c.popScope() // Setup outputs to store values across iterations. // Mark as borrowed so cleanupScope skips them - values are returned via out. - outputs := c.makeOutputs(dest, c.resolvedDestTypes(dest, info.OutTypes), true) + outputs := c.makeSeededTempOutputs(dest, info.OutTypes) + c.bindRangedTempOutputs(dest, outputs) rew := info.Rewrite.(*ast.InfixExpression) withCollectorPreparedLoopNest(c, rew, info.Ranges, nil, nil, func(prepared *ast.InfixExpression) { @@ -2121,17 +2133,7 @@ func (c *Compiler) compileInfixRanges(expr *ast.InfixExpression, info *ExprInfo, }) }) - // Load final values from outputs - out := make([]*Symbol, len(outputs)) - for i := range outputs { - elemType := outputs[i].Type.(Ptr).Elem - out[i] = &Symbol{ - Val: c.createLoad(outputs[i].Val, elemType, "final"), - Type: elemType, - } - } - - return out + return c.loadOutputValues(outputs, "final") } func (c *Compiler) compileRangeInfixSlot( @@ -2186,8 +2188,15 @@ func (c *Compiler) storeRangeCondScalar(op string, leftSym *Symbol, rightSym *Sy ifBlock, elseBlock, contBlock := c.createIfElseCont(cmpVal, "cond_store", "cond_drop_lhs", "cond_next") c.builder.SetInsertPointAtEnd(ifBlock) + toStore := lSym + if !leftTempsHandledInline { + // Identifier LHS values are borrowed from live bindings. Copy before + // releasing the loop-carried output, especially for self-reference + // where both values can currently point at the same payload. + toStore = c.deepCopyIfNeeded(lSym) + } c.freeSymbolValue(output, "old_output") - c.storeSymbolToSlot(output, lSym, output.Type.(Ptr).Elem, "range_cond_store") + c.storeSymbolToSlot(output, toStore, output.Type.(Ptr).Elem, "range_cond_store") c.builder.CreateBr(contBlock) c.builder.SetInsertPointAtEnd(elseBlock) @@ -2226,11 +2235,6 @@ func (c *Compiler) updateUnresolvedType(name string, sym *Symbol, resolved Type) sym.Type = resolved Put(c.Scopes, name, sym) } - case ArrayRange: - if t.Array.ElemType.Kind() == UnresolvedKind { - sym.Type = resolved - Put(c.Scopes, name, sym) - } case Ptr: if t.Elem.Kind() == UnresolvedKind { sym.Type = Ptr{Elem: resolved} @@ -2241,47 +2245,6 @@ func (c *Compiler) updateUnresolvedType(name string, sym *Symbol, resolved Type) } } -func (c *Compiler) makeOutputs(dest []*ast.Identifier, outTypes []Type, borrowed bool) []*Symbol { - outputs := make([]*Symbol, len(outTypes)) - - for i, outType := range outTypes { - // Determine the name for the alloca - var name string - if i < len(dest) { - name = dest[i].Value - } else { - name = fmt.Sprintf("tmp_out_%d", c.tmpCounter) - c.tmpCounter++ - } - - sym, exists := Get(c.Scopes, name) - if exists { - // Existing variable - update type if needed and promote to memory - c.updateUnresolvedType(name, sym, outType) - if sym.Type.Kind() == PtrKind { - // Shadow existing pointer symbols in the current scope so temporary - // ownership flags (e.g. Borrowed during range lowering) do not - // mutate outer-scope symbols. - sym = GetCopy(sym) - Put(c.Scopes, name, sym) - } else { - sym = c.promoteToMemory(name) - } - // Preserve existing borrowed ownership and only add temporary borrowed semantics. - // Example: function output params are already Borrowed=true (caller-owned slots). - // A call path uses borrowed=false, and must not clear that existing ownership. - sym.Borrowed = sym.Borrowed || borrowed - outputs[i] = sym - continue - } - - // New variable or intermediate value - create temp alloca without adding to scope. - // The permanent variable will be created by writeTo in FuncScope. - outputs[i] = c.makeTempOutput(name, outType, borrowed, nil) - } - return outputs -} - func (c *Compiler) makeTempOutput(name string, outType Type, borrowed bool, seed *Symbol) *Symbol { ptr := c.createEntryBlockAlloca(c.mapToLLVMType(outType), name) ptrElem := outType @@ -2301,21 +2264,153 @@ func (c *Compiler) makeTempOutput(name string, outType Type, borrowed bool, seed return output } -func (c *Compiler) makeTempOutputs(outTypes []Type, borrowed bool, seedFor func(int, Type) *Symbol) []*Symbol { - outputs := make([]*Symbol, len(outTypes)) - for i, outType := range outTypes { +// makeSeededTempOutputs creates independent result slots. Existing destination +// values seed output-parameter and empty-range behavior, but heap-backed seeds +// are copied so evaluating one RHS cannot mutate or free a destination before +// sibling RHS expressions have read the statement's original values. +func (c *Compiler) makeSeededTempOutputs(dest []*ast.Identifier, outTypes []Type) []*Symbol { + resolved := c.resolvedDestTypes(dest, outTypes) + outputs := make([]*Symbol, len(resolved)) + for i, outType := range resolved { name := fmt.Sprintf("calltmp_%d", c.tmpCounter) c.tmpCounter++ + var existing *Symbol + var exists bool + if dest != nil && i < len(dest) { + existing, exists = Get(c.Scopes, dest[i].Value) + } + var seed *Symbol - if seedFor != nil { - seed = seedFor(i, outType) + if exists { + seed = c.deepCopyIfNeeded(c.resolveDestSeed(dest[i], outType)) + } else { + seed = c.makeZeroValue(outType) + } + outputs[i] = c.makeTempOutput(name, outType, true, seed) + if exists { + outputs[i].WriteFlag = existing.WriteFlag } - outputs[i] = c.makeTempOutput(name, outType, borrowed, seed) } return outputs } +type callOutputAdapter struct { + abiOutput *Symbol + bridged bool +} + +// makeCallOutputAdapters adapts destination-typed result slots to an indirect +// callee's declared output types. Matching slots pass through directly. A +// mismatched slot gets an ABI-typed zero seed; the callee's explicit per-output +// write flag decides whether that value commits into the destination-typed +// stage. This prevents one static ownership/shape flavor from masquerading as +// another inside the callee. +func (c *Compiler) makeCallOutputAdapters(running []*Symbol, outTypes []Type) []callOutputAdapter { + adapters := make([]callOutputAdapter, len(running)) + for i, output := range running { + targetType := output.Type.(Ptr).Elem + if TypeEqual(targetType, outTypes[i]) { + adapters[i].abiOutput = output + continue + } + + name := fmt.Sprintf("calladapter_%d", c.tmpCounter) + c.tmpCounter++ + adapters[i].abiOutput = c.makeTempOutput(name, outTypes[i], true, nil) + adapters[i].bridged = true + } + return adapters +} + +func callAdapterOutputs(adapters []callOutputAdapter) []*Symbol { + outputs := make([]*Symbol, len(adapters)) + for i := range adapters { + outputs[i] = adapters[i].abiOutput + } + return outputs +} + +func (c *Compiler) propagateOutputWriteFlag(output *Symbol, didWrite llvm.Value, index int) { + if output.WriteFlag.IsNil() { + return + } + previous := c.builder.CreateLoad(c.Context.Int1Type(), output.WriteFlag, fmt.Sprintf("output_written_%d", index)) + merged := c.builder.CreateOr(previous, didWrite, fmt.Sprintf("output_written_merge_%d", index)) + c.builder.CreateStore(merged, output.WriteFlag) +} + +// commitCallOutputAdapters commits ABI-flavor temporaries into the independent +// result slots after (and only after) a call executes. Store/coercion happens +// before old-value cleanup so shape resets and StrG -> StrH copies can inspect +// the previous destination safely. +func (c *Compiler) commitCallOutputAdapters(running []*Symbol, adapters []callOutputAdapter, writeFlags []llvm.Value) { + for i, adapter := range adapters { + didWrite := c.builder.CreateLoad(c.Context.Int1Type(), writeFlags[i], fmt.Sprintf("call_output_written_%d", i)) + if !adapter.bridged { + c.propagateOutputWriteFlag(running[i], didWrite, i) + continue + } + + commit := func() { + targetType := running[i].Type.(Ptr).Elem + oldValue := c.derefIfPointer(running[i], fmt.Sprintf("calladapter_old_%d", i)) + source := c.derefIfPointer(adapter.abiOutput, fmt.Sprintf("calladapter_result_%d", i)) + stored := c.storeSymbolToSlot(running[i], source, targetType, fmt.Sprintf("calladapter_store_%d", i)) + c.freeSymbolValue(oldValue, "") + if stored.Val != source.Val { + c.freeSymbolValue(source, "") + } + } + + c.withCondBranch(didWrite, "calladapter", commit, func() { + c.freeSymbolValue(adapter.abiOutput, fmt.Sprintf("calladapter_unwritten_%d", i)) + }) + } +} + +func (c *Compiler) cleanupSkippedCallOutputAdapters(adapters []callOutputAdapter) { + for i, adapter := range adapters { + if adapter.bridged { + c.freeSymbolValue(adapter.abiOutput, fmt.Sprintf("calladapter_skip_%d", i)) + } + } +} + +// 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 condtmp_* 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. +func (c *Compiler) bindRangedTempOutputs(dest []*ast.Identifier, outputs []*Symbol) { + for i := 0; i < len(dest) && i < len(outputs); i++ { + 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 + } + } + } + for _, name := range names { + Put(c.Scopes, name, outputs[i]) + } + } +} + // Destination-aware prefix compilation, // mirroring compileInfixExpression/compileInfixRanges. @@ -2367,7 +2462,8 @@ func (c *Compiler) compilePrefixRanges(expr *ast.PrefixExpression, info *ExprInf // Allocate/seed per-destination temps (seed from existing value or zero). // Mark as borrowed so cleanupScope skips them - the values are returned via out. - outputs := c.makeOutputs(dest, c.resolvedDestTypes(dest, info.OutTypes), true) + outputs := c.makeSeededTempOutputs(dest, info.OutTypes) + c.bindRangedTempOutputs(dest, outputs) withCollectorPreparedLoopNest(c, info.Rewrite.(*ast.PrefixExpression), info.Ranges, nil, nil, func(prepared *ast.PrefixExpression) { rightRew := prepared.Right @@ -2387,17 +2483,7 @@ func (c *Compiler) compilePrefixRanges(expr *ast.PrefixExpression, info *ExprInf }) }) - // Materialize final values - out := make([]*Symbol, len(outputs)) - for i := range outputs { - elemType := outputs[i].Type.(Ptr).Elem - out[i] = &Symbol{ - Val: c.createLoad(outputs[i].Val, elemType, "final"), - Type: elemType, - } - } - - return out + return c.loadOutputValues(outputs, "final") } func (c *Compiler) compileRangePrefixSlot(op string, operand *Symbol, expected Type, output *Symbol) { @@ -2430,9 +2516,10 @@ func (c *Compiler) getReturnStruct(mangled string, outputTypes []Type) llvm.Type } // Otherwise, define it exactly once: st := c.Context.StructCreateNamed(retName) - fields := make([]llvm.Type, len(outputTypes)) + fields := make([]llvm.Type, len(outputTypes)*2) for i, t := range outputTypes { fields[i] = llvm.PointerType(c.mapToLLVMType(t), 0) + fields[len(outputTypes)+i] = llvm.PointerType(c.Context.Int1Type(), 0) } st.StructSetBody(fields, false) return st @@ -2555,6 +2642,9 @@ func (c *Compiler) processIndirectOutputs(fn *ast.FuncStatement, retStruct llvm. Borrowed: true, ReadOnly: false, } + flagField := c.builder.CreateStructGEP(retStruct, sretPtr, len(fn.Outputs)+i, outIdent.Value+"_written_field") + flagPtrType := llvm.PointerType(c.Context.Int1Type(), 0) + retPtrs[i].WriteFlag = c.builder.CreateLoad(flagPtrType, flagField, outIdent.Value+"_written") } return retPtrs } @@ -2621,8 +2711,7 @@ func (c *Compiler) processParams(template *ast.FuncStatement, sig *callSignature } } - kind := elemType.Kind() - if kind == RangeKind || kind == ArrayRangeKind { + if elemType.Kind() == RangeKind || elemType.Kind() == ArrayRangeKind { iterIndices = append(iterIndices, i) continue } @@ -2683,13 +2772,6 @@ func (c *Compiler) iterOverRange(rangeType Range, rangeVal llvm.Value, body func }) } -func (c *Compiler) iterOverArrayRange(arrRangeSym *Symbol, body func(llvm.Value, Type)) { - c.iterOverArrayRangeState(arrRangeSym, nil, func(iter llvm.Value, iterType Type, _ *Symbol) *Symbol { - body(iter, iterType) - return nil - }) -} - func (c *Compiler) iterOverRangeState(rangeType Range, rangeVal llvm.Value, currentOutput *Symbol, body func(llvm.Value, Type, *Symbol) *Symbol) *Symbol { iterType := rangeType.Iter hasState := currentOutput != nil @@ -2723,10 +2805,16 @@ func (c *Compiler) iterOverRangeState(rangeType Range, rangeVal llvm.Value, curr func (c *Compiler) iterOverArrayRangeState(arrRangeSym *Symbol, currentOutput *Symbol, body func(llvm.Value, Type, *Symbol) *Symbol) *Symbol { arrRangeType := arrRangeSym.Type.(ArrayRange) - arrPtr := c.builder.CreateExtractValue(arrRangeSym.Val, 0, "array_range_ptr") + arrayVal := c.builder.CreateExtractValue(arrRangeSym.Val, 0, "array_range_arr") rangeVal := c.builder.CreateExtractValue(arrRangeSym.Val, 1, "array_range_bounds") - arraySym := &Symbol{Val: arrPtr, Type: arrRangeType.Array} - elemType := arrRangeType.Array.ElemType + arraySym := &Symbol{ + Val: arrayVal, + Type: arrRangeType.Array, + Borrowed: true, + ReadOnly: true, + } + resultType := arrayIndexResultType(arrRangeType.Array) + hasState := currentOutput != nil stateType := llvm.Type{} seed := llvm.Value{} @@ -2736,28 +2824,41 @@ func (c *Compiler) iterOverArrayRangeState(arrRangeSym *Symbol, currentOutput *S } finalVal := c.createLoopCore(rangeVal, seed, stateType, hasState, func(iter llvm.Value, current llvm.Value) llvm.Value { - inBounds := c.arrayIndexInBounds(arraySym, elemType, iter) + inBounds := c.arrayIndexInBounds(arraySym, arrRangeType.Array.ElemType, iter) preCheck := c.builder.GetInsertBlock() iterBlock, contBlock := c.createIfCont(inBounds, "arr_iter_in_bounds", "arr_iter_cont") c.builder.SetInsertPointAtEnd(iterBlock) - elemVal := c.ArrayGetBorrowed(arraySym, elemType, iter) + var yielded *Symbol + if arrRangeType.Array.Rank == 1 { + yielded = &Symbol{ + Val: c.ArrayGetBorrowed(arraySym, arrRangeType.Array.ElemType, iter), + Type: resultType, + Borrowed: true, + ReadOnly: true, + } + } else { + yielded = c.compileArraySubarray(arraySym, iter) + } + var state *Symbol if hasState { state = GetCopy(currentOutput) state.Val = current } - next := body(elemVal, elemType, state) + next := body(yielded.Val, yielded.Type, state) + if arrRangeType.Array.Rank > 1 { + c.freeSymbolValue(yielded, "array_range_subarray") + } c.builder.CreateBr(contBlock) - iterEnd := c.builder.GetInsertBlock() + c.builder.SetInsertPointAtEnd(contBlock) if !hasState { return llvm.Value{} } - // Array-range iteration can skip the body when the element is out of - // bounds, so merge the unchanged incoming state with the body-updated - // state before handing it back to createLoopCore. + // Out-of-bounds indices skip the function body, so preserve the + // incoming direct-return state on that edge. merged := c.builder.CreatePHI(stateType, "arr_iter_state") merged.AddIncoming([]llvm.Value{current}, []llvm.BasicBlock{preCheck}) merged.AddIncoming([]llvm.Value{next.Val}, []llvm.BasicBlock{iterEnd}) @@ -2812,7 +2913,7 @@ func (c *Compiler) funcLoopNest(fn *ast.FuncStatement, fa *FuncArgs, level int, rangeVal := c.createLoad(paramPtr, elemType, name+"_range") result = c.iterOverRangeState(rangeType, rangeVal, currentOutput, next) case ArrayRangeKind: - arrRangeVal := c.createLoad(paramPtr, elemType, name+"_arrrange") + arrRangeVal := c.createLoad(paramPtr, elemType, name+"_array_range") arrRangeSym := &Symbol{ Val: arrRangeVal, Type: elemType, @@ -2884,31 +2985,75 @@ func (c *Compiler) createIfCont(cond llvm.Value, ifName, contName string) (llvm. return ifBlock, contBlock } -func (c *Compiler) compileCallArgs(ce *ast.CallExpression) []callArg { +func (c *Compiler) compileCallArgs(sig *callSignature, ce *ast.CallExpression) []callArg { args := []callArg{} + paramIndex := 0 for _, callArgExpr := range ce.Arguments { + if paramIndex >= len(sig.ParamTypes) { + panic("internal: call argument count exceeds resolved signature") + } + if arrayRangeType, ok := sig.ParamTypes[paramIndex].(ArrayRange); ok { + arrayRangeExpr, ok := callArgExpr.(*ast.ArrayRangeExpression) + if !ok { + panic(fmt.Sprintf("internal: ArrayRange parameter received %T", callArgExpr)) + } + args = append(args, callArg{ + Expr: callArgExpr, + Symbol: c.compileArrayRangeCallArg(arrayRangeExpr, arrayRangeType), + OutputAlias: -1, + }) + paramIndex++ + continue + } + if ident, ok := callArgExpr.(*ast.Identifier); ok { args = append(args, callArg{ - Expr: callArgExpr, - Name: ident.Value, + Expr: callArgExpr, + Name: ident.Value, + OutputAlias: -1, }) + paramIndex++ continue } res := c.compileExpression(callArgExpr, nil) for _, r := range res { + if paramIndex >= len(sig.ParamTypes) { + panic("internal: compiled call argument count exceeds resolved signature") + } args = append(args, callArg{ - Expr: callArgExpr, - Symbol: r, + Expr: callArgExpr, + Symbol: r, + OutputAlias: -1, }) + paramIndex++ } } + if paramIndex != len(sig.ParamTypes) { + panic(fmt.Sprintf("internal: compiled %d call arguments for %d parameters", paramIndex, len(sig.ParamTypes))) + } return args } +func (c *Compiler) indirectCallOutputAlias(sig *callSignature, paramIndex int, arg callArg, dest []*ast.Identifier) int { + if !sig.ABI.HasRangeParams || sig.ABI.Params[paramIndex].Mode != ABIParamIndirect || arg.Name == "" { + return -1 + } + for outputIndex, output := range dest { + if output.Value != arg.Name || outputIndex >= len(sig.ABI.Return.OutTypes) { + continue + } + if TypeEqual(sig.ParamTypes[paramIndex], sig.ABI.Return.OutTypes[outputIndex]) { + return outputIndex + } + } + return -1 +} + func (c *Compiler) lowerCallArgs(funcName string, args []callArg, sig *callSignature, dest []*ast.Identifier) []int { aliasIndices := c.buildCallParamAliasIndices(sig, args, dest) for i, arg := range args { + args[i].OutputAlias = c.indirectCallOutputAlias(sig, i, arg, dest) sym := arg.Symbol if sig.ABI.Params[i].Mode != ABIParamIndirect { if arg.Name != "" { @@ -2957,7 +3102,7 @@ func (c *Compiler) freeCallArgTemps(callArgs []callArg) { } func (c *Compiler) prepareCall(sig *callSignature, ce *ast.CallExpression, dest []*ast.Identifier) preparedCall { - callArgs := c.compileCallArgs(ce) + callArgs := c.compileCallArgs(sig, ce) aliasIndices := c.lowerCallArgs(sig.FuncName, callArgs, sig, dest) fn, funcType, retStruct := c.getOrCompileCallFunction(sig) return preparedCall{ @@ -2976,41 +3121,45 @@ func (c *Compiler) withPreparedCall(sig *callSignature, ce *ast.CallExpression, } func (c *Compiler) runCallWithBounds(run func()) { + c.runCallWithBoundsElse(run, nil) +} + +func (c *Compiler) runCallWithBoundsElse(run func(), onSkip func()) { if !c.withStmtBoundsGuard( "call_bounds_ok", "call_bounds_run", "call_bounds_skip", "call_bounds_cont", run, - nil, + onSkip, ) { run() } } -func (c *Compiler) loadOutputValues(outputs []*Symbol, outTypes []Type, name string) []*Symbol { +func (c *Compiler) loadOutputValues(outputs []*Symbol, name string) []*Symbol { out := make([]*Symbol, len(outputs)) for i := range outputs { - elemType := outTypes[i] + elemType := outputs[i].Type.(Ptr).Elem loadName := name if len(outputs) > 1 { loadName = fmt.Sprintf("%s_%d", name, i) } out[i] = &Symbol{ - Val: c.createLoad(outputs[i].Val, elemType, loadName), - Type: elemType, + Val: c.createLoad(outputs[i].Val, elemType, loadName), + Type: elemType, + WriteFlag: outputs[i].WriteFlag, } } return out } func (c *Compiler) compileDirectCallWithRanges(sig *callSignature, info *ExprInfo, dest []*ast.Identifier) []*Symbol { - outputs := c.makeTempOutputs(info.OutTypes, true, func(i int, outType Type) *Symbol { - if dest != nil && i < len(dest) { - return c.resolveDestSeed(dest[i], outType) - } - return c.makeZeroValue(outType) - }) + PushScope(&c.Scopes, BlockScope) + defer c.popScope() + + outputs := c.makeSeededTempOutputs(dest, info.OutTypes) + c.bindRangedTempOutputs(dest, outputs) withCollectorPreparedLoopNest(c, info.Rewrite.(*ast.CallExpression), info.Ranges, nil, nil, func(rewCall *ast.CallExpression) { c.pushBoundsGuard("call_iter_bounds_guard") c.compileCondExprValue(rewCall, llvm.Value{}, func() { @@ -3019,10 +3168,16 @@ func (c *Compiler) compileDirectCallWithRanges(sig *callSignature, info *ExprInf c.popBoundsGuard() }) - return c.loadOutputValues(outputs, info.OutTypes, "final") + return c.loadOutputValues(outputs, "final") } -func (c *Compiler) compileIndirectCallWithRanges(sig *callSignature, info *ExprInfo, dest []*ast.Identifier, outputs []*Symbol) []*Symbol { +func (c *Compiler) compileIndirectCallWithRanges(sig *callSignature, info *ExprInfo, dest []*ast.Identifier) []*Symbol { + PushScope(&c.Scopes, BlockScope) + defer c.popScope() + + outputs := c.makeSeededTempOutputs(dest, info.OutTypes) + c.bindRangedTempOutputs(dest, outputs) + withCollectorPreparedLoopNest(c, info.Rewrite.(*ast.CallExpression), info.Ranges, nil, nil, func(rewCall *ast.CallExpression) { // Scope bounds checks to this loop iteration: arguments can contain // multiple array reads, and the call should execute only when all are @@ -3031,15 +3186,15 @@ func (c *Compiler) compileIndirectCallWithRanges(sig *callSignature, info *ExprI // Inside loop, ranges are shadowed as scalars. If call arguments contain // conditional expressions, execute the call only when they hold. c.compileCondExprValue(rewCall, llvm.Value{}, func() { - c.compileCallInner(sig, rewCall, dest, outputs) + c.compileIndirectCallIntoStagedOutputs(sig, rewCall, dest, outputs) }) c.popBoundsGuard() }) // Loop path materializes final values from output slots after iteration. - // Slots are seeded by makeOutputs (existing value or zero for new vars), so + // Slots are seeded by makeSeededTempOutputs (existing value or zero for new vars), so // empty ranges naturally preserve no-op semantics for existing destinations. - return c.loadOutputValues(outputs, info.OutTypes, "final") + return c.loadOutputValues(outputs, "final") } func (c *Compiler) compileCallExpression(ce *ast.CallExpression, dest []*ast.Identifier) (res []*Symbol) { @@ -3054,6 +3209,11 @@ func (c *Compiler) compileCallExpression(ce *ast.CallExpression, dest []*ast.Ide if !ok { return nil } + // Mutual-recursion convergence can leave this call site's cache partially + // unresolved even though the selected function variant is now concrete. + // Keep every lowering path aligned with the authoritative ABI types. + info.OutTypes = append([]Type(nil), sig.ABI.Return.OutTypes...) + info.ExprLen = len(info.OutTypes) if sig.ABI.Return.Mode == ABIReturnDirect { if !info.LoopInside && len(info.Ranges) > 0 { @@ -3062,47 +3222,22 @@ func (c *Compiler) compileCallExpression(ce *ast.CallExpression, dest []*ast.Ide return c.compileDirectCallWithRanges(sig, info, dest) } - return c.compileCallInner(sig, ce, dest, nil) + return c.compileCallInner(sig, ce, dest) } - // Direct calls can materialize into temporary outputs first when the - // callee's return flavor differs from the destination slot flavor. Those - // temporaries are returned as normal RHS values so outer assignment/guard - // logic owns the eventual store, restore, and old-value cleanup. - if c.callNeedsTempOutputs(info, dest) { - tempOutputs := c.makeOutputs(nil, info.OutTypes, false) - c.compileCallInner(sig, ce, dest, tempOutputs) - return c.loadOutputValues(tempOutputs, info.OutTypes, "call_tmp") + if len(info.Ranges) > 0 && !info.LoopInside { + return c.compileIndirectCallWithRanges(sig, info, dest) } - outputs := c.makeOutputs(dest, info.OutTypes, false) - - if !info.LoopInside && len(info.Ranges) > 0 { - return c.compileIndirectCallWithRanges(sig, info, dest, outputs) - } - - // LoopInside=true or no ranges: direct call - c.compileCallInner(sig, ce, dest, outputs) - - // Update output types (e.g., Static flag for strings) when there are no ranges. - // Old value freeing is handled by writeTo using captured old values. - if !info.HasRanges { - c.updateOutputTypes(outputs, info.OutTypes, dest) - } - - return outputs -} - -// updateOutputTypes updates the destination symbols in scope to reference the output values. -// With StrG/StrH types, the type is determined at type-solving time and doesn't change. -func (c *Compiler) updateOutputTypes(outputs []*Symbol, outTypes []Type, dest []*ast.Identifier) { - for i, out := range outputs { - if i >= len(outTypes) || dest == nil || i >= len(dest) { - continue - } - // Update the symbol in scope to reference this output - Put(c.Scopes, dest[i].Value, out) - } + // 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) + return c.loadOutputValues(outputs, "call_final") } func (c *Compiler) getOrCompileCallFunction(sig *callSignature) (llvm.Value, llvm.Type, llvm.Type) { @@ -3137,39 +3272,92 @@ func (c *Compiler) compileDirectCallIntoOutput(sig *callSignature, ce *ast.CallE }) } -// compileCallInner compiles the actual function call -func (c *Compiler) compileCallInner(sig *callSignature, ce *ast.CallExpression, dest []*ast.Identifier, outputs []*Symbol) []*Symbol { - var results []*Symbol +func (c *Compiler) compileIndirectCallIntoOutputs( + sig *callSignature, + ce *ast.CallExpression, + dest []*ast.Identifier, + outputs []*Symbol, + afterCall func([]llvm.Value), + onSkip func(), +) { c.withPreparedCall(sig, ce, dest, func(call preparedCall) { - if sig.ABI.Return.Mode == ABIReturnDirect { - seed := c.makeZeroValue(sig.ABI.Return.DirectType) - if len(dest) > 0 { - seed = c.directReturnSeedForCall(sig.ABI.Return.DirectType, dest[0], nil) + 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) } - resultPtr := c.createEntryBlockAlloca(c.mapToLLVMType(sig.ABI.Return.DirectType), sig.FuncName+"_call_tmp") - c.createStore(seed.Val, resultPtr, seed.Type) + }, onSkip) + }) +} - c.runCallWithBounds(func() { - callResult := c.callDirect(call.Function, call.FuncType, sig, call, seed) - c.createStore(callResult.Val, resultPtr, callResult.Type) - }) +func (c *Compiler) compileIndirectCallIntoStagedOutputs( + sig *callSignature, + ce *ast.CallExpression, + dest []*ast.Identifier, + 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) }, + ) +} - results = []*Symbol{{ - Val: c.createLoad(resultPtr, sig.ABI.Return.DirectType, sig.FuncName+"_call_ret"), - Type: sig.ABI.Return.DirectType, - }} - return +func (c *Compiler) makeCallOutputWriteFlags(count int) []llvm.Value { + flags := make([]llvm.Value, count) + for i := range flags { + name := fmt.Sprintf("call_output_written_%d", c.tmpCounter) + c.tmpCounter++ + flags[i] = c.createEntryBlockAlloca(c.Context.Int1Type(), name) + c.builder.CreateStore(llvm.ConstInt(c.Context.Int1Type(), 0, false), flags[i]) + } + return flags +} + +// compileCallInner compiles the actual function call +func (c *Compiler) compileCallInner(sig *callSignature, ce *ast.CallExpression, dest []*ast.Identifier) []*Symbol { + var results []*Symbol + c.withPreparedCall(sig, ce, dest, func(call preparedCall) { + seed := c.makeZeroValue(sig.ABI.Return.DirectType) + if len(dest) > 0 { + seed = c.directReturnSeedForCall(sig.ABI.Return.DirectType, dest[0], nil) } + resultPtr := c.createEntryBlockAlloca(c.mapToLLVMType(sig.ABI.Return.DirectType), sig.FuncName+"_call_tmp") + c.createStore(seed.Val, resultPtr, seed.Type) c.runCallWithBounds(func() { - results = c.callFunction(call.Function, call.FuncType, sig, call, call.RetStruct, outputs) + callResult := c.callDirect(call.Function, call.FuncType, sig, call, seed) + c.createStore(callResult.Val, resultPtr, callResult.Type) }) + + results = []*Symbol{{ + Val: c.createLoad(resultPtr, sig.ABI.Return.DirectType, sig.FuncName+"_call_ret"), + Type: sig.ABI.Return.DirectType, + }} }) return results } -func (c *Compiler) callArgs(sig *callSignature, call preparedCall, retStruct llvm.Type, outputs []*Symbol, directSeed *Symbol) []llvm.Value { +func (c *Compiler) callArgs( + sig *callSignature, + call preparedCall, + retStruct llvm.Type, + outputs []*Symbol, + writeFlags []llvm.Value, + directSeed *Symbol, +) []llvm.Value { llvmArgs := []llvm.Value{} if sig.ABI.UsesIndirectReturn() { sretPtr := c.createEntryBlockAlloca(retStruct, "sret_tmp") @@ -3177,10 +3365,18 @@ func (c *Compiler) callArgs(sig *callSignature, call preparedCall, retStruct llv fieldPtr := c.builder.CreateStructGEP(retStruct, sretPtr, i, fmt.Sprintf("sret_field_%d", i)) c.builder.CreateStore(out.Val, fieldPtr) } + for i, flag := range writeFlags { + fieldPtr := c.builder.CreateStructGEP(retStruct, sretPtr, len(outputs)+i, fmt.Sprintf("sret_written_field_%d", i)) + c.builder.CreateStore(flag, fieldPtr) + } llvmArgs = append(llvmArgs, sretPtr) } for _, arg := range call.Args { - llvmArgs = append(llvmArgs, arg.Lowered.Val) + argVal := arg.Lowered.Val + if arg.OutputAlias >= 0 && arg.OutputAlias < len(outputs) { + argVal = outputs[arg.OutputAlias].Val + } + llvmArgs = append(llvmArgs, argVal) } for _, aliasIndex := range call.AliasIndices { llvmArgs = append(llvmArgs, llvm.ConstInt(c.Context.Int32Type(), uint64(aliasIndex), false)) @@ -3193,18 +3389,13 @@ func (c *Compiler) callArgs(sig *callSignature, call preparedCall, retStruct llv } func (c *Compiler) callDirect(fn llvm.Value, funcType llvm.Type, sig *callSignature, call preparedCall, directSeed *Symbol) *Symbol { - callVal := c.builder.CreateCall(funcType, fn, c.callArgs(sig, call, llvm.Type{}, nil, directSeed), sig.FuncName+"_ret") + callVal := c.builder.CreateCall(funcType, fn, c.callArgs(sig, call, llvm.Type{}, nil, nil, directSeed), sig.FuncName+"_ret") return &Symbol{ Val: callVal, Type: sig.ABI.Return.DirectType, } } -func (c *Compiler) callFunction(fn llvm.Value, funcType llvm.Type, sig *callSignature, call preparedCall, retStruct llvm.Type, outputs []*Symbol) []*Symbol { - c.builder.CreateCall(funcType, fn, c.callArgs(sig, call, retStruct, outputs, nil), "") - return outputs -} - // extract numeric fields of a range struct func (c *Compiler) rangeComponents(r llvm.Value) (start, stop, step llvm.Value) { start = c.builder.CreateExtractValue(r, 0, "start") @@ -3213,20 +3404,6 @@ func (c *Compiler) rangeComponents(r llvm.Value) (start, stop, step llvm.Value) return } -func (c *Compiler) rangeStrArg(s *Symbol) (arg llvm.Value) { - start, stop, step := c.rangeComponents(s.Val) - - // call range_i64_str - fnType, fn := c.GetCFunc(RANGE_I64_STR) - arg = c.builder.CreateCall( - fnType, - fn, - []llvm.Value{start, stop, step}, - RANGE_I64_STR, - ) - return -} - func (c *Compiler) floatStrArg(s *Symbol) llvm.Value { if s.Type.(Float).Width == 32 { fnTy, fn := c.GetCFunc(F32_STR) // char* f32_str(float) @@ -3495,15 +3672,6 @@ func (c *Compiler) appendPrintSymbol(s *Symbol, expr ast.Expression, formatStr * return } - // ArrayRange needs special handling (two string args) - if s.Type.Kind() == ArrayRangeKind { - arrStr, rngStr := c.arrayRangeStrArgs(s) - *formatStr += "%s[%s] " - *args = append(*args, arrStr, rngStr) - *toFree = append(*toFree, arrStr, rngStr) - return - } - // Get format specifier for this type spec, err := defaultSpecifier(s.Type) if err != nil { @@ -3518,10 +3686,6 @@ func (c *Compiler) appendPrintSymbol(s *Symbol, expr ast.Expression, formatStr * // Handle types that need string conversion switch s.Type.Kind() { - case RangeKind: - strPtr := c.rangeStrArg(s) - *args = append(*args, strPtr) - *toFree = append(*toFree, strPtr) case FloatKind: strPtr := c.floatStrArg(s) *args = append(*args, strPtr) diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index a6ad5d97..a31b5dbb 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -127,6 +127,27 @@ a, b` require.Contains(t, scriptIR, "call void @"+mangled+"(", "expected indirect multi-return call") } +func TestRangedArrayOutputMarksIndirectWrite(t *testing.T) { + code := `res = LastRow(matrix) + i = 0:2 + res = matrix[i]` + script := `matrix = [ + 1 2 + 3 4 +] +row = LastRow(matrix) +row` + + moduleName := "ranged_array_output_write" + scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) + mangled := Mangle(MangleDirPath(moduleName, ""), "LastRow", []Type{ + Array{ElemType: I64, Rank: 2}, + }) + + require.Contains(t, scriptIR, "define void @"+mangled+"(", "expected an indirect array return") + require.Contains(t, scriptIR, "store i1 true, ptr %res_written", "a yielded ranged selection must mark the output as written") +} + func TestPhase1ScalarABIRangeVariantUsesDirectScalarBoundary(t *testing.T) { code := `res = Acc(a, x) res = a + x` @@ -144,6 +165,112 @@ res` require.NotContains(t, scriptIR, mangled+"_ret", "single-scalar range variant should not use sret struct") } +func TestArrayRangeCallUsesOneStructuralDirectVariant(t *testing.T) { + code := `out = Square(x) + out = x * x` + script := `i = 0:3 +arr = [2 3 4] +res = Square(arr[i]) +res` + + moduleName := "array_range_direct_variant" + scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) + arrayRange := ArrayRange{ + Array: Array{ElemType: I64, Rank: 1}, + Range: Range{Iter: I64}, + } + mangled := Mangle(MangleDirPath(moduleName, ""), "Square", []Type{arrayRange}) + + require.Contains(t, mangled, "ArrayRange_t2_Array_t1_I64_Range_t1_I64", + "the specialization must encode the complete array and range schemas") + require.Contains(t, scriptIR, + "define noundef i64 @"+mangled+"(ptr noundef nonnull \"captures\"=\"none\" %0, i64 noundef %1)", + "the ArrayRange direct variant should receive its descriptor plus the hidden output seed") + require.Equal(t, 1, strings.Count(scriptIR, "call i64 @"+mangled+"("), + "an immediate bare array selection should enter the callee only once") +} + +func TestRankTwoArrayRangeCallUsesOneIndirectVariant(t *testing.T) { + code := `out = Identity(x) + out = x` + script := `rows = 0:2 +matrix = [ + 1 2 + 3 4 +] +row = Identity(matrix[rows]) +row` + + moduleName := "array_range_rank_two_indirect" + scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) + arrayRange := ArrayRange{ + Array: Array{ElemType: I64, Rank: 2}, + Range: Range{Iter: I64}, + } + mangled := Mangle(MangleDirPath(moduleName, ""), "Identity", []Type{arrayRange}) + + require.Contains(t, mangled, "ArrayRange_t2_Array_t1_Array_t1_I64_Range_t1_I64", + "the specialization must retain the rank-two source schema") + require.Contains(t, scriptIR, "define void @"+mangled+"(", + "the rank-reduced row result should use the indirect array-return ABI") + require.Contains(t, scriptIR, "array_subarray_start", + "the callee should materialize each selected rank-reduced row") + require.Contains(t, scriptIR, "call ptr @arr_i64_copy(", + "assigning the callee's row parameter should copy it into the indirect output") + require.Contains(t, scriptIR, mangled+"_ret", + "the indirect variant should expose output and write-flag slots") + require.Equal(t, 1, strings.Count(scriptIR, "call void @"+mangled+"("), + "a rank-two selection should be iterated inside one callee invocation") +} + +func TestSharedArrayRangeDriverUsesCallerScalarVariant(t *testing.T) { + code := `out = Add(x, y) + out = x + y` + script := `i = 0:3 +arr = [10 20 30] +res = Add(arr[i], i) +res` + + moduleName := "array_range_shared_driver" + scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) + scalarMangled := Mangle(MangleDirPath(moduleName, ""), "Add", []Type{I64, I64}) + arrayRangeMangled := Mangle(MangleDirPath(moduleName, ""), "Add", []Type{ + ArrayRange{ + Array: Array{ElemType: I64, Rank: 1}, + Range: Range{Iter: I64}, + }, + Range{Iter: I64}, + }) + + require.Contains(t, scriptIR, "define noundef i64 @"+scalarMangled+"(i64 noundef %0, i64 noundef %1)", + "a shared driver must select the ordinary scalar specialization") + require.GreaterOrEqual(t, strings.Count(scriptIR, "call i64 @"+scalarMangled+"("), 1, + "the shared caller-side loop should invoke the scalar specialization") + require.Contains(t, scriptIR, "call i64 @"+scalarMangled+"(i64 %get, i64 %iter)", + "the array access and scalar argument should use the same caller-loop iterator") + require.NotContains(t, scriptIR, arrayRangeMangled, + "arr[i] and i must not become independent callee iterators") +} + +func TestRangeIndirectCallUsesOneCalleeVariant(t *testing.T) { + code := `left, right = Pair(x) + left = x + right = x + 1` + script := `left, right = Pair(0:3) +left, right` + + moduleName := "range_indirect_one_call" + scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) + mangled := Mangle(MangleDirPath(moduleName, ""), "Pair", []Type{Range{Iter: I64}}) + + require.Contains(t, scriptIR, "define void @"+mangled+"(", + "a multi-output Range specialization should keep the indirect-return ABI") + require.Contains(t, scriptIR, mangled+"_ret", + "the indirect Range variant should expose output and write-flag slots") + require.Equal(t, 1, strings.Count(scriptIR, "call void @"+mangled+"("), + "a bare Range should be iterated by one callee invocation") +} + func TestConditionalDirectCallArgsDoNotPromoteDirectParams(t *testing.T) { code := `res = Id(x) res = x @@ -694,7 +821,7 @@ func TestStructUnknownFieldNoSpuriousError(t *testing.T) { require.True(t, found, "expected redefined struct error, got: %v", errs) } -func TestSetupRangeOutputsWithPointerSeed(t *testing.T) { +func TestMakeSeededTempOutputsCopiesPointerSeed(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() @@ -720,22 +847,20 @@ func TestSetupRangeOutputsWithPointerSeed(t *testing.T) { ReadOnly: true, }) - // Act: seed a loop temporary for a pointer-valued output. + // Act: seed an independent temporary from a pointer-backed destination. dest := []*ast.Identifier{{Value: "seed"}} outTypes := []Type{I64} - outputs := c.makeOutputs(dest, outTypes, false) + outputs := c.makeSeededTempOutputs(dest, outTypes) require.Len(t, outputs, 1, "expect a single output symbol") - // When seed is already a pointer, makeOutputs reuses it directly - require.Equal(t, global, outputs[0].Val, "expected existing pointer to be reused") + require.NotEqual(t, global.String(), outputs[0].Val.String(), "expected an independent output slot") require.Equal(t, ptrType, outputs[0].Type, "expected pointer type to be preserved") c.builder.CreateRetVoid() ir := c.Module.String() - // No store or load needed - we reuse the existing pointer directly - require.NotContains(t, ir, "store", "pointer seed should be reused without store") - require.NotContains(t, ir, "load i64, ptr @seed_global", "pointer seed should not be dereferenced") + require.Contains(t, ir, "load i64, ptr @seed_global", "expected destination seed to be loaded") + require.Contains(t, ir, "store i64", "expected destination seed to initialize the independent slot") } func TestCompileCondScalarStrHUsesBranch(t *testing.T) { diff --git a/compiler/cond.go b/compiler/cond.go index 332e3a66..5d8070e4 100644 --- a/compiler/cond.go +++ b/compiler/cond.go @@ -206,8 +206,19 @@ func (c *Compiler) createConditionalTempOutputsFor(dest []*ast.Identifier, outTy Type: Ptr{Elem: outTypes[i]}, Borrowed: true, } + existing, exists := Get(c.Scopes, ident.Value) seed := c.resolveDestSeed(ident, outTypes[i]) + // The conditional slot owns its seed independently. Otherwise an + // early RHS overwrite can free the real pre-statement destination + // before a later sibling RHS has read it. + // A fresh zero seed is already owned and must not be copied again. + if exists { + seed = c.deepCopyIfNeeded(seed) + } c.storeSymbolToSlot(tempSym, seed, outTypes[i], tempName+"_seed") + if exists { + tempSym.WriteFlag = existing.WriteFlag + } // Temporary conditional outputs are borrowed so scope cleanup does not free // values that are transferred to real destinations in the merge block. @@ -224,8 +235,9 @@ func (c *Compiler) commitConditionalOutputs(slots []OutputSlot) { finalType := s.outType finalVal := c.createLoad(tempSym.Val, finalType, s.dest.Value+"_cond_final") finalSym := &Symbol{ - Val: finalVal, - Type: finalType, + Val: finalVal, + Type: finalType, + WriteFlag: tempSym.WriteFlag, } oldSym, exists := Get(c.Scopes, s.dest.Value) @@ -233,6 +245,7 @@ func (c *Compiler) commitConditionalOutputs(slots []OutputSlot) { Put(c.Scopes, s.dest.Value, finalSym) continue } + oldValue := c.valueSymbol(s.dest.Value, oldSym, s.dest.Value+"_cond_old") if _, ok := oldSym.Type.(Ptr); ok { c.storeSymbolToSlot(oldSym, finalSym, oldSym.Type.(Ptr).Elem, s.dest.Value+"_cond_commit") @@ -244,12 +257,17 @@ func (c *Compiler) commitConditionalOutputs(slots []OutputSlot) { // SetExisting always finds the binding — and it must update it in its own // scope (which may be an outer block), not shadow it via Put in the current. SetExisting(c.Scopes, s.dest.Value, updated) + if !c.skipBorrowedOldValueFree(oldValue) { + c.freeSymbolValue(oldValue, s.dest.Value+"_cond_old") + } continue } - // Non-pointer symbols are replaced directly. Old value ownership is already - // handled in the IF branch assignment into temp slots. + // Non-pointer symbols are replaced directly. Put(c.Scopes, s.dest.Value, finalSym) + if !c.skipBorrowedOldValueFree(oldValue) { + c.freeSymbolValue(oldValue, s.dest.Value+"_cond_old") + } } } @@ -334,6 +352,9 @@ func (c *Compiler) createStageTempOutputsFor(commit []OutputSlot) []OutputSlot { seed := c.resolveDestSeed(cs.temp, outType) seed = c.deepCopyIfNeeded(seed) c.storeSymbolToSlot(stageTempSym, seed, outType, tempName+"_seed") + if commitSym, ok := Get(c.Scopes, cs.temp.Value); ok { + stageTempSym.WriteFlag = commitSym.WriteFlag + } Put(c.Scopes, tempName, stageTempSym) stage[i] = OutputSlot{dest: cs.dest, temp: tempIdent, outType: outType} } @@ -344,6 +365,7 @@ func (c *Compiler) commitStageTempOutputs(commit []OutputSlot, stage []OutputSlo for i := range stage { stageSym, _ := Get(c.Scopes, stage[i].temp.Value) stagedValue := c.valueSymbol(stage[i].temp.Value, stageSym, stage[i].temp.Value+"_stage_final") + stagedValue.WriteFlag = stageSym.WriteFlag c.commitSlotValue(commit[i].temp, stagedValue, false) } } @@ -635,6 +657,12 @@ func (c *Compiler) extractComparisonSlots(infix *ast.InfixExpression, info *Expr lSym, cmpVal := c.compareScalars(infix.Operator, left[i], right[i]) conds[i] = c.andConds(operandCond, cmpVal, fmt.Sprintf("slot_cond_%d", i)) lhsSyms[i] = lSym + if _, isIdent := infix.Left.(*ast.Identifier); isIdent { + // The retained value still belongs to the named binding. + // Mark it borrowed so a later assignment copies rather than + // transferring that binding's payload through a conditional. + lhsSyms[i].Borrowed = true + } } } @@ -874,11 +902,27 @@ func (c *Compiler) branchCond(cond llvm.Value, temps []condTemp, onTrue func(), func (c *Compiler) isRangeDriverCond(expr ast.Expression) bool { info := c.ExprCache[key(c.FuncNameMangled, expr)] - if len(info.OutTypes) != 1 { + if info == nil || len(info.OutTypes) != 1 || len(info.Ranges) == 0 { return false } - return isRangeDriverType(info.OutTypes[0]) + switch e := expr.(type) { + case *ast.Identifier, *ast.RangeLiteral: + return true + case *ast.ArrayRangeExpression: + arrInfo := c.ExprCache[key(c.FuncNameMangled, e.Array)] + if arrInfo == nil || arrInfo.HasRanges { + return false + } + switch e.Range.(type) { + case *ast.Identifier, *ast.RangeLiteral: + return true + default: + return false + } + default: + return false + } } func (c *Compiler) collectDriverRanges(expr ast.Expression) []*RangeInfo { @@ -895,7 +939,7 @@ func (c *Compiler) collectDriverRanges(expr ast.Expression) []*RangeInfo { } // splitCondRanges collects merged ranges and boolean guard expressions -// from statement conditions. Bare range/array-range drivers contribute only +// from statement conditions. Bare range/array-selection drivers contribute only // ranges; comparisons contribute both ranges and a per-iteration guard. // Returns nil, nil if no condition introduces ranges. func (c *Compiler) splitCondRanges(conditions []ast.Expression) ([]*RangeInfo, []ast.Expression) { diff --git a/compiler/format.go b/compiler/format.go index 9e49e29f..bdabf5b3 100644 --- a/compiler/format.go +++ b/compiler/format.go @@ -48,15 +48,11 @@ func defaultSpecifier(t Type) (string, error) { return "%s", nil case StrKind: return "%s", nil - case RangeKind: - return "%s", nil case ArrayKind: // Arrays are converted to char* via runtime helpers return "%s", nil case TableKind: return "%s", nil - case ArrayRangeKind: - return "%s", nil case StructKind: return "%s", nil default: @@ -594,10 +590,6 @@ func (c *Compiler) formatAsString(mainSym *Symbol, result *formattedMarker) bool strPtr := c.floatStrArg(mainSym) result.args = append(result.args, strPtr) result.toFree = append(result.toFree, strPtr) - case RangeKind: - strPtr := c.rangeStrArg(mainSym) - result.args = append(result.args, strPtr) - result.toFree = append(result.toFree, strPtr) case ArrayKind: arrType := mainSym.Type.(Array) if arrType.Rank == 1 && !hasConcreteArrayElemType(arrType.ElemType) { @@ -616,11 +608,6 @@ func (c *Compiler) formatAsString(mainSym *Symbol, result *formattedMarker) bool result.text = fmtStr result.args = append(result.args, fmtArgs...) result.toFree = append(result.toFree, fmtFree...) - case ArrayRangeKind: - arrStr, rangeStr := c.arrayRangeStrArgs(mainSym) - result.text += "[%s]" - result.args = append(result.args, arrStr, rangeStr) - result.toFree = append(result.toFree, arrStr, rangeStr) default: return false } @@ -885,11 +872,11 @@ func (c *Compiler) structFormatArgs(s *Symbol) (fmtStr string, args []llvm.Value return } -// hasValidMarkers checks if a format string contains any markers (-identifier) -// where the main identifier is defined according to the provided isDefined callback. -// This aligns with parseMarker/formatString semantics: each marker is resolved -// independently, including markers in text following an unresolved marker. -func hasValidMarkers(value string, isDefined func(string) bool) bool { +// formatMarkerIdentifiers returns the identifiers read by resolved markers in +// source order. Dynamic width/precision identifiers are included because a +// named Range there contributes the same iteration driver as a main marker. +func formatMarkerIdentifiers(value string, isDefined func(string) bool) []string { + var identifiers []string runes := []rune(value) for i := 0; i < len(runes); i++ { if runes[i] == '\\' { @@ -900,12 +887,30 @@ func hasValidMarkers(value string, isDefined func(string) bool) bool { if !maybeMarker(runes, i) { continue } - // Parse the identifier after the '-' - mainId, _ := parseIdentifier(runes, i+1) - // Only the main identifier matters - aligns with parseMarker behavior - if isDefined(mainId) { - return true + mainID, end := parseIdentifier(runes, i+1) + if !isDefined(mainID) { + continue + } + identifiers = append(identifiers, mainID) + + if end >= len(runes) || runes[end] != '%' { + i = end - 1 + continue + } + spec, _ := parseSpecifierSyntax(token.Token{}, value, runes, end) + for _, specID := range spec.ids { + if isDefined(specID) { + identifiers = append(identifiers, specID) + } + } + if spec.end > end { + i = spec.end - 1 } } - return false + return identifiers +} + +// hasValidMarkers checks if a format string contains a resolved marker. +func hasValidMarkers(value string, isDefined func(string) bool) bool { + return len(formatMarkerIdentifiers(value, isDefined)) > 0 } diff --git a/compiler/loop.go b/compiler/loop.go index c484aa8e..f5307804 100644 --- a/compiler/loop.go +++ b/compiler/loop.go @@ -33,36 +33,12 @@ func (c *Compiler) extractRangeSymbol(sym *Symbol, name string) (*Symbol, bool) return nil, false } -// extractArrayRangeSymbol loads an array-range aggregate from a symbol when needed. -func (c *Compiler) extractArrayRangeSymbol(sym *Symbol, name string) (*Symbol, bool) { - switch t := sym.Type.(type) { - case ArrayRange: - return sym, true - case Ptr: - arrRangeType, ok := t.Elem.(ArrayRange) - if ok { - return &Symbol{ - Val: c.createLoad(sym.Val, arrRangeType, name+"_arrrange"), - Type: arrRangeType, - FuncArg: sym.FuncArg, - Borrowed: true, - ReadOnly: sym.ReadOnly, - }, true - } - } - return nil, false -} - func (c *Compiler) rangeAggregateFromSymbol(sym *Symbol, name string) llvm.Value { if rangeSym, ok := c.extractRangeSymbol(sym, name); ok { return rangeSym.Val } - if arrRangeSym, ok := c.extractArrayRangeSymbol(sym, name); ok { - return c.builder.CreateExtractValue(arrRangeSym.Val, 1, name+"_range") - } - - panic(fmt.Sprintf("internal: %q is not a Range or ArrayRange during lowering (got %s)", name, sym.Type.String())) + panic(fmt.Sprintf("internal: %q is not a Range during lowering (got %s)", name, sym.Type.String())) } func (c *Compiler) iterOverDriverSymbol(sym *Symbol, name string, body func(*Symbol)) { @@ -78,23 +54,10 @@ func (c *Compiler) iterOverDriverSymbol(sym *Symbol, name string, body func(*Sym return } - if arrRangeSym, ok := c.extractArrayRangeSymbol(sym, name); ok { - c.iterOverArrayRange(arrRangeSym, func(iter llvm.Value, iterType Type) { - body(&Symbol{ - Val: iter, - Type: iterType, - FuncArg: arrRangeSym.FuncArg, - Borrowed: true, - }) - }) - return - } - - panic(fmt.Sprintf("internal: %q is not a Range or ArrayRange during lowering (got %s)", name, sym.Type.String())) + panic(fmt.Sprintf("internal: %q is not a Range during lowering (got %s)", name, sym.Type.String())) } // rangeAggregateForRI builds the {start,stop,step} aggregate for a driver. -// Named ArrayRange drivers contribute their underlying range component. func (c *Compiler) rangeAggregateForRI(ri *RangeInfo) llvm.Value { if ri.RangeLit != nil { return c.ToRange(ri.RangeLit, Range{Iter: Int{Width: 64}}) diff --git a/compiler/mangle_test.go b/compiler/mangle_test.go index fb81bf38..a5996bc1 100644 --- a/compiler/mangle_test.go +++ b/compiler/mangle_test.go @@ -290,6 +290,17 @@ func TestMangle(t *testing.T) { args: []Type{Range{Iter: I64}}, expected: "Pt_4iter_p_3sum_f1_Range_t1_I64", }, + { + name: "with array range type", + modName: "iter", + relPath: "", + funcName: "sum", + args: []Type{ArrayRange{ + Array: Array{ElemType: I64, Rank: 2}, + Range: Range{Iter: I64}, + }}, + expected: "Pt_4iter_p_3sum_f1_ArrayRange_t2_Array_t1_Array_t1_I64_Range_t1_I64", + }, { name: "with array type", modName: "arr", @@ -336,6 +347,85 @@ func TestMangle(t *testing.T) { } } +func TestArrayRangeMangleIsStructural(t *testing.T) { + tests := []struct { + name string + typ ArrayRange + expected string + }{ + { + name: "rank one I64 array", + typ: ArrayRange{ + Array: Array{ElemType: I64, Rank: 1}, + Range: Range{Iter: I64}, + }, + expected: "ArrayRange_t2_Array_t1_I64_Range_t1_I64", + }, + { + name: "rank two I64 array", + typ: ArrayRange{ + Array: Array{ElemType: I64, Rank: 2}, + Range: Range{Iter: I64}, + }, + expected: "ArrayRange_t2_Array_t1_Array_t1_I64_Range_t1_I64", + }, + { + name: "rank one F64 array", + typ: ArrayRange{ + Array: Array{ElemType: F64, Rank: 1}, + Range: Range{Iter: I64}, + }, + expected: "ArrayRange_t2_Array_t1_F64_Range_t1_I64", + }, + { + name: "different range iterator", + typ: ArrayRange{ + Array: Array{ElemType: I64, Rank: 1}, + Range: Range{Iter: F64}, + }, + expected: "ArrayRange_t2_Array_t1_I64_Range_t1_F64", + }, + } + + seen := make(map[string]string, len(tests)) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mangled := tt.typ.Mangle() + assert.Equal(t, tt.expected, mangled) + if other, exists := seen[mangled]; exists { + t.Errorf("ArrayRange mangle collision between %q and %q: %s", other, tt.name, mangled) + } + seen[mangled] = tt.name + }) + } +} + +func TestArrayRangeTypeIdentityIsStructural(t *testing.T) { + base := ArrayRange{ + Array: Array{ElemType: I64, Rank: 1}, + Range: Range{Iter: I64}, + } + same := ArrayRange{ + Array: Array{ElemType: Int{Width: 64}, Rank: 1}, + Range: Range{Iter: Int{Width: 64}}, + } + + assert.True(t, TypeEqual(base, same)) + assert.True(t, TypeEqual(base, base.Key())) + assert.False(t, TypeEqual(base, ArrayRange{ + Array: Array{ElemType: I64, Rank: 2}, + Range: Range{Iter: I64}, + })) + assert.False(t, TypeEqual(base, ArrayRange{ + Array: Array{ElemType: F64, Rank: 1}, + Range: Range{Iter: I64}, + })) + assert.False(t, TypeEqual(base, ArrayRange{ + Array: Array{ElemType: I64, Rank: 1}, + Range: Range{Iter: F64}, + })) +} + func TestMangleDistinguishesModuleFromSubdir(t *testing.T) { // This is the key test: ensure module path vs module+subdir produce different results // Module: github.com/user/math/stats (as a single module) @@ -484,6 +574,11 @@ func TestDemangle(t *testing.T) { mangled: "Pt_4iter_p_3sum_f1_Range_t1_I64", expected: "iter.sum(Range_t1_I64)", }, + { + name: "with array range type", + mangled: "Pt_4iter_p_3sum_f1_ArrayRange_t2_Array_t1_Array_t1_I64_Range_t1_I64", + expected: "iter.sum(ArrayRange_t2_Array_t1_Array_t1_I64_Range_t1_I64)", + }, { name: "with func type", mangled: "Pt_3hof_p_5apply_f1_Func_t2_I64_F64", @@ -561,6 +656,10 @@ func TestMangleDemangleRoundTrip(t *testing.T) { {"github.com/user/pkg", "sub", "Run", []Type{F64}, "github.com/user/pkg/sub.Run(F64)"}, {"math", "", "move", []Type{Struct{Name: "Person"}, I64}, "math.move(Person, I64)"}, {"math", "", "pair", []Type{Struct{Name: "Person"}, Struct{Name: "Animal"}}, "math.pair(Person, Animal)"}, + {"iter", "", "sum", []Type{ArrayRange{ + Array: Array{ElemType: I64, Rank: 2}, + Range: Range{Iter: I64}, + }}, "iter.sum(ArrayRange_t2_Array_t1_Array_t1_I64_Range_t1_I64)"}, // Mixed nominal identifiers (ASCII + Unicode) {"math", "", "f", []Type{Struct{Name: "foo_π"}, I64}, "math.f(foo_π, I64)"}, {"math", "", "f", []Type{Struct{Name: "πbar"}, I64}, "math.f(πbar, I64)"}, diff --git a/compiler/solver.go b/compiler/solver.go index 9d71c61e..c79191e9 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -10,10 +10,8 @@ import ( ) type RangeInfo struct { - Name string - RangeLit *ast.RangeLiteral - ArrayExpr ast.Expression - ArrayType Array + Name string + RangeLit *ast.RangeLiteral } // CondMode classifies how a comparison in value position is lowered. @@ -293,6 +291,8 @@ func (ts *TypeSolver) HandleRanges(e ast.Expression) (ranges []*RangeInfo, rew a return ts.HandleCallRanges(t) case *ast.Identifier: return ts.HandleIdentifierRanges(t) + case *ast.StringLiteral: + return ts.HandleStringLiteralRanges(t) default: return } @@ -495,6 +495,12 @@ func (ts *TypeSolver) HandleCallRanges(call *ast.CallExpression) (ranges []*Rang cp := *call cp.Arguments = args rew = &cp + // A surrounding collector consumes these ranges and invokes the rewritten + // call once per scalar yield, so make sure that scalar callee variant exists + // even though the original immediate call selected a range specialization. + if _, builtin := Builtins[call.Function.Value]; info.LoopInside && !builtin { + ts.ensureScalarCallVariant(call) + } // Cache the rewritten expression with no ranges (ranges have been extracted) ts.ExprCache[key(ts.FuncNameMangled, rew.(*ast.CallExpression))] = &ExprInfo{ OutTypes: info.OutTypes, @@ -509,42 +515,92 @@ func (ts *TypeSolver) HandleCallRanges(call *ast.CallExpression) (ranges []*Rang return } -// isBareRangeExpr checks if expression is a bare range expression. -// These are "simple" range arguments that can be passed to specialized functions. -// For ArrayRangeExpression, it's only bare if the array part doesn't have ranges -// and the index is itself bare (e.g., arr[i] is bare, but [i][j] or arr[i+1] is not). +// isBareRangeExpr reports whether expr is a driver that a function can consume +// through a range-specialized variant. A ranged array access is eligible only +// while it is an immediate call argument; collectCallArgs gives that call site +// an internal ArrayRange type without exposing it to other expression roots. func (ts *TypeSolver) isBareRangeExpr(expr ast.Expression) bool { switch e := expr.(type) { case *ast.Identifier, *ast.RangeLiteral: return true case *ast.ArrayRangeExpression: - // Only bare if array doesn't have ranges and index is bare arrInfo := ts.ExprCache[key(ts.FuncNameMangled, e.Array)] - return !arrInfo.HasRanges && ts.isBareRangeExpr(e.Range) + idxInfo := ts.ExprCache[key(ts.FuncNameMangled, e.Range)] + return arrInfo != nil && + !arrInfo.HasRanges && + idxInfo != nil && + len(idxInfo.OutTypes) == 1 && + idxInfo.OutTypes[0].Kind() == RangeKind && + ts.isBareRangeExpr(e.Range) default: return false } } // HandleIdentifierRanges processes identifier expressions, detecting if they refer -// to range-typed variables and including them in range tracking. -// Note: This returns ranges but does NOT set info.Ranges on the identifier itself. -// This is intentional - bare identifiers like `i` should print as range representations. -// Ranges are only extracted when the identifier is used in an expression context -// (e.g., in a function call or infix operation) that needs scalar values. +// to range-typed variables and including them in range tracking. A bare named +// range is a driver use; assignment and print roots close that stream rather +// than copying or printing the Range descriptor. func (ts *TypeSolver) HandleIdentifierRanges(ident *ast.Identifier) (ranges []*RangeInfo, rew ast.Expression) { typ, ok := ts.GetIdentifier(ident.Value) - if ok && (typ.Kind() == RangeKind || typ.Kind() == ArrayRangeKind) { + if ok && typ.Kind() == RangeKind { ri := &RangeInfo{ Name: ident.Value, RangeLit: nil, } ranges = []*RangeInfo{ri} + info := ts.ExprCache[key(ts.FuncNameMangled, ident)] + info.Ranges = append([]*RangeInfo(nil), ranges...) + info.Rewrite = ident } rew = ident return } +// HandleStringLiteralRanges exposes named Range dependencies hidden inside +// formatting markers so interpolation follows the same driver semantics as an +// ordinary identifier expression. +func (ts *TypeSolver) HandleStringLiteralRanges(lit *ast.StringLiteral) (ranges []*RangeInfo, rew ast.Expression) { + for _, name := range formatMarkerIdentifiers(lit.Token.Literal, ts.isDefined) { + typ, ok := ts.GetIdentifier(name) + if !ok || typ.Kind() != RangeKind { + continue + } + ranges = mergeUses(ranges, []*RangeInfo{{Name: name}}) + } + + info := ts.ExprCache[key(ts.FuncNameMangled, lit)] + info.Ranges = append([]*RangeInfo(nil), ranges...) + info.HasRanges = len(ranges) > 0 + info.Rewrite = lit + return ranges, lit +} + +// finalizeBareRangeIdentifier closes a named Range driver at an assignment +// root. Range literals remain constructors (`i = 0:n`), while a later bare use +// (`last = i`) yields the final iterator value. +func (ts *TypeSolver) finalizeBareRangeIdentifier(expr ast.Expression, types []Type) { + ident, ok := expr.(*ast.Identifier) + if !ok || len(types) != 1 { + return + } + + rangeType, ok := types[0].(Range) + if !ok { + return + } + + info := ts.ExprCache[key(ts.FuncNameMangled, ident)] + if len(info.Ranges) == 0 { + info.Ranges = []*RangeInfo{{Name: ident.Value}} + } + info.OutTypes = []Type{rangeType.Iter} + info.ExprLen = 1 + info.HasRanges = true + info.Rewrite = ident + types[0] = rangeType.Iter +} + func (ts *TypeSolver) TypeStatement(stmt ast.Statement) { switch s := stmt.(type) { case *ast.LetStatement: @@ -612,11 +668,8 @@ func (ts *TypeSolver) ensureScalarCallVariant(ce *ast.CallExpression) { } for _, t := range argInfo.OutTypes { innerType := t - switch t.Kind() { - case RangeKind: + if t.Kind() == RangeKind { innerType = t.(Range).Iter - case ArrayRangeKind: - innerType = t.(ArrayRange).Array.ElemType } scalarArgs = append(scalarArgs, innerType) } @@ -630,9 +683,23 @@ func (ts *TypeSolver) ensureScalarCallVariant(ce *ast.CallExpression) { } func (ts *TypeSolver) isRangeDriverCond(expr ast.Expression, condTypes []Type) bool { - return len(condTypes) == 1 && - ts.isBareRangeExpr(expr) && - isRangeDriverType(condTypes[0]) + if len(condTypes) != 1 { + return false + } + info := ts.ExprCache[key(ts.FuncNameMangled, expr)] + if info == nil || len(info.Ranges) == 0 { + return false + } + + switch e := expr.(type) { + case *ast.Identifier, *ast.RangeLiteral: + return true + case *ast.ArrayRangeExpression: + arrInfo := ts.ExprCache[key(ts.FuncNameMangled, e.Array)] + return arrInfo != nil && !arrInfo.HasRanges && ts.isBareRangeExpr(e.Range) + default: + return false + } } func (ts *TypeSolver) collectDriverRanges(expr ast.Expression, condTypes []Type) []*RangeInfo { @@ -722,7 +789,7 @@ func (ts *TypeSolver) validateStatementCondition(expr ast.Expression, condTypes ts.Errors = append(ts.Errors, &token.CompileError{ Token: expr.Tok(), - Msg: fmt.Sprintf("statement condition must be a comparison or bare range/array-range driver, got %s", condType), + Msg: fmt.Sprintf("statement condition must be a comparison or bare range/array-selection driver, got %s", condType), }) } @@ -741,11 +808,10 @@ func (ts *TypeSolver) collectConditionRanges(conditions []ast.Expression) []*Ran // mergeCondRangesIntoValue merges condition ranges into a value expression's // ExprInfo so ranged statement conditions can drive per-iteration RHS lowering. -// Bare range-like values (identifiers, direct range literals, and bare -// array-range views) also merge their own ranges here so they scalarize to the -// iterator / element type only inside that outer ranged context. Outside it -// they remain Range / ArrayRange values. Array literals still control -// accumulation; non-literal values remain last-value-wins. +// Bare Range values also merge their own ranges here so a range literal used +// under an outer statement driver scalarizes in that iteration context. Array +// indexing is already element-typed in every context. Array literals still +// control accumulation; non-literal values remain last-value-wins. func (ts *TypeSolver) mergeCondRangesIntoValue(expr ast.Expression, exprTypes []Type, condRanges []*RangeInfo) { if len(condRanges) == 0 { return @@ -754,9 +820,8 @@ func (ts *TypeSolver) mergeCondRangesIntoValue(expr ast.Expression, exprTypes [] info := ts.ExprCache[key(ts.FuncNameMangled, expr)] merged := condRanges - // Bare range values become per-iteration scalars only when the statement - // condition already introduced outer iteration. Outside that context they - // remain Range / ArrayRange values. + // A root range literal normally constructs a Range. Under an outer statement + // driver it participates in that iteration context and yields iterator values. if len(exprTypes) == 1 && ts.isBareRangeExpr(expr) { selfRanges := info.Ranges if ident, ok := expr.(*ast.Identifier); ok && exprTypes[0].Kind() == RangeKind { @@ -769,10 +834,6 @@ func (ts *TypeSolver) mergeCondRangesIntoValue(expr ast.Expression, exprTypes [] iterType := exprTypes[0].(Range).Iter exprTypes[0] = iterType info.OutTypes[0] = iterType - case ArrayRangeKind: - elemType := arrayIndexResultType(exprTypes[0].(ArrayRange).Array) - exprTypes[0] = elemType - info.OutTypes[0] = elemType } } @@ -803,6 +864,7 @@ func (ts *TypeSolver) TypeLetStatement(stmt *ast.LetStatement) { exprIdxs := make([]int, 0, len(stmt.Name)) for _, expr := range stmt.Value { exprTypes := ts.TypeExpression(expr, true) + ts.finalizeBareRangeIdentifier(expr, exprTypes) ts.mergeCondRangesIntoValue(expr, exprTypes, condRanges) for idx := range exprTypes { types = append(types, exprTypes[idx]) @@ -1424,7 +1486,7 @@ func (ts *TypeSolver) TypeRangeExpression(r *ast.RangeLiteral, isRoot bool) []Ty return types } -func (ts *TypeSolver) TypeArrayRangeExpression(ax *ast.ArrayRangeExpression, isRoot bool) []Type { +func (ts *TypeSolver) TypeArrayRangeExpression(ax *ast.ArrayRangeExpression, _ bool) []Type { info := &ExprInfo{OutTypes: []Type{Unresolved{}}, ExprLen: 1} ts.ExprCache[key(ts.FuncNameMangled, ax)] = info @@ -1441,7 +1503,9 @@ func (ts *TypeSolver) TypeArrayRangeExpression(ax *ast.ArrayRangeExpression, isR } resultType := arrayIndexResultType(arrType) - idxTypes := ts.TypeExpression(ax.Range, isRoot) + // Preserve the Range type long enough to validate the driver. The enclosing + // range rewrite later shadows it with a scalar index. + idxTypes := ts.TypeExpression(ax.Range, true) info.HasRanges = ts.ExprCache[key(ts.FuncNameMangled, ax.Array)].HasRanges || ts.ExprCache[key(ts.FuncNameMangled, ax.Range)].HasRanges if len(idxTypes) != 1 { ts.Errors = append(ts.Errors, &token.CompileError{ @@ -1472,41 +1536,20 @@ func (ts *TypeSolver) TypeArrayRangeExpression(ax *ast.ArrayRangeExpression, isR } } if idxType.Kind() == RangeKind { - if arrType.Rank > 1 { - ts.Errors = append(ts.Errors, &token.CompileError{ - Token: ax.Tok(), - Msg: "range indexing is currently supported only for rank-1 arrays", - }) - return info.OutTypes - } iterType := idxType.(Range).Iter if !TypeEqual(iterType, I64) { ts.Errors = append(ts.Errors, &token.CompileError{ Token: ax.Tok(), - Msg: fmt.Sprintf("array range index expects I64 iterator, got %s", iterType), + Msg: fmt.Sprintf("range-valued array index expects an I64 iterator, got %s", iterType), }) return info.OutTypes } } - if !isRoot { - // Nested indexing still removes one outer dimension. A range index is a - // rank-1 driver here because higher-rank ranges were rejected above. - info.OutTypes = []Type{resultType} - info.ExprLen = 1 - return info.OutTypes - } - - if idxType.Kind() == IntKind { - info.OutTypes = []Type{resultType} - info.ExprLen = 1 - return info.OutTypes - } - - info.OutTypes = []Type{ArrayRange{ - Array: arrType, - Range: idxType.(Range), - }} + // A ranged index is an ephemeral stream of resultType values, not a + // first-class view. Its surrounding context retains the final value, + // collects all values, or invokes a function once per yielded element. + info.OutTypes = []Type{resultType} info.ExprLen = 1 return info.OutTypes } @@ -1611,7 +1654,7 @@ func (ts *TypeSolver) TypeIdentifier(ident *ast.Identifier) (t Type) { return } - ts.ExprCache[key(ts.FuncNameMangled, ident)] = &ExprInfo{OutTypes: []Type{t}, ExprLen: 1, HasRanges: t.Kind() == RangeKind || t.Kind() == ArrayRangeKind} + ts.ExprCache[key(ts.FuncNameMangled, ident)] = &ExprInfo{OutTypes: []Type{t}, ExprLen: 1, HasRanges: t.Kind() == RangeKind} return } @@ -2125,8 +2168,6 @@ func (ts *TypeSolver) TypeCallExpression(ce *ast.CallExpression, isRoot bool) [] ts.ExprCache[key(ts.FuncNameMangled, ce)] = info args, innerArgs, loopInside := ts.collectCallArgs(ce, isRoot) - info.CallParamTypes = append([]Type(nil), args...) - info.ScalarCallParamTypes = append([]Type(nil), innerArgs...) // Compute hasRanges from all arguments hasRanges := false @@ -2136,6 +2177,14 @@ func (ts *TypeSolver) TypeCallExpression(ce *ast.CallExpression, isRoot bool) [] break } } + // Print has no callee body that can own iteration, so driver arguments are + // always expanded at the statement and printed as yielded scalar values. + if ce.Function.Value == Print && hasRanges { + loopInside = false + args = innerArgs + } + info.CallParamTypes = append([]Type(nil), args...) + info.ScalarCallParamTypes = append([]Type(nil), innerArgs...) // Handle builtins - no template lookup needed if builtin, ok := Builtins[ce.Function.Value]; ok { @@ -2178,6 +2227,14 @@ func (ts *TypeSolver) TypeExprsForIter(exprs []ast.Expression, isRoot bool) (out } } + // Reusing one driver in multiple parameters means those parameters advance + // together, not as a cartesian product. A callee specialization has one loop + // per Range/ArrayRange parameter, so keep this case at the caller where the + // shared RangeInfo is naturally deduplicated. + if loopInside && callArgsShareRangeDriver(exprs, ts.ExprCache, ts.FuncNameMangled) { + loopInside = false + } + if loopInside { return } @@ -2200,6 +2257,56 @@ func (ts *TypeSolver) TypeExprsForIter(exprs []ast.Expression, isRoot bool) (out return } +func callArgsShareRangeDriver(exprs []ast.Expression, cache map[ExprKey]*ExprInfo, funcNameMangled string) bool { + owner := make(map[string]int) + for argIndex, expr := range exprs { + info := cache[key(funcNameMangled, expr)] + if info == nil { + continue + } + + seenInArg := make(map[string]struct{}) + for _, driver := range info.Ranges { + if _, seen := seenInArg[driver.Name]; seen { + continue + } + seenInArg[driver.Name] = struct{}{} + + if previousArg, exists := owner[driver.Name]; exists && previousArg != argIndex { + return true + } + owner[driver.Name] = argIndex + } + } + return false +} + +// callScopedArrayRangeType returns the internal parameter type for an immediate +// bare array selection and the yielded type seen by the function body. +func (ts *TypeSolver) callScopedArrayRangeType(expr ast.Expression) (ArrayRange, Type, bool) { + ax, ok := expr.(*ast.ArrayRangeExpression) + if !ok || !ts.isBareRangeExpr(ax) { + return ArrayRange{}, nil, false + } + + arrInfo := ts.ExprCache[key(ts.FuncNameMangled, ax.Array)] + idxInfo := ts.ExprCache[key(ts.FuncNameMangled, ax.Range)] + if arrInfo == nil || idxInfo == nil || len(arrInfo.OutTypes) != 1 || len(idxInfo.OutTypes) != 1 { + return ArrayRange{}, nil, false + } + + arrType, ok := arrInfo.OutTypes[0].(Array) + if !ok { + return ArrayRange{}, nil, false + } + rangeType, ok := idxInfo.OutTypes[0].(Range) + if !ok { + return ArrayRange{}, nil, false + } + + return ArrayRange{Array: arrType, Range: rangeType}, arrayIndexResultType(arrType), true +} + // collectCallArgs types arguments and builds arg type lists for function lookup. // Uses the shared TypeExprsForIter for the core logic. func (ts *TypeSolver) collectCallArgs(ce *ast.CallExpression, isRoot bool) (args []Type, innerArgs []Type, loopInside bool) { @@ -2207,14 +2314,19 @@ func (ts *TypeSolver) collectCallArgs(ce *ast.CallExpression, isRoot bool) (args // Build args and innerArgs from outer types // If loopInside=false, ALL range args become their inner type (loop outside) - for _, outerTypes := range outerTypesPerArg { + for argIndex, outerTypes := range outerTypesPerArg { + if loopInside && len(outerTypes) == 1 { + if arrayRangeType, yieldedType, ok := ts.callScopedArrayRangeType(ce.Arguments[argIndex]); ok { + args = append(args, arrayRangeType) + innerArgs = append(innerArgs, yieldedType) + continue + } + } + for _, outerType := range outerTypes { innerType := outerType - switch outerType.Kind() { - case RangeKind: + if outerType.Kind() == RangeKind { innerType = outerType.(Range).Iter - case ArrayRangeKind: - innerType = outerType.(ArrayRange).Array.ElemType } innerArgs = append(innerArgs, innerType) @@ -2228,43 +2340,6 @@ func (ts *TypeSolver) collectCallArgs(ce *ast.CallExpression, isRoot bool) (args return } -/* -// getInnerType returns the type that operations work with when given a Range/ArrayRange. -// Range → Range.Iter, ArrayRange → element type, other → unchanged - - func getInnerType(t Type) Type { - switch t.Kind() { - case RangeKind: - return t.(Range).Iter - case ArrayRangeKind: - return t.(ArrayRange).Array.ElemType - default: - return t - } - } - - func (ts *TypeSolver) appendStandardCallArg(arg Type, args *[]Type, innerArgs *[]Type, hasIter *bool) { - var paramType Type - switch arg.Kind() { - case RangeKind: - paramType = arg - *innerArgs = append(*innerArgs, getInnerType(arg)) - *hasIter = true - case ArrayRangeKind: - arrRange := arg.(ArrayRange) - paramType = arrRange - // Like Range parameters, ArrayRange parameters are passed as-is to the function. - // The function will handle iteration internally via funcLoopNest. - // We pass the element type as innerArgs so the function body is typed correctly. - *innerArgs = append(*innerArgs, getInnerType(arg)) - *hasIter = true - default: - paramType = arg - *innerArgs = append(*innerArgs, arg) - } - *args = append(*args, paramType) - } -*/ func (ts *TypeSolver) expectSingleArray(source ast.Expression, tok token.Token, context string) (Array, bool) { arrayTypes := ts.TypeExpression(source, false) // nested expression if len(arrayTypes) != 1 { diff --git a/compiler/solver_test.go b/compiler/solver_test.go index c57979cd..94401037 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -348,11 +348,6 @@ func TestCollectionTypeErrors(t *testing.T) { script: "flat = [1 2]\nnested = [[3 4] [5 6]]\njoined = flat ⊕ nested\njoined", expectError: "cannot concatenate arrays with different ranks: 1 and 2", }, - { - name: "RangeIndexOnRank2", - script: "m = [\n 1 2\n 3 4\n]\nsub = m[0:2]\nsub", - expectError: "range indexing is currently supported only for rank-1 arrays", - }, } for _, tc := range cases { @@ -789,7 +784,7 @@ func TestScalarConditionEmitsTypeDiagnostic(t *testing.T) { ts.Solve() require.Len(t, ts.Errors, 1, "scalar-valued statement condition should emit one diagnostic") - require.Contains(t, ts.Errors[0].Msg, "statement condition must be a comparison or bare range/array-range driver, got I64") + require.Contains(t, ts.Errors[0].Msg, "statement condition must be a comparison or bare range/array-selection driver, got I64") } func TestLogicalAndDiagnostics(t *testing.T) { @@ -1013,14 +1008,14 @@ res = [idx]` require.IsType(t, &ast.ArrayLiteral{}, info.Rewrite) } -func TestArrayRangeTyping(t *testing.T) { +func TestRangedArrayAccessTypesAsElementStream(t *testing.T) { ctx := llvm.NewContext() code := ast.NewCode() - cc := NewCodeCompiler(ctx, "arrayRangeTyping", "", code) + cc := NewCodeCompiler(ctx, "rangedArrayAccessTyping", "", code) cc.Compile() script := "arr = [1 2 3]\nvalue = arr[0:2]\nsum = 0\nsum = sum + arr[0:2]" - sl := lexer.New("ArrayRangeTyping.spt", script) + sl := lexer.New("RangedArrayAccessTyping.spt", script) sp := parser.NewScriptParser(sl) program := sp.Parse() require.Empty(t, sp.Errors(), "unexpected parse errors: %v", sp.Errors()) @@ -1033,10 +1028,15 @@ func TestArrayRangeTyping(t *testing.T) { valueType, ok := ts.GetIdentifier("value") require.True(t, ok, "expected value identifier") - value, ok := valueType.(ArrayRange) - require.Truef(t, ok, "expected value to be ArrayRange, got %T", valueType) - require.EqualValues(t, value.Array.ElemType, Int{Width: 64}) - require.EqualValues(t, value.Range, Range{Iter: Int{Width: 64}}) + value, ok := valueType.(Int) + require.Truef(t, ok, "expected ranged access to finalize as Int, got %T", valueType) + require.EqualValues(t, 64, value.Width) + + valueStmt := program.Statements[1].(*ast.LetStatement) + valueExpr := valueStmt.Value[0].(*ast.ArrayRangeExpression) + valueInfo := ts.ExprCache[key(ts.FuncNameMangled, valueExpr)] + require.Equal(t, []Type{Int{Width: 64}}, valueInfo.OutTypes) + require.Len(t, valueInfo.Ranges, 1) sumType, ok := ts.GetIdentifier("sum") require.True(t, ok, "expected sum identifier") @@ -1045,6 +1045,147 @@ func TestArrayRangeTyping(t *testing.T) { require.EqualValues(t, 64, sumInt.Width) } +func TestImmediateArraySelectionUsesCallScopedArrayRange(t *testing.T) { + ctx := llvm.NewContext() + code := mustParseCode(t, `out = Identity(x) + out = x`) + cc := NewCodeCompiler(ctx, "callScopedArrayRange", "", code) + require.Empty(t, cc.Compile()) + + program := mustParseScript(t, `i = 0:2 +arr = [1 2 3] +value = Identity(arr[i]) +arr[i]`) + sc := NewScriptCompiler(ctx, program, cc, make(map[string]*Func), make(map[ExprKey]*ExprInfo)) + ts := NewTypeSolver(sc) + ts.Solve() + require.Emptyf(t, ts.Errors, "unexpected type errors: %v", ts.Errors) + + valueStmt := program.Statements[2].(*ast.LetStatement) + call := valueStmt.Value[0].(*ast.CallExpression) + callInfo := ts.ExprCache[key("", call)] + require.True(t, callInfo.LoopInside) + require.Equal(t, []Type{I64}, callInfo.ScalarCallParamTypes) + require.Len(t, callInfo.CallParamTypes, 1) + + arrayRange, ok := callInfo.CallParamTypes[0].(ArrayRange) + require.Truef(t, ok, "expected call-only ArrayRange, got %T", callInfo.CallParamTypes[0]) + require.Equal(t, Array{ElemType: I64, Rank: 1}, arrayRange.Array) + require.Equal(t, Range{Iter: I64}, arrayRange.Range) + + selection := call.Arguments[0].(*ast.ArrayRangeExpression) + require.Equal(t, []Type{I64}, ts.ExprCache[key("", selection)].OutTypes, + "the source expression must remain element-typed outside the call ABI") + valueType, ok := ts.GetIdentifier("value") + require.True(t, ok) + require.Equal(t, I64, valueType) + + printCall := program.Statements[3].(*ast.PrintStatement).Expression + printInfo := ts.ExprCache[key("", printCall)] + require.False(t, printInfo.LoopInside, "print must consume the selection at the caller") + require.Equal(t, []Type{I64}, printInfo.CallParamTypes) +} + +func TestArrayCollectorCreatesScalarVariantForArrayRangeCall(t *testing.T) { + ctx := llvm.NewContext() + code := mustParseCode(t, `out = Double(x) + out = x * 2`) + moduleName := "arrayRangeCollector" + cc := NewCodeCompiler(ctx, moduleName, "", code) + require.Empty(t, cc.Compile()) + + program := mustParseScript(t, `arr = [10 20 30] +values = [Double(arr[0:3])] +i = 0:3 +[i], [0:3]`) + sc := NewScriptCompiler(ctx, program, cc, make(map[string]*Func), make(map[ExprKey]*ExprInfo)) + ts := NewTypeSolver(sc) + ts.Solve() + require.Emptyf(t, ts.Errors, "unexpected type errors: %v", ts.Errors) + + scalarMangled := Mangle(MangleDirPath(moduleName, ""), "Double", []Type{I64}) + require.Contains(t, ts.ScriptCompiler.Compiler.FuncCache, scalarMangled, + "the surrounding array collector invokes a scalar callee per selected element") +} + +func TestRankTwoSelectionSpecializesOverFullArraySchema(t *testing.T) { + ctx := llvm.NewContext() + code := mustParseCode(t, `out = Identity(x) + out = x`) + cc := NewCodeCompiler(ctx, "rankTwoCallScopedArrayRange", "", code) + require.Empty(t, cc.Compile()) + + program := mustParseScript(t, `rows = 0:2 +matrix = [ + 1 2 + 3 4 +] +row = Identity(matrix[rows])`) + sc := NewScriptCompiler(ctx, program, cc, make(map[string]*Func), make(map[ExprKey]*ExprInfo)) + ts := NewTypeSolver(sc) + ts.Solve() + require.Emptyf(t, ts.Errors, "unexpected type errors: %v", ts.Errors) + + call := program.Statements[2].(*ast.LetStatement).Value[0].(*ast.CallExpression) + info := ts.ExprCache[key("", call)] + require.True(t, info.LoopInside) + require.Equal(t, []Type{Array{ElemType: I64, Rank: 1}}, info.ScalarCallParamTypes) + require.Equal(t, []Type{ArrayRange{ + Array: Array{ElemType: I64, Rank: 2}, + Range: Range{Iter: I64}, + }}, info.CallParamTypes) + + rowType, ok := ts.GetIdentifier("row") + require.True(t, ok) + require.Equal(t, Array{ElemType: I64, Rank: 1}, rowType) +} + +func TestCallRangePlacementPreservesSharedDriverIdentity(t *testing.T) { + ctx := llvm.NewContext() + code := mustParseCode(t, `out = Identity(x) + out = x + +left, right = Keep(a, b) + left = a + right = b`) + cc := NewCodeCompiler(ctx, "callRangePlacement", "", code) + require.Empty(t, cc.Compile()) + + program := mustParseScript(t, `i = 0:3 +j = 1:3 +arr = [10 20 30] +rangeValue = Identity(j) +distinctLeft, distinctRight = Keep(arr[i], j) +sharedLeft, sharedRight = Keep(arr[i], i)`) + sc := NewScriptCompiler(ctx, program, cc, make(map[string]*Func), make(map[ExprKey]*ExprInfo)) + ts := NewTypeSolver(sc) + ts.Solve() + require.Emptyf(t, ts.Errors, "unexpected type errors: %v", ts.Errors) + + rangeCall := program.Statements[3].(*ast.LetStatement).Value[0].(*ast.CallExpression) + rangeInfo := ts.ExprCache[key("", rangeCall)] + require.True(t, rangeInfo.LoopInside, "a bare Range argument should remain callee-iterated") + require.Equal(t, []Type{Range{Iter: I64}}, rangeInfo.CallParamTypes) + require.Equal(t, []Type{I64}, rangeInfo.ScalarCallParamTypes) + + distinctCall := program.Statements[4].(*ast.LetStatement).Value[0].(*ast.CallExpression) + distinctInfo := ts.ExprCache[key("", distinctCall)] + require.True(t, distinctInfo.LoopInside, + "distinct Range and ArrayRange drivers may form the callee's cartesian loop") + require.IsType(t, ArrayRange{}, distinctInfo.CallParamTypes[0]) + require.Equal(t, Range{Iter: I64}, distinctInfo.CallParamTypes[1]) + require.Equal(t, []Type{I64, I64}, distinctInfo.ScalarCallParamTypes) + + sharedCall := program.Statements[5].(*ast.LetStatement).Value[0].(*ast.CallExpression) + sharedInfo := ts.ExprCache[key("", sharedCall)] + require.False(t, sharedInfo.LoopInside, + "a driver reused by arr[i] and i must advance once at the caller") + require.Equal(t, []Type{I64, I64}, sharedInfo.CallParamTypes) + require.Equal(t, []Type{I64, I64}, sharedInfo.ScalarCallParamTypes) + require.Len(t, sharedInfo.Ranges, 1) + require.Equal(t, "i", sharedInfo.Ranges[0].Name) +} + func TestArrayIndexRejectsI1(t *testing.T) { ctx := llvm.NewContext() cc := NewCodeCompiler(ctx, "arrayIndexI1", "", ast.NewCode()) @@ -1114,7 +1255,7 @@ func TestArrayRangeIndexRequiresI64Iter(t *testing.T) { found := false for _, err := range ts.Errors { - if strings.Contains(err.Msg, "array range index expects I64 iterator") { + if strings.Contains(err.Msg, "range-valued array index expects an I64 iterator") { found = true break } diff --git a/compiler/types.go b/compiler/types.go index 3eddc062..04ed2319 100644 --- a/compiler/types.go +++ b/compiler/types.go @@ -403,9 +403,9 @@ func isHeaderOnlyTableType(table Table) bool { return true } -// ArrayRange represents an iteration over a range of an array. -// It carries the underlying array schema so type comparisons and mangling -// can remain structural; the actual range bounds are runtime values. +// ArrayRange is an internal, call-scoped view of an array selection. It keeps +// the full source array and range schemas so specialization identity remains +// structural without exposing ArrayRange as a source-level storable type. type ArrayRange struct { Array Array Range Range @@ -418,8 +418,9 @@ func (ar ArrayRange) String() string { func (ar ArrayRange) Kind() Kind { return ArrayRangeKind } func (ar ArrayRange) Mangle() string { - return "ArrayRange" + SEP + T + "1" + SEP + ar.Array.ElemType.Mangle() + return "ArrayRange" + SEP + T + "2" + SEP + ar.Array.Mangle() + SEP + ar.Range.Mangle() } + func (ar ArrayRange) Key() Type { return ArrayRange{ Array: ar.Array.Key().(Array), @@ -595,8 +596,8 @@ func CanRefineType(oldType, newType Type) bool { newTable, ok := newType.(Table) return ok && canRefineTable(old, newTable) case ArrayRange: - newSlice, ok := newType.(ArrayRange) - return ok && canRefineArrayRange(old, newSlice) + newArrayRange, ok := newType.(ArrayRange) + return ok && canRefineArrayRange(old, newArrayRange) case Ptr: newPtr, ok := newType.(Ptr) return ok && CanRefineType(old.Elem, newPtr.Elem) @@ -693,8 +694,9 @@ func canRefineTable(oldTable, newTable Table) bool { return true } -func canRefineArrayRange(oldSlice, newSlice ArrayRange) bool { - return CanRefineType(oldSlice.Array, newSlice.Array) && CanRefineType(oldSlice.Range, newSlice.Range) +func canRefineArrayRange(oldArrayRange, newArrayRange ArrayRange) bool { + return CanRefineType(oldArrayRange.Array, newArrayRange.Array) && + CanRefineType(oldArrayRange.Range, newArrayRange.Range) } func canRefineFunc(oldFunc, newFunc Func) bool { @@ -804,9 +806,10 @@ func eqTable(a, b Type) bool { } func eqArrayRange(a, b Type) bool { - aar := a.(ArrayRange) - bar := b.(ArrayRange) - return eqArray(aar.Array, bar.Array) && eqRange(aar.Range, bar.Range) + aArrayRange := a.(ArrayRange) + bArrayRange := b.(ArrayRange) + return TypeEqual(aArrayRange.Array, bArrayRange.Array) && + TypeEqual(aArrayRange.Range, bArrayRange.Range) } func eqStruct(a, b Type) bool { diff --git a/docs/Pluto Array Semantics.md b/docs/Pluto Array Semantics.md index 084ba88b..a6f5106c 100644 --- a/docs/Pluto Array Semantics.md +++ b/docs/Pluto Array Semantics.md @@ -137,17 +137,35 @@ cube[1][2] # rank 1 cube[1][2][0] # scalar ``` -A range-valued index is an iteration driver, not a materialized slice. Wrap -the access in `[]` to collect its results. For a rank-2 array, this stacks the -selected rows into another rank-2 array: +A range-valued index is an iteration driver, not a slice or view value. +An assignment root keeps the final valid selected element or subarray; wrap +the access in `[]` to collect all selected values: ```pluto i = 0:2 -selected = [matrix[i]] +last = vector[i] +selected = [vector[i]] + +lastRow = matrix[i] +selectedRows = [matrix[i]] ``` -Deferred nested range construction also uses chained indexing and is specified -in [Pluto Range Semantics](Pluto%20Range%20Semantics.md#deferred-nested-range-construction). +`last` is the final selected element, while `selected` contains every element. +For higher-rank arrays, `lastRow` owns the final selected row and +`selectedRows` stacks every selected row. +Range-indexed access cannot be stored or printed as an internal view; it must +be consumed, finalized, or collected. + +An immediate bare `array[range]` function argument may be consumed by a +specialized callee. The compiler can carry the array and range in an internal, +call-scoped descriptor and perform the iteration there. This descriptor is not +a source-level value and cannot be stored, returned, printed, or otherwise +escape the call. + +Planned deferred nested range construction also uses chained indexing and is +specified in +[Pluto Range Semantics](Pluto%20Range%20Semantics.md#deferred-nested-range-construction); +it remains deferred until PIR represents those scopes directly. Array-scalar operations preserve shape. Array-array element-wise operations require equal rank and zip every dimension to the shorter corresponding diff --git a/docs/Pluto C ABI Spec.md b/docs/Pluto C ABI Spec.md index 69054baa..81570895 100644 --- a/docs/Pluto C ABI Spec.md +++ b/docs/Pluto C ABI Spec.md @@ -205,8 +205,8 @@ Built-in compound types use the `_tN_` pattern: | Range | `Range_t1_[Iter]` | `Range_t1_I64` | | Array | `Array_t1_[Elem]` | `Array_t1_I64` | | Rank-N Array | repeated `Array_t1_` | `Array_t1_Array_t1_F64` | +| Internal ArrayRange | `ArrayRange_t2_[Array]_[Range]` | `ArrayRange_t2_Array_t1_I64_Range_t1_I64` | | Table | `Table_t2N_[EncodedName Elem]...` | `Table_t4_5nName_StrH_6nScore_I64` | -| ArrayRange | `ArrayRange_t1_[Elem]` | `ArrayRange_t1_I64` | | Function | `Func_tN_[ParamTypes...]` | `Func_t2_I64_F64` | **Note:** Function types only mangle parameter types; return types are NOT included (per §2.4 Arity rules). @@ -220,6 +220,56 @@ arrays lower as `{ data, dim0, ..., dim(N-1) }`, where `data` is one flat row-ma runtime vector. Dimension lengths are runtime values and do not participate in type mangling. +An `ArrayRange` is an internal, call-scoped descriptor used when an immediate +bare `array[range]` argument is specialized for callee-side iteration. Its two +type arguments are the full array type and the full range type: +`ArrayRange_t2__`. Both are part of the +mangle, so array rank, array element type, and range iterator type cannot +collide. For example, a rank-2 I64 array indexed by an I64 range mangles as: + +``` +ArrayRange_t2_Array_t1_Array_t1_I64_Range_t1_I64 +``` + +`ArrayRange` is not a source-level type. It cannot be bound to a variable, +stored, returned, printed, or otherwise escape the call that created it. The +function body observes one yielded element or owned subarray per iteration, +not the descriptor itself. + +At the native boundary the descriptor is passed indirectly as a pointer to +`{ , }`. For example, the following +illustrative C declarations show the rank-1 and rank-2 I64 layouts (the +emitted LLVM structs are structural rather than named with these C names): + +```c +typedef struct { + int64_t start; + int64_t stop; + int64_t step; +} PtRangeI64; + +typedef struct { + PtArrayI64 *array; + PtRangeI64 range; +} PtArrayRangeI64Rank1; + +typedef struct { + PtArrayI64 *data; + int64_t dim0; + int64_t dim1; +} PtArrayI64Rank2; + +typedef struct { + PtArrayI64Rank2 array; + PtRangeI64 range; +} PtArrayRangeI64Rank2; +``` + +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, when required, is last. + --- ## 4. Examples @@ -258,18 +308,77 @@ Module: `github.com/user/math`, RelPath: `stats/integral` ## 5. Calling Convention -* **SRET:** All functions return `void`. Return via pointer at arg 0. -* **Pass by reference:** All arguments (including primitives) are pointers. -* **Methods:** `self` is arg 1 (after SRET). +The native calling convention is selected from the solved parameter and output +types: + +- `I64` and `F64` parameters are passed directly. Ranges, internal + `ArrayRange` descriptors, and other values are passed indirectly. +- A function with exactly one `I64` or `F64` output returns that scalar + directly. A variant bearing a `Range` or internal `ArrayRange` parameter + also receives a hidden seed value so an empty range preserves the caller's + staged value. +- All other output lists use an indirect `void` return. Argument zero points + to a carrier whose first `N` fields are output pointers and whose next `N` + fields are pointers to `i1` write markers. +- The caller initializes every write marker to false. A callee sets the marker + only when that output is actually written. This lets an empty range or + skipped conditional preserve an existing caller destination. +- 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. + +Conceptually, a two-output indirect call uses: ```c -void Pt_..._6Person_m_5Clone_f2_..._6Person_I64( - Person* ret, // 0: SRET - Person* self, // 1: self - I64* count // 2: argument -); +struct Results { + T0 *out0; + T1 *out1; + bool *wrote0; + bool *wrote1; +}; + +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. + +An eligible immediate bare `array[range]` call argument may therefore select +an `ArrayRange` specialization and run its loop inside the callee. This +placement does not change source semantics. Calls that reuse one driver in +multiple argument positions, such as `F(i, i)`, must preserve one shared +iteration domain; the current lowering handles that case caller-side. Distinct +drivers, such as `F(i, j)`, retain their cartesian domain. + +### 5.1 Reserved Collector Specialization Suffix + +The following suffix is reserved for a possible future specialization that +writes each yielded result directly into one or more collectors: + +``` +_cN_ +``` + +`N` is the number of item types that follow. Examples are `_c1_I64`, +`_c1_Array_t1_I64`, and `_c2_I64_F64`. The suffix would follow the ordinary +function specialization mangle and includes the full type of every collected +item. + +This suffix is reserved only. The current compiler does not emit collector +specializations, and no collector ABI is implemented. If that ABI is added, a +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. + --- ## 6. Grammar diff --git a/docs/Pluto Conditional Value Semantics.md b/docs/Pluto Conditional Value Semantics.md index 99bb55d7..1fd65f16 100644 --- a/docs/Pluto Conditional Value Semantics.md +++ b/docs/Pluto Conditional Value Semantics.md @@ -329,9 +329,8 @@ old), sibling expressions in one statement commit independently (`a, b = arr[oob], 5` keeps `a`, sets `b`), a call merges its lanes (an OOB argument keeps that call's outputs old as a unit), comparisons and `||` fallbacks fed by an OOB read keep old rather than judging a fabricated zero, -and an unevaluated `||` right side cannot fail anything. The one place OOB -still reads as `0` is a **collector cell over an explicit range** — the -documented full-control opt-in. +and an unevaluated `||` right side cannot fail anything. Inside any collector +or fixed-array cell, an OOB read zero-fills that cell to preserve shape. ## Why this model diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index 3daea8a1..c6cde035 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -4,14 +4,20 @@ This document describes Pluto's semantic model and compares it with other major ## The Pluto Model (Summary) -1. **Assignment is Copy:** `a = b` creates a new independent value (Snapshot). +1. **Materialized Assignment is Copy:** assigning a scalar, array, table, string, + or struct creates an independent value. 2. **Arrays are Values:** `arr2 = arr1` copies data (COW). -3. **Range Selections are Views:** `s = arr[i]` (where `i` is a Range) - borrows `arr`; `[]` materializes the selection. -4. **Ranges are Loop Syntax:** `x = i + 1` generates a loop, not a lazy type. -5. **Zero-Value Initialization:** Variables in range expressions auto-initialize to zero. -6. **IterName Determines Looping:** Same range variable → zip, different → cartesian. -7. **Function Arguments by Value:** Scalar parameters passed by value, outputs initialized from parameters. +3. **Range Selections are Streams:** `s = arr[i]` keeps the final selected + value (an element or owned subarray); `s = [arr[i]]` materializes every + selected value. +4. **Ranges are Loop Syntax:** `x = i` and `x = i + 1` generate loops, not lazy + values. +5. **Empty-Domain Initialization:** A fresh destination keeps its type's zero + value; an existing destination remains unchanged. +6. **Driver Identity Determines Looping:** Repeated use of one range shares a + loop; distinct ranges form a cartesian domain. +7. **Function Arguments by Value:** Scalar parameters are passed by value; + 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). @@ -24,7 +30,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 | -| **Range selection (`a[range]`)** | **Borrowed view** | Copy (List) / View (NumPy) | View (Slice) | View (Slice) | Copy (default) / View (`@view`) | View (Slice) | +| **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** | **Loop Syntax** (Immediate) | Reference (Generator) | Reference (Iterator) | N/A | Reference (Iterator) | N/A | | **Mutability** | **In-Place Only** | Mutable Objects | Mutable (if `mut`) | Mutable | Mutable | Mutable | | **Memory Mgmt** | **Auto (Scope)** | Auto (GC) | Auto (Owner) | Auto (GC) | Auto (GC) | Manual | @@ -48,12 +54,15 @@ x = (i+1 for i in iter) # Lazy generator a = [1]; b = a; a[0] = 2 # b sees 1 (independent copy) i = 0:5 +x = i # Loop executes, x = 4 (last yield) x = i + 1 # Loop executes, x = 5 (last value) -i = 0:10 # OK! Ranges execute immediately -y = i + 1 # New loop, y = 10 +i = 0:10 # Bind a new reusable Range domain +y = i + 1 # Consuming statement runs the loop; y = 10 ``` -**Difference:** Pluto is safer and more predictable. Ranges execute immediately as loops, not lazy generators. +**Difference:** Pluto is safer and more predictable. A range literal binds a +reusable execution domain; a consuming statement runs it as a loop rather than +creating a lazy generator. --- @@ -66,14 +75,18 @@ let a = vec![1]; let b = a; // a is MOVED (invalidated) let s = &a[..]; // Borrow checking prevents mutation ``` -**Pluto:** "Copy on Write." +**Pluto:** "Values + Explicit Collection." ```python a = [1]; b = a # Both valid and independent -s = arr[i] # Runtime/Compiler checks ownership scope +i = 0:3 +x = arr[i] # Final selected element +s = [arr[i]] # Independent materialized array ``` -**Difference:** Pluto is easier to use (no borrow checker fighting) but relies on COW optimization instead of static moves. +**Difference:** Pluto does not expose a borrowed range-selection value, so the +selection cannot outlive its source. Materialized arrays use value semantics +and may use COW internally. --- @@ -89,15 +102,16 @@ s := arr[0:2] s[0] = 99 // Mutates arr via s ``` -**Pluto:** "Arrays are Values, Range Selections are Views with Locking." +**Pluto:** "Arrays are Values, Range Selections are Streams." ```python arr = [1 2 3 4 5] -s = arr[0:2] # View (like Go) -s[0] = 99 # Mutates arr +last = arr[0:2] # 2 +s = [arr[0:2]] # [1 2], independent of arr ``` -**Difference:** Pluto enforces **Read/Write Locks** on function arguments automatically, whereas Go allows data races (user must use `sync.Mutex`). +**Difference:** Pluto does not expose slice aliasing through range indexing. +Collected selections are ordinary array values. --- @@ -111,11 +125,12 @@ a[1:5] # Copy by default @view a[1:5] # View (explicit) ``` -**Pluto:** A range-valued `arr[i]` creates an `ArrayRange` view. Wrapping the -access as `[arr[i]]` materializes the selected values. +**Pluto:** A range-valued `arr[i]` is a value stream. At an assignment root it +keeps the final valid element or owned subarray; `[arr[i]]` materializes the +selected values. -**Difference:** Pluto separates range views from explicit collection, while -Julia copies a range selection unless a view is requested explicitly. +**Difference:** Pluto separates final-value selection from explicit collection; +it does not expose a persistent range-selection view. --- @@ -128,8 +143,8 @@ Julia copies a range selection unless a view is requested explicitly. // No hidden allocations. ``` -**Pluto:** `ArrayRange` similarly borrows an array and carries an iteration -range, but Pluto does not expose a separate slice type. +**Pluto:** Range-indexed access is consumed as a value stream and does not +expose a slice value. **Difference:** Pluto manages memory automatically (scope-based), Zig is manual. @@ -139,42 +154,40 @@ range, but Pluto does not expose a separate slice type. ### Statement-Level Loop Generation -Ranges generate loops at statement boundaries. All operations inside work on scalar values: +Ranges generate loops at statement boundaries. Operations consume one yielded +value at a time; a rank-N selection can yield an owned subarray: ```python i = 0:5 +x = i # Loop at statement: x = 4 (last yielded iterator) x = i + 1 # Loop at statement: x = 5 (last scalar value) y = i * 2 # Loop at statement: y = 8 (last scalar value) z = (i + 1) / (i + 2) # Single loop: z = 5/6 (last value) ``` -Bare ranged expressions execute as loop drivers rather than becoming lazy -values. An explicit range-indexed array access is the separate borrowed-view -case described above. +Bare ranged expressions and range-indexed array accesses execute as loop +drivers rather than becoming lazy values. An assignment root keeps the last +yield; `[]` collects every yield. -### IterName Determines Loop Structure +### Driver Identity Determines Loop Structure ```python i = 0:5 j = 0:5 -# Same variable → Zip (single loop) -x = i + 1 -y = i + 2 -result = x / y # Single loop over i +# Repeated use of one driver → one shared loop +ratio = (i + 1) / (i + 2) -# Different variables → Cartesian (nested loops) -x = i + 1 -y = j + 1 -result = x * y # Nested loops: i × j +# Distinct drivers → cartesian nested loops +product = (i + 1) * (j + 1) ``` ### Three Execution Modes | Mode | Syntax | Behavior | |------|--------|----------| -| **Last Value** | `x = i + 1` | Loop runs, x = last value | -| **Accumulate** | `x += i` | Loop runs, x accumulates | +| **Last Value** | `x = i` or `x = arr[i]` | Loop runs, x = last yielded value | +| **Accumulate** | `x = x + i` | Loop runs, x accumulates | | **Collect** | `arr = [i * 2]` | Loop runs, collects to array | ### Statement gates and value-position `&&` @@ -214,10 +227,12 @@ Desugars to: `if (condition) res = expression` Pluto sits in a "Sweet Spot" for parallel computing: 1. **Value Semantics (like R/Matlab)** make reasoning about concurrent code easy. "If I have `x`, I own `x`." -2. **Explicit Views (like Rust/Go)** allow high-performance mutation without copying. +2. **Explicit Collection** makes every allocation and materialization boundary visible. 3. **Loop Syntax Ranges (Unique)** provide clean iteration without lazy complexity. -4. **IterName-Based Zipping (Unique)** makes user intent explicit — same variable name means related iterations. -5. **Zero-Value Initialization (Unique)** simplifies accumulation patterns. +4. **Named Driver Reuse (Unique)** makes user intent explicit — repeated use + of one range name shares one loop. +5. **Defined Empty Domains (Unique)** give fresh and existing destinations + predictable behavior. It combines the **safety of R** with the **performance of Rust/Zig** and the **expressiveness of Julia**. @@ -235,18 +250,29 @@ res = sum(a, b) ``` - **Parameters**: Input values (passed by value for scalars) -- **Outputs**: Initialized from corresponding parameters at same position +- **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. - **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. + ### Call Site ```python res = sum(res, 5) # - Parameter 'a' receives value of 'res' # - Parameter 'b' receives 5 -# - Output 'res' initialized to 'a' (caller's res value) +# - Staged output 'res' starts with the caller destination's existing value # - Body executes: res = a + b -# - Result assigned back to caller's res +# - Result commits back to the caller's res after sibling RHS evaluation ``` ### Range Parameters @@ -256,18 +282,21 @@ res = process(a, i) res = a * i ``` -**Desugars to:** +**Semantically:** ```c for (int64_t i_val = 0; i_val < N; i_val++) { - res = a * i_val; // Function receives SCALAR i_val, not range + res = a * i_val; } ``` -**Key:** Functions always receive scalar values, not ranges. The loop is generated at the call site. +**Key:** The function body is evaluated once per yielded range value. Whether +the compiler places that loop around the call or in a specialized callee is an +implementation detail. -### Everything Is Scalar Inside Loops +### Per-Yield Evaluation -When a statement contains range variables, the loop is generated at the statement level, and all operations work on scalars: +When a statement contains range variables, its source-level meaning is +per-yield evaluation: ```python i = 0:5 @@ -277,12 +306,12 @@ result = Square(i) + i **Desugars to:** ```c for (int64_t i_val = 0; i_val < 5; i_val++) { - result = Square(i_val) + i_val; // Square called with scalar! + result = Square(i_val) + i_val; } ``` -- ✅ Operators work on scalars -- ✅ Functions receive scalar arguments +- ✅ Operators work on the current yielded values +- ✅ Function bodies evaluate once per yielded value - ✅ No special "range-aware" operations - ✅ Simple, unified model @@ -292,7 +321,8 @@ For complex expressions with named intermediates, use functions: ```python i = 0:5 -res += compute_ratio(i) +res = 0 +res = res + compute_ratio(i) numerator = i + 1 denominator = i + 2 res = numerator / denominator @@ -303,8 +333,8 @@ This avoids the issue where intermediate assignments execute immediately: ```python i = 0:5 x = i + 1 # Loop NOW: x = 5 (scalar) -y = i + 2 # Loop NOW: y = 7 (scalar) -res += x / y # No loop! Just res += 5/7 +y = i + 2 # Loop NOW: y = 6 (scalar) +res = res + x / y # No loop! Just scalar addition of 5/6 ``` Functions keep intermediates within the loop context. @@ -316,4 +346,4 @@ Functions keep intermediates within the loop context. - **Scope-based deallocation**: Memory freed when variables go out of scope - **No GC pauses**: Deterministic cleanup - **COW optimization**: Arrays copied only when modified -- **View safety**: Compiler/runtime prevents dangling references +- **No range-view lifetimes**: Range-indexed access is finalized or collected diff --git a/docs/Pluto Range Semantics.md b/docs/Pluto Range Semantics.md index becdece0..7cdb790c 100644 --- a/docs/Pluto Range Semantics.md +++ b/docs/Pluto Range Semantics.md @@ -2,20 +2,22 @@ ## Core Model -Expressions that mention ranges produce ordered per-iteration values. -Those values are not arrays by default. +Expressions that mention ranges produce ordered per-iteration values. The +stream is not collected into an array by default; an individual yield may be a +scalar or an owned subarray. There are two explicit closing steps: 1. `[]` closes a value stream into an array. -2. The root expression of a scalar assignment closes any remaining outer +2. The root expression of an assignment closes any remaining outer iteration by taking the final yielded value in iteration order. -This keeps array materialization and scalar finalization separate. +This keeps collection and final-value selection separate. ## Ranges And Drivers -A range or array-range used in an expression contributes an iteration driver. +A range identifier or range-indexed array access used in an expression +contributes an iteration driver. Multiple distinct drivers form a nested iteration domain in source order. Repeated use of the same driver name refers to the same loop, not a nested copy. @@ -29,6 +31,62 @@ x = i + 1 This iterates `i` over `0, 1, 2, 3, 4` and the root assignment keeps the final value, so `x = 5`. +A bare identifier is itself a ranged expression: + +```pluto +i = 0:5 +last = i +``` + +`last` becomes `4`. The range binding `i` remains available for later uses. +To bind another execution domain, write another range literal; range +`start`/`stop`/`step` fields are not part of the language. +The bound values are captured when the range is constructed, so later changes +to the source variables do not mutate the existing range. Functions that need +those bounds as data should currently receive the scalar values explicitly. + +Range-indexed arrays follow the same rule: + +```pluto +arr = [10 20 30 40] +i = 1:4 +last = arr[i] +selected = [arr[i]] +``` + +`last` becomes `40`, while `selected` becomes `[20 30 40]`. A range-indexed +access is not a public slice or view value: it is either consumed by its +surrounding expression, finalized at an assignment root, or materialized by +`[]`. For a rank-N source, one yield is an owned rank-(N-1) subarray, so the +final value can itself be an array. + +If a range produces no values, a fresh destination retains its type's zero +value (an empty array for a subarray result) and an existing destination is +unchanged. Outside `[]`, an out-of-bounds selection point yields nothing, so +the last valid selected value wins. Inside `[]`, failed cells are zero-filled +to preserve collection shape, as described below. + +Print statements consume drivers rather than exposing their internal +descriptor; Range descriptors have no printable representation. Printing `i` +emits one line per yielded value. Printing distinct +drivers together uses their normal cartesian domain, while repeated uses of +the same driver share one loop: + +```pluto +i = 0:2 +j = 2:4 +i, j +``` + +prints: + +```text +0 2 +0 3 +1 2 +1 3 +``` + Distinct drivers nest in source order, so collecting over two ranges walks their cartesian product: @@ -48,7 +106,28 @@ produces: Calls, infix operators, and prefix operators all follow the same rule: they transform the current per-iteration values of their range drivers. -They do not choose a special "base function" that owns the loop. +Whether the compiler places a call's loop around the call or in a specialized +callee is an implementation detail. + +An immediate bare range or range-indexed argument may select such a +range-bearing specialization. In particular, `F(arr[i])` may pass an internal, +call-scoped `ArrayRange` descriptor so the callee loops over the selection. +That descriptor is not a language value: it cannot be stored, returned, +printed, or otherwise escape the call, and the parameter inside `F` observes +one yielded element or owned subarray at a time. + +Driver identity takes priority over loop placement. When one driver occurs in +multiple call arguments, as in `F(i, i)`, both arguments must observe the same +iteration; the current lowering runs that loop caller-side. Distinct drivers, +as in `F(i, j)`, still form their normal cartesian domain. These choices do not +change the results visible to source code. + +Function outputs are staged independently before the call. If an empty driver +or skipped condition means an output is not written, its staged value is +preserved. At representation boundaries, such as a static string result being +assigned into an owned-string destination, the callee receives its declared +zero value and the caller commits the adapted result only when the callee +actually writes it. Examples: @@ -421,7 +500,7 @@ This is a semantic materialization boundary. The compiler may later hoist or fuse loops as an optimization, but that does not change the language meaning. -## Scalar Contexts +## Final-Value Contexts Outside `[]`, ranged expressions remain per-iteration values until the root assignment or statement consumes them. @@ -435,12 +514,31 @@ x = i + 1 `x` becomes `5`. +```pluto +last = i +``` + +`last` becomes `4`. + ```pluto arr = [i + 1] ``` `arr` becomes `[1 2 3 4 5]`. +For a range-indexed array, the same boundary chooses between one final value +and an explicit collection: + +```pluto +data = [10 20 30 40] +i = 1:4 +last = data[i] # 40 +many = [data[i]] # [20 30 40] +``` + +With a matrix, `last = matrix[i]` is the final owned row while +`many = [matrix[i]]` stacks all yielded rows. + ## Self-Reference: Fold When the destination also appears on the right-hand side of a ranged diff --git a/docs/Pluto String and Formatting Semantics.md b/docs/Pluto String and Formatting Semantics.md index 18ac49a4..98065412 100644 --- a/docs/Pluto String and Formatting Semantics.md +++ b/docs/Pluto String and Formatting Semantics.md @@ -63,6 +63,17 @@ width = 5 "-missing%(-width)d" # -missing%(5)d ``` +A marker that reads a `Range` participates in normal range execution. At an +assignment root, formatting runs once per yield and the final owned string is +kept; in print position, one formatted line is emitted per yield. Range +identifiers used for dynamic width or precision are drivers too. + +```pluto +i = 0:3 +last = "item -i" # "item 2" +"item -i" # prints item 0, item 1, item 2 on separate lines +``` + ## Literal percent and strict formatting A `%` outside a resolved marker is ordinary text. A `%` immediately after a diff --git a/parser/parser.go b/parser/parser.go index a139375b..05e955d0 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -772,7 +772,7 @@ func (p *StmtParser) conditionsOk(expList []ast.Expression) bool { if p.isCondition(exp) { continue } - msg := fmt.Sprintf("Expression %q is not a condition. Statement conditions must be comparisons or bare range/array-range drivers", exp.String()) + msg := fmt.Sprintf("Expression %q is not a condition. Statement conditions must be comparisons or bare range/array-selection drivers", exp.String()) ce := &token.CompileError{ Token: exp.Tok(), Msg: msg, diff --git a/parser/scriptparser_test.go b/parser/scriptparser_test.go index b8242b70..736ef492 100644 --- a/parser/scriptparser_test.go +++ b/parser/scriptparser_test.go @@ -478,8 +478,8 @@ func TestImplicitMultParsingSpaces(t *testing.T) { expErrLen int expErr string }{ - {"implicit mult with space", "x = 5 a", 1, "TestImplicitMultParsingSpaces:1:5:Expression \"5\" is not a condition. Statement conditions must be comparisons or bare range/array-range drivers"}, - {"implicit mult with space poly", "y = 1 + 2 x + 3 x^2", 2, "TestImplicitMultParsingSpaces:1:7:Expression \"(1 + 2)\" is not a condition. Statement conditions must be comparisons or bare range/array-range drivers TestImplicitMultParsingSpaces:1:15:expected next token to be =, got IDENT instead"}, + {"implicit mult with space", "x = 5 a", 1, "TestImplicitMultParsingSpaces:1:5:Expression \"5\" is not a condition. Statement conditions must be comparisons or bare range/array-selection drivers"}, + {"implicit mult with space poly", "y = 1 + 2 x + 3 x^2", 2, "TestImplicitMultParsingSpaces:1:7:Expression \"(1 + 2)\" is not a condition. Statement conditions must be comparisons or bare range/array-selection drivers TestImplicitMultParsingSpaces:1:15:expected next token to be =, got IDENT instead"}, } for _, tt := range tests { diff --git a/runtime/runtime.c b/runtime/runtime.c index d0e8d28d..4096388d 100644 --- a/runtime/runtime.c +++ b/runtime/runtime.c @@ -1,4 +1,3 @@ -#include #include #include #include @@ -173,25 +172,6 @@ char *str_hex(const char *s, int64_t byte_limit, int32_t uppercase, int32_t alte return result; } -// Convert a range [s..t) with step p into a NUL-terminated string. -// Caller is responsible for free()ing the returned buffer. -char *range_i64_str(int64_t s, int64_t t, int64_t p) { - // Reserve enough space: up to 20 digits per number, two colons, plus NUL. - // 3*21 + 2 = 65 bytes is plenty. - char *buf = malloc(65); - if (!buf) return NULL; - if (p == 1) { - // omit the default ":1" - /* bounded print to avoid CRT warnings/overflow */ - snprintf(buf, 65, "%" PRId64 ":%" PRId64, s, t); - } else { - /* bounded print to avoid CRT warnings/overflow */ - snprintf(buf, 65, "%" PRId64 ":%" PRId64 ":%" PRId64, s, t, p); - } - return buf; -} - - /* ---------- portable float formatting ---------- */ /* Canonicalize special values across platforms: - NaN => "NaN" (no sign) diff --git a/tests/array/array.exp b/tests/array/array.exp index fc94131c..ac7676b3 100644 --- a/tests/array/array.exp +++ b/tests/array/array.exp @@ -32,6 +32,9 @@ selected rows: [ 1 2 3 4 ] +last selected row: [3 4] +empty selected row: [] +kept selected row: [9 8] selected rows OOB: [ 3 4 0 0 diff --git a/tests/array/array.spt b/tests/array/array.spt index 07472fde..9e534995 100644 --- a/tests/array/array.spt +++ b/tests/array/array.spt @@ -57,6 +57,16 @@ matrixCell = matrixRow[0] rowRange = 0:2 selectedRows = [nestedMatrix[rowRange]] "selected rows: -selectedRows" +lastSelectedRow = nestedMatrix[rowRange] +"last selected row: -lastSelectedRow" + +emptyRowRange = 0:0 +emptySelectedRow = nestedMatrix[emptyRowRange] +"empty selected row: -emptySelectedRow" + +keptSelectedRow = [9 8] +keptSelectedRow = nestedMatrix[emptyRowRange] +"kept selected row: -keptSelectedRow" rowRangeOOB = 1:4 selectedRowsOOB = [nestedMatrix[rowRangeOOB]] diff --git a/tests/array/array_capture.exp b/tests/array/array_capture.exp index 0e9c7cbc..bc55cb0d 100644 --- a/tests/array/array_capture.exp +++ b/tests/array/array_capture.exp @@ -7,5 +7,5 @@ ScaledWithoutSpace: [3 12] DescendingCapture: [6 5 4 3 2 1] DescendingConcat: [12 10 8 6 4 2] MultiRangeCapture: [-14 -10 -6 -2 2] -ArrayRangeCapture: [2 3 4] -ArrayRangeConcat: [3 4 5] +RangeSelectionCapture: [2 3 4] +RangeSelectionConcat: [3 4 5] diff --git a/tests/array/array_capture.spt b/tests/array/array_capture.spt index 6ff2b249..8a1663b8 100644 --- a/tests/array/array_capture.spt +++ b/tests/array/array_capture.spt @@ -34,9 +34,9 @@ multiRangeCapture = i + [i] - [3j] "MultiRangeCapture: -multiRangeCapture" arrBase = [1 2 3 4 5] -arrRangeCapture = [arrBase[1:4]] -"ArrayRangeCapture: -arrRangeCapture" +rangeSelectionCapture = [arrBase[1:4]] +"RangeSelectionCapture: -rangeSelectionCapture" -arrRangeConcat = [] -arrRangeConcat = arrRangeConcat ⊕ [arrBase[2:5]] -"ArrayRangeConcat: -arrRangeConcat" +rangeSelectionConcat = [] +rangeSelectionConcat = rangeSelectionConcat ⊕ [arrBase[2:5]] +"RangeSelectionConcat: -rangeSelectionConcat" diff --git a/tests/array/array_func.exp b/tests/array/array_func.exp index 767dd00e..26c8deae 100644 --- a/tests/array/array_func.exp +++ b/tests/array/array_func.exp @@ -23,6 +23,17 @@ EmptyTableIdentity: [ ] TableCallColumn: [10 12] GatedTableColumn: ["" "Lin"] +ConditionalTableBefore: [ + : Name Score + "Grace" 30 +] +ConditionalTableSkipped: [ + : Name Score + "Grace" 30 +] +ConditionalTableWritten: [ + : Name Score +] DirectConcatScalar: [0] DirectConcatRange: [0 1 2 3 4] ConcatElemDirect: [1] @@ -31,6 +42,32 @@ ConcatElemEmptyNamed: [] ConcatElemInPlace: [3] ConcatEmpty: [] IdentityEmpty: [] +RangedRankReset: [ +] +RangedRankResetEmpty: [ + 5 6 + 7 8 +] +ConditionalRankResetBefore: [ + 11 12 + 13 14 +] +ConditionalRankResetSkipped: [ + 11 12 + 13 14 +] +RangedConditionalRankResetBefore: [ + 21 22 + 23 24 +] +RangedConditionalRankResetSkipped: [ + 21 22 + 23 24 +] +ZeroWidthRankResetBefore: [[] []] +ZeroWidthRankResetSkipped: [[] []] +ZeroWidthRankResetWritten: [ +] BeforeEmptyReset: [9] AfterEmptyReset: [] EmptyResetKeepsType: [4] @@ -43,4 +80,9 @@ RebuildNoAlias: [14] RebuildAssign2: [2] RebuildNoAlias2: [6] PairSumRange: [3 4 4 5] +ArrayRangeLastNamedRow: [3 4] +ArrayRangeLastLiteralRow: [7 8] +ArrayRangeEmptyKeepsRow: [9 10] +ArrayRangeLastLiteralWord: blue +ArrayRangeEmptyKeepsWord: keep [8 9 11] diff --git a/tests/array/array_func.pt b/tests/array/array_func.pt index 3b6bd5f5..c726fbfc 100644 --- a/tests/array/array_func.pt +++ b/tests/array/array_func.pt @@ -27,3 +27,13 @@ res = ConcatElem(a, elem) res = ConcatArrays(a, b) res = a ⊕ b + +# Rank-1 [] can reset an established higher-rank destination. +res = ResetArray(x) + res = x > -1 [] + +# A header-only table can conditionally reset an established concrete table. +res = ResetTable(x) + res = x > -1 [ + : Name Score + ] diff --git a/tests/array/array_func.spt b/tests/array/array_func.spt index bf664537..f4ad45ad 100644 --- a/tests/array/array_func.spt +++ b/tests/array/array_func.spt @@ -55,6 +55,16 @@ gatedTableColumn = (1 > 0 && Identity([ ])).Name > "K" "GatedTableColumn: -gatedTableColumn" +conditionalTable = [ + : Name Score + "Grace" 30 +] +"ConditionalTableBefore: -conditionalTable" +conditionalTable = ResetTable(-1) +"ConditionalTableSkipped: -conditionalTable" +conditionalTable = ResetTable(1) +"ConditionalTableWritten: -conditionalTable" + arr1 = [] ⊕ [0] "DirectConcatScalar: -arr1" @@ -80,6 +90,48 @@ emptyConcat = ConcatArrays([], []) emptyIdentity = Identity([]) "IdentityEmpty: -emptyIdentity" +# Ranged indirect calls returning rank-1 [] bridge safely into rank-N slots. +resetMatrix = [ + 1 2 + 3 4 +] +resetDriver = 0:2 +resetMatrix = ResetArray(resetDriver) +"RangedRankReset: -resetMatrix" + +keptMatrix = [ + 5 6 + 7 8 +] +emptyResetDriver = 0:0 +keptMatrix = ResetArray(emptyResetDriver) +"RangedRankResetEmpty: -keptMatrix" + +# An ordinary conditional [] return keeps an established rank-N destination +# when the callee leaves its output unwritten. +conditionalResetMatrix = [ + 11 12 + 13 14 +] +"ConditionalRankResetBefore: -conditionalResetMatrix" +conditionalResetMatrix = ResetArray(-1) +"ConditionalRankResetSkipped: -conditionalResetMatrix" + +rangedConditionalResetMatrix = [ + 21 22 + 23 24 +] +"RangedConditionalRankResetBefore: -rangedConditionalResetMatrix" +rangedConditionalResetMatrix = ResetArray(-1:0) +"RangedConditionalRankResetSkipped: -rangedConditionalResetMatrix" + +zeroWidthMatrix = [[] []] +"ZeroWidthRankResetBefore: -zeroWidthMatrix" +zeroWidthMatrix = ResetArray(-1) +"ZeroWidthRankResetSkipped: -zeroWidthMatrix" +zeroWidthMatrix = ResetArray(1) +"ZeroWidthRankResetWritten: -zeroWidthMatrix" + lockedArray = [9] "BeforeEmptyReset: -lockedArray" lockedArray = [] @@ -126,6 +178,36 @@ rebuiltNoAlias2 = Rebuild([5 6 7], -1:1) pairSum = [PairSum(0:2, 3:5)] "PairSumRange: -pairSum" +# A bare rank-N array selection stays internal to the call. Identity receives +# one rank-reduced row per iteration and the indirect result retains the last. +rowSource = [ + 1 2 + 3 4 +] +rowDriver = 0:2 +lastNamedRow = Identity(rowSource[rowDriver]) +"ArrayRangeLastNamedRow: -lastNamedRow" + +lastLiteralRow = Identity([ + 5 6 + 7 8 +][rowDriver]) +"ArrayRangeLastLiteralRow: -lastLiteralRow" + +keptRow = [9 10] +emptyRowDriver = 0:0 +keptRow = Identity(rowSource[emptyRowDriver]) +"ArrayRangeEmptyKeepsRow: -keptRow" + +# A temporary string backing array is released after the call, while the +# selected scalar result remains independently owned. +lastLiteralWord = Identity(["red" "blue"][0:2]) +"ArrayRangeLastLiteralWord: -lastLiteralWord" + +keptWord = "keep" +keptWord = Identity(["red" "blue"][0:0]) +"ArrayRangeEmptyKeepsWord: -keptWord" + # Accumulate in array res = [2 3 5] res = Acc(res, 0:4) diff --git a/tests/array/array_range.exp b/tests/array/array_range.exp index b9c6439c..c3456318 100644 --- a/tests/array/array_range.exp +++ b/tests/array/array_range.exp @@ -1,13 +1,21 @@ Index: 30 -RangeAssign: [10 20 30 40 50][1:4] +RangeFinal: 40 RangeWrappedMaterialized: [20 30] -RangeViewPrint: [10 20 30 40 50][1:3] +RangeElement 20 +RangeElement 30 RangeReduce: 150 RangeInfix: 51 -RangeRoot: [10 20 30 40 50][0:5] +RangeRoot: 50 NestedOp: 103 -DirectLiteral: [5 6 7 8][0:3] -IdentRange: [10 20 30 40 50][0:5] +DirectLiteral: 7 +IdentRange: 50 +DescendingRange: 30 +UnevenRange: 50 +EmptyRangeFresh: 0 +EmptyRangeExisting: 77 +OOBLastValid: 50 +AllOOBFresh: 0 +AllOOBExisting: 88 PrefixRange: -40 LiteralIterChain: [1 1 0 0 0 0] CallRangeRoot: 100 @@ -15,5 +23,11 @@ CallRootVec: [20 40 60 80 100] CallRangeInfix: 104 CallInfixVec: [20 21 22 23 24 40 41 42 43 44 60 61 62 63 64 80 81 82 83 84 100 101 102 103 104] CallInfixVecSingleRange: [20 41 62 83 104] -BorrowedReassignArr: [10 20 30 40 50] -BorrowedReassignView: [10 20 30 40 50][1:4] +StringRangeFinal: gamma +StringRangeReassign: beta +StringRangeSource: ["alpha" "beta" "gamma"] +LocalRangeWord: two +LocalRangePlane: [ + 9 10 + 11 12 +] diff --git a/tests/array/array_range.pt b/tests/array/array_range.pt index 5534ddfd..c3cb0cbf 100644 --- a/tests/array/array_range.pt +++ b/tests/array/array_range.pt @@ -1,2 +1,14 @@ res = Double(x) res = x * 2 + +word = LocalWord(i) + words = ["zero" "one" "two"] + word = words[i] + +plane = LocalPlane(i) + cube = [ + [1 2] [3 4] + [5 6] [7 8] + [9 10] [11 12] + ] + plane = cube[i] diff --git a/tests/array/array_range.spt b/tests/array/array_range.spt index 96c96fe4..aaa88292 100644 --- a/tests/array/array_range.spt +++ b/tests/array/array_range.spt @@ -3,13 +3,13 @@ value = arr[2] "Index: -value" current = arr[1:4] -"RangeAssign: -current" +"RangeFinal: -current" wrapped = [arr[1:3]] "RangeWrappedMaterialized: -wrapped" -viewPrint = arr[1:3] -"RangeViewPrint: -viewPrint" +# A direct range selection prints its yielded elements, never an internal view. +"RangeElement", arr[1:3] sum = 0 sum = sum + arr[0:5] @@ -31,6 +31,31 @@ idx = 0:5 viaIdent = arr[idx] "IdentRange: -viaIdent" +descending = arr[4:0:-2] +"DescendingRange: -descending" + +uneven = arr[0:5:2] +"UnevenRange: -uneven" + +# Empty selections keep existing destinations and zero-initialize fresh ones. +emptyFresh = arr[2:2] +"EmptyRangeFresh: -emptyFresh" + +emptyExisting = 77 +emptyExisting = arr[2:2] +"EmptyRangeExisting: -emptyExisting" + +# Out-of-bounds points are skipped, so the last valid selected element wins. +lastValid = arr[-2:7] +"OOBLastValid: -lastValid" + +allOOBFresh = arr[8:10] +"AllOOBFresh: -allOOBFresh" + +allOOBExisting = 88 +allOOBExisting = arr[8:10] +"AllOOBExisting: -allOOBExisting" + pref = -arr[1:4] "PrefixRange: -pref" @@ -40,6 +65,7 @@ chain = [1] chain = chain ⊕ [chain[idx]] "LiteralIterChain: -chain" +# A range-indexed call argument invokes the function for each valid element. callRoot = Double(arr[0:5]) "CallRangeRoot: -callRoot" @@ -55,8 +81,19 @@ callInfixVec = [Double(arr[0:5]) + idx] callInfixVecSingleRange = [Double(arr[idx]) + idx] "CallInfixVecSingleRange: -callInfixVecSingleRange" -# Borrowed ArrayRange reassignment must not free backing array ownership -view = arr[0:3] -view = arr[1:4] -"BorrowedReassignArr: -arr" -"BorrowedReassignView: -view" +# Final string elements become independently owned scalar results. +words = ["alpha" "beta" "gamma"] +lastWord = words[0:3] +"StringRangeFinal: -lastWord" +lastWord = words[1:2] +"StringRangeReassign: -lastWord" +"StringRangeSource: -words" + +# A Range parameter can select the final owned value from a function-local +# array after that local array has been cleaned up. +localDriver = 0:3 +localWord = LocalWord(localDriver) +"LocalRangeWord: -localWord" + +localPlane = LocalPlane(localDriver) +"LocalRangePlane: -localPlane" diff --git a/tests/array/cond_accum.exp b/tests/array/cond_accum.exp index 686c590d..f79f5816 100644 --- a/tests/array/cond_accum.exp +++ b/tests/array/cond_accum.exp @@ -2,10 +2,10 @@ Fill: [0 0 0 0 0] LiteralDriver: [1 1 1 1 1] IdentifierDriver: [2 2 2 2] MixedDriverGuard: [0 1 2] -ArrayRangeDriver: [7 7 7] -NamedArrayRangeDriver: [8 8 8] +RangeSelectionDriver: [7 7 7] +RepeatedRangeSelectionDriver: [8 8 8] RangeMixedTupleA: [0 1 2 3]. RangeMixedTupleB: 13 -ArrayRangeMixedTupleA: [8 8 8]. ArrayRangeMixedTupleB: 40 +RangeSelectionMixedTupleA: [8 8 8]. RangeSelectionMixedTupleB: 40 NestedDrivers: [0 1 2 10 11 12] MixedDrivers: [9 9 9 9 9 9] DriverScalar: 5 @@ -36,7 +36,7 @@ LitCross: 4 30 SeedLit: 88 77 -StrView: c +StrSelection: c ["a" "b"] TupleValueRangesA: [0 1 0 1]. TupleValueRangesB: [0 1] CondLocalDriversA: [1 1]. CondLocalDriversB: 1 diff --git a/tests/array/cond_accum.spt b/tests/array/cond_accum.spt index 38c1f570..044f1173 100644 --- a/tests/array/cond_accum.spt +++ b/tests/array/cond_accum.spt @@ -18,25 +18,24 @@ i = 0:5 mixed = i && i < 3 [i] "MixedDriverGuard: -mixed" -# Bare array-range expression driver uses its view range as the loop driver +# A range-indexed array expression can directly drive a statement. driverSrc = [10 20 30 40] sevens = driverSrc[1:4] [7] -"ArrayRangeDriver: -sevens" +"RangeSelectionDriver: -sevens" -# Bare array-range identifier driver behaves the same way -view = driverSrc[1:4] -eights = view [8] -"NamedArrayRangeDriver: -eights" +# Reuse names the index range, not a persistent selection value. +selection = 1:4 +eights = driverSrc[selection] [8] +"RepeatedRangeSelectionDriver: -eights" # Bare range driver with mixed tuple outputs accumulates arrays and keeps scalar last-value-wins i = 0:4 mixRangeA, mixRangeB = i [i], i + 10 "RangeMixedTupleA: -mixRangeA. RangeMixedTupleB: -mixRangeB" -# Bare array-range driver does the same with the per-iteration element value -viewMix = driverSrc[1:4] -mixViewA, mixViewB = viewMix [8], viewMix -"ArrayRangeMixedTupleA: -mixViewA. ArrayRangeMixedTupleB: -mixViewB" +# Reusing the same named index range keeps both selections on one driver. +mixSelectionA, mixSelectionB = driverSrc[selection] [8], driverSrc[selection] +"RangeSelectionMixedTupleA: -mixSelectionA. RangeSelectionMixedTupleB: -mixSelectionB" # Bare range drivers compose as nested loops i = 0:2 @@ -44,10 +43,9 @@ j = 0:3 grid = i && j [i * 10 + j] "NestedDrivers: -grid" -# Range and array-range drivers compose as nested loops +# Range and range-indexed array drivers compose as nested loops i = 0:2 -view2 = driverSrc[1:4] -gridView = i && view2 [9] +gridView = i && driverSrc[1:4] [9] "MixedDrivers: -gridView" # Bare range driver with scalar RHS keeps last iteration value @@ -185,11 +183,11 @@ r = 0:3 litcross = r < 2 0:5 "LitCross: -litcross" -# Scalar conditional iteration: direct bare array-range view +# Scalar conditional iteration: direct range-indexed array expression arr5 = [10 20 30 40] r = 0:3 -viewcross = r < 2 arr5[0:3] -viewcross +selectionCross = r < 2 arr5[0:3] +selectionCross # Scalar conditional iteration: direct bare range literal false path keeps old value seedlit = 88 @@ -197,18 +195,18 @@ r = 0:3 seedlit = r < 0 0:5 "SeedLit: -seedlit" -# Scalar conditional iteration: direct bare array-range view false path keeps old value -seedview = 77 +# A rejected direct range-indexed array expression keeps the old value +seedSelection = 77 arr6 = [10 20 30 40] r = 0:3 -seedview = r < 0 arr6[0:3] -seedview +seedSelection = r < 0 arr6[0:3] +seedSelection -# Scalar conditional iteration: direct bare string array-range view +# Direct string selection finalization owns its scalar result sarr = ["a" "b" "c" "d"] r = 0:3 -strview = r < 2 sarr[0:3] -"StrView: -strview" +strSelection = r < 2 sarr[0:3] +"StrSelection: -strSelection" # String accumulation sarr2 = ["a" "b"] diff --git a/tests/cond/expr_forms.exp b/tests/cond/expr_forms.exp index afa2b30f..9d3cd551 100644 --- a/tests/cond/expr_forms.exp +++ b/tests/cond/expr_forms.exp @@ -1,12 +1,12 @@ -CondArrayRangeExprTrue: 90 -CondArrayRangeExprFalseExisting: 90 -CondArrayRangeExprFalseNew: 0 -CondArrayRangeExprTrueNew: 40 +CondRangeSelectionExprTrue: 90 +CondRangeSelectionExprFalseExisting: 90 +CondRangeSelectionExprFalseNew: 0 +CondRangeSelectionExprTrueNew: 40 CondSelfRefScalarTrue: 15 CondSelfRefScalarFalseExisting: 15 CondPrefixTrue: 10 CondPrefixFalseExisting: 10 CondPrefixFalseNew: 0 -CondPrefixArrayRangeTrue: -40 -CondPrefixArrayRangeFalseExisting: -40 -CondPrefixArrayRangeFalseNew: 0 +CondPrefixRangeSelectionTrue: -40 +CondPrefixRangeSelectionFalseExisting: -40 +CondPrefixRangeSelectionFalseNew: 0 diff --git a/tests/cond/expr_forms.spt b/tests/cond/expr_forms.spt index 346319bf..746db536 100644 --- a/tests/cond/expr_forms.spt +++ b/tests/cond/expr_forms.spt @@ -1,16 +1,16 @@ arr = [10 20 30 40 50] -# Conditional expressions that include ArrayRange operands. +# Conditional expressions that include range-indexed array operands. sum = 0 # Self-reference: RHS must read from the same temp slot the IF branch writes. sum = 2 > 1 sum + arr[1:4] -"CondArrayRangeExprTrue: -sum" +"CondRangeSelectionExprTrue: -sum" sum = 1 > 2 sum + arr[2:5] -"CondArrayRangeExprFalseExisting: -sum" +"CondRangeSelectionExprFalseExisting: -sum" newSum = 1 > 2 0 + arr[1:4] -"CondArrayRangeExprFalseNew: -newSum" +"CondRangeSelectionExprFalseNew: -newSum" newSumTrue = 2 > 1 0 + arr[1:4] -"CondArrayRangeExprTrueNew: -newSumTrue" +"CondRangeSelectionExprTrueNew: -newSumTrue" sx = 5 sx = 2 > 1 sx + 10 @@ -29,8 +29,8 @@ z = 1 > 2 3x + -x pr = 7 pr = 1 > 0 0 + -arr[1:4] -"CondPrefixArrayRangeTrue: -pr" +"CondPrefixRangeSelectionTrue: -pr" pr = 1 > 2 0 + -arr[1:2] -"CondPrefixArrayRangeFalseExisting: -pr" +"CondPrefixRangeSelectionFalseExisting: -pr" k = 1 > 2 0 + -arr[1:4] -"CondPrefixArrayRangeFalseNew: -k" +"CondPrefixRangeSelectionFalseNew: -k" diff --git a/tests/math/func.spt b/tests/math/func.spt index 35798714..d42e4ac6 100644 --- a/tests/math/func.spt +++ b/tests/math/func.spt @@ -32,7 +32,7 @@ arrRangeFilterKeepPrev = Square(arr[1:3] > 2) "SquareRangeFilterTailFalse: -rangeFilterTailFalseVal\n" -# Range and array-range accumulation +# Range and range-indexed array collection rangeAccVal = [Square(1:3)] arrRangeAccVal = [Square(arr[1:3])] arrRangeFilterAccFalseVal = [Square(arr[1:3] > 3)] diff --git a/tests/math/func_array_range.exp b/tests/math/func_array_range.exp index 3c330f17..11f0d8c0 100644 --- a/tests/math/func_array_range.exp +++ b/tests/math/func_array_range.exp @@ -1,4 +1,4 @@ -Square of [0:5][0:2] with loop inside: 1 -Square of [0:5][0:2 + 1] with loop outside: 9 -SumSquares of arr[0:5], arr[1:3] with loop inside: 20 -SumSquares of [0 1 2 3][0:2], [1 2 3 4][1:3 + 1] with loop outside: 17 +Square of [0:5][0:2] final selection: 1 +Square of [0:5][0:2 + 1] final selection: 9 +SumSquares of arr[0:5], arr[1:3] nested selections: 20 +SumSquares of [0 1 2 3][0:2], [1 2 3 4][1:3 + 1] final selection: 17 diff --git a/tests/math/func_array_range.spt b/tests/math/func_array_range.spt index 0577137a..69c12cb6 100644 --- a/tests/math/func_array_range.spt +++ b/tests/math/func_array_range.spt @@ -1,14 +1,14 @@ i = 0:5 arr = [i] -loopInside = Square(arr[0:2]) -"Square of [0:5][0:2] with loop inside: -loopInside" +literalSelection = Square(arr[0:2]) +"Square of [0:5][0:2] final selection: -literalSelection" j = 1:3 -loopOutside = Square(arr[j + 1]) -"Square of [0:5][0:2 + 1] with loop outside: -loopOutside" +offsetSelection = Square(arr[j + 1]) +"Square of [0:5][0:2 + 1] final selection: -offsetSelection" -sumLoopInside = SumSquares(arr[i], arr[j]) -"SumSquares of arr[0:5], arr[1:3] with loop inside: -sumLoopInside" +nestedSelections = SumSquares(arr[i], arr[j]) +"SumSquares of arr[0:5], arr[1:3] nested selections: -nestedSelections" -sumLoopOutside = SumSquares([0 1 2 3][0:2], [1 2 3 4][j + 1]) -"SumSquares of [0 1 2 3][0:2], [1 2 3 4][1:3 + 1] with loop outside: -sumLoopOutside" \ No newline at end of file +literalSelections = SumSquares([0 1 2 3][0:2], [1 2 3 4][j + 1]) +"SumSquares of [0 1 2 3][0:2], [1 2 3 4][1:3 + 1] final selection: -literalSelections" diff --git a/tests/math/func_nested_range.spt b/tests/math/func_nested_range.spt index 6de165f5..2a559e32 100644 --- a/tests/math/func_nested_range.spt +++ b/tests/math/func_nested_range.spt @@ -2,23 +2,23 @@ i = 0:5 j = 0:3 -# [i] materializes to [0 1 2 3 4], then [j] indexes that array-range. +# [i] materializes to [0 1 2 3 4], then [j] selects from that array. # Last iteration for j is 2, so Square([0 1 2]) keeps the last value 4. nested = Square([i][j]) "Square([i][j]): -nested" -# Similar with SumSquares - both collectors materialize first, then the views iterate. +# Similar with SumSquares - both collectors materialize first, then indexing iterates. k = 1:4 # Last values are 2 and 3, so SumSquares = 2^2 + 3^2 = 13. sumNested = SumSquares([i][j], [i][k]) "SumSquares([i][j], [i][k]): -sumNested" -# Mix: first arg is bare range arr[m], second recollects [i] before indexing. +# Mix: the first argument is range-indexed arr[m]; the second recollects [i]. arr = [10 20 30 40 50] m = 0:3 n = 1:4 arr2 = [i] -# arr[m] is ArrayRange (loop inside), [i][n+0] materializes [0 1 2 3 4] then indexes it. +# arr[m] supplies one selected element per invocation; [i][n+0] materializes first. # SumSquares(30, 3) = 900 + 9 = 909 mixedNested = SumSquares(arr[m], [i][n + 0]) "SumSquares(arr[m], stored[n+0]): -mixedNested" diff --git a/tests/math/func_range.exp b/tests/math/func_range.exp index 66c13958..bb986026 100644 --- a/tests/math/func_range.exp +++ b/tests/math/func_range.exp @@ -1,4 +1,4 @@ -Square of 0:5 with loop inside: 16 -Square of 0:5 with loop outside: 25 -SumSquares of 0:5, 0:5 with loop inside: 32 -SumSquares of 0:5, 0:5 with loop outside: 16.6944444444444 \ No newline at end of file +Square of bare 0:5: 16 +Square of 0:5 + 1: 25 +SumSquares of bare 0:5, 0:5: 32 +SumSquares of 0:5 and range expression: 16.6944444444444 diff --git a/tests/math/func_range.spt b/tests/math/func_range.spt index 458f8222..bce1cf48 100644 --- a/tests/math/func_range.spt +++ b/tests/math/func_range.spt @@ -1,13 +1,13 @@ i = 0:5 -loopInside = Square(i) -"Square of 0:5 with loop inside: -loopInside" +bareRange = Square(i) +"Square of bare 0:5: -bareRange" -loopOutside = Square(i + 1) -"Square of 0:5 with loop outside: -loopOutside" +rangeExpression = Square(i + 1) +"Square of 0:5 + 1: -rangeExpression" j = 0:5 -sumLoopInside = SumSquares(i, j) -"SumSquares of 0:5, 0:5 with loop inside: -sumLoopInside" +twoBareRanges = SumSquares(i, j) +"SumSquares of bare 0:5, 0:5: -twoBareRanges" -sumLoopOutside = SumSquares(i, (j + 1) / (j + 2)) -"SumSquares of 0:5, 0:5 with loop outside: -sumLoopOutside" \ No newline at end of file +mixedRangeExpression = SumSquares(i, (j + 1) / (j + 2)) +"SumSquares of 0:5 and range expression: -mixedRangeExpression" diff --git a/tests/math/print_func.exp b/tests/math/print_func.exp index 9d049aa9..d859bed8 100644 --- a/tests/math/print_func.exp +++ b/tests/math/print_func.exp @@ -13,7 +13,14 @@ 1 1 2 4 3 9 -0:4 1:3 +0 1 +0 2 +1 1 +1 2 +2 1 +2 2 +3 1 +3 2 0 2 0 3 1 2 diff --git a/tests/math/print_func.spt b/tests/math/print_func.spt index 10335ed3..3d300ac7 100644 --- a/tests/math/print_func.spt +++ b/tests/math/print_func.spt @@ -17,7 +17,7 @@ Square(i) # Named range both direct and in function call i, Square(i) -# Two bare ranges - should print as representations +# Two bare ranges form a cartesian print domain. j = 1:3 i, j diff --git a/tests/mem/mem_str.exp b/tests/mem/mem_str.exp index 8038d8b0..1627afa8 100644 --- a/tests/mem/mem_str.exp +++ b/tests/mem/mem_str.exp @@ -24,6 +24,12 @@ left_owned reset heap from func static from func +keep_heap +CallStaticSeedKeep: keep_heap +CallStaticSeedWrite: static changed +CallStaticShareBefore: < shared_left > < shared_right > +CallStaticShareSkipped: < shared_left > < > +CallStaticShareWritten: < shared static > < shared static > slot plain ["slot" "array"] @@ -43,3 +49,14 @@ zero one two localarray +RangeStaticExpression: static ranged +RangeStaticExpressionEmpty: heap from func +RangeStaticBare: static ranged +RangeStaticBareEmpty: heap from func +RangeStaticConditional: static ranged +RangeStaticSimultaneous: static ranged heap from func +RangeStaticPair: pair static 2 +CallHeapSimultaneous: heap from func call_left +CondHeapSwap: cond_right cond_left +RangeCondSource: z_source z_source +RangeCondSelf: z_self diff --git a/tests/mem/mem_str.pt b/tests/mem/mem_str.pt index a6ec985e..59d20312 100644 --- a/tests/mem/mem_str.pt +++ b/tests/mem/mem_str.pt @@ -7,6 +7,24 @@ result = identity(s) s = getStatic() s = "static from func" +# Return a static string while consuming a scalar/range argument. +s = getStaticAt(x) + s = x > -1 "static ranged" + +# Conditionally replace a caller-supplied heap output with a static string. +s = maybeStatic(flag) + s = flag > 0 "static changed" + +# Propagate one conditionally written static output into another output. +s, t = shareStaticOutput(flag) + s = flag > 0 "shared static" + t = s + +# Mixed indirect outputs exercise one widened string slot and one exact scalar slot. +s, n = getStaticPair(x) + s = x > -1 "pair static" + n = x + # Return a heap string (concatenation) s = getHeap() s = "heap" ⊕ " from func" diff --git a/tests/mem/mem_str.spt b/tests/mem/mem_str.spt index 1b3f8253..069b5787 100644 --- a/tests/mem/mem_str.spt +++ b/tests/mem/mem_str.spt @@ -81,6 +81,26 @@ callReset callReset = getStatic() callReset +# A StrG-returning conditional callee still sees the established StrH +# destination seed when its write is skipped. +staticCallSeed = "keep" ⊕ "_heap" +staticCallSeed +staticCallSeed = maybeStatic(0) +"CallStaticSeedKeep: -staticCallSeed" +staticCallSeed = maybeStatic(1) +"CallStaticSeedWrite: -staticCallSeed" + +# Refinement adapters are ABI-zero-seeded inside the callee. An unwritten StrG +# output preserves its established StrH destination, while an explicit sibling +# copy of that ABI value commits the empty StrG value safely. +sharedStaticLeft = "shared" ⊕ "_left" +sharedStaticRight = "shared" ⊕ "_right" +"CallStaticShareBefore: < -sharedStaticLeft > < -sharedStaticRight >" +sharedStaticLeft, sharedStaticRight = shareStaticOutput(0) +"CallStaticShareSkipped: < -sharedStaticLeft > < -sharedStaticRight >" +sharedStaticLeft, sharedStaticRight = shareStaticOutput(1) +"CallStaticShareWritten: < -sharedStaticLeft > < -sharedStaticRight >" + # Array element copy infers StrH for the binding; later static stores should work arrSlot = ["slot" "array"] elemReset = arrSlot[0] @@ -151,3 +171,64 @@ elem2 # Function that uses array elements internally result = useArrayElem() result + +# Ranged indirect calls keep their ABI output flavor separate from an +# established owning destination. Bare and expression drivers must both +# preserve empty ranges and remain safe under simultaneous writes. +rangeStatic = getHeap() +rangeArg = 0:3 +rangeStatic = getStaticAt(rangeArg + 0) +"RangeStaticExpression: -rangeStatic" + +rangeStatic = getHeap() +emptyRangeArg = 0:0 +rangeStatic = getStaticAt(emptyRangeArg + 0) +"RangeStaticExpressionEmpty: -rangeStatic" + +rangeStatic = getHeap() +rangeStatic = getStaticAt(rangeArg) +"RangeStaticBare: -rangeStatic" + +rangeStatic = getHeap() +rangeStatic = getStaticAt(emptyRangeArg) +"RangeStaticBareEmpty: -rangeStatic" + +rangeStatic = getHeap() +rangeStatic = 1 > 0 getStaticAt(rangeArg + 0) +"RangeStaticConditional: -rangeStatic" + +rangeStatic = getHeap() +rangeSibling = "unset" +rangeStatic, rangeSibling = getStaticAt(rangeArg), rangeStatic +"RangeStaticSimultaneous: -rangeStatic -rangeSibling" + +pairStatic = getHeap() +pairNumber = -1 +pairStatic, pairNumber = getStaticPair(rangeArg + 0) +"RangeStaticPair: -pairStatic -pairNumber" + +# Ordinary indirect-return calls also stage their outputs until every sibling +# RHS has read the statement-start values. +callSiblingLeft = "call" ⊕ "_left" +callSiblingLeft, callSiblingRight = getHeap(), callSiblingLeft +"CallHeapSimultaneous: -callSiblingLeft -callSiblingRight" + +# Conditional-value RHS expressions remain simultaneous: committing the first +# heap-string result cannot invalidate a later sibling read. +condSwapLeft = "cond" ⊕ "_left" +condSwapRight = "cond" ⊕ "_right" +condSwapLeft, condSwapRight = condSwapRight > "", condSwapLeft +"CondHeapSwap: -condSwapLeft -condSwapRight" + +# A ranged comparison retains an owned copy of an identifier LHS. Cover both +# a distinct source and self-reference, where the staged output initially +# points at the same logical value. +rangeCompare = 0:3 +rangeCompareSource = "z" ⊕ "_source" +rangeCompareOut = "old" ⊕ "_out" +rangeCompareOut = rangeCompareSource > "item -rangeCompare" +"RangeCondSource: -rangeCompareOut -rangeCompareSource" + +rangeCompareSelf = "z" ⊕ "_self" +rangeCompareSelf = rangeCompareSelf > "item -rangeCompare" +"RangeCondSelf: -rangeCompareSelf" diff --git a/tests/range.exp b/tests/range.exp index d178c5ba..340a306d 100644 --- a/tests/range.exp +++ b/tests/range.exp @@ -1,2 +1,6 @@ -0:5 1:10 0:4:2 -3:5 0:6 -i is 0:6, j is 1:8, k is 6:8:10 0:8:3 +[0 1 2 3 4] [1 2 3 4 5 6 7 8 9] [0 2] [-3 -2 -1 0 1 2 3 4] [0 1 2 3 4 5] +[0 1 2 3 4 5] [1 2 3 4 5 6 7] [6] [0 3 6] +Cartesian 0 2 +Cartesian 0 3 +Cartesian 1 2 +Cartesian 1 3 diff --git a/tests/range.spt b/tests/range.spt index 01159e42..6a959442 100644 --- a/tests/range.spt +++ b/tests/range.spt @@ -2,7 +2,7 @@ i = 0:5 j = 1:10 k = 0:4:2 l = -3:5:1 -i, j, k, l, 0:6 +[i], [j], [k], [l], [0:6] zero = 0 a = 6 @@ -15,4 +15,9 @@ j = 1:b k = a:b:c u = 0:b:d -"i is -i, j is -j, k is -k", u \ No newline at end of file +[i], [j], [k], [u] + +# Distinct bare drivers in one print form a cartesian iteration domain. +p = 0:2 +q = 2:4 +"Cartesian", p, q diff --git a/tests/range_finalize.exp b/tests/range_finalize.exp new file mode 100644 index 00000000..9c498b08 --- /dev/null +++ b/tests/range_finalize.exp @@ -0,0 +1,21 @@ +AscendingFinal: 4 +DescendingFinal: 2 +UnevenFinal: 9 +EmptyFresh: 0 +EmptyExisting: 91 +Collected: [0 1 2 3 4] +CallFinal: 20 +MarkerFinal: Marker 2 +MarkerEach 0 +MarkerEach 1 +MarkerEach 2 +MarkerWidthFinal: | 7| +MarkerWidthEach: |7| +MarkerWidthEach: | 7| +MarkerWidthEach: | 7| +LiteralEach 0 +LiteralEach 1 +LiteralEach 2 +SimultaneousRange: 2 77 +SimultaneousArray: 30 88 +SimultaneousString: Marker 2 old diff --git a/tests/range_finalize.spt b/tests/range_finalize.spt new file mode 100644 index 00000000..9294b2d8 --- /dev/null +++ b/tests/range_finalize.spt @@ -0,0 +1,63 @@ +# A bare range identifier at an assignment root keeps its final yield. +ascendingRange = 0:5 +ascending = ascendingRange +"AscendingFinal: -ascending" + +descendingRange = 6:0:-2 +descending = descendingRange +"DescendingFinal: -descending" + +# The final yield is the last visited value, not stop - step. +unevenRange = 1:10:4 +uneven = unevenRange +"UnevenFinal: -uneven" + +# An empty range preserves an existing destination and leaves a fresh one at zero. +emptyRange = 3:3 +fresh = emptyRange +"EmptyFresh: -fresh" + +existing = 91 +existing = emptyRange +"EmptyExisting: -existing" + +# Brackets remain the explicit materialization boundary. +collected = [ascendingRange] +"Collected: -collected" + +# Function calls evaluate once per yield and the assignment keeps the final result. +callFinal = useShadow(ascendingRange) +"CallFinal: -callFinal" + +# Formatting markers are ordinary driver uses, including at print roots. +markerRange = 0:3 +formattedFinal = "Marker -markerRange" +"MarkerFinal: -formattedFinal" +"MarkerEach -markerRange" + +# Range markers used for dynamic formatting parameters are drivers too. +widthRange = 1:4 +widthValue = 7 +formattedWidth = "|-widthValue%(-widthRange)d|" +"MarkerWidthFinal: -formattedWidth" +"MarkerWidthEach: |-widthValue%(-widthRange)d|" + +# A direct literal in print position is also consumed as a driver. +"LiteralEach", 0:3 + +# Ranged RHS evaluation preserves simultaneous-assignment reads. +simRange = 0:3 +first = 77 +second = 0 +first, second = simRange, first +"SimultaneousRange: -first -second" + +simArray = [10 20 30] +first = 88 +first, second = simArray[simRange], first +"SimultaneousArray: -first -second" + +text = "old" +otherText = "unset" +text, otherText = "Marker -markerRange", text +"SimultaneousString: -text -otherText" diff --git a/token/token.go b/token/token.go index 99e997f8..5f333863 100644 --- a/token/token.go +++ b/token/token.go @@ -50,7 +50,7 @@ const ( LEQ // <= GEQ // >= - COLON // : is if we want to loop from 0:n. For eg: y += 0:n x + COLON // : separates range bounds, as in 0:n or start:stop:step comparison_end // Other tokens. From 64360e330a4b99309c1cc1a3253cc05b61c2bf4f Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 24 Jul 2026 11:51:21 +0530 Subject: [PATCH 03/60] refactor(compiler): remove redundant array-range arity check --- compiler/array.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/compiler/array.go b/compiler/array.go index 62eadc60..ea5a0613 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -977,11 +977,7 @@ func (c *Compiler) compileArrayRangeCallArg(expr *ast.ArrayRangeExpression, typ if arrayIdent, ok := expr.Array.(*ast.Identifier); ok { arrayLoadName = arrayIdent.Value + "_load" } - arrayValues := c.compileExpression(expr.Array, nil) - if len(arrayValues) != 1 { - panic("internal: ArrayRange call argument must have one array source") - } - arraySym := c.derefIfPointer(arrayValues[0], arrayLoadName) + arraySym := c.derefIfPointer(c.compileExpression(expr.Array, nil)[0], arrayLoadName) if !TypeEqual(arraySym.Type, typ.Array) { panic(fmt.Sprintf("internal: ArrayRange source type mismatch: got %s, want %s", arraySym.Type, typ.Array)) } From 5232ce36a819170f880bc67551d12bcbd8fadd14 Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 24 Jul 2026 12:57:00 +0530 Subject: [PATCH 04/60] refactor(compiler): trust solved array-range call types --- compiler/array.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/compiler/array.go b/compiler/array.go index ea5a0613..1c1d269f 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -978,9 +978,6 @@ func (c *Compiler) compileArrayRangeCallArg(expr *ast.ArrayRangeExpression, typ arrayLoadName = arrayIdent.Value + "_load" } arraySym := c.derefIfPointer(c.compileExpression(expr.Array, nil)[0], arrayLoadName) - if !TypeEqual(arraySym.Type, typ.Array) { - panic(fmt.Sprintf("internal: ArrayRange source type mismatch: got %s, want %s", arraySym.Type, typ.Array)) - } var rangeSym *Symbol switch rangeExpr := expr.Range.(type) { @@ -992,9 +989,6 @@ func (c *Compiler) compileArrayRangeCallArg(expr *ast.ArrayRangeExpression, typ panic(fmt.Sprintf("internal: unsupported ArrayRange call index %T", expr.Range)) } rangeSym = c.derefIfPointer(rangeSym, "array_range_index") - if !TypeEqual(rangeSym.Type, typ.Range) { - panic(fmt.Sprintf("internal: ArrayRange index type mismatch: got %s, want %s", rangeSym.Type, typ.Range)) - } _, arrayIsIdent := expr.Array.(*ast.Identifier) return &Symbol{ From 19156cbbe660154dd9ac331a754a1f7c629e3dfb Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 24 Jul 2026 15:33:18 +0530 Subject: [PATCH 05/60] refactor(compiler): simplify array-range index lowering --- compiler/array.go | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/compiler/array.go b/compiler/array.go index 1c1d269f..227543f6 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -980,13 +980,10 @@ func (c *Compiler) compileArrayRangeCallArg(expr *ast.ArrayRangeExpression, typ arraySym := c.derefIfPointer(c.compileExpression(expr.Array, nil)[0], arrayLoadName) var rangeSym *Symbol - switch rangeExpr := expr.Range.(type) { - case *ast.Identifier: - rangeSym = c.compileIdentifier(rangeExpr) - case *ast.RangeLiteral: - rangeSym = c.compileRangeExpression(rangeExpr)[0] - default: - panic(fmt.Sprintf("internal: unsupported ArrayRange call index %T", expr.Range)) + if rangeIdent, ok := expr.Range.(*ast.Identifier); ok { + rangeSym = c.compileIdentifier(rangeIdent) + } else { + rangeSym = c.compileExpression(expr.Range, nil)[0] } rangeSym = c.derefIfPointer(rangeSym, "array_range_index") From 1c56ad6c5d770d661e21e3d8cc6727c2ce7d12be Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 24 Jul 2026 17:33:28 +0530 Subject: [PATCH 06/60] fix(compiler)!: preserve conditional scalar outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seed every direct I64/F64 return from the caller so skipped conditional writes preserve existing destinations, including through nested calls. Keep the seed in the type-derived ABI so write-effect-only edits cannot change a mangled symbol’s native signature. Document the ABI contract, clarify named Range descriptor lowering, and add I64/F64 and conditional accumulator coverage. BREAKING CHANGE: Native C callers of functions with a single I64 or F64 output must pass a final seed argument containing the current destination value, or zero for a fresh destination. --- compiler/abi.go | 13 +++++---- compiler/array.go | 2 ++ compiler/compiler.go | 6 ++-- compiler/compiler_test.go | 24 +++++++++++---- docs/Pluto ABI Optimization Plan.md | 45 ++++++++++++++++++++++------- docs/Pluto C ABI Spec.md | 33 ++++++++++++++++++--- docs/Pluto IR Plan.md | 9 ++++++ tests/math/acc.exp | 3 ++ tests/math/acc.pt | 3 ++ tests/math/acc.spt | 11 +++++++ tests/math/func.exp | 8 +++++ tests/math/func.spt | 22 ++++++++++++++ tests/math/math.pt | 8 ++++- 13 files changed, 157 insertions(+), 30 deletions(-) diff --git a/compiler/abi.go b/compiler/abi.go index 796cd85a..87aef8b6 100644 --- a/compiler/abi.go +++ b/compiler/abi.go @@ -29,9 +29,9 @@ type ABIReturn struct { } // FuncABI captures the lowered function boundary for one mangled variant. -// Range-bearing variants may need hidden alias/seed state so direct scalar -// params and returns still preserve loop-carried accumulation and empty-range -// no-op semantics inside the callee. +// 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. type FuncABI struct { Params []ABIParam Return ABIReturn @@ -97,9 +97,10 @@ func classifyFuncABI(paramTypes []Type, outTypes []Type) FuncABI { if directType, ok := directScalarABIReturnType(outTypes); ok { abi.Return.Mode = ABIReturnDirect abi.Return.DirectType = directType - // Range-bearing variants need a seed so an empty range preserves the - // caller's destination. Ordinary scalar variants return directly. - abi.Return.HasSeedParam = abi.HasRangeParams + // Whether a function body writes its output conditionally is not part + // of the type-based mangle. Keep the native C ABI stable across body + // changes by giving every direct scalar return a destination seed. + abi.Return.HasSeedParam = true } return abi diff --git a/compiler/array.go b/compiler/array.go index 227543f6..d2c12b9c 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -980,6 +980,8 @@ func (c *Compiler) compileArrayRangeCallArg(expr *ast.ArrayRangeExpression, typ arraySym := c.derefIfPointer(c.compileExpression(expr.Array, nil)[0], arrayLoadName) var rangeSym *Symbol + // A named Range must bypass value-root finalization: this call boundary + // needs its descriptor, not the last value produced by iterating it. if rangeIdent, ok := expr.Range.(*ast.Identifier); ok { rangeSym = c.compileIdentifier(rangeIdent) } else { diff --git a/compiler/compiler.go b/compiler/compiler.go index 74f948a8..54c82df9 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -350,9 +350,9 @@ func (c *Compiler) buildCallParamAliasIndices(sig *callSignature, args []callArg } // directReturnSeedForCall captures the caller's current destination value for a -// direct scalar return. Range-bearing variants thread this through a hidden ABI -// param so the callee can preserve empty-range and loop-carried accumulation -// semantics even though the LLVM return itself is by value. +// direct scalar return. The callee receives it through a hidden ABI parameter +// so a skipped output write preserves the destination even though the LLVM +// return itself is by value. func (c *Compiler) directReturnSeedForCall(outType Type, dest *ast.Identifier, output *Symbol) *Symbol { if output != nil { return c.derefIfPointer(output, "call_seed") diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index a31b5dbb..a8aa92d8 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -55,6 +55,18 @@ out` require.Less(t, division, falseLabel, "the second condition must not escape the lazy RHS block") } +func TestDirectScalarABIAlwaysHasDestinationSeed(t *testing.T) { + for _, outType := range []Type{I64, F64} { + abi := classifyFuncABI([]Type{I64}, []Type{outType}) + require.Equal(t, ABIReturnDirect, abi.Return.Mode) + require.True(t, abi.Return.HasSeedParam) + } + + indirect := classifyFuncABI([]Type{I64}, []Type{I64, I64}) + require.Equal(t, ABIReturnIndirect, indirect.Return.Mode) + require.False(t, indirect.Return.HasSeedParam) +} + func TestPhase1ScalarABIDirectI64(t *testing.T) { code := `res = Add(x, y) res = x + y` @@ -65,8 +77,8 @@ res` scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) mangled := Mangle(MangleDirPath(moduleName, ""), "Add", []Type{I64, I64}) - require.Contains(t, scriptIR, "define noundef i64 @"+mangled+"(i64 noundef %0, i64 noundef %1)", "expected direct scalar signature with noundef attrs") - require.Contains(t, scriptIR, "call i64 @"+mangled+"(i64 2, i64 3)", "expected direct scalar call") + require.Contains(t, scriptIR, "define noundef i64 @"+mangled+"(i64 noundef %0, i64 noundef %1, i64 noundef %2)", "expected direct scalar signature with a hidden destination seed") + require.Contains(t, scriptIR, "call i64 @"+mangled+"(i64 2, i64 3, i64 0)", "expected direct scalar call with a fresh-destination seed") require.NotContains(t, scriptIR, mangled+"_ret", "single-scalar return should not use sret struct") } @@ -106,8 +118,8 @@ res` scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) mangled := Mangle(MangleDirPath(moduleName, ""), "AddF", []Type{F64, F64}) - require.Contains(t, scriptIR, "define noundef double @"+mangled+"(double noundef %0, double noundef %1)", "expected direct float signature with noundef attrs") - require.Contains(t, scriptIR, "call double @"+mangled+"(double 2.500000e+00, double 3.500000e+00)", "expected direct float call") + require.Contains(t, scriptIR, "define noundef double @"+mangled+"(double noundef %0, double noundef %1, double noundef %2)", "expected direct float signature with a hidden destination seed") + require.Contains(t, scriptIR, "call double @"+mangled+"(double 2.500000e+00, double 3.500000e+00, double 0.000000e+00)", "expected direct float call with a fresh-destination seed") require.NotContains(t, scriptIR, mangled+"_ret", "single-scalar float return should not use sret struct") } @@ -242,11 +254,11 @@ res` Range{Iter: I64}, }) - require.Contains(t, scriptIR, "define noundef i64 @"+scalarMangled+"(i64 noundef %0, i64 noundef %1)", + require.Contains(t, scriptIR, "define noundef i64 @"+scalarMangled+"(i64 noundef %0, i64 noundef %1, i64 noundef %2)", "a shared driver must select the ordinary scalar specialization") require.GreaterOrEqual(t, strings.Count(scriptIR, "call i64 @"+scalarMangled+"("), 1, "the shared caller-side loop should invoke the scalar specialization") - require.Contains(t, scriptIR, "call i64 @"+scalarMangled+"(i64 %get, i64 %iter)", + require.Contains(t, scriptIR, "call i64 @"+scalarMangled+"(i64 %get, i64 %iter, i64 %call_seed)", "the array access and scalar argument should use the same caller-loop iterator") require.NotContains(t, scriptIR, arrayRangeMangled, "arr[i] and i must not become independent callee iterators") diff --git a/docs/Pluto ABI Optimization Plan.md b/docs/Pluto ABI Optimization Plan.md index 40dfbf85..aa72cee5 100644 --- a/docs/Pluto ABI Optimization Plan.md +++ b/docs/Pluto ABI Optimization Plan.md @@ -60,13 +60,22 @@ Concretely, it hooks between `TypeLetStatement` / `TypeExpression` (which resolv ### 3.3 Internal vs external ABI -| | Internal (Pluto-to-Pluto) | External (C callers) | -| -------------- | ------------------------------------- | ------------------------ | -| **Convention** | Classified ABI (direct scalars, etc.) | Current all-pointer ABI | -| **Stability** | Can change between compiler versions | Stable, documented | -| **Migration** | Transparent to Pluto code | Wrapper thunks if needed | - -Name mangling encodes semantic types, not physical ABI. Changing `I64` from pointer-passed to value-passed does not require a mangling change. But the binary calling convention does change, so external callers need ABI wrappers or versioning. +The current compiler emits one classified mangled entry point used by both +Pluto calls and direct C callers. There is not yet a separate stable wrapper: + +| Entry point | Consumers | Convention | Stability | +| --- | --- | --- | --- | +| Current mangled symbol | Pluto and C | Documented classified ABI | Write-effect-independent within the ABI version | +| Future private clone | Pluto only | Internally optimized | May vary freely | +| Future public wrapper | C | Documented wrapper ABI | Stable or explicitly versioned | + +Name mangling encodes semantic types, not physical ABI. Changing `I64` from +pointer-passed to value-passed therefore does not change the name, even though +it changes the C prototype. Until wrappers exist, every physical ABI decision +on the current symbol must be derived from its solved signature—not output write +effects in its body or the bodies of callees. Because return types are inferred +and not mangled, a body edit that changes the solved parameter/output signature +is still an ABI-breaking source change even though the symbol name is unchanged. ## 4. Implementation Phases @@ -77,7 +86,20 @@ Direct lowering for scalar numeric inputs and single scalar outputs. - pass `I64`/`F64` by value instead of by pointer - return single scalar in register instead of via `sret` - keep function-body semantics stable by spilling direct scalar params into local addressable slots in the callee -- preserve range-bearing accumulator / empty-range behavior with hidden alias/seed state where needed +- 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 + +`MustWrite`/`MayWrite` has limited utility at the public boundary and must not +decide whether the seed parameter exists. Adding one conditional output write +to a function body—or making a reachable output-producing callee conditional— +could otherwise change the C prototype without changing the type-based mangled +name. Previously compiled C callers would then invoke the same symbol with the +wrong argument list. Write-effect information may still eliminate seed use +inside Pluto code; a seedless fast path needs a distinctly named private clone +behind the stable public entry point. This was the highest-value initial optimization because it benefits all scalar-heavy code, not just specific patterns. It reduces stack traffic, simplifies IR, and materially improved `fib`, `fib_tail`, and `harmonic`. @@ -108,7 +130,9 @@ Broaden to more scalar types, small direct aggregates in both params and results ### Phase 5: External ABI wrappers -Once internal ABI is stable, decide whether exported symbols keep the current C ABI (add wrappers) or version the ABI docs explicitly. +Add stable/versioned public wrappers if private Pluto-only entry points begin +using more aggressive conventions. Until then, the documented classified +mangled symbol remains the C boundary. ### Parallel track: non-ABI loop/codegen work @@ -160,7 +184,8 @@ Each phase should: 1. **Feature-flag the new ABI** — compile both old and new paths, compare program behavior and test results (not raw IR, which will differ by design) 2. **Run the full test suite** (`python3 test.py --leak-check`) against both paths 3. **Benchmark before/after** using `bench/` suite to validate the expected gains -4. **Merge internal ABI first** — external wrappers come later (Phase 5) +4. **Keep public symbols write-effect-independent** — private ABI experiments + require distinct symbols until external wrappers arrive in Phase 5 ## 6. Practical Recommendation diff --git a/docs/Pluto C ABI Spec.md b/docs/Pluto C ABI Spec.md index 81570895..9c8a0e57 100644 --- a/docs/Pluto C ABI Spec.md +++ b/docs/Pluto C ABI Spec.md @@ -268,7 +268,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, when required, is last. +seed is last. --- @@ -314,9 +314,9 @@ types: - `I64` and `F64` parameters are passed directly. Ranges, internal `ArrayRange` descriptors, and other values are passed indirectly. - A function with exactly one `I64` or `F64` output returns that scalar - directly. A variant bearing a `Range` or internal `ArrayRange` parameter - also receives a hidden seed value so an empty range preserves the caller's - staged value. + directly and receives one hidden seed value. The seed preserves the caller's + staged value when the callee does not write its output, including a failed + conditional assignment or an empty `Range`/internal `ArrayRange`. - All other output lists use an indirect `void` return. Argument zero points to a carrier whose first `N` fields are output pointers and whose next `N` fields are pointers to `i1` write markers. @@ -330,6 +330,31 @@ types: 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. +The direct-return seed is always present, even when the function body +unconditionally overwrites its output. Schematically, with mangled names +abbreviated: + +```c +int64_t Pt_Square_I64(int64_t x, int64_t seed); +int64_t Pt_ConditionalSquare_I64(int64_t x, int64_t seed); +int64_t Pt_Acc_I64_Range( + int64_t a, + const PtRangeI64 *range, + int32_t a_output_alias, + int64_t seed +); +``` + +A C caller passes the destination's current value to request Pluto's keep-old +semantics, or the output type's zero value for a fresh destination. The seed is +`noundef` and must still be supplied to a function such as `Square` that does +not inspect it. Whether the body must write or may skip a write never changes +the public signature or mangled name. In particular, adding a conditional +assignment—or changing a reachable output-producing callee from must-write to +may-write—cannot alter the prototype of an existing symbol. A future seedless +internal fast path must therefore use a distinct private symbol behind this +stable boundary. + Conceptually, a two-output indirect call uses: ```c diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index 2278ffc1..06da87e7 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -55,6 +55,15 @@ before the final commit; for example, `x = arr[i] > 0 || 0` is `MustWrite`, whil `x = arr[i] > 0 || other[j] > 0` remains `MayWrite`. This matches the compiler's existing solver-then-CFG order, so migration requires no pass reordering. +This per-target `WriteEffect` is not a public function-ABI classifier. Every +exported direct `I64`/`F64` return keeps its final hidden seed parameter. +Collapsing statement effects into a function-level `MustWrite`/`MayWrite` +summary may optimize internal seed use, but it cannot add or remove that +parameter: a local conditional or a newly conditional output-producing callee +would otherwise change the C prototype of the same type-mangled symbol after a +body-only edit. A seedless variant requires a distinctly named private clone +behind the stable entry point. + PIR may refer to solved AST expressions, but LLVM lowering must not reclassify their range, conditional, OOB, collector, affine, or commit behavior. diff --git a/tests/math/acc.exp b/tests/math/acc.exp index 8f1c2453..e7344376 100644 --- a/tests/math/acc.exp +++ b/tests/math/acc.exp @@ -2,3 +2,6 @@ 10 25 10 +10 +12 +13 diff --git a/tests/math/acc.pt b/tests/math/acc.pt index 7bfdded5..0e6b8f07 100644 --- a/tests/math/acc.pt +++ b/tests/math/acc.pt @@ -7,3 +7,6 @@ res = AccViaId(a, x) res = Acc(a, x) res = a + x + +res = ConditionalAcc(a, x) + res = x > 0 a + x diff --git a/tests/math/acc.spt b/tests/math/acc.spt index d0a50739..c742834a 100644 --- a/tests/math/acc.spt +++ b/tests/math/acc.spt @@ -13,3 +13,14 @@ res res = 10 res = AccViaId(res, 0:-2) res + +res = 10 +res = ConditionalAcc(res, -1) +res + +res = ConditionalAcc(res, 2) +res + +res = 10 +res = ConditionalAcc(res, -2:3) +res diff --git a/tests/math/func.exp b/tests/math/func.exp index 220c2caa..cf452f67 100644 --- a/tests/math/func.exp +++ b/tests/math/func.exp @@ -2,6 +2,14 @@ SquareInt: 25 SquareFloat: 4.84 SquareArr: [1 4 9 25] +ConditionalSquareKeep: 14 +ConditionalSquareWrite: 49 +ConditionalSquareFresh: 0 +ConditionalSquareForwarded: 14 +ConditionalSquareFloatKeep: 14.5 +ConditionalSquareFloatWrite: 56.25 +ConditionalSquareFloatFresh: 0 + SquareRange: 4 SquareArrRange: 9 SquareArrMask: [0 0 0 25] diff --git a/tests/math/func.spt b/tests/math/func.spt index d42e4ac6..4752b9b4 100644 --- a/tests/math/func.spt +++ b/tests/math/func.spt @@ -9,6 +9,28 @@ arrVal = Square(arr) "SquareFloat: -floatVal" "SquareArr: -arrVal\n" +# A skipped direct-scalar function output preserves an existing caller +# destination, while a fresh destination keeps its zero seed. +conditionalSquareKeep = 14 +conditionalSquareKeep = ConditionalSquare(conditionalSquareKeep - 12) +conditionalSquareWrite = 14 +conditionalSquareWrite = ConditionalSquare(conditionalSquareWrite - 7) +conditionalSquareFresh = ConditionalSquare(2) +conditionalSquareForwarded = 14 +conditionalSquareForwarded = ForwardConditionalSquare(conditionalSquareForwarded - 12) +conditionalSquareFloatKeep = 14.5 +conditionalSquareFloatKeep = ConditionalSquare(conditionalSquareFloatKeep - 12.5) +conditionalSquareFloatWrite = ConditionalSquare(7.5) +conditionalSquareFloatFresh = ConditionalSquare(2.0) + +"ConditionalSquareKeep: -conditionalSquareKeep" +"ConditionalSquareWrite: -conditionalSquareWrite" +"ConditionalSquareFresh: -conditionalSquareFresh" +"ConditionalSquareForwarded: -conditionalSquareForwarded" +"ConditionalSquareFloatKeep: -conditionalSquareFloatKeep" +"ConditionalSquareFloatWrite: -conditionalSquareFloatWrite" +"ConditionalSquareFloatFresh: -conditionalSquareFloatFresh\n" + # Range filters and array masks (non-accumulated) rangeVal = Square(1:3) arrRangeVal = Square(arr[1:3]) diff --git a/tests/math/math.pt b/tests/math/math.pt index 5372be05..f9166b25 100644 --- a/tests/math/math.pt +++ b/tests/math/math.pt @@ -7,6 +7,12 @@ greeting = "hello" res = Square(x) res = x * x +res = ConditionalSquare(x) + res = x > 5 x * x + +res = ForwardConditionalSquare(x) + res = ConditionalSquare(x) + z = SumSquares(a, b) z = Square(a) + Square(b) @@ -22,4 +28,4 @@ x, y = F(i) x, y = 2 + i, 3 + i a, b = G(i) - a, b = 2.3 * i, 5.6 * i \ No newline at end of file + a, b = 2.3 * i, 5.6 * i From 4c51094bc9999a407b96d13a58577551ff53ebcd Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 24 Jul 2026 17:50:01 +0530 Subject: [PATCH 07/60] docs(readme): restore concise memory wording --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c05603b2..d31d1cdd 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Intended for performance-sensitive scripting, numerical work, simulation, and sy Range-driven auto-vectorization and safe arrays. -Scope-based memory (no null values, no unsafe out-of-bounds access, no GC), and concurrency by construction. +Scope-based memory (no nulls, no out-of-bounds, no GC), and concurrency by construction. --- @@ -43,7 +43,7 @@ Scope-based memory (no null values, no unsafe out-of-bounds access, no GC), and - Template functions in `.pt`: specialized per argument types (generics by use) - Range literals with auto-vectorized execution - First-class rectangular arrays of any rank, columnar tables, and link semantics -- Scope-based memory: no null values, no unsafe out-of-bounds access, no garbage collector +- Scope-based memory: no nulls, no out-of-bounds, no garbage collector - printf-style formatting; arrays printable and range streams iterable - Cross-platform (Linux/macOS/Windows) @@ -311,7 +311,7 @@ Pluto uses deterministic, scope-based memory: - No garbage collector - No null values -- No unsafe out-of-bounds memory access +- No out-of-bounds access - Memory freed when scope ends Predictable performance with minimal runtime overhead. From 917ab118adf6f515551ed7beb9442d1770858e58 Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 24 Jul 2026 18:11:47 +0530 Subject: [PATCH 08/60] refactor(compiler): derive direct seed from return mode Remove the redundant HasSeedParam state and use ABIReturnDirect as the single predicate for LLVM signatures and call arguments. Cover zero-argument numeric and string return classifications. --- compiler/abi.go | 16 +++++++--------- compiler/compiler.go | 4 ++-- compiler/compiler_test.go | 14 ++++++++++++-- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/compiler/abi.go b/compiler/abi.go index 87aef8b6..f725975b 100644 --- a/compiler/abi.go +++ b/compiler/abi.go @@ -22,10 +22,9 @@ type ABIParam struct { } type ABIReturn struct { - Mode ABIReturnMode - DirectType Type - OutTypes []Type - HasSeedParam bool + Mode ABIReturnMode + DirectType Type + OutTypes []Type } // FuncABI captures the lowered function boundary for one mangled variant. @@ -95,12 +94,11 @@ func classifyFuncABI(paramTypes []Type, outTypes []Type) FuncABI { } if directType, ok := directScalarABIReturnType(outTypes); ok { - abi.Return.Mode = ABIReturnDirect - abi.Return.DirectType = directType // Whether a function body writes its output conditionally is not part // of the type-based mangle. Keep the native C ABI stable across body - // changes by giving every direct scalar return a destination seed. - abi.Return.HasSeedParam = true + // changes: direct-return mode always implies a destination seed. + abi.Return.Mode = ABIReturnDirect + abi.Return.DirectType = directType } return abi @@ -144,7 +142,7 @@ func (abi FuncABI) AliasFunctionParamIndex(paramIndex int) int { } func (abi FuncABI) DirectReturnSeedParamIndex() int { - if abi.Return.Mode != ABIReturnDirect || !abi.Return.HasSeedParam { + if abi.Return.Mode != ABIReturnDirect { return -1 } return abi.AliasParamBaseIndex() + abi.NumAliasSlots() diff --git a/compiler/compiler.go b/compiler/compiler.go index 54c82df9..a1328e6d 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -2543,7 +2543,7 @@ func (c *Compiler) getFuncType(mangled string, abi FuncABI) (llvm.Type, llvm.Typ for i := 0; i < abi.NumAliasSlots(); i++ { llvmParams = append(llvmParams, c.Context.Int32Type()) } - if abi.Return.HasSeedParam { + if abi.Return.Mode == ABIReturnDirect { llvmParams = append(llvmParams, c.mapToLLVMType(abi.Return.DirectType)) } @@ -3381,7 +3381,7 @@ func (c *Compiler) callArgs( for _, aliasIndex := range call.AliasIndices { llvmArgs = append(llvmArgs, llvm.ConstInt(c.Context.Int32Type(), uint64(aliasIndex), false)) } - if sig.ABI.Return.HasSeedParam { + 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 a8aa92d8..ddbebd1f 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -59,12 +59,22 @@ func TestDirectScalarABIAlwaysHasDestinationSeed(t *testing.T) { for _, outType := range []Type{I64, F64} { abi := classifyFuncABI([]Type{I64}, []Type{outType}) require.Equal(t, ABIReturnDirect, abi.Return.Mode) - require.True(t, abi.Return.HasSeedParam) + require.True(t, TypeEqual(outType, abi.Return.DirectType)) + require.Equal(t, 1, abi.DirectReturnSeedParamIndex()) } + zeroArg := classifyFuncABI(nil, []Type{I64}) + require.Equal(t, ABIReturnDirect, zeroArg.Return.Mode) + require.Equal(t, 0, zeroArg.DirectReturnSeedParamIndex()) + indirect := classifyFuncABI([]Type{I64}, []Type{I64, I64}) require.Equal(t, ABIReturnIndirect, indirect.Return.Mode) - require.False(t, indirect.Return.HasSeedParam) + require.Nil(t, indirect.Return.DirectType) + require.Equal(t, -1, indirect.DirectReturnSeedParamIndex()) + + stringReturn := classifyFuncABI(nil, []Type{StrG{}}) + require.Equal(t, ABIReturnIndirect, stringReturn.Return.Mode) + require.Equal(t, -1, stringReturn.DirectReturnSeedParamIndex()) } func TestPhase1ScalarABIDirectI64(t *testing.T) { From 59d46cef831b711133b3c3ba0d5f24413a8cae2a Mon Sep 17 00:00:00 2001 From: Tejas Date: Fri, 24 Jul 2026 23:36:13 +0530 Subject: [PATCH 09/60] refactor(compiler): unify ranged call alias metadata Represent destination aliases once per prepared call and derive both indirect pointer substitution and direct ABI selector arguments from that plan. Add mixed direct-input and indirect-return accumulator coverage. --- compiler/compiler.go | 122 ++++++++++++++++++++----------------------- tests/math/acc.exp | 1 + tests/math/acc.pt | 4 ++ tests/math/acc.spt | 4 ++ 4 files changed, 67 insertions(+), 64 deletions(-) diff --git a/compiler/compiler.go b/compiler/compiler.go index a1328e6d..0b9ffc1c 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -72,11 +72,10 @@ type FuncArgs struct { } type callArg struct { - Expr ast.Expression - Name string - Symbol *Symbol - Lowered *Symbol - OutputAlias int + Expr ast.Expression + Name string + Symbol *Symbol + Lowered *Symbol } type callSignature struct { @@ -88,11 +87,11 @@ type callSignature struct { } type preparedCall struct { - Args []callArg - AliasIndices []int - Function llvm.Value - FuncType llvm.Type - RetStruct llvm.Type + Args []callArg + OutputAliases []int // Per source argument; -1 means no destination alias. + Function llvm.Value + FuncType llvm.Type + RetStruct llvm.Type } // BindingKey identifies a variable binding within a specific function variant. @@ -318,35 +317,42 @@ func (c *Compiler) resolveCallSignature(funcName string, ce *ast.CallExpression, }, true } -// buildCallParamAliasIndices records which direct scalar params alias caller -// destinations for range-bearing variants. The callee uses these indices to -// redirect a by-value param spill to the matching output slot so loop-carried -// accumulation keeps the same semantics as the legacy indirect ABI. -func (c *Compiler) buildCallParamAliasIndices(sig *callSignature, args []callArg, dest []*ast.Identifier) []int { - aliasIndices := make([]int, sig.ABI.NumAliasSlots()) - if dest == nil { - return aliasIndices +// buildCallArgOutputAliases records which named inputs alias caller +// destinations 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. +func (c *Compiler) buildCallArgOutputAliases(sig *callSignature, args []callArg, dest []*ast.Identifier) []int { + aliases := make([]int, len(args)) + for i := range aliases { + aliases[i] = -1 + } + if !sig.ABI.HasRangeParams || dest == nil { + return aliases } - for i, arg := range args { - aliasSlot := sig.ABI.Params[i].AliasSlot - if aliasSlot < 0 || arg.Name == "" { + for paramIndex, arg := range args { + paramABI := sig.ABI.Params[paramIndex] + if arg.Name == "" || (paramABI.Mode == ABIParamDirect && paramABI.AliasSlot < 0) { continue } - for j := range sig.FnInfo.OutTypes { - if j >= len(dest) { + for outputIndex, output := range dest { + if outputIndex >= len(sig.ABI.Return.OutTypes) { break } - if dest[j].Value != arg.Name { + if output.Value != arg.Name { + continue + } + if paramABI.Mode == ABIParamIndirect && + !TypeEqual(sig.ParamTypes[paramIndex], sig.ABI.Return.OutTypes[outputIndex]) { continue } - aliasIndices[aliasSlot] = j + 1 + aliases[paramIndex] = outputIndex break } } - return aliasIndices + return aliases } // directReturnSeedForCall captures the caller's current destination value for a @@ -2998,9 +3004,8 @@ func (c *Compiler) compileCallArgs(sig *callSignature, ce *ast.CallExpression) [ panic(fmt.Sprintf("internal: ArrayRange parameter received %T", callArgExpr)) } args = append(args, callArg{ - Expr: callArgExpr, - Symbol: c.compileArrayRangeCallArg(arrayRangeExpr, arrayRangeType), - OutputAlias: -1, + Expr: callArgExpr, + Symbol: c.compileArrayRangeCallArg(arrayRangeExpr, arrayRangeType), }) paramIndex++ continue @@ -3008,9 +3013,8 @@ func (c *Compiler) compileCallArgs(sig *callSignature, ce *ast.CallExpression) [ if ident, ok := callArgExpr.(*ast.Identifier); ok { args = append(args, callArg{ - Expr: callArgExpr, - Name: ident.Value, - OutputAlias: -1, + Expr: callArgExpr, + Name: ident.Value, }) paramIndex++ continue @@ -3022,9 +3026,8 @@ func (c *Compiler) compileCallArgs(sig *callSignature, ce *ast.CallExpression) [ panic("internal: compiled call argument count exceeds resolved signature") } args = append(args, callArg{ - Expr: callArgExpr, - Symbol: r, - OutputAlias: -1, + Expr: callArgExpr, + Symbol: r, }) paramIndex++ } @@ -3035,25 +3038,8 @@ func (c *Compiler) compileCallArgs(sig *callSignature, ce *ast.CallExpression) [ return args } -func (c *Compiler) indirectCallOutputAlias(sig *callSignature, paramIndex int, arg callArg, dest []*ast.Identifier) int { - if !sig.ABI.HasRangeParams || sig.ABI.Params[paramIndex].Mode != ABIParamIndirect || arg.Name == "" { - return -1 - } - for outputIndex, output := range dest { - if output.Value != arg.Name || outputIndex >= len(sig.ABI.Return.OutTypes) { - continue - } - if TypeEqual(sig.ParamTypes[paramIndex], sig.ABI.Return.OutTypes[outputIndex]) { - return outputIndex - } - } - return -1 -} - -func (c *Compiler) lowerCallArgs(funcName string, args []callArg, sig *callSignature, dest []*ast.Identifier) []int { - aliasIndices := c.buildCallParamAliasIndices(sig, args, dest) +func (c *Compiler) lowerCallArgs(funcName string, args []callArg, sig *callSignature) { for i, arg := range args { - args[i].OutputAlias = c.indirectCallOutputAlias(sig, i, arg, dest) sym := arg.Symbol if sig.ABI.Params[i].Mode != ABIParamIndirect { if arg.Name != "" { @@ -3077,7 +3063,6 @@ func (c *Compiler) lowerCallArgs(funcName string, args []callArg, sig *callSigna } args[i].Lowered = sym } - return aliasIndices } func (c *Compiler) freeCallArgTemps(callArgs []callArg) { @@ -3103,14 +3088,15 @@ func (c *Compiler) freeCallArgTemps(callArgs []callArg) { func (c *Compiler) prepareCall(sig *callSignature, ce *ast.CallExpression, dest []*ast.Identifier) preparedCall { callArgs := c.compileCallArgs(sig, ce) - aliasIndices := c.lowerCallArgs(sig.FuncName, callArgs, sig, dest) + argOutputAliases := c.buildCallArgOutputAliases(sig, callArgs, dest) + c.lowerCallArgs(sig.FuncName, callArgs, sig) fn, funcType, retStruct := c.getOrCompileCallFunction(sig) return preparedCall{ - Args: callArgs, - AliasIndices: aliasIndices, - Function: fn, - FuncType: funcType, - RetStruct: retStruct, + Args: callArgs, + OutputAliases: argOutputAliases, + Function: fn, + FuncType: funcType, + RetStruct: retStruct, } } @@ -3371,14 +3357,22 @@ func (c *Compiler) callArgs( } llvmArgs = append(llvmArgs, sretPtr) } - for _, arg := range call.Args { + for i, arg := range call.Args { argVal := arg.Lowered.Val - if arg.OutputAlias >= 0 && arg.OutputAlias < len(outputs) { - argVal = outputs[arg.OutputAlias].Val + outputAlias := call.OutputAliases[i] + if sig.ABI.Params[i].Mode == ABIParamIndirect && outputAlias >= 0 && outputAlias < len(outputs) { + argVal = outputs[outputAlias].Val } llvmArgs = append(llvmArgs, argVal) } - for _, aliasIndex := range call.AliasIndices { + aliasIndices := make([]int, sig.ABI.NumAliasSlots()) + for i, paramABI := range sig.ABI.Params { + if paramABI.AliasSlot < 0 { + continue + } + aliasIndices[paramABI.AliasSlot] = call.OutputAliases[i] + 1 + } + for _, aliasIndex := range aliasIndices { llvmArgs = append(llvmArgs, llvm.ConstInt(c.Context.Int32Type(), uint64(aliasIndex), false)) } if sig.ABI.Return.Mode == ABIReturnDirect { diff --git a/tests/math/acc.exp b/tests/math/acc.exp index e7344376..85323eda 100644 --- a/tests/math/acc.exp +++ b/tests/math/acc.exp @@ -5,3 +5,4 @@ 10 12 13 +16 3 diff --git a/tests/math/acc.pt b/tests/math/acc.pt index 0e6b8f07..c971eeee 100644 --- a/tests/math/acc.pt +++ b/tests/math/acc.pt @@ -10,3 +10,7 @@ res = Acc(a, x) res = ConditionalAcc(a, x) res = x > 0 a + x + +sum, last = AccPair(a, x) + sum = a + x + last = x diff --git a/tests/math/acc.spt b/tests/math/acc.spt index c742834a..4ae15c16 100644 --- a/tests/math/acc.spt +++ b/tests/math/acc.spt @@ -24,3 +24,7 @@ res res = 10 res = ConditionalAcc(res, -2:3) res + +sum = 10 +sum, last = AccPair(sum, 1:4) +sum, last From 5b1e85fdc14a2b3d97e657cbbd05977c772041c9 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 25 Jul 2026 10:20:36 +0530 Subject: [PATCH 10/60] fix(compiler): instantiate scalar callee variants for named range selections HandleCallRanges only registered the scalar callee variant on the path where an argument was syntactically rewritten. Promoting a range-indexed array selection to an internal ArrayRange changes the call's mangled name without rewriting the argument list, so a collector over such a call asked codegen for a variant that was never created. The scalar variant is required whenever a surrounding collector invokes the call once per yield, which is independent of any rewrite, so hoist the registration above the unchanged-arguments early return. Require merged ranges as well: LoopInside is true for any ordinary call, so it alone would register a redundant variant for every user call rather than only for collectors. Before this, "g = [Double(arr[i])]" with a named driver "i" aborted with an internal compiler error, and any other call to the same function anywhere in the script instantiated the variant and masked it. The new test calls each helper exactly once so no sibling call can hide a regression. Co-Authored-By: Claude Opus 5 --- compiler/compiler_test.go | 16 ++++++++++++++++ compiler/solver.go | 16 ++++++++++------ tests/array/collector_variant.exp | 2 ++ tests/array/collector_variant.pt | 7 +++++++ tests/array/collector_variant.spt | 15 +++++++++++++++ 5 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 tests/array/collector_variant.exp create mode 100644 tests/array/collector_variant.pt create mode 100644 tests/array/collector_variant.spt diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index ddbebd1f..c9179ef6 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -170,6 +170,22 @@ row` require.Contains(t, scriptIR, "store i1 true, ptr %res_written", "a yielded ranged selection must mark the output as written") } +func TestCollectorOverRangeSelectionRegistersScalarVariant(t *testing.T) { + code := `res = Scale(x) + res = x * 3` + script := `arr = [10 20 30] +i = 0:3 +scaled = [Scale(arr[i])] +scaled` + + moduleName := "collector_scalar_variant" + scriptIR, _ := compileScriptAndCodeIR(t, moduleName, code, script) + mangled := Mangle(MangleDirPath(moduleName, ""), "Scale", []Type{I64}) + + require.Contains(t, scriptIR, "define noundef i64 @"+mangled+"(", + "a collector invokes the callee once per scalar yield, so promoting the argument to an internal ArrayRange must still define the scalar variant") +} + func TestPhase1ScalarABIRangeVariantUsesDirectScalarBoundary(t *testing.T) { code := `res = Acc(a, x) res = a + x` diff --git a/compiler/solver.go b/compiler/solver.go index c79191e9..a0e30f86 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -486,6 +486,16 @@ func (ts *TypeSolver) HandleCallRanges(call *ast.CallExpression) (ranges []*Rang ranges, args, changed := ts.collectExprRanges(call.Arguments) info := ts.ExprCache[key(ts.FuncNameMangled, call)] + // A surrounding collector consumes these ranges and invokes the call once + // per scalar yield, so that scalar callee variant must exist even though the + // immediate call selected a range specialization. Promoting an argument to + // an internal ArrayRange changes the mangled name without rewriting the + // argument list, so this cannot be gated on a syntactic rewrite. LoopInside + // is true for any ordinary call, so require ranges to reach only collectors. + if _, builtin := Builtins[call.Function.Value]; len(ranges) > 0 && info.LoopInside && !builtin { + ts.ensureScalarCallVariant(call) + } + if !changed { info.Ranges = ranges info.Rewrite = call @@ -495,12 +505,6 @@ func (ts *TypeSolver) HandleCallRanges(call *ast.CallExpression) (ranges []*Rang cp := *call cp.Arguments = args rew = &cp - // A surrounding collector consumes these ranges and invokes the rewritten - // call once per scalar yield, so make sure that scalar callee variant exists - // even though the original immediate call selected a range specialization. - if _, builtin := Builtins[call.Function.Value]; info.LoopInside && !builtin { - ts.ensureScalarCallVariant(call) - } // Cache the rewritten expression with no ranges (ranges have been extracted) ts.ExprCache[key(ts.FuncNameMangled, rew.(*ast.CallExpression))] = &ExprInfo{ OutTypes: info.OutTypes, diff --git a/tests/array/collector_variant.exp b/tests/array/collector_variant.exp new file mode 100644 index 00000000..49ee7f7a --- /dev/null +++ b/tests/array/collector_variant.exp @@ -0,0 +1,2 @@ +Scaled: [30 60 90] +Offset: [110 121 132] diff --git a/tests/array/collector_variant.pt b/tests/array/collector_variant.pt new file mode 100644 index 00000000..730cf4ca --- /dev/null +++ b/tests/array/collector_variant.pt @@ -0,0 +1,7 @@ +# Helpers for collector_variant.spt. Each is called from exactly one collector +# so the script never instantiates their scalar variants through another call. +res = ScaleCell(x) + res = x * 3 + +res = OffsetCell(x) + res = x + 100 diff --git a/tests/array/collector_variant.spt b/tests/array/collector_variant.spt new file mode 100644 index 00000000..55916e74 --- /dev/null +++ b/tests/array/collector_variant.spt @@ -0,0 +1,15 @@ +# A call over a range-indexed array selection inside a collector must +# instantiate the scalar callee variant, even though promoting the argument to +# an internal ArrayRange leaves the argument list syntactically unchanged. +# +# Neither helper may be called anywhere else in this script: any other call to +# the same function registers that scalar variant and masks the regression. +arr = [10 20 30] + +i = 0:3 +scaled = [ScaleCell(arr[i])] +"Scaled: -scaled" + +j = 0:3 +offset = [OffsetCell(arr[j]) + j] +"Offset: -offset" From 7a544b3d39310fd2e8dda27cf258cd0281acf204 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 25 Jul 2026 10:21:15 +0530 Subject: [PATCH 11/60] fix(compiler): treat skippable call outputs as conditional writes Since direct scalar returns gained a destination seed, a callee that skips its output write leaves the caller's previous value in place. Dead-store analysis still classified "dest = Call(...)" as an unconditional write, so it rejected programs whose earlier assignment is observably live: v = 7 v = CondScalar(-1) # callee skips, v stays 7 "V: -v" This affects direct scalar returns and indirect array/table outputs alike. Only a call at a value root can preserve the destination; a call feeding an operator always contributes to a new value, so that stays unconditional. The rule is deliberately conservative. Proving a specific callee always writes would need its per-specialization range types, which the CFG does not have, so this gives up dead-store detection through call roots rather than reject valid code. Both directions are covered by tests so the lint cannot be silently lost. Co-Authored-By: Claude Opus 5 --- compiler/cfg.go | 21 ++++++++++++++++++++- compiler/cfg_test.go | 17 +++++++++++++++++ tests/cond/skipped_call_keeps_dest.exp | 4 ++++ tests/cond/skipped_call_keeps_dest.pt | 7 +++++++ tests/cond/skipped_call_keeps_dest.spt | 18 ++++++++++++++++++ 5 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 tests/cond/skipped_call_keeps_dest.exp create mode 100644 tests/cond/skipped_call_keeps_dest.pt create mode 100644 tests/cond/skipped_call_keeps_dest.spt diff --git a/compiler/cfg.go b/compiler/cfg.go index aa75205b..e5dceae8 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -174,7 +174,7 @@ func (cfg *CFG) extractStmtEvents(stmt ast.Statement) []VarEvent { // 3. Write to the destination variable(s). // Determine the type of write writeKind := Write - if len(s.Condition) > 0 || cfg.HasRangeExpr(s.Value) { + if len(s.Condition) > 0 || cfg.HasRangeExpr(s.Value) || cfg.HasSkippableCallRoot(s.Value) { writeKind = ConditionalWrite } for _, lhs := range s.Name { @@ -214,6 +214,25 @@ func (cfg *CFG) HasRangeExpr(values []ast.Expression) bool { return false } +// HasSkippableCallRoot reports whether any value is a bare call to a +// user-defined function. Such a callee may leave an output unwritten, so the +// caller keeps its previous value and that previous write is not dead. Only +// root position qualifies: a call feeding an operator always yields a new +// value. Proving a given callee always writes would need per-specialization +// range types unavailable here, so this stays conservative. +func (cfg *CFG) HasSkippableCallRoot(values []ast.Expression) bool { + for _, v := range values { + call, ok := v.(*ast.CallExpression) + if !ok { + continue + } + if _, builtin := Builtins[call.Function.Value]; !builtin { + return true + } + } + return false +} + // hasRangeExpr checks if an expression contains ranges by looking at ExprCache func (cfg *CFG) hasRangeExpr(e ast.Expression) bool { // Only possible when we have ScriptCompiler with ExprCache diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index 2f21d1f0..a1616eb8 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -90,6 +90,14 @@ func getValidTestCases() []cfgTestCase { name: "Var Not Defined", input: `"Value: -x%s"`, }, + { + // The callee may skip its write, leaving the destination's previous + // value in place, so that previous write is live. + name: "Write then Skippable Call Root", + code: `res = maybeWrite(x) + res = x > 0 42`, + input: "x = 7\nx = maybeWrite(-1)\nx", + }, } } @@ -140,6 +148,15 @@ func getErrorTestCases() []cfgTestCase { input: "a=1\nb=2\nc=3\na, b", errorContains: `value assigned to "c" is never used`, }, + { + // A call feeding an operator always contributes to a new value, so + // the write stays unconditional and the earlier one is still dead. + name: "Call Feeding Operator Stays Unconditional", + code: `res = alwaysWrite(x) + res = x * 2`, + input: "x = 7\nx = alwaysWrite(3) + 1\nx", + errorContains: `unconditional assignment to "x" overwrites a previous value that was never used`, + }, { name: "Print Use Before Def", input: `"x is", x`, diff --git a/tests/cond/skipped_call_keeps_dest.exp b/tests/cond/skipped_call_keeps_dest.exp new file mode 100644 index 00000000..4e4ad45d --- /dev/null +++ b/tests/cond/skipped_call_keeps_dest.exp @@ -0,0 +1,4 @@ +ScalarKept: 7 +ScalarWritten: 42 +ArrayKept: [1 2 3] +ArrayWritten: [7 8] diff --git a/tests/cond/skipped_call_keeps_dest.pt b/tests/cond/skipped_call_keeps_dest.pt new file mode 100644 index 00000000..615f318c --- /dev/null +++ b/tests/cond/skipped_call_keeps_dest.pt @@ -0,0 +1,7 @@ +# Outputs written only when the condition holds, so a call to either may leave +# the caller's destination untouched. +res = KeepScalar(x) + res = x > 0 42 + +res = KeepArray(x) + res = x > 0 [7 8] diff --git a/tests/cond/skipped_call_keeps_dest.spt b/tests/cond/skipped_call_keeps_dest.spt new file mode 100644 index 00000000..a461e93b --- /dev/null +++ b/tests/cond/skipped_call_keeps_dest.spt @@ -0,0 +1,18 @@ +# A call whose callee may skip its output write is not an unconditional write. +# The destination keeps its previous value, so that previous assignment is live +# and dead-store analysis must not reject these statements. +scalarKept = 7 +scalarKept = KeepScalar(-1) +"ScalarKept: -scalarKept" + +scalarWritten = 7 +scalarWritten = KeepScalar(1) +"ScalarWritten: -scalarWritten" + +arrayKept = [1 2 3] +arrayKept = KeepArray(-1) +"ArrayKept: -arrayKept" + +arrayWritten = [1 2 3] +arrayWritten = KeepArray(1) +"ArrayWritten: -arrayWritten" From e7f9882e7cb803ade6be94b8009f677173043e92 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 25 Jul 2026 10:21:30 +0530 Subject: [PATCH 12/60] fix(compiler): surface array-literal cell errors instead of panicking compileExpression records a CompileError and returns no symbols when call resolution fails, but storeArrayCellSlotWhenInBounds indexed vals[0] unconditionally, so such a cell aborted with an internal compiler error and swallowed the diagnostic that was already recorded. Leaving the cell seed in place matches how conditional skips and out-of-bounds paths already behave, and lets the real error reach the user. An empty result with no recorded error is an internal fault rather than user error, so that case still panics instead of silently dropping the cell. Co-Authored-By: Claude Opus 5 --- compiler/array.go | 11 +++++++++++ compiler/compiler_test.go | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/compiler/array.go b/compiler/array.go index d2c12b9c..229fc3ef 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -498,6 +498,17 @@ func (c *Compiler) storeArrayCellSlotWhenInBounds( vals []*Symbol, cell ast.Expression, ) { + if len(vals) == 0 { + // Lowering recorded a CompileError and yielded no value. Leaving the + // seed in place lets that diagnostic surface instead of an ICE. Without + // a recorded error an empty result is an internal fault, not user error, + // so stay loud rather than silently dropping the cell. + if len(c.Errors) == 0 { + panic("internal: array cell lowering produced no value and recorded no error") + } + return + } + slotElemType := cellSlot.Type.(Ptr).Elem store := func() { cellValue := c.derefIfPointer(vals[0], "") diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index c9179ef6..278e3cfe 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -186,6 +186,29 @@ scaled` "a collector invokes the callee once per scalar yield, so promoting the argument to an internal ArrayRange must still define the scalar variant") } +func TestArrayCellSinkSkipsEmptyLoweringResult(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + cc := NewCodeCompiler(ctx, "array_cell_empty_vals", "", ast.NewCode()) + c := cc.Compiler + + fn := llvm.AddFunction(c.Module, "probe", llvm.FunctionType(ctx.VoidType(), nil, false)) + c.builder.SetInsertPointAtEnd(c.Context.AddBasicBlock(fn, "entry")) + + slot := c.newArrayCellSlot(I64) + cell := &ast.Identifier{Value: "cell"} + + require.Panics(t, func() { + c.storeArrayCellSlotWhenInBounds(slot, nil, cell) + }, "an empty result with no recorded error is an internal fault and must stay loud") + + c.Errors = append(c.Errors, &token.CompileError{Token: cell.Tok(), Msg: "recorded"}) + require.NotPanics(t, func() { + c.storeArrayCellSlotWhenInBounds(slot, nil, cell) + }, "a cell whose lowering recorded an error yields no value; the sink must leave the seed rather than index it") +} + func TestPhase1ScalarABIRangeVariantUsesDirectScalarBoundary(t *testing.T) { code := `res = Acc(a, x) res = a + x` From c79a918ed9bb8c36640631f7a57d86e0a3fa8b51 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 25 Jul 2026 10:22:20 +0530 Subject: [PATCH 13/60] fix(compiler): correct heap-string ownership for comparison operands Two halves of one ownership bug, fixed together because fixing either alone leaves the other reachable. extractComparisonSlots marked the comparison's left operand Borrowed so a later assignment would copy rather than transfer that binding's payload. For a non-pointer binding, compareScalars hands back the scope's own *Symbol, so the mark landed on the binding itself and left the variable borrowed for the rest of its life; scope cleanup then never freed its heap payload. Mark a copy. That in turn exposed the print path. appendPrintSymbol decided whether printf owns a heap string from the AST node kind rather than from ownership, and a root-position comparison yields its left operand's payload under an InfixExpression node. Printing "a > \"abc\"" therefore freed the binding's payload: previously a silent use-after-free that left the name dangling, and a double-free abort once the binding is correctly no longer borrowed. Ownership now decides, behind a named predicate. The node-kind check remains alongside it because identifier symbols are not marked Borrowed today; dropping it double-frees in five existing tests. Marking bindings correctly and removing the proxy needs its own change. Co-Authored-By: Claude Opus 5 --- compiler/compiler.go | 18 ++++++++++++++---- compiler/cond.go | 9 ++++++--- tests/mem/mem_cmp_lhs.exp | 7 +++++++ tests/mem/mem_cmp_lhs.pt | 5 +++++ tests/mem/mem_cmp_lhs.spt | 23 +++++++++++++++++++++++ 5 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 tests/mem/mem_cmp_lhs.exp create mode 100644 tests/mem/mem_cmp_lhs.pt create mode 100644 tests/mem/mem_cmp_lhs.spt diff --git a/compiler/compiler.go b/compiler/compiler.go index 0b9ffc1c..94715d99 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -3648,6 +3648,18 @@ func (c *Compiler) appendPrintExpression(expr ast.Expression, formatStr *string, c.freeTemporary(expr, nonStringTemps) } +// printOwnsHeapString reports whether a printf argument is a temporary this +// print must release. A named binding's payload is not ours to free, whether +// the expression names it directly or a root-position comparison yields its +// left operand's value under a non-Identifier node. +func printOwnsHeapString(s *Symbol, expr ast.Expression) bool { + if !IsStrH(s.Type) || s.Borrowed { + return false + } + _, isIdent := expr.(*ast.Identifier) + return !isIdent +} + // appendPrintSymbol handles printing one symbol based on its type func (c *Compiler) appendPrintSymbol(s *Symbol, expr ast.Expression, formatStr *string, args *[]llvm.Value, toFree *[]llvm.Value) { // Dereference pointers first - treat print args like function args @@ -3700,10 +3712,8 @@ func (c *Compiler) appendPrintSymbol(s *Symbol, expr ast.Expression, formatStr * case StrKind: *args = append(*args, s.Val) // Heap string temporaries must survive until printf executes. - if IsStrH(s.Type) { - if _, isIdent := expr.(*ast.Identifier); !isIdent { - *toFree = append(*toFree, s.Val) - } + if printOwnsHeapString(s, expr) { + *toFree = append(*toFree, s.Val) } default: *args = append(*args, s.Val) diff --git a/compiler/cond.go b/compiler/cond.go index 5d8070e4..47d1437e 100644 --- a/compiler/cond.go +++ b/compiler/cond.go @@ -658,9 +658,12 @@ func (c *Compiler) extractComparisonSlots(infix *ast.InfixExpression, info *Expr conds[i] = c.andConds(operandCond, cmpVal, fmt.Sprintf("slot_cond_%d", i)) lhsSyms[i] = lSym if _, isIdent := infix.Left.(*ast.Identifier); isIdent { - // The retained value still belongs to the named binding. - // Mark it borrowed so a later assignment copies rather than - // transferring that binding's payload through a conditional. + // The retained value still belongs to the named binding, so a + // later assignment must copy rather than transfer that payload. + // Borrow a copy: compareScalars returns the scope's own *Symbol + // for a non-pointer binding, and marking that would leave the + // variable borrowed for life, so cleanup would never free it. + lhsSyms[i] = GetCopy(lSym) lhsSyms[i].Borrowed = true } } diff --git a/tests/mem/mem_cmp_lhs.exp b/tests/mem/mem_cmp_lhs.exp new file mode 100644 index 00000000..3ace287b --- /dev/null +++ b/tests/mem/mem_cmp_lhs.exp @@ -0,0 +1,7 @@ +Picked: 49 +Flag: 1 +Name: world +mmnn +Label: mmnn +qqrr +Chained: qqrr diff --git a/tests/mem/mem_cmp_lhs.pt b/tests/mem/mem_cmp_lhs.pt new file mode 100644 index 00000000..fd167560 --- /dev/null +++ b/tests/mem/mem_cmp_lhs.pt @@ -0,0 +1,5 @@ +# A heap string used as a comparison LHS. The comparison must not leave the +# named binding permanently borrowed, or scope cleanup never frees its payload. +out = pickByHeapCompare(k) + label = "he" ⊕ "llo" + out = label > "abc" k diff --git a/tests/mem/mem_cmp_lhs.spt b/tests/mem/mem_cmp_lhs.spt new file mode 100644 index 00000000..bc452922 --- /dev/null +++ b/tests/mem/mem_cmp_lhs.spt @@ -0,0 +1,23 @@ +# Running the comparison over a range makes any per-iteration leak of the +# comparison LHS observable to the leak checker. +i = 0:50 +picked = pickByHeapCompare(i) +"Picked: -picked" + +# The same shape at script scope, where the binding outlives the comparison. +name = "wor" ⊕ "ld" +flag = 0 +flag = name > "abc" 1 +"Flag: -flag" +"Name: -name" + +# A comparison at print position yields its left operand's payload. That value +# belongs to the binding, so the print path must not free it: doing so left the +# name dangling and then double-freed it at scope cleanup. +label = "mm" ⊕ "nn" +label > "abc" +"Label: -label" + +chained = "qq" ⊕ "rr" +chained > "aa" > "A" +"Chained: -chained" From ea82ea0dfe2c16675c772980f4aaddd39c351563 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 25 Jul 2026 10:22:38 +0530 Subject: [PATCH 14/60] refactor(compiler): remove unreferenced helpers updateUnresolvedType lost its last caller when array-range call types became trusted; addCallTypeError and createLoopState were already unreferenced. No behavior change. Co-Authored-By: Claude Opus 5 --- compiler/compiler.go | 30 ------------------------------ compiler/loop.go | 4 ---- 2 files changed, 34 deletions(-) diff --git a/compiler/compiler.go b/compiler/compiler.go index 94715d99..611b7603 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -278,14 +278,6 @@ func (c *Compiler) resolvedDestTypes(dest []*ast.Identifier, outTypes []Type) [] return resolved } -func (c *Compiler) addCallTypeError(tok token.Token, msg string) bool { - c.Errors = append(c.Errors, &token.CompileError{ - Token: tok, - Msg: msg, - }) - return false -} - // inferCallParamTypes selects the solver-cached call variant to use at the // current lowering site. Once outer loops have consumed all pending ranges, the // scalarized param types become the right callee variant for code generation. @@ -2229,28 +2221,6 @@ func (c *Compiler) cleanupRangeInfixTemps( c.freeTemporary(rightExpr, right) } -func (c *Compiler) updateUnresolvedType(name string, sym *Symbol, resolved Type) { - switch t := sym.Type.(type) { - case Array: - if !hasConcreteArrayElemType(t.ElemType) { - sym.Type = resolved - Put(c.Scopes, name, sym) - } - case Table: - if !IsFullyResolvedType(t) { - sym.Type = resolved - Put(c.Scopes, name, sym) - } - case Ptr: - if t.Elem.Kind() == UnresolvedKind { - sym.Type = Ptr{Elem: resolved} - Put(c.Scopes, name, sym) - } - default: - // No action needed for other types - } -} - func (c *Compiler) makeTempOutput(name string, outType Type, borrowed bool, seed *Symbol) *Symbol { ptr := c.createEntryBlockAlloca(c.mapToLLVMType(outType), name) ptrElem := outType diff --git a/compiler/loop.go b/compiler/loop.go index f5307804..8553378d 100644 --- a/compiler/loop.go +++ b/compiler/loop.go @@ -283,7 +283,3 @@ func (c *Compiler) createLoop(r llvm.Value, bodyGen func(iter llvm.Value)) { return llvm.Value{} }) } - -func (c *Compiler) createLoopState(r llvm.Value, seed llvm.Value, stateType llvm.Type, bodyGen func(iter llvm.Value, current llvm.Value) llvm.Value) llvm.Value { - return c.createLoopCore(r, seed, stateType, true, bodyGen) -} From 61693ce06a694cd6bc5150ae3e063dcc3bad42df Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 25 Jul 2026 11:08:14 +0530 Subject: [PATCH 15/60] fix(compiler): skip type-incompatible outputs in parameter alias slots The hidden alias selector names an output by position, and the callee then reads that storage as the parameter's own type. directParamValue folded every output into the select chain regardless of type, so a range-bearing callee with a direct scalar parameter and a differently-typed sibling output emitted "select i1, double, i64" followed by an arithmetic op on the wrong type. The program compiled and returned a wrong answer: an I64 accumulator paired with an F64 output yielded 0 instead of the accumulated sum. Skip incompatible outputs rather than filter them, so index i keeps meaning the i-th output for the remaining slots; filtering would renumber every selector after the gap. The same rule now guards caller alias planning, which previously applied it only to indirect parameters, and pointer promotion, where a selected pointer would otherwise be loaded as the wrong type. Function-level LLVM verification covers the emitted IR for float, string and array sibling outputs. Verification is per function because module scope also trips on format-string globals built in the global LLVM context instead of the module's own. Fixes #66 Co-Authored-By: Claude Opus 5 --- compiler/abi.go | 12 +++++++++ compiler/compiler.go | 17 +++++++++++-- compiler/compiler_test.go | 39 ++++++++++++++++++++++++++++++ tests/math/alias_mixed_outputs.exp | 2 ++ tests/math/alias_mixed_outputs.pt | 10 ++++++++ tests/math/alias_mixed_outputs.spt | 11 +++++++++ 6 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 tests/math/alias_mixed_outputs.exp create mode 100644 tests/math/alias_mixed_outputs.pt create mode 100644 tests/math/alias_mixed_outputs.spt diff --git a/compiler/abi.go b/compiler/abi.go index f725975b..b3efd1ec 100644 --- a/compiler/abi.go +++ b/compiler/abi.go @@ -48,6 +48,18 @@ 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 diff --git a/compiler/compiler.go b/compiler/compiler.go index 611b7603..84919496 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -335,8 +335,7 @@ func (c *Compiler) buildCallArgOutputAliases(sig *callSignature, args []callArg, if output.Value != arg.Name { continue } - if paramABI.Mode == ABIParamIndirect && - !TypeEqual(sig.ParamTypes[paramIndex], sig.ABI.Return.OutTypes[outputIndex]) { + if !aliasableOutput(sig.ParamTypes[paramIndex], sig.ABI.Return.OutTypes[outputIndex]) { continue } aliases[paramIndex] = outputIndex @@ -364,6 +363,9 @@ func (c *Compiler) directReturnSeedForCall(outType Type, dest *ast.Identifier, o 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, @@ -404,6 +406,12 @@ func (c *Compiler) directParamValue(name string, sym *Symbol, alias *paramAlias) 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, @@ -1595,6 +1603,11 @@ func (c *Compiler) promoteAlias(name string, sym *Symbol, alias *paramAlias) *Sy 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. diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 278e3cfe..60da5503 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -170,6 +170,45 @@ row` require.Contains(t, scriptIR, "store i1 true, ptr %res_written", "a yielded ranged selection must mark the output as written") } +// verifyCompiledFunctions asserts LLVM accepts every function a source pair +// lowers to. The alias selector picks an output by position, so a mistyped +// output reaching it yields IR that runs wrong or fails object emission. +// Verification is per function because module scope additionally trips on +// format-string globals built in the wrong LLVM context. +func verifyCompiledFunctions(t *testing.T, moduleName, codeSrc, scriptSrc string) { + t.Helper() + + ctx := llvm.NewContext() + defer ctx.Dispose() + + cc := NewCodeCompiler(ctx, moduleName, "", mustParseCode(t, codeSrc)) + sc := NewScriptCompiler(ctx, mustParseScript(t, scriptSrc), cc, make(map[string]*Func), cc.Compiler.ExprCache) + require.Empty(t, sc.Compile()) + + verified := 0 + for fn := sc.Compiler.Module.FirstFunction(); !fn.IsNil(); fn = llvm.NextFunction(fn) { + if fn.IsDeclaration() { + continue + } + require.NoError(t, llvm.VerifyFunction(fn, llvm.ReturnStatusAction), "function %s must verify", fn.Name()) + verified++ + } + require.NotZero(t, verified, "expected at least one defined function to verify") +} + +func TestAliasSelectorSkipsMismatchedOutputs(t *testing.T) { + cases := []struct{ name, code string }{ + {"float sibling", "sum, other = Mixed(a, x)\n sum = a + x\n other = x * 0.5"}, + {"string sibling", "sum, other = Mixed(a, x)\n sum = a + x\n other = \"n\""}, + {"array sibling", "sum, other = Mixed(a, x)\n sum = a + x\n other = [x x]"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + verifyCompiledFunctions(t, "alias_mismatch", tc.code, "s = 1\nq, r = Mixed(s, 0:4)\nq, r") + }) + } +} + func TestCollectorOverRangeSelectionRegistersScalarVariant(t *testing.T) { code := `res = Scale(x) res = x * 3` diff --git a/tests/math/alias_mixed_outputs.exp b/tests/math/alias_mixed_outputs.exp new file mode 100644 index 00000000..0b6bfc5a --- /dev/null +++ b/tests/math/alias_mixed_outputs.exp @@ -0,0 +1,2 @@ +MixedAccSum: 7 MixedAccHalf: 1.5 +MixedStrSum: 7 MixedStrNote: n diff --git a/tests/math/alias_mixed_outputs.pt b/tests/math/alias_mixed_outputs.pt new file mode 100644 index 00000000..6512c2e1 --- /dev/null +++ b/tests/math/alias_mixed_outputs.pt @@ -0,0 +1,10 @@ +# Loop-carried accumulators whose sibling output lowers to a different type +# than the accumulated parameter. The hidden alias selector must reach the +# matching output only; the sibling cannot back an I64 parameter's slot. +sum, half = MixedAcc(a, x) + sum = a + x + half = x * 0.5 + +sum, note = MixedStr(a, x) + sum = a + x + note = "n" diff --git a/tests/math/alias_mixed_outputs.spt b/tests/math/alias_mixed_outputs.spt new file mode 100644 index 00000000..5b5ef9eb --- /dev/null +++ b/tests/math/alias_mixed_outputs.spt @@ -0,0 +1,11 @@ +# Each accumulator must still alias its own output across iterations, so the +# sums advance 1 -> 1 -> 2 -> 4 -> 7 rather than restarting from the argument. +s = 1 +h = 0.0 +s, h = MixedAcc(s, 0:4) +"MixedAccSum: -s MixedAccHalf: -h" + +u = 1 +n = "z" +u, n = MixedStr(u, 0:4) +"MixedStrSum: -u MixedStrNote: -n" From 3a6d988c3309ca5684b33f63a9884f2e967edd67 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 25 Jul 2026 12:14:31 +0530 Subject: [PATCH 16/60] test(compiler): cover alias selectors reached past a skipped output Every existing case put the compatible accumulator first, so the accumulator was always selector 1 and nothing proved that a preceding incompatible output stays a numbering gap. Add reversed-output variants where the mismatched output comes first and the accumulator is reached through selector 2, plus an F64 accumulator behind a string output. These catch a distinct failure from the existing coverage. Filtering the incompatible outputs instead of skipping them still emits valid IR, so function verification passes, but every later selector shifts by one and the accumulator silently stops aliasing: the reversed end-to-end expectation is what fails. Co-Authored-By: Claude Opus 5 --- compiler/compiler_test.go | 19 ++++++++++++++----- tests/math/alias_mixed_outputs.exp | 2 ++ tests/math/alias_mixed_outputs.pt | 10 ++++++++++ tests/math/alias_mixed_outputs.spt | 11 +++++++++++ 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 60da5503..22daa13f 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -197,14 +197,23 @@ func verifyCompiledFunctions(t *testing.T, moduleName, codeSrc, scriptSrc string } func TestAliasSelectorSkipsMismatchedOutputs(t *testing.T) { - cases := []struct{ name, code string }{ - {"float sibling", "sum, other = Mixed(a, x)\n sum = a + x\n other = x * 0.5"}, - {"string sibling", "sum, other = Mixed(a, x)\n sum = a + x\n other = \"n\""}, - {"array sibling", "sum, other = Mixed(a, x)\n sum = a + x\n other = [x x]"}, + // The accumulator leads in the first group, so it is reached through + // selector 1. In the second group the mismatched output leads, so the + // accumulator is selector 2 and the skipped slot must stay a numbering gap. + const accFirst = "s = 1\nq, r = Mixed(s, 0:4)\nq, r" + const accSecond = "s = 1\nq, s = Mixed(s, 0:4)\nq, s" + + cases := []struct{ name, code, script string }{ + {"float sibling", "sum, other = Mixed(a, x)\n sum = a + x\n other = x * 0.5", accFirst}, + {"string sibling", "sum, other = Mixed(a, x)\n sum = a + x\n other = \"n\"", accFirst}, + {"array sibling", "sum, other = Mixed(a, x)\n sum = a + x\n other = [x x]", accFirst}, + {"float sibling first", "other, sum = Mixed(a, x)\n other = x * 0.5\n sum = a + x", accSecond}, + {"string sibling first", "other, sum = Mixed(a, x)\n other = \"n\"\n sum = a + x", accSecond}, + {"array sibling first", "other, sum = Mixed(a, x)\n other = [x x]\n sum = a + x", accSecond}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - verifyCompiledFunctions(t, "alias_mismatch", tc.code, "s = 1\nq, r = Mixed(s, 0:4)\nq, r") + verifyCompiledFunctions(t, "alias_mismatch", tc.code, tc.script) }) } } diff --git a/tests/math/alias_mixed_outputs.exp b/tests/math/alias_mixed_outputs.exp index 0b6bfc5a..fe482c85 100644 --- a/tests/math/alias_mixed_outputs.exp +++ b/tests/math/alias_mixed_outputs.exp @@ -1,2 +1,4 @@ MixedAccSum: 7 MixedAccHalf: 1.5 MixedStrSum: 7 MixedStrNote: n +ReversedHalf: 1.5 ReversedSum: 7 +FloatNote: n FloatTotal: 7 diff --git a/tests/math/alias_mixed_outputs.pt b/tests/math/alias_mixed_outputs.pt index 6512c2e1..3d35a603 100644 --- a/tests/math/alias_mixed_outputs.pt +++ b/tests/math/alias_mixed_outputs.pt @@ -8,3 +8,13 @@ sum, half = MixedAcc(a, x) sum, note = MixedStr(a, x) sum = a + x note = "n" + +# Sibling first, so the accumulator sits at the second output and its selector +# is 2. The skipped slot has to stay a numbering gap rather than shift. +half, sum = ReversedAcc(a, x) + half = x * 0.5 + sum = a + x + +note, total = FloatAcc(b, x) + note = "n" + total = b + x diff --git a/tests/math/alias_mixed_outputs.spt b/tests/math/alias_mixed_outputs.spt index 5b5ef9eb..49085944 100644 --- a/tests/math/alias_mixed_outputs.spt +++ b/tests/math/alias_mixed_outputs.spt @@ -9,3 +9,14 @@ u = 1 n = "z" u, n = MixedStr(u, 0:4) "MixedStrSum: -u MixedStrNote: -n" + +# The accumulator is the second output here, so it is reached through selector 2. +rh = 0.0 +rs = 1 +rh, rs = ReversedAcc(rs, 0:4) +"ReversedHalf: -rh ReversedSum: -rs" + +fn = "z" +ft = 1.0 +fn, ft = FloatAcc(ft, 0:4) +"FloatNote: -fn FloatTotal: -ft" From 7da9079106db416bf00ba1d5566a02eab7b7a1f8 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 25 Jul 2026 13:15:50 +0530 Subject: [PATCH 17/60] test(compiler): cover the pointer-promotion selector gap Writing a parameter through %n promotes it to memory, so the aliased slot is picked by pointer rather than by value. Nothing exercised that path with a mismatched leading output. An end-to-end reversed accumulator is not enough on its own: opaque pointers make a mistyped pointer select valid IR, and the selector never matches the skipped index at runtime, so the program behaves identically either way. Assert on the emitted slot selects instead, which is what actually distinguishes it, and add the reversed %n accumulator so the runtime result is pinned too. Co-Authored-By: Claude Opus 5 --- compiler/compiler_test.go | 22 ++++++++++++++++++++++ tests/math/acc_fmt.exp | 4 ++++ tests/math/acc_fmt.pt | 8 ++++++++ tests/math/acc_fmt.spt | 5 +++++ 4 files changed, 39 insertions(+) diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 22daa13f..0b34a107 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -218,6 +218,28 @@ func TestAliasSelectorSkipsMismatchedOutputs(t *testing.T) { } } +// 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 TestPointerPromotionSkipsMismatchedOutputSlot(t *testing.T) { + code := `half, res = Rev(a, x) + "count-a%n chars" + half = x * 0.5 + res = a + x` + script := `h = 0.0 +r = 10 +h, r = Rev(r, 1:4) +h, r` + + ir, _ := compileScriptAndCodeIR(t, "pointer_promotion_gap", code, script) + + require.Contains(t, ir, "%a_slot_1 = select", + "the compatible output must stay at selector position 1, not be renumbered") + require.NotContains(t, ir, "%a_slot_0 = select", + "the mismatched leading output must never be selectable as the parameter's slot") +} + func TestCollectorOverRangeSelectionRegistersScalarVariant(t *testing.T) { code := `res = Scale(x) res = x * 3` diff --git a/tests/math/acc_fmt.exp b/tests/math/acc_fmt.exp index d16913d3..b5780d98 100644 --- a/tests/math/acc_fmt.exp +++ b/tests/math/acc_fmt.exp @@ -2,3 +2,7 @@ count chars count chars count chars 8 +count chars +count chars +count chars +RevHalf: 1.5 RevRes: 8 diff --git a/tests/math/acc_fmt.pt b/tests/math/acc_fmt.pt index 8949a6e4..59c4a863 100644 --- a/tests/math/acc_fmt.pt +++ b/tests/math/acc_fmt.pt @@ -1,3 +1,11 @@ res = AccFmt(a, x) "count-a%n chars" res = a + x + +# Sibling output first, so the accumulator is the second output and its selector +# is 2. Writing through %n promotes the parameter to memory, which picks the +# aliased slot by pointer rather than by value. +half, res = AccFmtRev(a, x) + "count-a%n chars" + half = x * 0.5 + res = a + x diff --git a/tests/math/acc_fmt.spt b/tests/math/acc_fmt.spt index 833789ba..c3fad08f 100644 --- a/tests/math/acc_fmt.spt +++ b/tests/math/acc_fmt.spt @@ -1,3 +1,8 @@ res = 10 res = AccFmt(res, 1:4) res + +revHalf = 0.0 +revRes = 10 +revHalf, revRes = AccFmtRev(revRes, 1:4) +"RevHalf: -revHalf RevRes: -revRes" From 333ebc117bd824a2f2b9f36b5659156c8940dc45 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 25 Jul 2026 13:58:56 +0530 Subject: [PATCH 18/60] test(compiler): pin the alias selector value and its target The slot assertions proved a select existed at the right position but not what it compared against or chose. Assert the ABI selector value is 2 and that it selects the caller's destination, so compacting the compatible outputs is caught by its own message rather than only by the absent-slot check. Also record why the caller-side compatibility check has no test: a name that is both argument and destination must hold one type, so the solver rejects the mismatch before alias planning runs. The check is unreachable for valid source and kept only so all three alias sites share one rule. Co-Authored-By: Claude Opus 5 --- compiler/compiler.go | 3 +++ compiler/compiler_test.go | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/compiler/compiler.go b/compiler/compiler.go index 84919496..07dec775 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -335,6 +335,9 @@ func (c *Compiler) buildCallArgOutputAliases(sig *callSignature, args []callArg, if output.Value != arg.Name { continue } + // Unreachable for valid source today: a name that is both argument + // and destination must hold one type, so the solver rejects the + // mismatch first. Kept so all three alias sites share one rule. if !aliasableOutput(sig.ParamTypes[paramIndex], sig.ABI.Return.OutTypes[outputIndex]) { continue } diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 0b34a107..096088cc 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -234,8 +234,10 @@ h, r` ir, _ := compileScriptAndCodeIR(t, "pointer_promotion_gap", code, script) - require.Contains(t, ir, "%a_slot_1 = select", - "the compatible output must stay at selector position 1, not be renumbered") + 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") } From 635035fad7924ff8fd5630e467b866fac8830e18 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 25 Jul 2026 16:13:45 +0530 Subject: [PATCH 19/60] refactor(compiler): carry the alias selector on the call argument The destination a call argument aliases was kept in a slice parallel to the argument list, so two loops cross-indexed it against sig.ABI.Params and the "same length" invariant had to be maintained by hand. Move it onto callArg, where it travels with the argument it describes. Store the hidden ABI selector itself rather than an output index: the value is what the call actually transmits, and 0 already means "no aliased destination", so the zero value is correct for the three callArg literals that never set it. An output index would have made 0 mean "aliases the first output". Behavior is unchanged: alias emission is byte-identical across every test module. (Whole-file IR comparison is not usable here because scope cleanup iterates a map, so free ordering varies between runs of the same binary.) Co-Authored-By: Claude Opus 5 --- compiler/compiler.go | 57 +++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 30 deletions(-) diff --git a/compiler/compiler.go b/compiler/compiler.go index 07dec775..e14ef2e5 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -76,6 +76,11 @@ type callArg struct { Name string Symbol *Symbol Lowered *Symbol + // AliasSelector is the hidden ABI selector for the caller destination this + // argument aliases, exactly as transmitted: 0 means none, N means output + // N-1. Encoding it this way keeps the zero value correct for the arguments + // that alias nothing. + AliasSelector int } type callSignature struct { @@ -87,11 +92,10 @@ type callSignature struct { } type preparedCall struct { - Args []callArg - OutputAliases []int // Per source argument; -1 means no destination alias. - Function llvm.Value - FuncType llvm.Type - RetStruct llvm.Type + Args []callArg + Function llvm.Value + FuncType llvm.Type + RetStruct llvm.Type } // BindingKey identifies a variable binding within a specific function variant. @@ -309,17 +313,13 @@ func (c *Compiler) resolveCallSignature(funcName string, ce *ast.CallExpression, }, true } -// buildCallArgOutputAliases records which named inputs alias caller -// destinations 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. -func (c *Compiler) buildCallArgOutputAliases(sig *callSignature, args []callArg, dest []*ast.Identifier) []int { - aliases := make([]int, len(args)) - for i := range aliases { - aliases[i] = -1 - } +// 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 aliases + return } for paramIndex, arg := range args { @@ -341,12 +341,10 @@ func (c *Compiler) buildCallArgOutputAliases(sig *callSignature, args []callArg, if !aliasableOutput(sig.ParamTypes[paramIndex], sig.ABI.Return.OutTypes[outputIndex]) { continue } - aliases[paramIndex] = outputIndex + args[paramIndex].AliasSelector = outputIndex + 1 break } } - - return aliases } // directReturnSeedForCall captures the caller's current destination value for a @@ -3074,15 +3072,14 @@ func (c *Compiler) freeCallArgTemps(callArgs []callArg) { func (c *Compiler) prepareCall(sig *callSignature, ce *ast.CallExpression, dest []*ast.Identifier) preparedCall { callArgs := c.compileCallArgs(sig, ce) - argOutputAliases := c.buildCallArgOutputAliases(sig, callArgs, dest) + c.setCallArgAliasSelectors(sig, callArgs, dest) c.lowerCallArgs(sig.FuncName, callArgs, sig) fn, funcType, retStruct := c.getOrCompileCallFunction(sig) return preparedCall{ - Args: callArgs, - OutputAliases: argOutputAliases, - Function: fn, - FuncType: funcType, - RetStruct: retStruct, + Args: callArgs, + Function: fn, + FuncType: funcType, + RetStruct: retStruct, } } @@ -3345,18 +3342,18 @@ func (c *Compiler) callArgs( } for i, arg := range call.Args { argVal := arg.Lowered.Val - outputAlias := call.OutputAliases[i] - if sig.ABI.Params[i].Mode == ABIParamIndirect && outputAlias >= 0 && outputAlias < len(outputs) { - argVal = outputs[outputAlias].Val + if sig.ABI.Params[i].Mode == ABIParamIndirect && arg.AliasSelector > 0 && arg.AliasSelector <= len(outputs) { + argVal = outputs[arg.AliasSelector-1].Val } llvmArgs = append(llvmArgs, argVal) } aliasIndices := make([]int, sig.ABI.NumAliasSlots()) - for i, paramABI := range sig.ABI.Params { - if paramABI.AliasSlot < 0 { + for i, arg := range call.Args { + slot := sig.ABI.Params[i].AliasSlot + if slot < 0 { continue } - aliasIndices[paramABI.AliasSlot] = call.OutputAliases[i] + 1 + aliasIndices[slot] = arg.AliasSelector } for _, aliasIndex := range aliasIndices { llvmArgs = append(llvmArgs, llvm.ConstInt(c.Context.Int32Type(), uint64(aliasIndex), false)) From 7d0fb6cf56253d87f690fd20f34b77cd34aa1d81 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 25 Jul 2026 17:38:00 +0530 Subject: [PATCH 20/60] fix(compiler): document and cover the indirect alias refinement guard The comment claimed an incompatible caller alias was unreachable for valid source. That holds only for direct scalars, where the solver rejects a name that would need two numeric types. Indirect parameters can legitimately differ in ownership flavor from a same-named output: a StrH binding may receive a StrG output, and that program compiles. Without the guard the caller redirects the StrH input to the StrG output's adapter, so a sibling output reading the input sees the adapter's value: "Sibling: static!" instead of "Sibling: hello!". The guard predates this branch for exactly that reason; only its rationale had been lost. Add the ranged StrH-input/StrG-output regression under the leak-checked suite, and reword AliasSelector: indirect parameters do not transmit it, they consume it caller-side to substitute the staged output pointer. Co-Authored-By: Claude Opus 5 --- compiler/compiler.go | 19 ++++++++++++------- tests/mem/mem_alias_refine.exp | 1 + tests/mem/mem_alias_refine.pt | 7 +++++++ tests/mem/mem_alias_refine.spt | 7 +++++++ 4 files changed, 27 insertions(+), 7 deletions(-) create mode 100644 tests/mem/mem_alias_refine.exp create mode 100644 tests/mem/mem_alias_refine.pt create mode 100644 tests/mem/mem_alias_refine.spt diff --git a/compiler/compiler.go b/compiler/compiler.go index e14ef2e5..c20f9b5c 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -76,10 +76,12 @@ type callArg struct { Name string Symbol *Symbol Lowered *Symbol - // AliasSelector is the hidden ABI selector for the caller destination this - // argument aliases, exactly as transmitted: 0 means none, N means output - // N-1. Encoding it this way keeps the zero value correct for the arguments - // that alias nothing. + // 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 } @@ -335,9 +337,12 @@ func (c *Compiler) setCallArgAliasSelectors(sig *callSignature, args []callArg, if output.Value != arg.Name { continue } - // Unreachable for valid source today: a name that is both argument - // and destination must hold one type, so the solver rejects the - // mismatch first. Kept so all three alias sites share one rule. + // 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 } diff --git a/tests/mem/mem_alias_refine.exp b/tests/mem/mem_alias_refine.exp new file mode 100644 index 00000000..8b32da5b --- /dev/null +++ b/tests/mem/mem_alias_refine.exp @@ -0,0 +1 @@ +Refined: static Sibling: hello! diff --git a/tests/mem/mem_alias_refine.pt b/tests/mem/mem_alias_refine.pt new file mode 100644 index 00000000..26a0ff20 --- /dev/null +++ b/tests/mem/mem_alias_refine.pt @@ -0,0 +1,7 @@ +# 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. +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 new file mode 100644 index 00000000..c32420dc --- /dev/null +++ b/tests/mem/mem_alias_refine.spt @@ -0,0 +1,7 @@ +# The range driver is what makes the callee alias-bearing, so this only covers +# the intended path while the call carries a range. +text = "he" ⊕ "llo" +sibling = "z" +i = 0:3 +text, sibling = RefineEcho(text, i) +"Refined: -text Sibling: -sibling" From 29fed52141e4f1464ca0bd9f3d987534dcb2b195 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sat, 25 Jul 2026 20:47:24 +0530 Subject: [PATCH 21/60] test(compiler): cover the ranged conditional reset slot flavor The staged slot for a conditional over a ranged call takes the destination's element flavor, not the call's own output type, but nothing covered that. The suite passed with the flavor recovery removed, which made a load-bearing fallback look like dead defensive code. Add the case that distinguishes them: an established rank-2 destination reset through a ranged call. With an empty driver the call yields nothing and the destination must keep its matrix; taking the call's empty-array flavor instead empties it and leaks the payload. Fold the two lookups into destSlotType so the resolution reads in one place and stops duplicating bindingSlotType inline. Behavior is unchanged. Co-Authored-By: Claude Opus 5 --- compiler/compiler.go | 33 ++++++++++++++++--------------- tests/array/cond_ranged_reset.exp | 6 ++++++ tests/array/cond_ranged_reset.pt | 5 +++++ tests/array/cond_ranged_reset.spt | 20 +++++++++++++++++++ 4 files changed, 48 insertions(+), 16 deletions(-) create mode 100644 tests/array/cond_ranged_reset.exp create mode 100644 tests/array/cond_ranged_reset.pt create mode 100644 tests/array/cond_ranged_reset.spt diff --git a/compiler/compiler.go b/compiler/compiler.go index c20f9b5c..256cfd08 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -264,26 +264,27 @@ func (c *Compiler) resolvedDestTypes(dest []*ast.Identifier, outTypes []Type) [] if dest == nil || i >= len(dest) { continue } - bindingType, ok := c.BindingTypes[BindingKey{ - FuncNameMangled: c.FuncNameMangled, - Name: dest[i].Value, - }] - if ok { - resolved[i] = bindingType - continue - } - // Conditional lowering writes through synthetic condtmp_* identifiers. - // They have no solver binding entry, but their pointer element is the - // authoritative slot flavor selected for the real destination. - if sym, exists := Get(c.Scopes, dest[i].Value); exists { - if ptrType, isPtr := sym.Type.(Ptr); isPtr { - resolved[i] = ptrType.Elem - } - } + resolved[i] = c.destSlotType(dest[i].Value, outType) } return resolved } +// destSlotType returns the authoritative element type for one destination slot. +// The solver owns the type of a source binding. Conditional lowering writes +// through synthetic condtmp_* identifiers, which have no solver entry, so fall +// back to the storage it already created for them: that pointer's element is +// the flavor chosen for the real destination. Staging allocates an independent +// slot, so taking the expression's own type here would let an empty range or a +// skipped write reset the destination instead of preserving it. +func (c *Compiler) destSlotType(name string, outType Type) Type { + if sym, exists := Get(c.Scopes, name); exists { + if ptrType, isPtr := sym.Type.(Ptr); isPtr { + outType = ptrType.Elem + } + } + return c.bindingSlotType(name, outType) +} + // inferCallParamTypes selects the solver-cached call variant to use at the // current lowering site. Once outer loops have consumed all pending ranges, the // scalarized param types become the right callee variant for code generation. diff --git a/tests/array/cond_ranged_reset.exp b/tests/array/cond_ranged_reset.exp new file mode 100644 index 00000000..aeb812d6 --- /dev/null +++ b/tests/array/cond_ranged_reset.exp @@ -0,0 +1,6 @@ +Written: [ +] +Preserved: [ + 5 6 + 7 8 +] diff --git a/tests/array/cond_ranged_reset.pt b/tests/array/cond_ranged_reset.pt new file mode 100644 index 00000000..e7a16c6f --- /dev/null +++ b/tests/array/cond_ranged_reset.pt @@ -0,0 +1,5 @@ +# Conditionally reset an array through a ranged call. The staged slot must take +# the destination's element flavor rather than this call's empty-array output, +# or an empty range cannot preserve what the destination already holds. +res = CondReset(x) + res = x > -1 [] diff --git a/tests/array/cond_ranged_reset.spt b/tests/array/cond_ranged_reset.spt new file mode 100644 index 00000000..5e460080 --- /dev/null +++ b/tests/array/cond_ranged_reset.spt @@ -0,0 +1,20 @@ +# A statement condition over a ranged call stages through a synthetic slot, and +# the destination is an established rank-2 owner whose flavor differs from the +# call's output. A non-empty driver writes the reset through. +written = [ + 1 2 + 3 4 +] +driver = 0:3 +written = 1 > 0 CondReset(driver + 0) +"Written: -written" + +# An empty driver yields nothing, so the destination keeps its own matrix. Taking +# the call's flavor here instead empties the destination and leaks its payload. +kept = [ + 5 6 + 7 8 +] +empty = 0:0 +kept = 1 > 0 CondReset(empty + 0) +"Preserved: -kept" From b986fda7984d63a7ca4d5681339911d2e118f6f0 Mon Sep 17 00:00:00 2001 From: Tejas Date: Sun, 26 Jul 2026 16:10:14 +0530 Subject: [PATCH 22/60] feat(compiler): copy bare range descriptors Treat complete bare Range assignments as descriptor construction or copy while preserving ranged execution for operations, active statement drivers, calls, interpolation, and array indexing. Simplify identifier and ArrayRange lowering, document independent named-driver identity and migration behavior, and update regression coverage for conditional and empty domains. BREAKING CHANGE: assigning a bare Range to a fresh name now copies the descriptor instead of retaining its final yield. Use an operation such as + 0 when a final scalar iterator value is intended. --- README.md | 16 +++-- compiler/array.go | 9 +-- compiler/cfg.go | 6 +- compiler/compiler.go | 33 ++------- compiler/solver.go | 93 +++++++++++++------------- compiler/solver_test.go | 45 +++++++++++++ docs/Pluto Memory Model.md | 65 ++++++++++-------- docs/Pluto Range Semantics.md | 122 ++++++++++++++++++++++++++-------- tests/array/cond_accum.exp | 7 +- tests/array/cond_accum.spt | 24 ++++--- tests/range_finalize.exp | 15 +++-- tests/range_finalize.spt | 54 +++++++++++---- tests/range_shadow.pt | 5 +- 13 files changed, 318 insertions(+), 176 deletions(-) diff --git a/README.md b/README.md index d31d1cdd..df412d55 100644 --- a/README.md +++ b/README.md @@ -178,15 +178,19 @@ A range literal binds an execution domain: ```python i = 0:5 -last = i # 4 -values = [i] # [0 1 2 3 4] -lastSquare = Square(i) # 16 +copy = i # same bounds, independent named driver +values = [copy] # [0 1 2 3 4] +last = i + 0 # 4 +lastSquare = Square(i) # 16 ``` -A bare range at an assignment root keeps its final yield. Brackets -materialize all yields into an array. Passing a range to a template executes -the call once for each value. The compiler can map these +A bare range at an assignment root is a value and can be copied. Using it in +an operation creates a ranged computation: the assignment keeps the final +yield, brackets materialize all yields into an array, and passing it to a +template executes the call once for each value. The compiler can map these operations to SIMD instructions — this is range-driven auto-vectorization. +Each named Range binding is a distinct driver: different names form a +cartesian domain when consumed together, even when their bounds are equal. Range-indexed array access follows the same rule: diff --git a/compiler/array.go b/compiler/array.go index 229fc3ef..6a574c42 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -990,14 +990,7 @@ func (c *Compiler) compileArrayRangeCallArg(expr *ast.ArrayRangeExpression, typ } arraySym := c.derefIfPointer(c.compileExpression(expr.Array, nil)[0], arrayLoadName) - var rangeSym *Symbol - // A named Range must bypass value-root finalization: this call boundary - // needs its descriptor, not the last value produced by iterating it. - if rangeIdent, ok := expr.Range.(*ast.Identifier); ok { - rangeSym = c.compileIdentifier(rangeIdent) - } else { - rangeSym = c.compileExpression(expr.Range, nil)[0] - } + rangeSym := c.compileExpression(expr.Range, nil)[0] rangeSym = c.derefIfPointer(rangeSym, "array_range_index") _, arrayIsIdent := expr.Array.(*ast.Identifier) diff --git a/compiler/cfg.go b/compiler/cfg.go index e5dceae8..1d6fa363 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -243,9 +243,9 @@ func (cfg *CFG) hasRangeExpr(e ast.Expression) bool { switch t := e.(type) { case *ast.Identifier: - // A bare named Range is an iterated scalar-finalization root. Unlike a - // range literal constructor, an empty driver may leave an existing - // destination unchanged, so its write is conditional. + // Descriptor-copy assignments clear their cached ranges during typing. + // A remaining range here is a scalar use of a driver already bound by + // the statement, so an empty driver may leave the destination unchanged. return len(c.ExprCache[key(c.FuncNameMangled, t)].Ranges) > 0 case *ast.StringLiteral: // Formatting markers can reference named Range drivers even though the diff --git a/compiler/compiler.go b/compiler/compiler.go index 256cfd08..9bd5fddd 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -1495,9 +1495,9 @@ func (c *Compiler) compileExpression(expr ast.Expression, dest []*ast.Identifier res = c.compileStringLiteralExpression(e, dest) case *ast.RangeLiteral: info := c.ExprCache[key(c.FuncNameMangled, e)] - // Root bare range literals can be scalarized by an outer ranged context. - // When all of this literal's ranges are already bound, compile the rewrite - // iterator identifier instead of materializing a Range aggregate again. + // A consuming expression can lower this literal inside the loop that + // already bound its iterator rewrite. Complete descriptor assignments + // clear the rewrite and materialize the Range aggregate below. if rewIdent, ok := info.Rewrite.(*ast.Identifier); ok && len(c.pendingLoopRanges(info.Ranges)) == 0 { return []*Symbol{c.compileIdentifier(rewIdent)} } @@ -1510,7 +1510,7 @@ func (c *Compiler) compileExpression(expr ast.Expression, dest []*ast.Identifier case *ast.DotExpression: return c.compileDotExpression(e) case *ast.Identifier: - res = c.compileIdentifierExpression(e, dest) + res = []*Symbol{c.compileIdentifier(e)} case *ast.InfixExpression: res = c.compileInfixExpression(e, dest) case *ast.PrefixExpression: @@ -1740,7 +1740,7 @@ func (c *Compiler) compileIdentifier(ident *ast.Identifier) *Symbol { // named Range drivers once per yield and retains the final formatted string. func (c *Compiler) compileStringLiteralExpression(lit *ast.StringLiteral, dest []*ast.Identifier) []*Symbol { info := c.ExprCache[key(c.FuncNameMangled, lit)] - if info == nil || len(c.pendingLoopRanges(info.Ranges)) == 0 { + if len(c.pendingLoopRanges(info.Ranges)) == 0 { return []*Symbol{c.compileStringLiteral(lit.Token)} } @@ -1758,29 +1758,6 @@ func (c *Compiler) compileStringLiteralExpression(lit *ast.StringLiteral, dest [ return c.loadOutputValues(outputs, "format_range_final") } -// compileIdentifierExpression closes a bare named Range driver to its final -// yielded iterator value. When an outer loop has already shadowed the Range -// with a scalar, the identifier compiles directly. -func (c *Compiler) compileIdentifierExpression(ident *ast.Identifier, dest []*ast.Identifier) []*Symbol { - info := c.ExprCache[key(c.FuncNameMangled, ident)] - if info == nil || len(c.pendingLoopRanges(info.Ranges)) == 0 { - return []*Symbol{c.compileIdentifier(ident)} - } - - PushScope(&c.Scopes, BlockScope) - defer c.popScope() - - outputs := c.makeSeededTempOutputs(dest, info.OutTypes) - c.bindRangedTempOutputs(dest, outputs) - output := outputs[0] - c.withLoopNest(info.Ranges, func() { - value := c.compileIdentifier(ident) - c.storeRangedOutput(output, value.Val, value.Type) - }) - - return c.loadOutputValues(outputs, "range_final") -} - func (c *Compiler) compileDotExpression(expr *ast.DotExpression) []*Symbol { leftSym := c.compileExpression(expr.Left, nil)[0] leftSym = c.derefIfPointer(leftSym, "dot_left") diff --git a/compiler/solver.go b/compiler/solver.go index a0e30f86..f935b15c 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -541,10 +541,10 @@ func (ts *TypeSolver) isBareRangeExpr(expr ast.Expression) bool { } } -// HandleIdentifierRanges processes identifier expressions, detecting if they refer -// to range-typed variables and including them in range tracking. A bare named -// range is a driver use; assignment and print roots close that stream rather -// than copying or printing the Range descriptor. +// HandleIdentifierRanges processes identifier expressions, detecting if they +// refer to range-typed variables and including them in range tracking. The +// enclosing context decides whether that occurrence consumes the driver or a +// complete assignment copies the descriptor. func (ts *TypeSolver) HandleIdentifierRanges(ident *ast.Identifier) (ranges []*RangeInfo, rew ast.Expression) { typ, ok := ts.GetIdentifier(ident.Value) if ok && typ.Kind() == RangeKind { @@ -580,12 +580,21 @@ func (ts *TypeSolver) HandleStringLiteralRanges(lit *ast.StringLiteral) (ranges return ranges, lit } -// finalizeBareRangeIdentifier closes a named Range driver at an assignment -// root. Range literals remain constructors (`i = 0:n`), while a later bare use -// (`last = i`) yields the final iterator value. -func (ts *TypeSolver) finalizeBareRangeIdentifier(expr ast.Expression, types []Type) { - ident, ok := expr.(*ast.Identifier) - if !ok || len(types) != 1 { +func rangeDriverNamed(ranges []*RangeInfo, name string) bool { + for _, ri := range ranges { + if ri.RangeLit == nil && ri.Name == name { + return true + } + } + return false +} + +// resolveBareRangeAssignment distinguishes Range descriptor copies from uses of +// a Range that an enclosing statement condition has already bound as an +// iterator. `copy = source` and `copy = 0:n` preserve the descriptor; in +// `filtered = source > 2 source`, the RHS reads the current scalar yield. +func (ts *TypeSolver) resolveBareRangeAssignment(expr ast.Expression, types []Type, condRanges []*RangeInfo) { + if len(types) != 1 { return } @@ -594,15 +603,28 @@ func (ts *TypeSolver) finalizeBareRangeIdentifier(expr ast.Expression, types []T return } - info := ts.ExprCache[key(ts.FuncNameMangled, ident)] - if len(info.Ranges) == 0 { - info.Ranges = []*RangeInfo{{Name: ident.Value}} + info := ts.ExprCache[key(ts.FuncNameMangled, expr)] + switch e := expr.(type) { + case *ast.Identifier: + if !rangeDriverNamed(condRanges, e.Value) { + info.Ranges = nil + info.HasRanges = false + info.Rewrite = nil + return + } + if len(info.Ranges) == 0 { + info.Ranges = []*RangeInfo{{Name: e.Value}} + } + info.OutTypes = []Type{rangeType.Iter} + info.ExprLen = 1 + info.HasRanges = true + info.Rewrite = e + types[0] = rangeType.Iter + case *ast.RangeLiteral: + info.Ranges = nil + info.HasRanges = false + info.Rewrite = nil } - info.OutTypes = []Type{rangeType.Iter} - info.ExprLen = 1 - info.HasRanges = true - info.Rewrite = ident - types[0] = rangeType.Iter } func (ts *TypeSolver) TypeStatement(stmt ast.Statement) { @@ -812,36 +834,17 @@ func (ts *TypeSolver) collectConditionRanges(conditions []ast.Expression) []*Ran // mergeCondRangesIntoValue merges condition ranges into a value expression's // ExprInfo so ranged statement conditions can drive per-iteration RHS lowering. -// Bare Range values also merge their own ranges here so a range literal used -// under an outer statement driver scalarizes in that iteration context. Array -// indexing is already element-typed in every context. Array literals still -// control accumulation; non-literal values remain last-value-wins. -func (ts *TypeSolver) mergeCondRangesIntoValue(expr ast.Expression, exprTypes []Type, condRanges []*RangeInfo) { +// Bare Range assignments have already been classified as descriptor copies or +// reads of a driver bound by this statement. Array indexing is element-typed in +// every context. Array literals still control accumulation; non-literal values +// remain last-value-wins. +func (ts *TypeSolver) mergeCondRangesIntoValue(expr ast.Expression, condRanges []*RangeInfo) { if len(condRanges) == 0 { return } info := ts.ExprCache[key(ts.FuncNameMangled, expr)] - - merged := condRanges - // A root range literal normally constructs a Range. Under an outer statement - // driver it participates in that iteration context and yields iterator values. - if len(exprTypes) == 1 && ts.isBareRangeExpr(expr) { - selfRanges := info.Ranges - if ident, ok := expr.(*ast.Identifier); ok && exprTypes[0].Kind() == RangeKind { - selfRanges = []*RangeInfo{{Name: ident.Value}} - } - merged = mergeUses(condRanges, selfRanges) - - switch exprTypes[0].Kind() { - case RangeKind: - iterType := exprTypes[0].(Range).Iter - exprTypes[0] = iterType - info.OutTypes[0] = iterType - } - } - - info.Ranges = mergeUses(merged, info.Ranges) + info.Ranges = mergeUses(condRanges, info.Ranges) info.HasRanges = true } @@ -868,8 +871,8 @@ func (ts *TypeSolver) TypeLetStatement(stmt *ast.LetStatement) { exprIdxs := make([]int, 0, len(stmt.Name)) for _, expr := range stmt.Value { exprTypes := ts.TypeExpression(expr, true) - ts.finalizeBareRangeIdentifier(expr, exprTypes) - ts.mergeCondRangesIntoValue(expr, exprTypes, condRanges) + ts.resolveBareRangeAssignment(expr, exprTypes, condRanges) + ts.mergeCondRangesIntoValue(expr, condRanges) for idx := range exprTypes { types = append(types, exprTypes[idx]) exprRefs = append(exprRefs, expr) diff --git a/compiler/solver_test.go b/compiler/solver_test.go index 94401037..28795255 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -1008,6 +1008,51 @@ res = [idx]` require.IsType(t, &ast.ArrayLiteral{}, info.Rewrite) } +func TestBareRangeAssignmentsCopyDescriptors(t *testing.T) { + ctx := llvm.NewContext() + cc := NewCodeCompiler(ctx, "bareRangeCopies", "", ast.NewCode()) + program := mustParseScript(t, `source = 0:5 +copy = (source) +last = source + 0 +outer = 0:2 +gatedCopy = outer < 2 source +filtered = source > 2 source`) + + sc := NewScriptCompiler(ctx, program, cc, make(map[string]*Func), make(map[ExprKey]*ExprInfo)) + ts := NewTypeSolver(sc) + ts.Solve() + require.Emptyf(t, ts.Errors, "unexpected type errors: %v", ts.Errors) + + for _, name := range []string{"source", "copy", "gatedCopy"} { + typ, ok := ts.GetIdentifier(name) + require.Truef(t, ok, "expected %s binding", name) + require.Equal(t, Range{Iter: I64}, typ) + } + for _, name := range []string{"last", "filtered"} { + typ, ok := ts.GetIdentifier(name) + require.Truef(t, ok, "expected %s binding", name) + require.Equal(t, I64, typ) + } + + copyExpr := program.Statements[1].(*ast.LetStatement).Value[0] + copyInfo := ts.ExprCache[key("", copyExpr)] + require.False(t, copyInfo.HasRanges) + require.Empty(t, copyInfo.Ranges) + require.Nil(t, copyInfo.Rewrite) + + gatedCopyExpr := program.Statements[4].(*ast.LetStatement).Value[0] + gatedCopyInfo := ts.ExprCache[key("", gatedCopyExpr)] + require.Equal(t, []Type{Range{Iter: I64}}, gatedCopyInfo.OutTypes) + require.Len(t, gatedCopyInfo.Ranges, 1) + require.Equal(t, "outer", gatedCopyInfo.Ranges[0].Name) + + filteredExpr := program.Statements[5].(*ast.LetStatement).Value[0] + filteredInfo := ts.ExprCache[key("", filteredExpr)] + require.Equal(t, []Type{I64}, filteredInfo.OutTypes) + require.Len(t, filteredInfo.Ranges, 1) + require.Equal(t, "source", filteredInfo.Ranges[0].Name) +} + func TestRangedArrayAccessTypesAsElementStream(t *testing.T) { ctx := llvm.NewContext() code := ast.NewCode() diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index c6cde035..3221344b 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -4,18 +4,20 @@ This document describes Pluto's semantic model and compares it with other major ## The Pluto Model (Summary) -1. **Materialized Assignment is Copy:** assigning a scalar, array, table, string, - or struct creates an independent value. +1. **Materialized Assignment is Copy:** assigning a scalar, Range descriptor, + array, table, string, or struct creates an independent value. 2. **Arrays are Values:** `arr2 = arr1` copies data (COW). 3. **Range Selections are Streams:** `s = arr[i]` keeps the final selected value (an element or owned subarray); `s = [arr[i]]` materializes every selected value. -4. **Ranges are Loop Syntax:** `x = i` and `x = i + 1` generate loops, not lazy - values. -5. **Empty-Domain Initialization:** A fresh destination keeps its type's zero - value; an existing destination remains unchanged. -6. **Driver Identity Determines Looping:** Repeated use of one range shares a - loop; distinct ranges form a cartesian domain. +4. **Ranges are Descriptor Values:** `j = i` copies a Range; consuming it in + `x = i + 1`, `arr[i]`, a call, print, interpolation, or `[]` drives a loop. +5. **Empty-Domain Initialization:** An empty Range descriptor still assigns. + An empty ranged computation leaves an existing destination unchanged and a + fresh destination at its type's zero value. +6. **Driver Identity Determines Looping:** Repeated use of one Range binding + shares a loop; distinct bindings form a cartesian domain even when their + descriptors have equal bounds. 7. **Function Arguments by Value:** Scalar parameters are passed by value; outputs write into caller destination slots. 8. **Function Locking:** Input arguments hold read locks, outputs hold write locks (automatic concurrency safety). @@ -31,7 +33,7 @@ This document describes Pluto's semantic model and compares it with other major | **Array Assign** | **Copy** (COW) | Reference | Move | Reference (Slice) | Reference | Copy | | **Function Args** | **Value** (Scalars) | 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** | **Loop Syntax** (Immediate) | Reference (Generator) | Reference (Iterator) | N/A | Reference (Iterator) | N/A | +| **Range Usage** | **Copyable descriptor; operations iterate** | Reference (Generator) | Reference (Iterator) | N/A | Reference (Iterator) | N/A | | **Mutability** | **In-Place Only** | Mutable Objects | Mutable (if `mut`) | Mutable | Mutable | Mutable | | **Memory Mgmt** | **Auto (Scope)** | Auto (GC) | Auto (Owner) | Auto (GC) | Auto (GC) | Manual | @@ -54,15 +56,16 @@ x = (i+1 for i in iter) # Lazy generator a = [1]; b = a; a[0] = 2 # b sees 1 (independent copy) i = 0:5 -x = i # Loop executes, x = 4 (last yield) +j = i # Descriptor copy; no loop +x = i + 0 # Loop executes, x = 4 (last yield) x = i + 1 # Loop executes, x = 5 (last value) i = 0:10 # Bind a new reusable Range domain y = i + 1 # Consuming statement runs the loop; y = 10 ``` **Difference:** Pluto is safer and more predictable. A range literal binds a -reusable execution domain; a consuming statement runs it as a loop rather than -creating a lazy generator. +reusable descriptor. A bare assignment copies it; a consuming expression runs +it as a loop rather than creating a lazy generator. --- @@ -154,20 +157,22 @@ expose a slice value. ### Statement-Level Loop Generation -Ranges generate loops at statement boundaries. Operations consume one yielded -value at a time; a rank-N selection can yield an owned subarray: +Range-consuming expressions generate loops at statement boundaries. +Operations consume one yielded value at a time; a rank-N selection can yield +an owned subarray: ```python i = 0:5 -x = i # Loop at statement: x = 4 (last yielded iterator) +j = i # Same bounds, independent named driver +x = i + 0 # Loop at statement: x = 4 (last yielded iterator) x = i + 1 # Loop at statement: x = 5 (last scalar value) y = i * 2 # Loop at statement: y = 8 (last scalar value) z = (i + 1) / (i + 2) # Single loop: z = 5/6 (last value) ``` -Bare ranged expressions and range-indexed array accesses execute as loop -drivers rather than becoming lazy values. An assignment root keeps the last -yield; `[]` collects every yield. +Complete Range expressions construct or copy descriptors. Operations and +range-indexed array accesses consume descriptors as loop drivers. An +assignment root keeps the last computation yield; `[]` collects every yield. ### Driver Identity Determines Loop Structure @@ -182,11 +187,12 @@ ratio = (i + 1) / (i + 2) product = (i + 1) * (j + 1) ``` -### Three Execution Modes +### Four Execution Modes | Mode | Syntax | Behavior | |------|--------|----------| -| **Last Value** | `x = i` or `x = arr[i]` | Loop runs, x = last yielded value | +| **Descriptor Copy** | `j = i` | No loop; j receives the Range value | +| **Last Value** | `x = i + 0` or `x = arr[i]` | Loop runs, x = last yielded value | | **Accumulate** | `x = x + i` | Loop runs, x accumulates | | **Collect** | `arr = [i * 2]` | Loop runs, collects to array | @@ -215,7 +221,7 @@ Guard expressions with conditions: res = arr[i] > res arr[i] # Conditional update -res = i * i > 10 i +res = (i * i) > 10 i ``` Desugars to: `if (condition) res = expression` @@ -228,7 +234,8 @@ Pluto sits in a "Sweet Spot" for parallel computing: 1. **Value Semantics (like R/Matlab)** make reasoning about concurrent code easy. "If I have `x`, I own `x`." 2. **Explicit Collection** makes every allocation and materialization boundary visible. -3. **Loop Syntax Ranges (Unique)** provide clean iteration without lazy complexity. +3. **Range-Driven Execution (Unique)** separates copyable descriptors from + operations that iterate without lazy-generator complexity. 4. **Named Driver Reuse (Unique)** makes user intent explicit — repeated use of one range name shares one loop. 5. **Defined Empty Domains (Unique)** give fresh and existing destinations @@ -317,15 +324,21 @@ for (int64_t i_val = 0; i_val < 5; i_val++) { ### Composition Using Functions -For complex expressions with named intermediates, use functions: +For complex expressions with named intermediates, define a function in `.pt`: + +```python +ratio = compute_ratio(i) + numerator = i + 1 + denominator = i + 2 + ratio = numerator / denominator +``` + +Then consume it from `.spt`: ```python i = 0:5 res = 0 res = res + compute_ratio(i) - numerator = i + 1 - denominator = i + 2 - res = numerator / denominator ``` This avoids the issue where intermediate assignments execute immediately: diff --git a/docs/Pluto Range Semantics.md b/docs/Pluto Range Semantics.md index 7cdb790c..74bbce27 100644 --- a/docs/Pluto Range Semantics.md +++ b/docs/Pluto Range Semantics.md @@ -2,24 +2,67 @@ ## Core Model -Expressions that mention ranges produce ordered per-iteration values. The -stream is not collected into an array by default; an individual yield may be a +A `Range` is a descriptor value. A range literal constructs one, and a +complete Range-valued assignment copies it: + +```pluto +i = 0:5 +j = i +k = (i) +``` + +`i`, `j`, and `k` contain equal descriptor values with the same captured +bounds. Parentheses are transparent. Each binding is nevertheless a distinct +driver identity: consuming `i` and `j` together forms a cartesian domain, +while repeated uses of `i` share one loop. + +Using a Range in an operation creates a ranged computation. `Ranged` is a +useful description of that expression effect, not a storable source type. An +operation produces ordered per-iteration values; an individual yield may be a scalar or an owned subarray. -There are two explicit closing steps: +There are two explicit closing steps for a ranged computation: + +1. `[]` closes it into an array. +2. The root expression of an assignment closes any remaining outer iteration + by taking the final yielded value in iteration order. + +A Range descriptor assignment is not a closing step and does not iterate. +This keeps descriptor copying, collection, and final-value selection separate. -1. `[]` closes a value stream into an array. -2. The root expression of an assignment closes any remaining outer - iteration by taking the final yielded value in iteration order. +## Migration From Bare-Range Finalization -This keeps collection and final-value selection separate. +Previously, assigning a bare named Range kept its final yield. It now copies +the descriptor: + +```pluto +i = 0:5 +copy = i +last = i + 0 +``` + +`copy` is a Range descriptor; `last` is the scalar `4`. Use an operation such +as `+ 0` when migrating code that intended the old final-value behavior. + +This change can be silent for a fresh destination. A later print, +interpolation, call, index, or collector consumes the copied Range and runs its +whole domain; printing an empty copied Range emits no line. Assigning a Range +to an existing scalar is instead rejected as a type-changing reassignment. +Descriptor copies are unconditional writes and participate in the ordinary +dead-store checks. Range-indexed expressions such as `last = data[i]` are +unchanged because indexing is already a ranged computation. ## Ranges And Drivers -A range identifier or range-indexed array access used in an expression -contributes an iteration driver. -Multiple distinct drivers form a nested iteration domain in source order. -Repeated use of the same driver name refers to the same loop, not a nested copy. +A range identifier consumed by an operator, array index, interpolation, +print, collector, statement condition, or function argument contributes an +iteration driver. A range-indexed array access is itself a ranged computation. +Multiple distinct drivers form a nested iteration domain in source order: the +first distinct driver is outermost and the last is innermost. Repeated use of +the same driver name refers to the same loop, not a nested copy. +Driver identity belongs to the binding name, not to descriptor equality. +Substituting one Range name for another can therefore change a shared loop into +a cartesian domain. Example: @@ -31,20 +74,30 @@ x = i + 1 This iterates `i` over `0, 1, 2, 3, 4` and the root assignment keeps the final value, so `x = 5`. -A bare identifier is itself a ranged expression: +A complete bare Range expression is a descriptor value: ```pluto i = 0:5 -last = i +copy = i +last = i + 0 ``` -`last` becomes `4`. The range binding `i` remains available for later uses. -To bind another execution domain, write another range literal; range -`start`/`stop`/`step` fields are not part of the language. +`copy` is another Range descriptor and an independent named driver with the +same bounds, while `last` becomes `4`. Copy a Range to bind another driver with +the same bounds, or write another range literal to define different bounds. +Range `start`/`stop`/`step` fields are not part of the language. The bound values are captured when the range is constructed, so later changes to the source variables do not mutate the existing range. Functions that need those bounds as data should currently receive the scalar values explicitly. +Range is nameable and copyable, but is not yet a fully first-class container +element. Arrays and tables contain scalar/string elements rather than Range +descriptors, so `[i]` consumes `i` and collects its yields. Passing a Range to a +function likewise consumes it as a driver rather than passing inert metadata. +A function may return a Range descriptor to a binding, but anonymous +Range-returning expressions are not yet accepted uniformly by every consuming +context; bind the result before consuming it. + Range-indexed arrays follow the same rule: ```pluto @@ -58,13 +111,15 @@ selected = [arr[i]] access is not a public slice or view value: it is either consumed by its surrounding expression, finalized at an assignment root, or materialized by `[]`. For a rank-N source, one yield is an owned rank-(N-1) subarray, so the -final value can itself be an array. +final value can itself be an array. This ownership is semantic: copy-on-write +is permitted, but an escaping view into the source is not. -If a range produces no values, a fresh destination retains its type's zero -value (an empty array for a subarray result) and an existing destination is -unchanged. Outside `[]`, an out-of-bounds selection point yields nothing, so -the last valid selected value wins. Inside `[]`, failed cells are zero-filled -to preserve collection shape, as described below. +Assigning an empty Range descriptor still performs a normal write. Consuming +an empty Range produces no yields: a fresh ranged-computation destination +retains its type's zero value (an empty array for a subarray result), while an +existing destination is unchanged. Outside `[]`, an out-of-bounds selection +point yields nothing, so the last valid selected value wins. Inside `[]`, +failed cells are zero-filled to preserve collection shape, as described below. Print statements consume drivers rather than exposing their internal descriptor; Range descriptors have no printable representation. Printing `i` @@ -434,13 +489,14 @@ Examples: ```pluto i = 0:3 j = 0:2 -x, y = i < 2 [1], j +x, y = i < 2 [1], j + 0 ``` The statement condition `i < 2` is shared. `x` collects once for each admitted `i`, producing `[1 1]`. -`y` uses its own local `j` driver inside that shared gate and ends with the -final `j` value `1`. +`y`'s operation uses its own local `j` driver inside that shared gate and ends +with the final result `1`. A bare `j` in this position would instead copy the +Range descriptor because only `i` belongs to the active statement domain. Likewise: @@ -462,6 +518,12 @@ the statement condition opens that outer loop first. Inside the RHS, the same name refers to the current scalar iterator value, not to a fresh nested loop. +Consequently, `filtered = i > 2 i` keeps the final admitted scalar, while +`copy = outer > 2 i` copies `i` when only `outer` belongs to the statement +domain. If the statement domain is empty, it performs no write: an existing +Range destination stays unchanged and a fresh one keeps the zero Range +descriptor. + For non-collector tuple outputs, one admitted statement iteration is still one shared scalar update step, but each RHS has its own local yield outcome. If one RHS hits an out-of-bounds failure, only that RHS keeps its previous value; @@ -502,8 +564,9 @@ not change the language meaning. ## Final-Value Contexts -Outside `[]`, ranged expressions remain per-iteration values until the root -assignment or statement consumes them. +Outside `[]`, ranged computations remain per-iteration values until the root +assignment or statement consumes them. Complete Range expressions remain +descriptors. Examples: @@ -515,10 +578,11 @@ x = i + 1 `x` becomes `5`. ```pluto -last = i +copy = i ``` -`last` becomes `4`. +`copy` is another descriptor. To request the final yielded iterator, use an +operation such as `last = i + 0`; `last` then becomes `4`. ```pluto arr = [i + 1] diff --git a/tests/array/cond_accum.exp b/tests/array/cond_accum.exp index f79f5816..7223eb8f 100644 --- a/tests/array/cond_accum.exp +++ b/tests/array/cond_accum.exp @@ -31,10 +31,11 @@ MultiCell: [0 10 1 11] ScalarArith: 3 4 ScalarElem: 20 -4 -LitCross: 4 +CrossRange: [0 1 2 3 4] +LitCross: [0 1 2 3 4] 30 -SeedLit: 88 +SeedLit: [8 9] +FreshLit: [] 77 StrSelection: c ["a" "b"] diff --git a/tests/array/cond_accum.spt b/tests/array/cond_accum.spt index 044f1173..28bacb99 100644 --- a/tests/array/cond_accum.spt +++ b/tests/array/cond_accum.spt @@ -172,16 +172,18 @@ q = 0:3 elem = q < 2 arr4[q] "ScalarElem: -elem" -# Scalar conditional iteration: cross-range +# A statement driver gates a distinct bare Range descriptor without consuming it. r = 0:3 i = 0:5 cross = r < 4 i -cross +crossValues = [cross] +"CrossRange: -crossValues" -# Scalar conditional iteration: direct bare range literal +# A direct bare Range literal is likewise assigned as a descriptor. r = 0:3 litcross = r < 2 0:5 -"LitCross: -litcross" +litcrossValues = [litcross] +"LitCross: -litcrossValues" # Scalar conditional iteration: direct range-indexed array expression arr5 = [10 20 30 40] @@ -189,11 +191,17 @@ r = 0:3 selectionCross = r < 2 arr5[0:3] selectionCross -# Scalar conditional iteration: direct bare range literal false path keeps old value -seedlit = 88 +# A rejected descriptor assignment keeps the existing Range value. +seedlit = 8:10 r = 0:3 seedlit = r < 0 0:5 -"SeedLit: -seedlit" +seedlitValues = [seedlit] +"SeedLit: -seedlitValues" + +# A fresh rejected descriptor assignment keeps the zero Range descriptor. +freshlit = r < 0 0:5 +freshlitValues = [freshlit] +"FreshLit: -freshlitValues" # A rejected direct range-indexed array expression keeps the old value seedSelection = 77 @@ -223,7 +231,7 @@ tc, td = i < 2 [0:2], [i] # drivers stay local to the expression that mentions them. i = 0:3 j = 0:2 -localLeakA, localLeakB = i < 2 [1], j +localLeakA, localLeakB = i < 2 [1], j + 0 "CondLocalDriversA: -localLeakA. CondLocalDriversB: -localLeakB" # When a statement condition and an RHS mention the same driver, the condition diff --git a/tests/range_finalize.exp b/tests/range_finalize.exp index 9c498b08..5184bff0 100644 --- a/tests/range_finalize.exp +++ b/tests/range_finalize.exp @@ -1,10 +1,15 @@ -AscendingFinal: 4 -DescendingFinal: 2 -UnevenFinal: 9 -EmptyFresh: 0 -EmptyExisting: 91 +AscendingCopy: [0 1 2 3 4] +ParenthesizedCopy: [0 1 2 3 4] +DescendingCopy: [6 4 2] +UnevenCopy: [1 5 9] +EmptyRangeFresh: [] +EmptyRangeBefore: [0 1] +EmptyRangeExisting: [] +EmptyComputeFresh: 0 +EmptyComputeExisting: 91 Collected: [0 1 2 3 4] CallFinal: 20 +ReturnedRange: [2 3 4] MarkerFinal: Marker 2 MarkerEach 0 MarkerEach 1 diff --git a/tests/range_finalize.spt b/tests/range_finalize.spt index 9294b2d8..80713938 100644 --- a/tests/range_finalize.spt +++ b/tests/range_finalize.spt @@ -1,34 +1,60 @@ -# A bare range identifier at an assignment root keeps its final yield. +# Bare range assignments copy descriptors. Brackets observe the copied stream. ascendingRange = 0:5 ascending = ascendingRange -"AscendingFinal: -ascending" +ascendingValues = [ascending] +"AscendingCopy: -ascendingValues" + +# Parentheses do not change a complete descriptor expression into a computation. +parenthesized = (ascendingRange) +parenthesizedValues = [parenthesized] +"ParenthesizedCopy: -parenthesizedValues" descendingRange = 6:0:-2 descending = descendingRange -"DescendingFinal: -descending" +descendingValues = [descending] +"DescendingCopy: -descendingValues" -# The final yield is the last visited value, not stop - step. +# Uneven descriptors keep their exact traversal after copying. unevenRange = 1:10:4 uneven = unevenRange -"UnevenFinal: -uneven" +unevenValues = [uneven] +"UnevenCopy: -unevenValues" -# An empty range preserves an existing destination and leaves a fresh one at zero. +# Empty descriptors still assign: both destinations become empty ranges. emptyRange = 3:3 -fresh = emptyRange -"EmptyFresh: -fresh" +freshRange = emptyRange +freshRangeValues = [freshRange] +"EmptyRangeFresh: -freshRangeValues" + +existingRange = 0:2 +existingRangeBefore = [existingRange] +"EmptyRangeBefore: -existingRangeBefore" +existingRange = emptyRange +existingRangeValues = [existingRange] +"EmptyRangeExisting: -existingRangeValues" + +# Empty ranged computations yield no value: fresh scalar destinations use zero, +# while existing scalar destinations retain their seed. +fresh = emptyRange + 1 +"EmptyComputeFresh: -fresh" existing = 91 -existing = emptyRange -"EmptyExisting: -existing" +existing = emptyRange + 1 +"EmptyComputeExisting: -existing" # Brackets remain the explicit materialization boundary. collected = [ascendingRange] "Collected: -collected" -# Function calls evaluate once per yield and the assignment keeps the final result. -callFinal = useShadow(ascendingRange) +# A copied descriptor remains consumable by functions. +callFinal = useShadow(ascending) "CallFinal: -callFinal" +# A Range returned by a function is a descriptor once bound. +returnedRange = makeRange() +returnedValues = [returnedRange] +"ReturnedRange: -returnedValues" + # Formatting markers are ordinary driver uses, including at print roots. markerRange = 0:3 formattedFinal = "Marker -markerRange" @@ -45,11 +71,11 @@ formattedWidth = "|-widthValue%(-widthRange)d|" # A direct literal in print position is also consumed as a driver. "LiteralEach", 0:3 -# Ranged RHS evaluation preserves simultaneous-assignment reads. +# Ranged computations preserve simultaneous-assignment reads. simRange = 0:3 first = 77 second = 0 -first, second = simRange, first +first, second = simRange + 0, first "SimultaneousRange: -first -second" simArray = [10 20 30] diff --git a/tests/range_shadow.pt b/tests/range_shadow.pt index 724278a9..6248129f 100644 --- a/tests/range_shadow.pt +++ b/tests/range_shadow.pt @@ -4,4 +4,7 @@ res = useShadow(x) res = AddMul(x, y) i = 10 - res = i * x + y \ No newline at end of file + res = i * x + y + +res = makeRange() + res = 2:5 From 62da7d7070de623d223711b9baccba4d927c938e Mon Sep 17 00:00:00 2001 From: Tejas Date: Sun, 26 Jul 2026 18:54:51 +0530 Subject: [PATCH 23/60] refactor(compiler): localize array cell error recovery Snapshot the compiler error count at the expression-lowering boundary so only a newly recorded diagnostic can explain an empty cell result. Keep the storage sink focused on storing a guaranteed value and cover recovery when an unrelated error already exists. --- compiler/array.go | 20 +++++++++----------- compiler/compiler_test.go | 30 ++++++++++++++++++++---------- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/compiler/array.go b/compiler/array.go index 6a574c42..c8924ce0 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -439,7 +439,16 @@ func (c *Compiler) compileArrayLiteralCell(cell ast.Expression, elemType Type, c c.withArrayLiteralCellMode(func() { c.compileCondExprValue(cell, llvm.Value{}, func() { + errorsBefore := len(c.Errors) vals := c.compileExpression(cell, nil) + if len(vals) == 0 { + // Only an error from this lowering explains the missing value; + // leave the seed in place so that diagnostic can surface. + if len(c.Errors) > errorsBefore { + return + } + panic("internal: array cell lowering produced no value and recorded no error") + } c.storeArrayCellSlotWhenInBounds(cellSlot, vals, cell) }) }) @@ -498,17 +507,6 @@ func (c *Compiler) storeArrayCellSlotWhenInBounds( vals []*Symbol, cell ast.Expression, ) { - if len(vals) == 0 { - // Lowering recorded a CompileError and yielded no value. Leaving the - // seed in place lets that diagnostic surface instead of an ICE. Without - // a recorded error an empty result is an internal fault, not user error, - // so stay loud rather than silently dropping the cell. - if len(c.Errors) == 0 { - panic("internal: array cell lowering produced no value and recorded no error") - } - return - } - slotElemType := cellSlot.Type.(Ptr).Elem store := func() { cellValue := c.derefIfPointer(vals[0], "") diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 096088cc..11cc0726 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -258,7 +258,7 @@ scaled` "a collector invokes the callee once per scalar yield, so promoting the argument to an internal ArrayRange must still define the scalar variant") } -func TestArrayCellSinkSkipsEmptyLoweringResult(t *testing.T) { +func TestArrayCellLoweringSurfacesNewError(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() @@ -268,17 +268,27 @@ func TestArrayCellSinkSkipsEmptyLoweringResult(t *testing.T) { fn := llvm.AddFunction(c.Module, "probe", llvm.FunctionType(ctx.VoidType(), nil, false)) c.builder.SetInsertPointAtEnd(c.Context.AddBasicBlock(fn, "entry")) - slot := c.newArrayCellSlot(I64) - cell := &ast.Identifier{Value: "cell"} - - require.Panics(t, func() { - c.storeArrayCellSlotWhenInBounds(slot, nil, cell) - }, "an empty result with no recorded error is an internal fault and must stay loud") + cell := &ast.CallExpression{ + Token: token.Token{Type: token.LPAREN, Literal: token.SYM_LPAREN}, + Function: &ast.Identifier{ + Token: token.Token{Type: token.IDENT, Literal: "Missing"}, + Value: "Missing", + }, + } + c.ExprCache[key(c.FuncNameMangled, cell)] = &ExprInfo{ + OutTypes: []Type{I64}, + ExprLen: 1, + } + c.Errors = append(c.Errors, &token.CompileError{Token: cell.Tok(), Msg: "prior"}) + errorsBefore := len(c.Errors) - c.Errors = append(c.Errors, &token.CompileError{Token: cell.Tok(), Msg: "recorded"}) + c.pushStmtCtx() + defer c.popStmtCtx() require.NotPanics(t, func() { - c.storeArrayCellSlotWhenInBounds(slot, nil, cell) - }, "a cell whose lowering recorded an error yields no value; the sink must leave the seed rather than index it") + c.compileArrayLiteralCell(cell, I64, func(*Symbol) bool { return false }) + }, "a cell whose lowering records an error must leave its seed rather than index an empty result") + require.Len(t, c.Errors, errorsBefore+1) + require.Contains(t, c.Errors[errorsBefore].Msg, "function Missing not found") } func TestPhase1ScalarABIRangeVariantUsesDirectScalarBoundary(t *testing.T) { From fb5e436e6fa3321c36601d604538160200f062de Mon Sep 17 00:00:00 2001 From: Tejas Date: Sun, 26 Jul 2026 23:18:52 +0530 Subject: [PATCH 24/60] fix(compiler): treat non-yielding values as conditional writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dead-store analysis recognized three ways a destination could keep its previous value: an explicit statement condition, a driver that iterates zero times, and a root call whose callee may skip its write. A right-hand side that simply yields nothing was still classified as an unconditional write, so valid programs were rejected: y = 10 y = Square(x < 5) + 5 # x >= 5 leaves y at 10 Three shapes were affected. A condition below the value root, reached through any operator; an out-of-bounds read, which fails its lanes the same way; and either of those inside a .pt function body, where no typed classification exists because templates are validated before specialization. Give the solver's traversal a caller-supplied node predicate so both dataflow passes walk the tree once, from one implementation, and the resolver boundaries cannot drift: an array literal settles a failed cell locally, and a || fails only when its final fallback does. The solver keeps a condition-only predicate because it also decides which programs are valid — ||, && and statement conditions all require an operand that can fail — so folding array indexing in there would legalize "arr[9] || -1". Only the CFG predicate, which feeds diagnostics alone, counts an out-of-bounds read. A .pt body has nothing cached, so it falls back to the syntactic shape and errs toward "may fail". Known imprecision, both deliberate. Treating every array read as failable means a statically safe "arr[0]" also suppresses a real dead-store warning. Classification stays per statement rather than per target, so "a, b = x < 5, 30" marks both destinations conditional and misses a genuinely dead b; fixing that would newly reject code that compiles today. Ranges inside a .pt body remain unhandled: an empty local driver still reports a false dead store, because deciding that syntactically would require treating every identifier as a possible Range. That and exact per-lane yield and write effects belong in PIR. Co-Authored-By: Claude Opus 5 --- compiler/cfg.go | 63 +++++++++++++++++++++++++++++++- compiler/cfg_test.go | 25 +++++++++++++ compiler/solver.go | 32 +++++++++++----- tests/cond/nested_cond_write.exp | 6 +++ tests/cond/nested_cond_write.pt | 12 ++++++ tests/cond/nested_cond_write.spt | 33 +++++++++++++++++ 6 files changed, 161 insertions(+), 10 deletions(-) create mode 100644 tests/cond/nested_cond_write.exp create mode 100644 tests/cond/nested_cond_write.pt create mode 100644 tests/cond/nested_cond_write.spt diff --git a/compiler/cfg.go b/compiler/cfg.go index 1d6fa363..81a071bd 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -174,7 +174,7 @@ func (cfg *CFG) extractStmtEvents(stmt ast.Statement) []VarEvent { // 3. Write to the destination variable(s). // Determine the type of write writeKind := Write - if len(s.Condition) > 0 || cfg.HasRangeExpr(s.Value) || cfg.HasSkippableCallRoot(s.Value) { + if cfg.mayNotWrite(s) { writeKind = ConditionalWrite } for _, lhs := range s.Name { @@ -214,6 +214,67 @@ func (cfg *CFG) HasRangeExpr(values []ast.Expression) bool { return false } +// mayNotWrite reports whether a statement's destinations may keep their +// previous values, which makes an earlier write to them live. The sources are +// independent: an explicit statement condition, a driver that may iterate zero +// times, a callee that may leave its output alone, and a value that may yield +// nothing. The last two are genuinely distinct — a skipped callee write is +// invisible in the caller's ExprCache, while a failed yield never reaches the +// callee — so both checks are required. +func (cfg *CFG) mayNotWrite(s *ast.LetStatement) bool { + return len(s.Condition) > 0 || + cfg.HasRangeExpr(s.Value) || + cfg.HasSkippableCallRoot(s.Value) || + cfg.anyValueMayNotYield(s.Value) +} + +func (cfg *CFG) anyValueMayNotYield(values []ast.Expression) bool { + for _, v := range values { + if cfg.valueMayNotYield(v) { + return true + } + } + return false +} + +// valueMayNotYield reports whether an expression may produce no value, leaving +// its destination untouched. It shares the solver's traversal, so it counts +// anywhere in the tree rather than only at a root: `y = Square(x < 5) + 5` is +// recognized even though the call feeds an operator. +func (cfg *CFG) valueMayNotYield(expr ast.Expression) bool { + return treeCanFail(expr, cfg.nodeMayNotYield) +} + +// nodeMayNotYield classifies one node for diagnostics. Beyond the solver's +// conditions it counts an array read, whose out-of-bounds case preserves the +// destination the same way. That widening belongs here and nowhere else: the +// solver's predicate also decides which programs are valid, so treating +// indexing as failable there would legalize `arr[9] || -1`. The cost is that a +// statically safe read like `arr[0]` also suppresses a real dead-store warning. +func (cfg *CFG) nodeMayNotYield(expr ast.Expression) bool { + if _, ok := expr.(*ast.ArrayRangeExpression); ok { + return true + } + return cfg.conditionMayFail(expr) +} + +// conditionMayFail classifies one node. A script has been typed already, so its +// solver classification is exact. A .pt function body is validated before any +// specialization exists, so nothing is cached and the syntactic shape is the +// only signal; erring toward "may fail" there keeps the diagnostic conservative. +func (cfg *CFG) conditionMayFail(expr ast.Expression) bool { + if cfg.ScriptCompiler != nil { + c := cfg.ScriptCompiler.Compiler + if info := c.ExprCache[key(c.FuncNameMangled, expr)]; info != nil { + return info.HasCondScalar() || info.HasCondAnd() + } + } + if infix, ok := expr.(*ast.InfixExpression); ok { + return infix.Token.IsComparison() || infix.IsLogicalAnd() + } + return false +} + // HasSkippableCallRoot reports whether any value is a bare call to a // user-defined function. Such a callee may leave an output unwritten, so the // caller keeps its previous value and that previous write is not dead. Only diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index a1616eb8..a2375cc7 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -98,6 +98,17 @@ func getValidTestCases() []cfgTestCase { res = x > 0 42`, input: "x = 7\nx = maybeWrite(-1)\nx", }, + { + // A condition below the value root still leaves the whole RHS able + // to yield nothing, so the earlier write stays live. + name: "Nested Condition Below Root", + input: "x = 7\ny = 10\ny = (x < 5) + 5\ny", + }, + { + // An out-of-bounds read fails its lanes and preserves the target. + name: "Out Of Bounds Read Preserves Destination", + input: "arr = [1]\ny = 10\ny = arr[9]\ny", + }, } } @@ -157,6 +168,20 @@ func getErrorTestCases() []cfgTestCase { input: "x = 7\nx = alwaysWrite(3) + 1\nx", errorContains: `unconditional assignment to "x" overwrites a previous value that was never used`, }, + { + // A || yields whenever its final fallback does, so the resolver + // boundary holds and this write is unconditional. + name: "Logical Or With Unconditional Fallback", + input: "x = 7\ny = 10\ny = (x < 5) || 99\ny", + errorContains: `unconditional assignment to "y" overwrites a previous value that was never used`, + }, + { + // An array literal settles a failed cell locally, so the literal + // always yields and the boundary holds. + name: "Array Literal Cell Stays Unconditional", + input: "x = 7\ny = [1]\ny = [x < 5]\ny", + errorContains: `unconditional assignment to "y" overwrites a previous value that was never used`, + }, { name: "Print Use Before Def", input: `"x is", x`, diff --git a/compiler/solver.go b/compiler/solver.go index f935b15c..fd8ff4ea 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -747,29 +747,43 @@ func (ts *TypeSolver) collectDriverRanges(expr ast.Expression, condTypes []Type) return []*RangeInfo{{Name: ident.Value}} } -// expressionCanFail reports whether a value-position expression can propagate -// a failed yield to its parent. Array cells resolve failures locally. A || can -// fail only when its final fallback can; other nodes propagate a root scalar -// comparison/&& or a failure from any child. -func (ts *TypeSolver) expressionCanFail(expr ast.Expression) bool { +// treeCanFail reports whether a value-position expression can propagate a +// failed yield to its parent, asking nodeFails to classify each node. The +// solver and the CFG pass different predicates but share this walk, so the two +// resolver boundaries cannot drift apart: an array literal settles a failed +// cell locally, and a || fails only when its final fallback does. +func treeCanFail(expr ast.Expression, nodeFails func(ast.Expression) bool) bool { if _, ok := expr.(*ast.ArrayLiteral); ok { return false } if infix, ok := ast.IsLogicalOr(expr); ok { - return ts.expressionCanFail(infix.Right) + return treeCanFail(infix.Right, nodeFails) } - info := ts.ExprCache[key(ts.FuncNameMangled, expr)] - if info != nil && (info.HasCondScalar() || info.HasCondAnd()) { + if nodeFails(expr) { return true } for _, child := range ast.ExprChildren(expr) { - if ts.expressionCanFail(child) { + if treeCanFail(child, nodeFails) { return true } } return false } +// conditionPropagates classifies one node for the solver: a root scalar +// comparison or gating &&. Conditions only, because this also decides which +// programs are valid — ||, && and statement conditions all require an operand +// that can fail — so anything that merely fails to yield at runtime, such as an +// out-of-bounds read, must not be folded in. +func (ts *TypeSolver) conditionPropagates(expr ast.Expression) bool { + info := ts.ExprCache[key(ts.FuncNameMangled, expr)] + return info != nil && (info.HasCondScalar() || info.HasCondAnd()) +} + +func (ts *TypeSolver) expressionCanFail(expr ast.Expression) bool { + return treeCanFail(expr, ts.conditionPropagates) +} + func (ts *TypeSolver) validateStatementCondition(expr ast.Expression, condTypes []Type) { // Defer until the operand types resolve (an unresolved cell may still become a // comparison during fixpoint iteration). diff --git a/tests/cond/nested_cond_write.exp b/tests/cond/nested_cond_write.exp new file mode 100644 index 00000000..409fb50d --- /dev/null +++ b/tests/cond/nested_cond_write.exp @@ -0,0 +1,6 @@ +UnderOperatorFalse: 10 +NoCallFalse: 10 +UnderOperatorTrue: 14 +NoCallTrue: 8 +OutOfBounds: 10 +InBodyFalse: 10 diff --git a/tests/cond/nested_cond_write.pt b/tests/cond/nested_cond_write.pt new file mode 100644 index 00000000..6425712d --- /dev/null +++ b/tests/cond/nested_cond_write.pt @@ -0,0 +1,12 @@ +# Always writes its output, so any optionality at a call site comes from the +# caller's own argument, not from this body. +res = Sq(v) + res = v * v + +# The same nested-condition shape inside a function body. A .pt template is +# validated before any specialization exists, so this path has no typed +# classification to consult and relies on the syntactic fallback. +out = NestedInBody(x) + y = 10 + y = (x < 5) + 5 + out = y diff --git a/tests/cond/nested_cond_write.spt b/tests/cond/nested_cond_write.spt new file mode 100644 index 00000000..5262af2e --- /dev/null +++ b/tests/cond/nested_cond_write.spt @@ -0,0 +1,33 @@ +# A condition nested below the value root still makes the write optional: the +# whole right-hand side yields nothing, so the destination keeps its value. The +# earlier write is therefore live and must not be reported as a dead store. +x = 7 +underOperator = 10 +underOperator = Sq(x < 5) + 5 +"UnderOperatorFalse: -underOperator" + +# No call involved at all — the comparison alone can fail to yield. +noCall = 10 +noCall = (x < 5) + 5 +"NoCallFalse: -noCall" + +# Same shapes when the condition holds, so the write actually lands. +y = 3 +underOperatorTrue = 10 +underOperatorTrue = Sq(y < 5) + 5 +"UnderOperatorTrue: -underOperatorTrue" + +noCallTrue = 10 +noCallTrue = (y < 5) + 5 +"NoCallTrue: -noCallTrue" + +# An out-of-bounds read fails its lanes, so the destination survives untouched. +arr = [1] +oob = 10 +oob = arr[9] +"OutOfBounds: -oob" + +# The same nested condition inside a function body, which is validated without +# any type information available. +inBody = NestedInBody(7) +"InBodyFalse: -inBody" From 542fbd29d0b52b903505bbd99e53c143449a14cd Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 27 Jul 2026 00:03:02 +0530 Subject: [PATCH 25/60] fix(compiler)!: classify destination writes per value expression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failable value suspended every destination of its statement, so an unconditional sibling write was also treated as conditional and a genuinely dead store behind it went unreported: a = 10 b = 20 # dead, but previously invisible a, b = x < 5, 30 # only a's write is optional Runtime behavior says the effects are per expression, not per statement: with an empty domain, "a, b = i + 0, 30" still writes b while preserving a. So a statement condition keeps suspending the whole simultaneous assignment, and every other source of a skipped write — an empty driver, a callee that keeps its output, a value that never yields — now marks only the destinations its own expression feeds, placed by the solver's ExprLen. A .pt body has no typing yet, so spans are placeable there only when values pair one to one with destinations; anything else stays statement-wide. BREAKING CHANGE: dead stores previously hidden behind a failable sibling are now reported. The suite carried three, all fresh-destination initializations overwritten by an unconditional sibling read; they are removed here. Co-Authored-By: Claude Opus 5 --- compiler/cfg.go | 124 ++++++++++++++++++++++++++++++--------- compiler/cfg_test.go | 36 ++++++++++++ tests/mem/mem_str.spt | 1 - tests/range_finalize.spt | 2 - 4 files changed, 131 insertions(+), 32 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index 81a071bd..ed64610c 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -172,18 +172,14 @@ func (cfg *CFG) extractStmtEvents(stmt ast.Statement) []VarEvent { evs = append(evs, cfg.collectReads(expr)...) } // 3. Write to the destination variable(s). - // Determine the type of write - writeKind := Write - if cfg.mayNotWrite(s) { - writeKind = ConditionalWrite - } - for _, lhs := range s.Name { + kinds := cfg.destWriteKinds(s) + for i, lhs := range s.Name { // Treat '_' as a discard target: do not record writes or liveness. if lhs.Value == "_" { continue } - ve := VarEvent{Name: lhs.Value, Kind: writeKind, Token: lhs.Tok()} + ve := VarEvent{Name: lhs.Value, Kind: kinds[i], Token: lhs.Tok()} Put(cfg.Scopes, lhs.Value, ve) evs = append(evs, ve) } @@ -214,18 +210,81 @@ func (cfg *CFG) HasRangeExpr(values []ast.Expression) bool { return false } -// mayNotWrite reports whether a statement's destinations may keep their -// previous values, which makes an earlier write to them live. The sources are -// independent: an explicit statement condition, a driver that may iterate zero -// times, a callee that may leave its output alone, and a value that may yield -// nothing. The last two are genuinely distinct — a skipped callee write is -// invisible in the caller's ExprCache, while a failed yield never reaches the -// callee — so both checks are required. -func (cfg *CFG) mayNotWrite(s *ast.LetStatement) bool { - return len(s.Condition) > 0 || - cfg.HasRangeExpr(s.Value) || - cfg.HasSkippableCallRoot(s.Value) || - cfg.anyValueMayNotYield(s.Value) +// destWriteKinds classifies each destination write of a statement. A statement +// condition suspends the whole simultaneous assignment. Every other source of +// a skipped write belongs to one value expression — an empty driver, a callee +// that keeps its output, a value that never yields — and leaves sibling +// expressions' writes untouched, so those mark only the destinations their own +// expression feeds and a dead store behind an unconditional sibling is still +// reported. +func (cfg *CFG) destWriteKinds(s *ast.LetStatement) []EventType { + kinds := make([]EventType, len(s.Name)) + for i := range kinds { + kinds[i] = Write + } + if len(s.Condition) > 0 { + for i := range kinds { + kinds[i] = ConditionalWrite + } + return kinds + } + + spans, known := cfg.valueOutputSpans(s) + if !known { + // Without per-expression arity a failable span cannot be placed, so + // any failable value must suspend every destination. + if cfg.HasRangeExpr(s.Value) || cfg.HasSkippableCallRoot(s.Value) || cfg.anyValueMayNotYield(s.Value) { + for i := range kinds { + kinds[i] = ConditionalWrite + } + } + return kinds + } + + dest := 0 + for vi, v := range s.Value { + failable := cfg.hasRangeExpr(v) || cfg.callRootMaySkip(v) || cfg.valueMayNotYield(v) + for j := 0; j < spans[vi] && dest < len(kinds); j++ { + if failable { + kinds[dest] = ConditionalWrite + } + dest++ + } + } + return kinds +} + +// valueOutputSpans reports how many destinations each value expression feeds. +// Scripts are typed before analysis, so ExprLen is exact. A .pt body has no +// typing yet; values pairing one to one with destinations is the only mapping +// that needs no arity, and anything else falls back to statement-wide +// classification. +func (cfg *CFG) valueOutputSpans(s *ast.LetStatement) ([]int, bool) { + spans := make([]int, len(s.Value)) + if cfg.ScriptCompiler == nil { + if len(s.Value) != len(s.Name) { + return nil, false + } + for i := range spans { + spans[i] = 1 + } + return spans, true + } + + c := cfg.ScriptCompiler.Compiler + total := 0 + for i, v := range s.Value { + info := c.ExprCache[key(c.FuncNameMangled, v)] + if info == nil || info.ExprLen <= 0 { + return nil, false + } + spans[i] = info.ExprLen + total += info.ExprLen + } + if total != len(s.Name) { + return nil, false + } + return spans, true } func (cfg *CFG) anyValueMayNotYield(values []ast.Expression) bool { @@ -276,24 +335,31 @@ func (cfg *CFG) conditionMayFail(expr ast.Expression) bool { } // HasSkippableCallRoot reports whether any value is a bare call to a -// user-defined function. Such a callee may leave an output unwritten, so the -// caller keeps its previous value and that previous write is not dead. Only -// root position qualifies: a call feeding an operator always yields a new -// value. Proving a given callee always writes would need per-specialization -// range types unavailable here, so this stays conservative. +// user-defined function. func (cfg *CFG) HasSkippableCallRoot(values []ast.Expression) bool { for _, v := range values { - call, ok := v.(*ast.CallExpression) - if !ok { - continue - } - if _, builtin := Builtins[call.Function.Value]; !builtin { + if cfg.callRootMaySkip(v) { return true } } return false } +// callRootMaySkip reports whether a value is a bare call to a user-defined +// function. Such a callee may leave an output unwritten, so the caller keeps +// its previous value and that previous write is not dead. Only root position +// qualifies: a call feeding an operator always yields a new value. Proving a +// given callee always writes would need per-specialization range types +// unavailable here, so this stays conservative. +func (cfg *CFG) callRootMaySkip(v ast.Expression) bool { + call, ok := v.(*ast.CallExpression) + if !ok { + return false + } + _, builtin := Builtins[call.Function.Value] + return !builtin +} + // hasRangeExpr checks if an expression contains ranges by looking at ExprCache func (cfg *CFG) hasRangeExpr(e ast.Expression) bool { // Only possible when we have ScriptCompiler with ExprCache diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index a2375cc7..6e607df3 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -109,6 +109,12 @@ func getValidTestCases() []cfgTestCase { name: "Out Of Bounds Read Preserves Destination", input: "arr = [1]\ny = 10\ny = arr[9]\ny", }, + { + // The failable expression suspends only its own destination, and + // b is fresh, so nothing behind the unconditional sibling is dead. + name: "Failable Value Protects Only Its Own Destination", + input: "x = 7\na = 10\na, b = x < 5, 30\na, b", + }, } } @@ -182,6 +188,13 @@ func getErrorTestCases() []cfgTestCase { input: "x = 7\ny = [1]\ny = [x < 5]\ny", errorContains: `unconditional assignment to "y" overwrites a previous value that was never used`, }, + { + // A failable sibling no longer suspends the whole statement, so + // the dead store behind the unconditional literal is reported. + name: "Failable Sibling Does Not Protect Unconditional Write", + input: "x = 7\na = 10\nb = 20\na, b = x < 5, 30\na, b", + errorContains: `unconditional assignment to "b" overwrites a previous value that was never used`, + }, { name: "Print Use Before Def", input: `"x is", x`, @@ -242,6 +255,29 @@ func assertHasExpectedError(t *testing.T, errors []*token.CompileError, expected } } +// A ranged expression suspends its own destination only: the sibling literal +// writes even when the domain is empty, so the store behind it is dead. Range +// classification needs the solver, so this runs the full script pipeline +// rather than the bare-CFG harness. +func TestEmptyDomainDoesNotProtectSiblingWrite(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + cc := NewCodeCompiler(ctx, "emptyDomainSibling", "", ast.NewCode()) + program := parseInput(t, "emptyDomainSibling", "i = 0:0\na = 1\nb = 2\na, b = i + 0, 30\na, b") + sc := NewScriptCompiler(ctx, program, cc, make(map[string]*Func), cc.Compiler.ExprCache) + errs := sc.Compile() + require.NotEmpty(t, errs, "the dead store behind the sibling literal must be reported") + + msgs := make([]string, len(errs)) + for i, e := range errs { + msgs[i] = e.Msg + } + joined := strings.Join(msgs, "\n") + assert.Contains(t, joined, `unconditional assignment to "b"`) + assert.NotContains(t, joined, `to "a"`, "the ranged destination must stay protected") +} + func TestValidateFuncOutputsNotDeadStore(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() diff --git a/tests/mem/mem_str.spt b/tests/mem/mem_str.spt index 069b5787..579bdcf4 100644 --- a/tests/mem/mem_str.spt +++ b/tests/mem/mem_str.spt @@ -198,7 +198,6 @@ rangeStatic = 1 > 0 getStaticAt(rangeArg + 0) "RangeStaticConditional: -rangeStatic" rangeStatic = getHeap() -rangeSibling = "unset" rangeStatic, rangeSibling = getStaticAt(rangeArg), rangeStatic "RangeStaticSimultaneous: -rangeStatic -rangeSibling" diff --git a/tests/range_finalize.spt b/tests/range_finalize.spt index 80713938..cff544ce 100644 --- a/tests/range_finalize.spt +++ b/tests/range_finalize.spt @@ -74,7 +74,6 @@ formattedWidth = "|-widthValue%(-widthRange)d|" # Ranged computations preserve simultaneous-assignment reads. simRange = 0:3 first = 77 -second = 0 first, second = simRange + 0, first "SimultaneousRange: -first -second" @@ -84,6 +83,5 @@ first, second = simArray[simRange], first "SimultaneousArray: -first -second" text = "old" -otherText = "unset" text, otherText = "Marker -markerRange", text "SimultaneousString: -text -otherText" From a74e8f13fab873e8fe1b4f0a4289c4bb840941dc Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 27 Jul 2026 00:28:45 +0530 Subject: [PATCH 26/60] feat(compiler)!: print Range descriptors as values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assignment copies a bare Range, but print still consumed it as a driver, so the model was inconsistent at its most visible point: `j = i` preserved the descriptor while `"j is -j"` iterated it. Print is a sink, not an operation — a value prints as itself. A bare Range print argument or main interpolation marker now formats the descriptor as start:stop or start:stop:step, restoring range_i64_str, which this branch had removed. Computations are unchanged and still drive the print loop: `i + 0`, `Square(i)`, and collectors iterate exactly as before, and a bare name bound as a driver by a sibling computation prints its per-iteration scalar, mirroring how a statement condition binds a driver for its values. A width or precision operand is consumed as a number, so a named Range in a specifier remains a driver; only specifier identifiers now contribute string drivers, split out in formatMarkerIdentifiers. BREAKING CHANGE: printing a bare Range or a bare main marker emits the descriptor once instead of one line per yield, and two bare ranges print on one line instead of a cartesian expansion. Iterate explicitly with an operation: `"each", i + 0`. Co-Authored-By: Claude Opus 5 --- README.md | 16 +++++----- compiler/cfuncs.go | 7 +++++ compiler/compiler.go | 11 +++++++ compiler/format.go | 25 ++++++++++----- compiler/solver.go | 57 ++++++++++++++++++++++++++++++++++- docs/Pluto Memory Model.md | 3 +- docs/Pluto Range Semantics.md | 37 ++++++++++------------- runtime/runtime.c | 17 +++++++++++ tests/math/print_func.exp | 29 +++--------------- tests/math/print_func.spt | 9 +++--- tests/range.exp | 1 + tests/range.spt | 6 ++-- tests/range_finalize.exp | 14 ++++----- tests/range_finalize.spt | 20 +++++++++--- 14 files changed, 171 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index df412d55..19c4c48c 100644 --- a/README.md +++ b/README.md @@ -182,15 +182,17 @@ copy = i # same bounds, independent named driver values = [copy] # [0 1 2 3 4] last = i + 0 # 4 lastSquare = Square(i) # 16 +i # prints the descriptor: 0:5 ``` -A bare range at an assignment root is a value and can be copied. Using it in -an operation creates a ranged computation: the assignment keeps the final -yield, brackets materialize all yields into an array, and passing it to a -template executes the call once for each value. The compiler can map these -operations to SIMD instructions — this is range-driven auto-vectorization. -Each named Range binding is a distinct driver: different names form a -cartesian domain when consumed together, even when their bounds are equal. +A bare range is a value: assignment copies it and print shows its descriptor. +Using it in an operation creates a ranged computation: the assignment keeps +the final yield, brackets materialize all yields into an array, and passing it +to a template executes the call once for each value. The compiler can map +these operations to SIMD instructions — this is range-driven +auto-vectorization. Each named Range binding is a distinct driver: different +names form a cartesian domain when consumed together, even when their bounds +are equal. Range-indexed array access follows the same rule: diff --git a/compiler/cfuncs.go b/compiler/cfuncs.go index 059bb19e..eca0f5d0 100644 --- a/compiler/cfuncs.go +++ b/compiler/cfuncs.go @@ -9,6 +9,9 @@ const ( FREE = "free" STRDUP = "strdup" + // Range functions + RANGE_I64_STR = "range_i64_str" + // Scalar string functions F64_STR = "f64_str" F32_STR = "f32_str" @@ -79,6 +82,10 @@ func (c *Compiler) GetFnType(name string) llvm.Type { case STRDUP: return llvm.FunctionType(charPtr, []llvm.Type{charPtr}, false) + // Range functions + case RANGE_I64_STR: + return llvm.FunctionType(charPtr, []llvm.Type{i64, i64, i64}, false) + // Scalar string functions case F64_STR: return llvm.FunctionType(charPtr, []llvm.Type{f64}, false) diff --git a/compiler/compiler.go b/compiler/compiler.go index 9bd5fddd..215d3cb6 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -3364,6 +3364,13 @@ func (c *Compiler) rangeComponents(r llvm.Value) (start, stop, step llvm.Value) return } +// rangeStrArg formats a Range descriptor as "start:stop" or "start:stop:step". +func (c *Compiler) rangeStrArg(s *Symbol) llvm.Value { + start, stop, step := c.rangeComponents(s.Val) + fnType, fn := c.GetCFunc(RANGE_I64_STR) + return c.builder.CreateCall(fnType, fn, []llvm.Value{start, stop, step}, RANGE_I64_STR) +} + func (c *Compiler) floatStrArg(s *Symbol) llvm.Value { if s.Type.(Float).Width == 32 { fnTy, fn := c.GetCFunc(F32_STR) // char* f32_str(float) @@ -3681,6 +3688,10 @@ func (c *Compiler) appendPrintSymbol(s *Symbol, expr ast.Expression, formatStr * if printOwnsHeapString(s, expr) { *toFree = append(*toFree, s.Val) } + case RangeKind: + strPtr := c.rangeStrArg(s) + *args = append(*args, strPtr) + *toFree = append(*toFree, strPtr) default: *args = append(*args, s.Val) } diff --git a/compiler/format.go b/compiler/format.go index bdabf5b3..6634df9f 100644 --- a/compiler/format.go +++ b/compiler/format.go @@ -48,6 +48,9 @@ func defaultSpecifier(t Type) (string, error) { return "%s", nil case StrKind: return "%s", nil + case RangeKind: + // Range descriptors are converted to char* via range_i64_str + return "%s", nil case ArrayKind: // Arrays are converted to char* via runtime helpers return "%s", nil @@ -603,6 +606,10 @@ func (c *Compiler) formatAsString(mainSym *Symbol, result *formattedMarker) bool strPtr := c.tableStrArg(mainSym) result.args = append(result.args, strPtr) result.toFree = append(result.toFree, strPtr) + case RangeKind: + strPtr := c.rangeStrArg(mainSym) + result.args = append(result.args, strPtr) + result.toFree = append(result.toFree, strPtr) case StructKind: fmtStr, fmtArgs, fmtFree := c.structFormatArgs(mainSym) result.text = fmtStr @@ -873,10 +880,11 @@ func (c *Compiler) structFormatArgs(s *Symbol) (fmtStr string, args []llvm.Value } // formatMarkerIdentifiers returns the identifiers read by resolved markers in -// source order. Dynamic width/precision identifiers are included because a -// named Range there contributes the same iteration driver as a main marker. -func formatMarkerIdentifiers(value string, isDefined func(string) bool) []string { - var identifiers []string +// source order, separating main-marker identifiers from dynamic +// width/precision identifiers. A main marker formats its value whatever the +// type, while a specifier operand is consumed as a number, so only specifier +// identifiers can turn a named Range into an iteration driver. +func formatMarkerIdentifiers(value string, isDefined func(string) bool) (mains, specs []string) { runes := []rune(value) for i := 0; i < len(runes); i++ { if runes[i] == '\\' { @@ -891,7 +899,7 @@ func formatMarkerIdentifiers(value string, isDefined func(string) bool) []string if !isDefined(mainID) { continue } - identifiers = append(identifiers, mainID) + mains = append(mains, mainID) if end >= len(runes) || runes[end] != '%' { i = end - 1 @@ -900,17 +908,18 @@ func formatMarkerIdentifiers(value string, isDefined func(string) bool) []string spec, _ := parseSpecifierSyntax(token.Token{}, value, runes, end) for _, specID := range spec.ids { if isDefined(specID) { - identifiers = append(identifiers, specID) + specs = append(specs, specID) } } if spec.end > end { i = spec.end - 1 } } - return identifiers + return mains, specs } // hasValidMarkers checks if a format string contains a resolved marker. func hasValidMarkers(value string, isDefined func(string) bool) bool { - return len(formatMarkerIdentifiers(value, isDefined)) > 0 + mains, specs := formatMarkerIdentifiers(value, isDefined) + return len(mains)+len(specs) > 0 } diff --git a/compiler/solver.go b/compiler/solver.go index fd8ff4ea..cad18a4e 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -486,6 +486,13 @@ func (ts *TypeSolver) HandleCallRanges(call *ast.CallExpression) (ranges []*Rang ranges, args, changed := ts.collectExprRanges(call.Arguments) info := ts.ExprCache[key(ts.FuncNameMangled, call)] + // Print is a sink rather than an operation, so a bare descriptor argument + // prints as a value; only ranges bound by sibling computations remain + // drivers of the print loop. + if call.Function.Value == Print { + ranges = ts.resolveBareRangePrintArgs(call.Arguments) + } + // A surrounding collector consumes these ranges and invokes the call once // per scalar yield, so that scalar callee variant must exist even though the // immediate call selected a range specialization. Promoting an argument to @@ -519,6 +526,50 @@ func (ts *TypeSolver) HandleCallRanges(call *ast.CallExpression) (ranges []*Rang return } +// resolveBareRangePrintArgs keeps each bare Range print argument a descriptor +// value unless a sibling computation binds that name as a driver, mirroring +// resolveBareRangeAssignment at assignment roots. Returns the drivers that +// remain for the print loop, in source order. +func (ts *TypeSolver) resolveBareRangePrintArgs(args []ast.Expression) []*RangeInfo { + drivers := []*RangeInfo{} + for _, arg := range args { + if ts.bareRangeDescriptorArg(arg) { + continue + } + if info := ts.ExprCache[key(ts.FuncNameMangled, arg)]; info != nil { + drivers = mergeUses(drivers, info.Ranges) + } + } + + for _, arg := range args { + if !ts.bareRangeDescriptorArg(arg) { + continue + } + info := ts.ExprCache[key(ts.FuncNameMangled, arg)] + if ident, ok := arg.(*ast.Identifier); ok && rangeDriverNamed(drivers, ident.Value) { + drivers = mergeUses(drivers, info.Ranges) + continue + } + info.Ranges = nil + info.HasRanges = false + info.Rewrite = nil + } + return drivers +} + +// bareRangeDescriptorArg reports whether a print argument is a complete Range +// descriptor: a range literal or a name bound to a Range. +func (ts *TypeSolver) bareRangeDescriptorArg(arg ast.Expression) bool { + switch a := arg.(type) { + case *ast.RangeLiteral: + return true + case *ast.Identifier: + typ, ok := ts.GetIdentifier(a.Value) + return ok && typ.Kind() == RangeKind + } + return false +} + // isBareRangeExpr reports whether expr is a driver that a function can consume // through a range-specialized variant. A ranged array access is eligible only // while it is an immediate call argument; collectCallArgs gives that call site @@ -565,7 +616,11 @@ func (ts *TypeSolver) HandleIdentifierRanges(ident *ast.Identifier) (ranges []*R // formatting markers so interpolation follows the same driver semantics as an // ordinary identifier expression. func (ts *TypeSolver) HandleStringLiteralRanges(lit *ast.StringLiteral) (ranges []*RangeInfo, rew ast.Expression) { - for _, name := range formatMarkerIdentifiers(lit.Token.Literal, ts.isDefined) { + // A main marker formats its value, so a bare Range there stays a + // descriptor. Width and precision operands are consumed as numbers, which + // makes a named Range in a specifier an iteration driver. + _, specs := formatMarkerIdentifiers(lit.Token.Literal, ts.isDefined) + for _, name := range specs { typ, ok := ts.GetIdentifier(name) if !ok || typ.Kind() != RangeKind { continue diff --git a/docs/Pluto Memory Model.md b/docs/Pluto Memory Model.md index 3221344b..4c267e46 100644 --- a/docs/Pluto Memory Model.md +++ b/docs/Pluto Memory Model.md @@ -11,7 +11,8 @@ This document describes Pluto's semantic model and compares it with other major value (an element or owned subarray); `s = [arr[i]]` materializes every selected value. 4. **Ranges are Descriptor Values:** `j = i` copies a Range; consuming it in - `x = i + 1`, `arr[i]`, a call, print, interpolation, or `[]` drives a loop. + `x = i + 1`, `arr[i]`, a call, or `[]` drives a loop. Print and main + interpolation markers format the descriptor itself. 5. **Empty-Domain Initialization:** An empty Range descriptor still assigns. An empty ranged computation leaves an existing destination unchanged and a fresh destination at its type's zero value. diff --git a/docs/Pluto Range Semantics.md b/docs/Pluto Range Semantics.md index 74bbce27..27ebd825 100644 --- a/docs/Pluto Range Semantics.md +++ b/docs/Pluto Range Semantics.md @@ -44,9 +44,9 @@ last = i + 0 `copy` is a Range descriptor; `last` is the scalar `4`. Use an operation such as `+ 0` when migrating code that intended the old final-value behavior. -This change can be silent for a fresh destination. A later print, -interpolation, call, index, or collector consumes the copied Range and runs its -whole domain; printing an empty copied Range emits no line. Assigning a Range +This change can be silent for a fresh destination. A later call, index, or +collector consumes the copied Range and runs its whole domain, while print and +interpolation format the descriptor itself. Assigning a Range to an existing scalar is instead rejected as a type-changing reassignment. Descriptor copies are unconditional writes and participate in the ordinary dead-store checks. Range-indexed expressions such as `last = data[i]` are @@ -54,9 +54,12 @@ unchanged because indexing is already a ranged computation. ## Ranges And Drivers -A range identifier consumed by an operator, array index, interpolation, -print, collector, statement condition, or function argument contributes an -iteration driver. A range-indexed array access is itself a ranged computation. +A range identifier consumed by an operator, array index, collector, statement +condition, or function argument contributes an iteration driver. Print +arguments and main interpolation markers are display positions, not +consumers: a bare Range there formats its descriptor. A width or precision +operand is consumed as a number, so a named Range in a specifier still +drives. A range-indexed array access is itself a ranged computation. Multiple distinct drivers form a nested iteration domain in source order: the first distinct driver is outermost and the last is innermost. Repeated use of the same driver name refers to the same loop, not a nested copy. @@ -121,25 +124,17 @@ existing destination is unchanged. Outside `[]`, an out-of-bounds selection point yields nothing, so the last valid selected value wins. Inside `[]`, failed cells are zero-filled to preserve collection shape, as described below. -Print statements consume drivers rather than exposing their internal -descriptor; Range descriptors have no printable representation. Printing `i` -emits one line per yielded value. Printing distinct -drivers together uses their normal cartesian domain, while repeated uses of -the same driver share one loop: +Print is a sink, not an operation: a bare Range argument or main marker +formats the descriptor as `start:stop` (or `start:stop:step`), exactly as the +value it is. Computations still drive the print loop, and a bare name bound as +a driver by a sibling computation prints its per-iteration scalar: ```pluto i = 0:2 j = 2:4 -i, j -``` - -prints: - -```text -0 2 -0 3 -1 2 -1 3 +i, j # one line: 0:2 2:4 +i + 0, j + 0 # cartesian: 0 2 / 0 3 / 1 2 / 1 3 +i, Square(i) # i is driven: 0 0 / 1 1 ``` Distinct drivers nest in source order, so collecting over two ranges walks diff --git a/runtime/runtime.c b/runtime/runtime.c index 4096388d..f06d9e5d 100644 --- a/runtime/runtime.c +++ b/runtime/runtime.c @@ -1,3 +1,4 @@ +#include #include #include #include @@ -172,6 +173,22 @@ char *str_hex(const char *s, int64_t byte_limit, int32_t uppercase, int32_t alte return result; } +// Format a range descriptor [s..t) with step p as a NUL-terminated string. +// Caller is responsible for free()ing the returned buffer. +char *range_i64_str(int64_t s, int64_t t, int64_t p) { + // Reserve enough space: up to 20 digits per number, two colons, plus NUL. + // 3*21 + 2 = 65 bytes is plenty. + char *buf = malloc(65); + if (!buf) return NULL; + if (p == 1) { + // omit the default ":1" + snprintf(buf, 65, "%" PRId64 ":%" PRId64, s, t); + } else { + snprintf(buf, 65, "%" PRId64 ":%" PRId64 ":%" PRId64, s, t, p); + } + return buf; +} + /* ---------- portable float formatting ---------- */ /* Canonicalize special values across platforms: - NaN => "NaN" (no sign) diff --git a/tests/math/print_func.exp b/tests/math/print_func.exp index d859bed8..64be9257 100644 --- a/tests/math/print_func.exp +++ b/tests/math/print_func.exp @@ -13,30 +13,11 @@ 1 1 2 4 3 9 -0 1 -0 2 -1 1 -1 2 -2 1 -2 2 -3 1 -3 2 -0 2 -0 3 -1 2 -1 3 -2 2 -2 3 -3 2 -3 3 -0 4 -0 9 -1 4 -1 9 -2 4 -2 9 -3 4 -3 9 +0:4 1:3 +0:4 2 +0:4 3 +0:4 4 +0:4 9 1 4 2 9 1 0 diff --git a/tests/math/print_func.spt b/tests/math/print_func.spt index 3d300ac7..4a3c92a9 100644 --- a/tests/math/print_func.spt +++ b/tests/math/print_func.spt @@ -17,17 +17,18 @@ Square(i) # Named range both direct and in function call i, Square(i) -# Two bare ranges form a cartesian print domain. +# Two bare ranges print as descriptor values on one line. j = 1:3 i, j -# One bare range, one expression - should iterate +# A computation drives only its own name; the undriven bare range stays a +# descriptor, printed once per driver yield. i, j + 1 -# Different ranges - nested iteration (4 x 2 = 8 lines) +# The undriven bare range prints its descriptor beside each computed value. i, Square(j + 1) -# Same range used bare and in expression - single loop +# Same driver bare and in a computation - the computation binds it, single loop j, Square(j + 1) # Regression test: mixed expression with function not called with scalars before diff --git a/tests/range.exp b/tests/range.exp index 340a306d..d4e337d7 100644 --- a/tests/range.exp +++ b/tests/range.exp @@ -1,5 +1,6 @@ [0 1 2 3 4] [1 2 3 4 5 6 7 8 9] [0 2] [-3 -2 -1 0 1 2 3 4] [0 1 2 3 4 5] [0 1 2 3 4 5] [1 2 3 4 5 6 7] [6] [0 3 6] +Descriptors 0:2 2:4 Cartesian 0 2 Cartesian 0 3 Cartesian 1 2 diff --git a/tests/range.spt b/tests/range.spt index 6a959442..a1453771 100644 --- a/tests/range.spt +++ b/tests/range.spt @@ -17,7 +17,9 @@ u = 0:b:d [i], [j], [k], [u] -# Distinct bare drivers in one print form a cartesian iteration domain. +# Bare descriptors print as values on one line; computations over distinct +# drivers still form a cartesian iteration domain. p = 0:2 q = 2:4 -"Cartesian", p, q +"Descriptors", p, q +"Cartesian", p + 0, q + 0 diff --git a/tests/range_finalize.exp b/tests/range_finalize.exp index 5184bff0..f571fc9b 100644 --- a/tests/range_finalize.exp +++ b/tests/range_finalize.exp @@ -10,17 +10,15 @@ EmptyComputeExisting: 91 Collected: [0 1 2 3 4] CallFinal: 20 ReturnedRange: [2 3 4] -MarkerFinal: Marker 2 -MarkerEach 0 -MarkerEach 1 -MarkerEach 2 +MarkerFinal: Marker 0:3 +MarkerDescriptor 0:3 MarkerWidthFinal: | 7| MarkerWidthEach: |7| MarkerWidthEach: | 7| MarkerWidthEach: | 7| -LiteralEach 0 -LiteralEach 1 -LiteralEach 2 +LiteralDescriptor 0:3 +EmptyDescriptor: 3:3 +SteppedDescriptor: 6:0:-2 SimultaneousRange: 2 77 SimultaneousArray: 30 88 -SimultaneousString: Marker 2 old +SimultaneousString: Marker 0:3 old diff --git a/tests/range_finalize.spt b/tests/range_finalize.spt index cff544ce..bfe702f0 100644 --- a/tests/range_finalize.spt +++ b/tests/range_finalize.spt @@ -55,21 +55,29 @@ returnedRange = makeRange() returnedValues = [returnedRange] "ReturnedRange: -returnedValues" -# Formatting markers are ordinary driver uses, including at print roots. +# A bare Range in a main marker formats the descriptor: the string is one +# value with no drivers, in interpolation exactly as in assignment. markerRange = 0:3 formattedFinal = "Marker -markerRange" "MarkerFinal: -formattedFinal" -"MarkerEach -markerRange" +"MarkerDescriptor -markerRange" -# Range markers used for dynamic formatting parameters are drivers too. +# A width or precision operand is consumed as a number, so a named Range there +# is still an iteration driver. widthRange = 1:4 widthValue = 7 formattedWidth = "|-widthValue%(-widthRange)d|" "MarkerWidthFinal: -formattedWidth" "MarkerWidthEach: |-widthValue%(-widthRange)d|" -# A direct literal in print position is also consumed as a driver. -"LiteralEach", 0:3 +# A range literal in print position is a descriptor value too. +"LiteralDescriptor", 0:3 + +# Descriptors print their exact bounds, including empty and stepped forms. +emptyDescriptor = 3:3 +"EmptyDescriptor:", emptyDescriptor +steppedDescriptor = 6:0:-2 +"SteppedDescriptor:", steppedDescriptor # Ranged computations preserve simultaneous-assignment reads. simRange = 0:3 @@ -82,6 +90,8 @@ first = 88 first, second = simArray[simRange], first "SimultaneousArray: -first -second" +# A bare-marker string is a single value, so this write is unconditional; the +# sibling still reads text's pre-statement value. text = "old" text, otherText = "Marker -markerRange", text "SimultaneousString: -text -otherText" From cbb8614f3a1452fb7a2bfbc454a802673cb9a77c Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 27 Jul 2026 02:19:44 +0530 Subject: [PATCH 27/60] fix(compiler): decide print descriptor arguments before rewriting collectExprRanges rewrites a bare Range literal into a loop iterator before print classification runs, so descriptor-izing the literal afterwards removed its driver while the rewritten call still referenced the now-unbound iterator. With a sibling computation driving the loop, `0:3, i + 0` crashed with a nil dereference. Collect print arguments through a sink-aware collector instead: computations contribute drivers exactly as before, a bare name a sibling binds is rewritten as a driver, and every other bare descriptor keeps its original expression so lowering materializes the Range aggregate inside or outside the loop alike. Co-Authored-By: Claude Opus 5 --- compiler/solver.go | 54 +++++++++++++++++++++++---------------- tests/math/print_func.exp | 4 +++ tests/math/print_func.spt | 4 +++ 3 files changed, 40 insertions(+), 22 deletions(-) diff --git a/compiler/solver.go b/compiler/solver.go index cad18a4e..60289bc4 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -483,15 +483,16 @@ func (ts *TypeSolver) collectExprRanges(exprs []ast.Expression) (ranges []*Range // HandleCallRanges processes function call expressions, handling all arguments // and merging their range information for proper loop generation. func (ts *TypeSolver) HandleCallRanges(call *ast.CallExpression) (ranges []*RangeInfo, rew ast.Expression) { - ranges, args, changed := ts.collectExprRanges(call.Arguments) - info := ts.ExprCache[key(ts.FuncNameMangled, call)] - - // Print is a sink rather than an operation, so a bare descriptor argument - // prints as a value; only ranges bound by sibling computations remain - // drivers of the print loop. + var args []ast.Expression + var changed bool + // Print is a sink rather than an operation, so its bare descriptor + // arguments print as values and must not be rewritten into loop iterators. if call.Function.Value == Print { - ranges = ts.resolveBareRangePrintArgs(call.Arguments) + ranges, args, changed = ts.collectPrintArgRanges(call) + } else { + ranges, args, changed = ts.collectExprRanges(call.Arguments) } + info := ts.ExprCache[key(ts.FuncNameMangled, call)] // A surrounding collector consumes these ranges and invokes the call once // per scalar yield, so that scalar callee variant must exist even though the @@ -526,35 +527,44 @@ func (ts *TypeSolver) HandleCallRanges(call *ast.CallExpression) (ranges []*Rang return } -// resolveBareRangePrintArgs keeps each bare Range print argument a descriptor -// value unless a sibling computation binds that name as a driver, mirroring -// resolveBareRangeAssignment at assignment roots. Returns the drivers that -// remain for the print loop, in source order. -func (ts *TypeSolver) resolveBareRangePrintArgs(args []ast.Expression) []*RangeInfo { - drivers := []*RangeInfo{} - for _, arg := range args { +// collectPrintArgRanges collects drivers for a print statement, deciding +// descriptor versus driver before any argument is rewritten. Computations +// contribute drivers exactly as in collectExprRanges. A bare descriptor +// argument whose name a sibling binds stays a driver; any other bare +// descriptor keeps its original expression — rewriting it first would leave +// the print loop referencing an iterator no loop binds — and prints as a +// value, mirroring resolveBareRangeAssignment at assignment roots. +func (ts *TypeSolver) collectPrintArgRanges(call *ast.CallExpression) (ranges []*RangeInfo, args []ast.Expression, changed bool) { + args = make([]ast.Expression, len(call.Arguments)) + for i, arg := range call.Arguments { if ts.bareRangeDescriptorArg(arg) { + args[i] = arg continue } - if info := ts.ExprCache[key(ts.FuncNameMangled, arg)]; info != nil { - drivers = mergeUses(drivers, info.Ranges) - } + argRanges, rew := ts.HandleRanges(arg) + args[i] = rew + changed = changed || rew != arg + ranges = mergeUses(ranges, argRanges) } - for _, arg := range args { + for i, arg := range call.Arguments { if !ts.bareRangeDescriptorArg(arg) { continue } - info := ts.ExprCache[key(ts.FuncNameMangled, arg)] - if ident, ok := arg.(*ast.Identifier); ok && rangeDriverNamed(drivers, ident.Value) { - drivers = mergeUses(drivers, info.Ranges) + if ident, ok := arg.(*ast.Identifier); ok && rangeDriverNamed(ranges, ident.Value) { + argRanges, rew := ts.HandleRanges(arg) + args[i] = rew + changed = changed || rew != arg + ranges = mergeUses(ranges, argRanges) continue } + info := ts.ExprCache[key(ts.FuncNameMangled, arg)] info.Ranges = nil info.HasRanges = false info.Rewrite = nil + args[i] = arg } - return drivers + return ranges, args, changed } // bareRangeDescriptorArg reports whether a print argument is a complete Range diff --git a/tests/math/print_func.exp b/tests/math/print_func.exp index 64be9257..0ab3fa29 100644 --- a/tests/math/print_func.exp +++ b/tests/math/print_func.exp @@ -22,3 +22,7 @@ 2 9 1 0 2 2 +0:9 0 +0:9 1 +0:9 2 +0:9 3 diff --git a/tests/math/print_func.spt b/tests/math/print_func.spt index 4a3c92a9..9f677222 100644 --- a/tests/math/print_func.spt +++ b/tests/math/print_func.spt @@ -35,3 +35,7 @@ j, Square(j + 1) # SumSquares(k, k) creates Range variant, but k+1 forces iteration needing scalar variant k = 0:2 k + 1, SumSquares(k, k) + +# Regression: a descriptor literal must not be rewritten into a loop iterator +# when a sibling computation drives the print loop. +0:9, i + 0 From 852572c1be977301e1836f7292dacb2057a60f96 Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 27 Jul 2026 02:19:44 +0530 Subject: [PATCH 28/60] fix(compiler)!: classify collector writes as unconditional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A collector materializes an array even when its domain is empty — the destination receives [] rather than keeping its previous value — but dead-store analysis recursed into the literal's cells and treated the write as conditional, hiding a genuinely dead store behind it: c = [9] # dead: the next write always lands c = [i + 0] # [] when i's domain is empty Stop at the array-literal boundary, the same resolver boundary treeCanFail already uses: cells settle their failures locally, so the literal always yields. BREAKING CHANGE: dead stores previously hidden behind a ranged collector are now reported. Co-Authored-By: Claude Opus 5 --- compiler/cfg.go | 10 +++------- compiler/cfg_test.go | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index ed64610c..938d57da 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -394,13 +394,9 @@ func (cfg *CFG) hasRangeExpr(e ast.Expression) bool { } return false case *ast.ArrayLiteral: - for _, row := range t.Rows { - for _, cell := range row { - if cfg.hasRangeExpr(cell) { - return true - } - } - } + // A collector materializes an array even when its domain is empty, so + // its write is unconditional; cells resolve failures locally. Same + // boundary as treeCanFail. return false case *ast.StructLiteral: for _, cell := range t.Row { diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index 6e607df3..af9d6f10 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -255,6 +255,21 @@ func assertHasExpectedError(t *testing.T, errors []*token.CompileError, expected } } +// A collector materializes an array even over an empty domain, so its write is +// unconditional and the store behind it is dead. Range classification needs the +// solver, so this runs the full script pipeline. +func TestCollectorWriteIsUnconditional(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + cc := NewCodeCompiler(ctx, "collectorWrite", "", ast.NewCode()) + program := parseInput(t, "collectorWrite", "i = 0:0\nc = [9]\nc = [i + 0]\nc") + sc := NewScriptCompiler(ctx, program, cc, make(map[string]*Func), cc.Compiler.ExprCache) + errs := sc.Compile() + require.NotEmpty(t, errs, "the dead store behind the collector must be reported") + assert.Contains(t, errs[0].Msg, `unconditional assignment to "c"`) +} + // A ranged expression suspends its own destination only: the sibling literal // writes even when the domain is empty, so the store behind it is dead. Range // classification needs the solver, so this runs the full script pipeline From a2730e8ec5321c5050edc18dc6d86dc11eeb1351 Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 27 Jul 2026 02:19:44 +0530 Subject: [PATCH 29/60] docs(strings): describe Range markers as descriptor formatting The formatting doc still said a Range main marker iterates and keeps or prints each yield. Describe the current model: a main marker formats the descriptor and contributes no iteration, width and precision operands remain drivers, a sibling-bound name formats its per-iteration scalar, and an explicit numeric conversion such as -i%d is a compile error rather than an iteration trigger. Co-Authored-By: Claude Opus 5 --- docs/Pluto String and Formatting Semantics.md | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/Pluto String and Formatting Semantics.md b/docs/Pluto String and Formatting Semantics.md index 98065412..f9c3957b 100644 --- a/docs/Pluto String and Formatting Semantics.md +++ b/docs/Pluto String and Formatting Semantics.md @@ -63,17 +63,25 @@ width = 5 "-missing%(-width)d" # -missing%(5)d ``` -A marker that reads a `Range` participates in normal range execution. At an -assignment root, formatting runs once per yield and the final owned string is -kept; in print position, one formatted line is emitted per yield. Range -identifiers used for dynamic width or precision are drivers too. +A main marker formats its value whatever the type, so a bare `Range` formats +its descriptor and contributes no iteration: the string is a single value in +assignment and in print alike. A Range identifier used for dynamic width or +precision is consumed as a number, which makes it an iteration driver. An +explicit numeric conversion on a Range main marker, such as `-i%d`, is a +compile error — a descriptor is not a number; iterate with a computation +instead. ```pluto i = 0:3 -last = "item -i" # "item 2" -"item -i" # prints item 0, item 1, item 2 on separate lines +s = "item -i" # "item 0:3" +"item -i" # prints item 0:3 on one line +w = 1:3 +"|-n%(-w)d|" # one line per width: |7| then | 7| ``` +When a sibling computation in the same statement binds the marker's name as a +driver, the marker formats that iteration's scalar yield instead. + ## Literal percent and strict formatting A `%` outside a resolved marker is ordinary text. A `%` immediately after a From b53211bb3a21246a51fcfc9de6a6eac938737c5e Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 27 Jul 2026 02:46:13 +0530 Subject: [PATCH 30/60] fix(compiler)!: classify ranged-gate collector writes as unconditional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scalar gate can skip its whole statement, but a ranged gate always runs its loop, and an inline collector under it is closed and committed even when no iteration is admitted — including an empty domain — so the destination is overwritten regardless: c = [9] # dead: the gated collector always commits c = i < 0 [i] # [] when nothing is admitted Classify per slot under a gate: an inline collector under a fully ranged gate is an unconditional write, scalar-gated statements stay conditional throughout, and sibling slots commit only on admitted iterations so they stay conditional too. Block-layout literals keep the conservative classification. BREAKING CHANGE: dead stores behind a ranged-gate collector are now reported. The suite carried two, both initializations demonstrating replacement; they now read the value first, which also strengthens the leak coverage they were written for. Co-Authored-By: Claude Opus 5 --- compiler/cfg.go | 39 +++++++++++++++++++++++++++++++++++--- compiler/cfg_test.go | 22 +++++++++++++++++++++ tests/array/cond_accum.exp | 2 ++ tests/array/cond_accum.spt | 5 ++++- 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index 938d57da..690efbd2 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -223,9 +223,7 @@ func (cfg *CFG) destWriteKinds(s *ast.LetStatement) []EventType { kinds[i] = Write } if len(s.Condition) > 0 { - for i := range kinds { - kinds[i] = ConditionalWrite - } + cfg.applyGateKinds(s, kinds) return kinds } @@ -254,6 +252,41 @@ func (cfg *CFG) destWriteKinds(s *ast.LetStatement) []EventType { return kinds } +// applyGateKinds classifies destinations under a statement gate. A scalar gate +// can skip the whole statement, so every write is conditional. A ranged gate +// always runs its loop, and an inline collector under it is closed and +// committed even when no iteration is admitted — including an empty domain — +// so that slot's write is unconditional. Sibling slots commit only on admitted +// iterations and stay conditional. +func (cfg *CFG) applyGateKinds(s *ast.LetStatement, kinds []EventType) { + rangedGate := false + for _, cond := range s.Condition { + if cfg.hasRangeExpr(cond) { + rangedGate = true + break + } + } + spans, known := cfg.valueOutputSpans(s) + if !rangedGate || !known { + for i := range kinds { + kinds[i] = ConditionalWrite + } + return + } + + dest := 0 + for vi, v := range s.Value { + lit, isLit := v.(*ast.ArrayLiteral) + committed := isLit && len(lit.Rows) == 1 + for j := 0; j < spans[vi] && dest < len(kinds); j++ { + if !committed { + kinds[dest] = ConditionalWrite + } + dest++ + } + } +} + // valueOutputSpans reports how many destinations each value expression feeds. // Scripts are typed before analysis, so ExprLen is exact. A .pt body has no // typing yet; values pairing one to one with destinations is the only mapping diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index af9d6f10..995bbde1 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -270,6 +270,28 @@ func TestCollectorWriteIsUnconditional(t *testing.T) { assert.Contains(t, errs[0].Msg, `unconditional assignment to "c"`) } +// A ranged gate always runs its loop and an inline collector under it commits +// even when nothing is admitted, so the store behind it is dead; a scalar +// sibling under the same gate stays conditional. Needs the solver. +func TestRangedGateCollectorWriteIsUnconditional(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + cc := NewCodeCompiler(ctx, "rangedGateCollector", "", ast.NewCode()) + program := parseInput(t, "rangedGateCollector", "i = 0:1\nc = [9]\ns = 42\nc, s = i < 0 [i], i + 7\nc, s") + sc := NewScriptCompiler(ctx, program, cc, make(map[string]*Func), cc.Compiler.ExprCache) + errs := sc.Compile() + require.NotEmpty(t, errs, "the dead store behind the gated collector must be reported") + + msgs := make([]string, len(errs)) + for i, e := range errs { + msgs[i] = e.Msg + } + joined := strings.Join(msgs, "\n") + assert.Contains(t, joined, `unconditional assignment to "c"`) + assert.NotContains(t, joined, `to "s"`, "the scalar sibling commits per admitted iteration and must stay conditional") +} + // A ranged expression suspends its own destination only: the sibling literal // writes even when the domain is empty, so the store behind it is dead. Range // classification needs the solver, so this runs the full script pipeline diff --git a/tests/array/cond_accum.exp b/tests/array/cond_accum.exp index 7223eb8f..9f48f6d6 100644 --- a/tests/array/cond_accum.exp +++ b/tests/array/cond_accum.exp @@ -10,6 +10,7 @@ NestedDrivers: [0 1 2 10 11 12] MixedDrivers: [9 9 9 9 9 9] DriverScalar: 5 [0 1 2] +FalseCondBefore: [10 20 30] FalseCondEmpty: [] [] RangeCell: [0 1 0 1] @@ -74,4 +75,5 @@ CondCollectorAffine: [20 30 40 50] CondCollectorAffineOOB: [20 40] CapturedCollector: [0 0 0]. CapturedCollectorUse: [5 5 5] NestedCondCell: [3 4 5] +[1 2 3] [9 9] diff --git a/tests/array/cond_accum.spt b/tests/array/cond_accum.spt index 28bacb99..d96c1be8 100644 --- a/tests/array/cond_accum.spt +++ b/tests/array/cond_accum.spt @@ -58,8 +58,10 @@ j = 0:5 arr = j < 3 [j] arr -# Empty result when condition always false +# Empty result when condition always false: the gated collector still commits, +# replacing the previous array, so read it first to show the replacement. old = [10 20 30] +"FalseCondBefore: -old" k = 0:3 old = k < 0 [99] "FalseCondEmpty: -old" @@ -391,6 +393,7 @@ nestedCondCell = i > 2 [([0] + i)[0]] # Existing array replaced on successful accumulation (leak check) seed = [1 2 3] +seed p = 0:3 seed = p < 2 [9] seed From 7d310c226f786c981f77097e9ef26ebec040301f Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 27 Jul 2026 02:46:13 +0530 Subject: [PATCH 31/60] test(compiler): cover literal-driver siblings and driven numeric markers A descriptor literal beside a literal-range computation exercises the print collector with an unnamed driver, and a driven marker with an explicit %d pins that the conversion applies to the iteration's I64 yield rather than erroring on the Range descriptor. Co-Authored-By: Claude Opus 5 --- tests/math/print_func.exp | 3 +++ tests/math/print_func.spt | 3 +++ tests/range_finalize.exp | 3 +++ tests/range_finalize.spt | 4 ++++ 4 files changed, 13 insertions(+) diff --git a/tests/math/print_func.exp b/tests/math/print_func.exp index 0ab3fa29..7aa03591 100644 --- a/tests/math/print_func.exp +++ b/tests/math/print_func.exp @@ -26,3 +26,6 @@ 0:9 1 0:9 2 0:9 3 +0:9 1 +0:9 2 +0:9 3 diff --git a/tests/math/print_func.spt b/tests/math/print_func.spt index 9f677222..9fc9fea1 100644 --- a/tests/math/print_func.spt +++ b/tests/math/print_func.spt @@ -39,3 +39,6 @@ k + 1, SumSquares(k, k) # Regression: a descriptor literal must not be rewritten into a loop iterator # when a sibling computation drives the print loop. 0:9, i + 0 + +# Regression: a descriptor literal beside a literal-range computation. +0:9, 1:4 + 0 diff --git a/tests/range_finalize.exp b/tests/range_finalize.exp index f571fc9b..20b3139e 100644 --- a/tests/range_finalize.exp +++ b/tests/range_finalize.exp @@ -17,6 +17,9 @@ MarkerWidthEach: |7| MarkerWidthEach: | 7| MarkerWidthEach: | 7| LiteralDescriptor 0:3 +DrivenMarker i=0 0 +DrivenMarker i=1 1 +DrivenMarker i=2 2 EmptyDescriptor: 3:3 SteppedDescriptor: 6:0:-2 SimultaneousRange: 2 77 diff --git a/tests/range_finalize.spt b/tests/range_finalize.spt index bfe702f0..89f5ba0c 100644 --- a/tests/range_finalize.spt +++ b/tests/range_finalize.spt @@ -73,6 +73,10 @@ formattedWidth = "|-widthValue%(-widthRange)d|" # A range literal in print position is a descriptor value too. "LiteralDescriptor", 0:3 +# A sibling computation binds the marker's name, so the marker formats that +# iteration's I64 yield and an explicit %d applies to it. +"DrivenMarker i=-markerRange%d", markerRange + 0 + # Descriptors print their exact bounds, including empty and stepped forms. emptyDescriptor = 3:3 "EmptyDescriptor:", emptyDescriptor From 2602e3e05d445fe4cc4d02004ec208d88235daf2 Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 27 Jul 2026 02:46:13 +0530 Subject: [PATCH 32/60] docs(strings): define n and qualify the Range %d rule The width example read an undefined n, and the %d statement was too broad: the conversion errors only while the name is an undriven Range descriptor, and applies to the I64 yield once a sibling computation binds the name. Co-Authored-By: Claude Opus 5 --- docs/Pluto String and Formatting Semantics.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/Pluto String and Formatting Semantics.md b/docs/Pluto String and Formatting Semantics.md index f9c3957b..da91f9d4 100644 --- a/docs/Pluto String and Formatting Semantics.md +++ b/docs/Pluto String and Formatting Semantics.md @@ -67,20 +67,25 @@ A main marker formats its value whatever the type, so a bare `Range` formats its descriptor and contributes no iteration: the string is a single value in assignment and in print alike. A Range identifier used for dynamic width or precision is consumed as a number, which makes it an iteration driver. An -explicit numeric conversion on a Range main marker, such as `-i%d`, is a -compile error — a descriptor is not a number; iterate with a computation -instead. +explicit numeric conversion such as `-i%d` is a compile error while `i` is an +undriven Range descriptor — a descriptor is not a number. ```pluto i = 0:3 s = "item -i" # "item 0:3" "item -i" # prints item 0:3 on one line +n = 7 w = 1:3 "|-n%(-w)d|" # one line per width: |7| then | 7| ``` When a sibling computation in the same statement binds the marker's name as a -driver, the marker formats that iteration's scalar yield instead. +driver, the marker formats that iteration's scalar yield instead — and a +numeric conversion then applies to that yield: + +```pluto +"i=-i%d", i + 0 # i=0 0, i=1 1, i=2 2 +``` ## Literal percent and strict formatting From d7610c7602e39018649ce31c12f480a95b8b681f Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 27 Jul 2026 03:45:44 +0530 Subject: [PATCH 33/60] fix(compiler): align gated collector analysis Use typed condition range metadata and the shared inline-collector predicate so CFG write classification matches lowering for literal, block-layout, and scalar gates. Add focused regressions and clarify that sibling Range-driver binding is specific to print arguments. --- compiler/cfg.go | 30 +++++--- compiler/cfg_test.go | 75 +++++++++++++------ docs/Pluto String and Formatting Semantics.md | 14 +++- 3 files changed, 85 insertions(+), 34 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index 690efbd2..e877baa5 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -259,15 +259,8 @@ func (cfg *CFG) destWriteKinds(s *ast.LetStatement) []EventType { // so that slot's write is unconditional. Sibling slots commit only on admitted // iterations and stay conditional. func (cfg *CFG) applyGateKinds(s *ast.LetStatement, kinds []EventType) { - rangedGate := false - for _, cond := range s.Condition { - if cfg.hasRangeExpr(cond) { - rangedGate = true - break - } - } spans, known := cfg.valueOutputSpans(s) - if !rangedGate || !known { + if !cfg.hasRangedGate(s.Condition) || !known { for i := range kinds { kinds[i] = ConditionalWrite } @@ -277,7 +270,7 @@ func (cfg *CFG) applyGateKinds(s *ast.LetStatement, kinds []EventType) { dest := 0 for vi, v := range s.Value { lit, isLit := v.(*ast.ArrayLiteral) - committed := isLit && len(lit.Rows) == 1 + committed := isLit && isInlineArrayCollector(lit) for j := 0; j < spans[vi] && dest < len(kinds); j++ { if !committed { kinds[dest] = ConditionalWrite @@ -287,6 +280,25 @@ func (cfg *CFG) applyGateKinds(s *ast.LetStatement, kinds []EventType) { } } +// hasRangedGate reports whether a statement condition contributes a loop +// driver. Unlike a value-position range literal, a bare Range in condition +// position is consumed as a driver, so this uses the typed condition metadata +// that lowering also consumes rather than hasRangeExpr's value semantics. +// Untyped .pt analysis stays conservative and treats every gate as skippable. +func (cfg *CFG) hasRangedGate(conditions []ast.Expression) bool { + if cfg.ScriptCompiler == nil { + return false + } + + c := cfg.ScriptCompiler.Compiler + for _, cond := range conditions { + if len(c.ExprCache[key(c.FuncNameMangled, cond)].Ranges) > 0 { + return true + } + } + return false +} + // valueOutputSpans reports how many destinations each value expression feeds. // Scripts are typed before analysis, so ExprLen is exact. A .pt body has no // typing yet; values pairing one to one with destinations is the only mapping diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index 995bbde1..5f3b36df 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -255,17 +255,23 @@ func assertHasExpectedError(t *testing.T, errors []*token.CompileError, expected } } -// A collector materializes an array even over an empty domain, so its write is -// unconditional and the store behind it is dead. Range classification needs the -// solver, so this runs the full script pipeline. -func TestCollectorWriteIsUnconditional(t *testing.T) { +func compileScriptForCFGTest(t *testing.T, name, input string) []*token.CompileError { + t.Helper() + ctx := llvm.NewContext() defer ctx.Dispose() - cc := NewCodeCompiler(ctx, "collectorWrite", "", ast.NewCode()) - program := parseInput(t, "collectorWrite", "i = 0:0\nc = [9]\nc = [i + 0]\nc") + cc := NewCodeCompiler(ctx, name, "", ast.NewCode()) + program := parseInput(t, name, input) sc := NewScriptCompiler(ctx, program, cc, make(map[string]*Func), cc.Compiler.ExprCache) - errs := sc.Compile() + return sc.Compile() +} + +// A collector materializes an array even over an empty domain, so its write is +// unconditional and the store behind it is dead. Range classification needs the +// solver, so this runs the full script pipeline. +func TestCollectorWriteIsUnconditional(t *testing.T) { + errs := compileScriptForCFGTest(t, "collectorWrite", "i = 0:0\nc = [9]\nc = [i + 0]\nc") require.NotEmpty(t, errs, "the dead store behind the collector must be reported") assert.Contains(t, errs[0].Msg, `unconditional assignment to "c"`) } @@ -274,13 +280,7 @@ func TestCollectorWriteIsUnconditional(t *testing.T) { // even when nothing is admitted, so the store behind it is dead; a scalar // sibling under the same gate stays conditional. Needs the solver. func TestRangedGateCollectorWriteIsUnconditional(t *testing.T) { - ctx := llvm.NewContext() - defer ctx.Dispose() - - cc := NewCodeCompiler(ctx, "rangedGateCollector", "", ast.NewCode()) - program := parseInput(t, "rangedGateCollector", "i = 0:1\nc = [9]\ns = 42\nc, s = i < 0 [i], i + 7\nc, s") - sc := NewScriptCompiler(ctx, program, cc, make(map[string]*Func), cc.Compiler.ExprCache) - errs := sc.Compile() + errs := compileScriptForCFGTest(t, "rangedGateCollector", "i = 0:1\nc = [9]\ns = 42\nc, s = i < 0 [i], i + 7\nc, s") require.NotEmpty(t, errs, "the dead store behind the gated collector must be reported") msgs := make([]string, len(errs)) @@ -292,18 +292,51 @@ func TestRangedGateCollectorWriteIsUnconditional(t *testing.T) { assert.NotContains(t, joined, `to "s"`, "the scalar sibling commits per admitted iteration and must stay conditional") } +func TestGateArrayWriteKinds(t *testing.T) { + tests := []struct { + name string + input string + errorContains string + }{ + { + name: "range literal collector commits", + input: "c = [9]\nc = 0:0 [1]\nc", + errorContains: `unconditional assignment to "c"`, + }, + { + name: "ranged block preserves destination", + input: "c = [\n 9\n]\ni = 0:1\nc = i < 0 [\n 1\n]\nc", + }, + { + name: "scalar collector preserves destination", + input: "flag = 0\nc = [9]\nc = flag > 0 [1]\nc", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := compileScriptForCFGTest(t, tt.name, tt.input) + if tt.errorContains == "" { + require.Empty(t, errs) + return + } + + require.NotEmpty(t, errs) + msgs := make([]string, len(errs)) + for i, err := range errs { + msgs[i] = err.Msg + } + assert.Contains(t, strings.Join(msgs, "\n"), tt.errorContains) + }) + } +} + // A ranged expression suspends its own destination only: the sibling literal // writes even when the domain is empty, so the store behind it is dead. Range // classification needs the solver, so this runs the full script pipeline // rather than the bare-CFG harness. func TestEmptyDomainDoesNotProtectSiblingWrite(t *testing.T) { - ctx := llvm.NewContext() - defer ctx.Dispose() - - cc := NewCodeCompiler(ctx, "emptyDomainSibling", "", ast.NewCode()) - program := parseInput(t, "emptyDomainSibling", "i = 0:0\na = 1\nb = 2\na, b = i + 0, 30\na, b") - sc := NewScriptCompiler(ctx, program, cc, make(map[string]*Func), cc.Compiler.ExprCache) - errs := sc.Compile() + errs := compileScriptForCFGTest(t, "emptyDomainSibling", "i = 0:0\na = 1\nb = 2\na, b = i + 0, 30\na, b") require.NotEmpty(t, errs, "the dead store behind the sibling literal must be reported") msgs := make([]string, len(errs)) diff --git a/docs/Pluto String and Formatting Semantics.md b/docs/Pluto String and Formatting Semantics.md index da91f9d4..d82b65ac 100644 --- a/docs/Pluto String and Formatting Semantics.md +++ b/docs/Pluto String and Formatting Semantics.md @@ -79,12 +79,18 @@ w = 1:3 "|-n%(-w)d|" # one line per width: |7| then | 7| ``` -When a sibling computation in the same statement binds the marker's name as a -driver, the marker formats that iteration's scalar yield instead — and a -numeric conversion then applies to that yield: +Within a print statement, another argument that consumes the same named `Range` +makes it a driver for that print loop. During each iteration, the main marker +reads the current `I64` yield, so an explicit numeric conversion applies to the +yield. This sibling binding is specific to print arguments; it does not apply +across sibling values in an assignment. ```pluto -"i=-i%d", i + 0 # i=0 0, i=1 1, i=2 2 +i = 0:3 +"i=-i%d", i + 0 +# i=0 0 +# i=1 1 +# i=2 2 ``` ## Literal percent and strict formatting From 72d7c0ad241883d49fce5b24e3b4ee6e901561d0 Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 27 Jul 2026 10:30:51 +0530 Subject: [PATCH 34/60] fix(compiler): unify ranged gate classification Record statement range-driver classification during type solving and consume that result during lowering, preventing nested array selections from producing invalid IR. Remove obsolete driver fallbacks and duplicate CFG scans, reuse existing ArrayRange operand lowering, make Symbol copies future-proof, and align the ABI plan with the emitted SSA behavior. --- compiler/array.go | 9 +---- compiler/cfg.go | 60 +++++++++++------------------ compiler/cfg_test.go | 12 ++++++ compiler/compiler.go | 12 ++---- compiler/cond.go | 42 +------------------- compiler/solver.go | 26 +++---------- docs/Pluto ABI Optimization Plan.md | 3 +- tests/cond/nested_range_driver.exp | 1 + tests/cond/nested_range_driver.spt | 4 ++ 9 files changed, 52 insertions(+), 117 deletions(-) create mode 100644 tests/cond/nested_range_driver.exp create mode 100644 tests/cond/nested_range_driver.spt diff --git a/compiler/array.go b/compiler/array.go index c8924ce0..702e84d2 100644 --- a/compiler/array.go +++ b/compiler/array.go @@ -982,14 +982,7 @@ func (c *Compiler) compileArrayRangeOperands(expr *ast.ArrayRangeExpression) (*S // immediate call boundary. Ordinary array indexing still lowers to the final // selected element/subarray and therefore cannot expose or retain ArrayRange. func (c *Compiler) compileArrayRangeCallArg(expr *ast.ArrayRangeExpression, typ ArrayRange) *Symbol { - arrayLoadName := "" - if arrayIdent, ok := expr.Array.(*ast.Identifier); ok { - arrayLoadName = arrayIdent.Value + "_load" - } - arraySym := c.derefIfPointer(c.compileExpression(expr.Array, nil)[0], arrayLoadName) - - rangeSym := c.compileExpression(expr.Range, nil)[0] - rangeSym = c.derefIfPointer(rangeSym, "array_range_index") + arraySym, rangeSym, _ := c.compileArrayRangeOperands(expr) _, arrayIsIdent := expr.Array.(*ast.Identifier) return &Symbol{ diff --git a/compiler/cfg.go b/compiler/cfg.go index e877baa5..a9bf846a 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -192,24 +192,6 @@ func (cfg *CFG) extractStmtEvents(stmt ast.Statement) []VarEvent { return evs } -// HasRangeExpr returns true if any RHS expression has a range -// used in an iterated position, mirroring the solver's iterate behavior. -// Examples that return true: -// - y = y + 1:5 -// - y = f(x) + 2:3 -// - y = f(1:5) -// -// Example that returns false: -// - i = 1:5 (non-iterated range literal, just a plain write of a range value) -func (cfg *CFG) HasRangeExpr(values []ast.Expression) bool { - for _, v := range values { - if cfg.hasRangeExpr(v) { - return true - } - } - return false -} - // destWriteKinds classifies each destination write of a statement. A statement // condition suspends the whole simultaneous assignment. Every other source of // a skipped write belongs to one value expression — an empty driver, a callee @@ -231,7 +213,7 @@ func (cfg *CFG) destWriteKinds(s *ast.LetStatement) []EventType { if !known { // Without per-expression arity a failable span cannot be placed, so // any failable value must suspend every destination. - if cfg.HasRangeExpr(s.Value) || cfg.HasSkippableCallRoot(s.Value) || cfg.anyValueMayNotYield(s.Value) { + if cfg.anyValueMaySkip(s.Value) { for i := range kinds { kinds[i] = ConditionalWrite } @@ -241,9 +223,9 @@ func (cfg *CFG) destWriteKinds(s *ast.LetStatement) []EventType { dest := 0 for vi, v := range s.Value { - failable := cfg.hasRangeExpr(v) || cfg.callRootMaySkip(v) || cfg.valueMayNotYield(v) - for j := 0; j < spans[vi] && dest < len(kinds); j++ { - if failable { + maySkip := cfg.valueMaySkip(v) + for j := 0; j < spans[vi]; j++ { + if maySkip { kinds[dest] = ConditionalWrite } dest++ @@ -271,7 +253,7 @@ func (cfg *CFG) applyGateKinds(s *ast.LetStatement, kinds []EventType) { for vi, v := range s.Value { lit, isLit := v.(*ast.ArrayLiteral) committed := isLit && isInlineArrayCollector(lit) - for j := 0; j < spans[vi] && dest < len(kinds); j++ { + for j := 0; j < spans[vi]; j++ { if !committed { kinds[dest] = ConditionalWrite } @@ -292,7 +274,8 @@ func (cfg *CFG) hasRangedGate(conditions []ast.Expression) bool { c := cfg.ScriptCompiler.Compiler for _, cond := range conditions { - if len(c.ExprCache[key(c.FuncNameMangled, cond)].Ranges) > 0 { + info := c.ExprCache[key(c.FuncNameMangled, cond)] + if info != nil && len(info.Ranges) > 0 { return true } } @@ -332,15 +315,25 @@ func (cfg *CFG) valueOutputSpans(s *ast.LetStatement) ([]int, bool) { return spans, true } -func (cfg *CFG) anyValueMayNotYield(values []ast.Expression) bool { +// anyValueMaySkip reports whether any RHS expression can leave its destination +// unchanged. +func (cfg *CFG) anyValueMaySkip(values []ast.Expression) bool { for _, v := range values { - if cfg.valueMayNotYield(v) { + if cfg.valueMaySkip(v) { return true } } return false } +// valueMaySkip reports whether an RHS expression can leave its destination +// unchanged: outside an inline collector, an empty range driver can run no +// iterations; a root call can keep an unwritten output; and a failable value +// can yield nothing. +func (cfg *CFG) valueMaySkip(expr ast.Expression) bool { + return cfg.hasRangeExpr(expr) || cfg.callRootMaySkip(expr) || cfg.valueMayNotYield(expr) +} + // valueMayNotYield reports whether an expression may produce no value, leaving // its destination untouched. It shares the solver's traversal, so it counts // anywhere in the tree rather than only at a root: `y = Square(x < 5) + 5` is @@ -379,17 +372,6 @@ func (cfg *CFG) conditionMayFail(expr ast.Expression) bool { return false } -// HasSkippableCallRoot reports whether any value is a bare call to a -// user-defined function. -func (cfg *CFG) HasSkippableCallRoot(values []ast.Expression) bool { - for _, v := range values { - if cfg.callRootMaySkip(v) { - return true - } - } - return false -} - // callRootMaySkip reports whether a value is a bare call to a user-defined // function. Such a callee may leave an output unwritten, so the caller keeps // its previous value and that previous write is not dead. Only root position @@ -405,7 +387,9 @@ func (cfg *CFG) callRootMaySkip(v ast.Expression) bool { return !builtin } -// hasRangeExpr checks if an expression contains ranges by looking at ExprCache +// hasRangeExpr reports whether an RHS expression uses a range in an iterated +// position, mirroring the solver's behavior. A bare range literal is a +// descriptor value and therefore does not count. func (cfg *CFG) hasRangeExpr(e ast.Expression) bool { // Only possible when we have ScriptCompiler with ExprCache if cfg.ScriptCompiler == nil { diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index 5f3b36df..bd0e5efb 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -267,6 +267,18 @@ func compileScriptForCFGTest(t *testing.T, name, input string) []*token.CompileE return sc.Compile() } +func TestHasRangedGateIgnoresMissingExprInfo(t *testing.T) { + program := parseInput(t, "missingGateInfo", "x = 1 > 0 2") + stmt, ok := program.Statements[0].(*ast.LetStatement) + require.True(t, ok) + require.NotEmpty(t, stmt.Condition) + + cfg := NewCFG(&ScriptCompiler{ + Compiler: &Compiler{ExprCache: make(map[ExprKey]*ExprInfo)}, + }, nil) + assert.False(t, cfg.hasRangedGate(stmt.Condition)) +} + // A collector materializes an array even over an empty domain, so its write is // unconditional and the store behind it is dead. Range classification needs the // solver, so this runs the full script pipeline. diff --git a/compiler/compiler.go b/compiler/compiler.go index 215d3cb6..91e11506 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -124,15 +124,9 @@ const ( symbolCode ) -func GetCopy(s *Symbol) (newSym *Symbol) { - newSym = &Symbol{} - newSym.Val = s.Val - newSym.Type = s.Type - newSym.FuncArg = s.FuncArg - newSym.Borrowed = s.Borrowed - newSym.ReadOnly = s.ReadOnly - newSym.WriteFlag = s.WriteFlag - return newSym +func GetCopy(s *Symbol) *Symbol { + newSym := *s + return &newSym } type Compiler struct { diff --git a/compiler/cond.go b/compiler/cond.go index 47d1437e..6f9403a6 100644 --- a/compiler/cond.go +++ b/compiler/cond.go @@ -903,44 +903,6 @@ func (c *Compiler) branchCond(cond llvm.Value, temps []condTemp, onTrue func(), }) } -func (c *Compiler) isRangeDriverCond(expr ast.Expression) bool { - info := c.ExprCache[key(c.FuncNameMangled, expr)] - if info == nil || len(info.OutTypes) != 1 || len(info.Ranges) == 0 { - return false - } - - switch e := expr.(type) { - case *ast.Identifier, *ast.RangeLiteral: - return true - case *ast.ArrayRangeExpression: - arrInfo := c.ExprCache[key(c.FuncNameMangled, e.Array)] - if arrInfo == nil || arrInfo.HasRanges { - return false - } - switch e.Range.(type) { - case *ast.Identifier, *ast.RangeLiteral: - return true - default: - return false - } - default: - return false - } -} - -func (c *Compiler) collectDriverRanges(expr ast.Expression) []*RangeInfo { - info := c.ExprCache[key(c.FuncNameMangled, expr)] - if len(info.Ranges) > 0 { - return info.Ranges - } - - ident, ok := expr.(*ast.Identifier) - if !ok { - panic(fmt.Sprintf("internal: bare range driver %T missing cached ranges", expr)) - } - return []*RangeInfo{{Name: ident.Value}} -} - // splitCondRanges collects merged ranges and boolean guard expressions // from statement conditions. Bare range/array-selection drivers contribute only // ranges; comparisons contribute both ranges and a per-iteration guard. @@ -950,8 +912,8 @@ func (c *Compiler) splitCondRanges(conditions []ast.Expression) ([]*RangeInfo, [ var condExprs []ast.Expression for _, expr := range conditions { info := c.ExprCache[key(c.FuncNameMangled, expr)] - if c.isRangeDriverCond(expr) { - ranges = mergeUses(ranges, c.collectDriverRanges(expr)) + if info.RangeDriverCond { + ranges = mergeUses(ranges, info.Ranges) continue } diff --git a/compiler/solver.go b/compiler/solver.go index 60289bc4..bbc10f48 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -37,6 +37,7 @@ type ExprInfo struct { ScalarCallParamTypes []Type // Param types to use once outer loops consume ranges into scalars CompareModes []CondMode // Per-slot lowering mode for comparisons in value position (nil for non-comparisons) ArrayShape []uint64 // Statically known dimensions for array literals; nil when runtime-dependent + RangeDriverCond bool // Solver-classified loop-domain condition; true implies len(Ranges) > 0. } // HasCondScalar returns true if any slot is a scalar conditional expression. @@ -793,25 +794,6 @@ func (ts *TypeSolver) isRangeDriverCond(expr ast.Expression, condTypes []Type) b } } -func (ts *TypeSolver) collectDriverRanges(expr ast.Expression, condTypes []Type) []*RangeInfo { - info := ts.ExprCache[key(ts.FuncNameMangled, expr)] - if len(info.Ranges) > 0 { - return info.Ranges - } - - // Non-driver conditions (for example, comparisons with no ranged operands) - // fall through here harmlessly: they contribute no loop driver ranges. - if !ts.isRangeDriverCond(expr, condTypes) { - return nil - } - - ident, ok := expr.(*ast.Identifier) - if !ok { - panic(fmt.Sprintf("internal: bare range driver %T missing cached ranges", expr)) - } - return []*RangeInfo{{Name: ident.Value}} -} - // treeCanFail reports whether a value-position expression can propagate a // failed yield to its parent, asking nodeFails to classify each node. The // solver and the CFG pass different predicates but share this walk, so the two @@ -871,7 +853,9 @@ func (ts *TypeSolver) validateStatementCondition(expr ast.Expression, condTypes return } - if ts.isRangeDriverCond(expr, condTypes) { + info := ts.ExprCache[key(ts.FuncNameMangled, expr)] + info.RangeDriverCond = ts.isRangeDriverCond(expr, condTypes) + if info.RangeDriverCond { return } @@ -906,7 +890,7 @@ func (ts *TypeSolver) collectConditionRanges(conditions []ast.Expression) []*Ran var ranges []*RangeInfo for _, expr := range conditions { info := ts.ExprCache[key(ts.FuncNameMangled, expr)] - ranges = mergeUses(ranges, ts.collectDriverRanges(expr, info.OutTypes)) + ranges = mergeUses(ranges, info.Ranges) } return ranges } diff --git a/docs/Pluto ABI Optimization Plan.md b/docs/Pluto ABI Optimization Plan.md index aa72cee5..5c23014e 100644 --- a/docs/Pluto ABI Optimization Plan.md +++ b/docs/Pluto ABI Optimization Plan.md @@ -85,7 +85,8 @@ Direct lowering for scalar numeric inputs and single scalar outputs. - pass `I64`/`F64` by value instead of by pointer - return single scalar in register instead of via `sret` -- keep function-body semantics stable by spilling direct scalar params into local addressable slots in the callee +- keep direct scalar params as SSA values in function scope, materializing + addressable slots only when semantically required - 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 diff --git a/tests/cond/nested_range_driver.exp b/tests/cond/nested_range_driver.exp new file mode 100644 index 00000000..6d64dadb --- /dev/null +++ b/tests/cond/nested_range_driver.exp @@ -0,0 +1 @@ +[1 1] diff --git a/tests/cond/nested_range_driver.spt b/tests/cond/nested_range_driver.spt new file mode 100644 index 00000000..99404fa5 --- /dev/null +++ b/tests/cond/nested_range_driver.spt @@ -0,0 +1,4 @@ +idx = [0 1] +data = [10 20] +out = data[idx[0:2]] [1] +out From 24074808accca80677b13e408faba9d6f2f989bd Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 27 Jul 2026 14:41:13 +0530 Subject: [PATCH 35/60] fix(compiler): retain scalar guards in ranged gates Separate range discovery from per-iteration guard selection so scalar conjuncts remain active when another condition supplies the loop domain. Fixes #72 --- compiler/cond.go | 26 +++++++++++----------- tests/cond/mixed_ranged_gate.exp | 9 ++++++++ tests/cond/mixed_ranged_gate.spt | 37 ++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 13 deletions(-) create mode 100644 tests/cond/mixed_ranged_gate.exp create mode 100644 tests/cond/mixed_ranged_gate.spt diff --git a/compiler/cond.go b/compiler/cond.go index 6f9403a6..c185b190 100644 --- a/compiler/cond.go +++ b/compiler/cond.go @@ -903,34 +903,34 @@ func (c *Compiler) branchCond(cond llvm.Value, temps []condTemp, onTrue func(), }) } -// splitCondRanges collects merged ranges and boolean guard expressions -// from statement conditions. Bare range/array-selection drivers contribute only -// ranges; comparisons contribute both ranges and a per-iteration guard. +// splitCondRanges collects merged ranges and boolean guard expressions from +// statement conditions. Once any condition contributes a range, every +// non-driver condition remains a per-iteration guard, including scalar +// conjuncts with no ranges of their own. Bare range/array-selection drivers +// contribute only ranges. // Returns nil, nil if no condition introduces ranges. func (c *Compiler) splitCondRanges(conditions []ast.Expression) ([]*RangeInfo, []ast.Expression) { var ranges []*RangeInfo + for _, expr := range conditions { + info := c.ExprCache[key(c.FuncNameMangled, expr)] + ranges = mergeUses(ranges, info.Ranges) + } + if len(ranges) == 0 { + return nil, nil + } + var condExprs []ast.Expression for _, expr := range conditions { info := c.ExprCache[key(c.FuncNameMangled, expr)] if info.RangeDriverCond { - ranges = mergeUses(ranges, info.Ranges) continue } - - if len(info.Ranges) == 0 { - continue - } - - ranges = mergeUses(ranges, info.Ranges) if info.Rewrite != nil { condExprs = append(condExprs, info.Rewrite) continue } condExprs = append(condExprs, expr) } - if len(ranges) == 0 { - return nil, nil - } return ranges, condExprs } diff --git a/tests/cond/mixed_ranged_gate.exp b/tests/cond/mixed_ranged_gate.exp new file mode 100644 index 00000000..4d698f4e --- /dev/null +++ b/tests/cond/mixed_ranged_gate.exp @@ -0,0 +1,9 @@ +CollectorScalarFirstFalse: [] +CollectorScalarFirstTrue: [0 1 2 3 4] +CollectorRangeFirstFalse: [] +CollectorRangeFirstTrue: [0 1 2 3 4] +ScalarScalarFirstFalse: 10 +ScalarScalarFirstTrue: 11 +ScalarRangeFirstFalse: 30 +ScalarRangeFirstTrue: 11 +LoopCarried: 3 diff --git a/tests/cond/mixed_ranged_gate.spt b/tests/cond/mixed_ranged_gate.spt new file mode 100644 index 00000000..4c21e986 --- /dev/null +++ b/tests/cond/mixed_ranged_gate.spt @@ -0,0 +1,37 @@ +# When any statement-condition conjunct supplies a range, scalar conjuncts +# remain per-iteration guards regardless of their order. +driver = 0:7 +falseFlag = 0 +trueFlag = 1 + +# Inline collectors commit an empty array when a scalar conjunct rejects every +# iteration, and collect once per iteration when it admits the range. +collectorScalarFirstFalse = falseFlag > 0 && driver < 5 [driver] +"CollectorScalarFirstFalse: -collectorScalarFirstFalse" +collectorScalarFirstTrue = trueFlag > 0 && driver < 5 [driver] +"CollectorScalarFirstTrue: -collectorScalarFirstTrue" +collectorRangeFirstFalse = driver < 5 && falseFlag > 0 [driver] +"CollectorRangeFirstFalse: -collectorRangeFirstFalse" +collectorRangeFirstTrue = driver < 5 && trueFlag > 0 [driver] +"CollectorRangeFirstTrue: -collectorRangeFirstTrue" + +# Scalar destinations keep their old value when no iteration is admitted and +# take the final admitted iteration's value otherwise. +scalarScalarFirstFalse = 10 +scalarScalarFirstFalse = falseFlag > 0 && driver < 5 driver + 7 +"ScalarScalarFirstFalse: -scalarScalarFirstFalse" +scalarScalarFirstTrue = 20 +scalarScalarFirstTrue = trueFlag > 0 && driver < 5 driver + 7 +"ScalarScalarFirstTrue: -scalarScalarFirstTrue" +scalarRangeFirstFalse = 30 +scalarRangeFirstFalse = driver < 5 && falseFlag > 0 driver + 7 +"ScalarRangeFirstFalse: -scalarRangeFirstFalse" +scalarRangeFirstTrue = 40 +scalarRangeFirstTrue = driver < 5 && trueFlag > 0 driver + 7 +"ScalarRangeFirstTrue: -scalarRangeFirstTrue" + +# A scalar guard can observe a destination updated by earlier range iterations; +# it must remain inside the loop rather than being hoisted ahead of it. +loopCarried = 1 +loopCarried = loopCarried < 3 && driver loopCarried + 1 +"LoopCarried: -loopCarried" From c322df6af41579f8db578e674ae8bf2e0df9af08 Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 27 Jul 2026 19:47:52 +0530 Subject: [PATCH 36/60] fix(compiler): preserve collectors behind blocked gates Commit ranged collector outputs only after the shared statement gate admits an iteration, and release unused accumulator storage on blocked paths. Treat every statement-gated destination as conditional and cover scalar, string, rank-N, empty-domain, and function-output cases. --- compiler/cfg.go | 52 ++---------------------- compiler/cfg_test.go | 34 +++------------- compiler/cond.go | 55 +++++++++++++++++--------- docs/Pluto IR Plan.md | 4 +- docs/Pluto Range Semantics.md | 5 +++ tests/array/cond_accum.exp | 9 +++-- tests/array/cond_accum.spt | 19 ++++++--- tests/cond/mixed_ranged_gate.exp | 4 +- tests/cond/mixed_ranged_gate.spt | 6 ++- tests/cond/skipped_call_keeps_dest.exp | 1 + tests/cond/skipped_call_keeps_dest.pt | 3 ++ tests/cond/skipped_call_keeps_dest.spt | 4 ++ 12 files changed, 86 insertions(+), 110 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index a9bf846a..0e1a98d7 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -205,7 +205,9 @@ func (cfg *CFG) destWriteKinds(s *ast.LetStatement) []EventType { kinds[i] = Write } if len(s.Condition) > 0 { - cfg.applyGateKinds(s, kinds) + for i := range kinds { + kinds[i] = ConditionalWrite + } return kinds } @@ -234,54 +236,6 @@ func (cfg *CFG) destWriteKinds(s *ast.LetStatement) []EventType { return kinds } -// applyGateKinds classifies destinations under a statement gate. A scalar gate -// can skip the whole statement, so every write is conditional. A ranged gate -// always runs its loop, and an inline collector under it is closed and -// committed even when no iteration is admitted — including an empty domain — -// so that slot's write is unconditional. Sibling slots commit only on admitted -// iterations and stay conditional. -func (cfg *CFG) applyGateKinds(s *ast.LetStatement, kinds []EventType) { - spans, known := cfg.valueOutputSpans(s) - if !cfg.hasRangedGate(s.Condition) || !known { - for i := range kinds { - kinds[i] = ConditionalWrite - } - return - } - - dest := 0 - for vi, v := range s.Value { - lit, isLit := v.(*ast.ArrayLiteral) - committed := isLit && isInlineArrayCollector(lit) - for j := 0; j < spans[vi]; j++ { - if !committed { - kinds[dest] = ConditionalWrite - } - dest++ - } - } -} - -// hasRangedGate reports whether a statement condition contributes a loop -// driver. Unlike a value-position range literal, a bare Range in condition -// position is consumed as a driver, so this uses the typed condition metadata -// that lowering also consumes rather than hasRangeExpr's value semantics. -// Untyped .pt analysis stays conservative and treats every gate as skippable. -func (cfg *CFG) hasRangedGate(conditions []ast.Expression) bool { - if cfg.ScriptCompiler == nil { - return false - } - - c := cfg.ScriptCompiler.Compiler - for _, cond := range conditions { - info := c.ExprCache[key(c.FuncNameMangled, cond)] - if info != nil && len(info.Ranges) > 0 { - return true - } - } - return false -} - // valueOutputSpans reports how many destinations each value expression feeds. // Scripts are typed before analysis, so ExprLen is exact. A .pt body has no // typing yet; values pairing one to one with destinations is the only mapping diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index bd0e5efb..60f13afd 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -267,18 +267,6 @@ func compileScriptForCFGTest(t *testing.T, name, input string) []*token.CompileE return sc.Compile() } -func TestHasRangedGateIgnoresMissingExprInfo(t *testing.T) { - program := parseInput(t, "missingGateInfo", "x = 1 > 0 2") - stmt, ok := program.Statements[0].(*ast.LetStatement) - require.True(t, ok) - require.NotEmpty(t, stmt.Condition) - - cfg := NewCFG(&ScriptCompiler{ - Compiler: &Compiler{ExprCache: make(map[ExprKey]*ExprInfo)}, - }, nil) - assert.False(t, cfg.hasRangedGate(stmt.Condition)) -} - // A collector materializes an array even over an empty domain, so its write is // unconditional and the store behind it is dead. Range classification needs the // solver, so this runs the full script pipeline. @@ -288,20 +276,11 @@ func TestCollectorWriteIsUnconditional(t *testing.T) { assert.Contains(t, errs[0].Msg, `unconditional assignment to "c"`) } -// A ranged gate always runs its loop and an inline collector under it commits -// even when nothing is admitted, so the store behind it is dead; a scalar -// sibling under the same gate stays conditional. Needs the solver. -func TestRangedGateCollectorWriteIsUnconditional(t *testing.T) { +// A ranged gate can admit no iterations, so collector and scalar destinations +// both preserve their prior values and both writes stay conditional. +func TestRangedGateCollectorWriteIsConditional(t *testing.T) { errs := compileScriptForCFGTest(t, "rangedGateCollector", "i = 0:1\nc = [9]\ns = 42\nc, s = i < 0 [i], i + 7\nc, s") - require.NotEmpty(t, errs, "the dead store behind the gated collector must be reported") - - msgs := make([]string, len(errs)) - for i, e := range errs { - msgs[i] = e.Msg - } - joined := strings.Join(msgs, "\n") - assert.Contains(t, joined, `unconditional assignment to "c"`) - assert.NotContains(t, joined, `to "s"`, "the scalar sibling commits per admitted iteration and must stay conditional") + require.Empty(t, errs) } func TestGateArrayWriteKinds(t *testing.T) { @@ -311,9 +290,8 @@ func TestGateArrayWriteKinds(t *testing.T) { errorContains string }{ { - name: "range literal collector commits", - input: "c = [9]\nc = 0:0 [1]\nc", - errorContains: `unconditional assignment to "c"`, + name: "empty ranged gate preserves collector", + input: "c = [9]\nc = 0:0 [1]\nc", }, { name: "ranged block preserves destination", diff --git a/compiler/cond.go b/compiler/cond.go index c185b190..2c8ff1b1 100644 --- a/compiler/cond.go +++ b/compiler/cond.go @@ -963,7 +963,6 @@ func (c *Compiler) withCondRangeLoop(allRanges []*RangeInfo, condExprs []ast.Exp type statementArrayCollector struct { literal *ast.ArrayLiteral destination *ast.Identifier - oldValue *Symbol scalar *ArrayAccumulator stacked *stackedArrayAccumulator } @@ -972,7 +971,6 @@ func (c *Compiler) newStatementArrayCollector(lit *ast.ArrayLiteral, destination collector := &statementArrayCollector{ literal: lit, destination: destination, - oldValue: c.captureOldValues([]*ast.Identifier{destination})[0], } if arrayLiteralHasArrayCells(lit, arrayType) { collector.stacked = c.newStackedArrayAccumulator(arrayType) @@ -990,14 +988,31 @@ func (c *Compiler) appendStatementArrayCollector(collector *statementArrayCollec c.appendArrayLiteral(collector.scalar, collector.literal) } -func (c *Compiler) commitStatementArrayCollector(collector *statementArrayCollector) { - var result *Symbol - if collector.stacked != nil { - result = c.stackedArrayAccumulatorResult(collector.stacked) - } else { - result = c.ArrayAccResult(collector.scalar) +// finishStatementArrayCollectors commits the accumulated results only when the +// shared statement gate admitted an iteration; otherwise it keeps the seeded +// destinations and releases the unused accumulators. +func (c *Compiler) finishStatementArrayCollectors(collectors []*statementArrayCollector, didAdmit llvm.Value) { + results := make([]*Symbol, len(collectors)) + for i, collector := range collectors { + if collector.stacked != nil { + results[i] = c.stackedArrayAccumulatorResult(collector.stacked) + } else { + results[i] = c.ArrayAccResult(collector.scalar) + } + c.ensureSeededDest(collector.destination, results[i]) } - c.storeAccumulatedArray(collector.destination, result, collector.oldValue) + + c.withCondBranch(didAdmit, "collector_write", func() { + for i, collector := range collectors { + c.commitSlotValue(collector.destination, results[i], false) + } + }, func() { + // Every accumulator owns a runtime vector even when it collected no + // cells, so a blocked statement must release the uncommitted results. + for _, result := range results { + c.freeTemporarySymbol(result, "collector_skip") + } + }) } // compileCondRangedStatement lowers ranged statement conditions. @@ -1061,9 +1076,18 @@ func (c *Compiler) compileCondRangedStatement(stmt *ast.LetStatement, condRanges } } + var collectorAdmitted llvm.Value + if len(collectors) > 0 { + collectorAdmitted = c.createEntryBlockAlloca(c.Context.Int1Type(), "cond_collector_admitted") + c.createStore(llvm.ConstInt(c.Context.Int1Type(), 0, false), collectorAdmitted, Int{Width: 1}) + } + // Guards and RHS staging must read the same loop-carried destination slots. aliases := c.aliasCondDests(assignSlots) c.withCondRangeLoop(condRanges, condExprs, loopProbes, "cond_iter_guard", "cond_iter_if", "cond_iter_cont", func() { + if !collectorAdmitted.IsNil() { + c.createStore(llvm.ConstInt(c.Context.Int1Type(), 1, false), collectorAdmitted, Int{Width: 1}) + } c.compileCondRangedIteration(assignExprs, assignSlots, appendCollectors) }) c.restoreCondDests(aliases) @@ -1073,17 +1097,10 @@ func (c *Compiler) compileCondRangedStatement(stmt *ast.LetStatement, condRanges DeleteBulk(c.Scopes, slotTempStrings(assignSlots)) } - for _, collector := range collectors { - c.commitStatementArrayCollector(collector) - } -} - -func (c *Compiler) storeAccumulatedArray(dest *ast.Identifier, result, oldValue *Symbol) { - c.storeValue(dest.Value, result, false) - if oldValue == nil || c.skipBorrowedOldValueFree(oldValue) { - return + if len(collectors) > 0 { + didAdmit := c.createLoad(collectorAdmitted, Int{Width: 1}, "cond_collector_admitted") + c.finishStatementArrayCollectors(collectors, didAdmit) } - c.freeSymbolValue(oldValue, "old_accum") } // compileCondRangedIteration runs inside the per-iteration body of diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index 06da87e7..2eef72a7 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -717,8 +717,8 @@ long-lived parallel path. - mixed RHS expressions produce effects aligned per LHS slot, such as `[]WriteEffect{MayWrite, MustWrite}` for `a, b = arr[i], i + 1` - shared conditions and possibly empty ranges produce `MayWrite` for keep-old or - unresolved last-yield targets, while an unconditional collector or zero-fill - closing policy can still produce `MustWrite` + unresolved last-yield targets, while an ungated collector or cell-local + zero-fill closing policy can still produce `MustWrite` - a fallback that resolves every conditional or checked-access failure produces `MustWrite` - a fallback whose final alternative can still fail remains `MayWrite` diff --git a/docs/Pluto Range Semantics.md b/docs/Pluto Range Semantics.md index 27ebd825..2d6af13d 100644 --- a/docs/Pluto Range Semantics.md +++ b/docs/Pluto Range Semantics.md @@ -367,6 +367,11 @@ rejected domain point, none of its RHS expressions, collector appends, carried updates, or output commits execute. RHS-local ranges are nested inside each admitted point. +If no domain point is admitted, the statement performs no write and an existing +collector destination keeps its old value. This differs from an ungated +collector over an empty range: `[i]` still evaluates to `[]` when `i` itself has +an empty domain. + Example: ```pluto diff --git a/tests/array/cond_accum.exp b/tests/array/cond_accum.exp index 9f48f6d6..eeadd7fd 100644 --- a/tests/array/cond_accum.exp +++ b/tests/array/cond_accum.exp @@ -10,9 +10,9 @@ NestedDrivers: [0 1 2 10 11 12] MixedDrivers: [9 9 9 9 9 9] DriverScalar: 5 [0 1 2] -FalseCondBefore: [10 20 30] -FalseCondEmpty: [] +FalseCondBlocked: [10 20 30] [] +EmptyDomainBlocked: [7] RangeCell: [0 1 0 1] [10 20 0] FloatBounded: [1.5 2.5 0 0] @@ -40,6 +40,7 @@ FreshLit: [] 77 StrSelection: c ["a" "b"] +StrAccumBlocked: ["a" "b"] TupleValueRangesA: [0 1 0 1]. TupleValueRangesB: [0 1] CondLocalDriversA: [1 1]. CondLocalDriversB: 1 CondSharedReuse: [0 0 1 1 2 2] @@ -65,7 +66,9 @@ GatedRows: [ 10 11 20 21 ] -GatedRowsEmpty: [ +GatedRowsBlocked: [ + 10 11 + 20 21 ] GatedNestedRows: [ 11 12 diff --git a/tests/array/cond_accum.spt b/tests/array/cond_accum.spt index d96c1be8..87e6c493 100644 --- a/tests/array/cond_accum.spt +++ b/tests/array/cond_accum.spt @@ -58,19 +58,22 @@ j = 0:5 arr = j < 3 [j] arr -# Empty result when condition always false: the gated collector still commits, -# replacing the previous array, so read it first to show the replacement. +# When the ranged statement gate admits no iteration, the whole assignment is +# blocked and the collector keeps its previous value. old = [10 20 30] -"FalseCondBefore: -old" k = 0:3 old = k < 0 [99] -"FalseCondEmpty: -old" +"FalseCondBlocked: -old" # Empty result when the driving range itself is empty i = 0:0 empty = i < 5 [i] empty +emptyExisting = [7] +emptyExisting = i < 5 [i] +"EmptyDomainBlocked: -emptyExisting" + # Range cell in value literal i = 0:3 ranged = i < 2 [0:2] @@ -224,6 +227,12 @@ i = 0:3 stracc = i < 2 [sarr2[i]] stracc +# An all-false ranged gate preserves the owned string array and releases the +# unused empty accumulator. +i = 0:3 +stracc = i < 0 ["unused"] +"StrAccumBlocked: -stracc" + # Tuple accumulation with mixed value-level ranges keeps each [] collector local. i = 0:3 tc, td = i < 2 [0:2], [i] @@ -362,7 +371,7 @@ gatedRows = i < 2 [domainMatrix[i]] "GatedRows: -gatedRows" i = 0:3 gatedRows = i < 0 [domainMatrix[i]] -"GatedRowsEmpty: -gatedRows" +"GatedRowsBlocked: -gatedRows" i = 0:3 gatedNestedRows = i < 2 [domainMatrix[0]] + 1 "GatedNestedRows: -gatedNestedRows" diff --git a/tests/cond/mixed_ranged_gate.exp b/tests/cond/mixed_ranged_gate.exp index 4d698f4e..45f3b1ad 100644 --- a/tests/cond/mixed_ranged_gate.exp +++ b/tests/cond/mixed_ranged_gate.exp @@ -1,6 +1,6 @@ -CollectorScalarFirstFalse: [] +CollectorScalarFirstFalse: [90] CollectorScalarFirstTrue: [0 1 2 3 4] -CollectorRangeFirstFalse: [] +CollectorRangeFirstFalse: [80] CollectorRangeFirstTrue: [0 1 2 3 4] ScalarScalarFirstFalse: 10 ScalarScalarFirstTrue: 11 diff --git a/tests/cond/mixed_ranged_gate.spt b/tests/cond/mixed_ranged_gate.spt index 4c21e986..fc9bdbde 100644 --- a/tests/cond/mixed_ranged_gate.spt +++ b/tests/cond/mixed_ranged_gate.spt @@ -4,12 +4,14 @@ driver = 0:7 falseFlag = 0 trueFlag = 1 -# Inline collectors commit an empty array when a scalar conjunct rejects every -# iteration, and collect once per iteration when it admits the range. +# Inline collectors preserve their old values when a scalar conjunct rejects +# every iteration, and collect once per iteration when it admits the range. +collectorScalarFirstFalse = [90] collectorScalarFirstFalse = falseFlag > 0 && driver < 5 [driver] "CollectorScalarFirstFalse: -collectorScalarFirstFalse" collectorScalarFirstTrue = trueFlag > 0 && driver < 5 [driver] "CollectorScalarFirstTrue: -collectorScalarFirstTrue" +collectorRangeFirstFalse = [80] collectorRangeFirstFalse = driver < 5 && falseFlag > 0 [driver] "CollectorRangeFirstFalse: -collectorRangeFirstFalse" collectorRangeFirstTrue = driver < 5 && trueFlag > 0 [driver] diff --git a/tests/cond/skipped_call_keeps_dest.exp b/tests/cond/skipped_call_keeps_dest.exp index 4e4ad45d..344e5218 100644 --- a/tests/cond/skipped_call_keeps_dest.exp +++ b/tests/cond/skipped_call_keeps_dest.exp @@ -2,3 +2,4 @@ ScalarKept: 7 ScalarWritten: 42 ArrayKept: [1 2 3] ArrayWritten: [7 8] +RangedArrayKept: [4 5] diff --git a/tests/cond/skipped_call_keeps_dest.pt b/tests/cond/skipped_call_keeps_dest.pt index 615f318c..5b3e9c46 100644 --- a/tests/cond/skipped_call_keeps_dest.pt +++ b/tests/cond/skipped_call_keeps_dest.pt @@ -5,3 +5,6 @@ res = KeepScalar(x) res = KeepArray(x) res = x > 0 [7 8] + +res = KeepRangedArray(i) + res = i < 0 [i] diff --git a/tests/cond/skipped_call_keeps_dest.spt b/tests/cond/skipped_call_keeps_dest.spt index a461e93b..64d59a7a 100644 --- a/tests/cond/skipped_call_keeps_dest.spt +++ b/tests/cond/skipped_call_keeps_dest.spt @@ -16,3 +16,7 @@ arrayKept = KeepArray(-1) arrayWritten = [1 2 3] arrayWritten = KeepArray(1) "ArrayWritten: -arrayWritten" + +rangedArrayKept = [4 5] +rangedArrayKept = KeepRangedArray(0:3) +"RangedArrayKept: -rangedArrayKept" From ffdd28bfb808316ee96c5f6a4ebdd6aa5e0e3eb3 Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 27 Jul 2026 22:05:53 +0530 Subject: [PATCH 37/60] refactor(compiler): simplify CFG yield classification Inline the single-use value and condition classifier wrappers into their natural callers. Keep the shared tree traversal and CFG node predicate separate so collector, logical fallback, and out-of-bounds semantics remain explicit and unchanged. --- compiler/cfg.go | 45 +++++++++++++++++++-------------------------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index 0e1a98d7..4aeadacb 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -283,37 +283,30 @@ func (cfg *CFG) anyValueMaySkip(values []ast.Expression) bool { // valueMaySkip reports whether an RHS expression can leave its destination // unchanged: outside an inline collector, an empty range driver can run no // iterations; a root call can keep an unwritten output; and a failable value -// can yield nothing. +// can yield nothing. The shared tree traversal finds failures below the root, +// so `y = Square(x < 5) + 5` is recognized even though the call feeds an +// operator. func (cfg *CFG) valueMaySkip(expr ast.Expression) bool { - return cfg.hasRangeExpr(expr) || cfg.callRootMaySkip(expr) || cfg.valueMayNotYield(expr) -} - -// valueMayNotYield reports whether an expression may produce no value, leaving -// its destination untouched. It shares the solver's traversal, so it counts -// anywhere in the tree rather than only at a root: `y = Square(x < 5) + 5` is -// recognized even though the call feeds an operator. -func (cfg *CFG) valueMayNotYield(expr ast.Expression) bool { - return treeCanFail(expr, cfg.nodeMayNotYield) -} - -// nodeMayNotYield classifies one node for diagnostics. Beyond the solver's -// conditions it counts an array read, whose out-of-bounds case preserves the -// destination the same way. That widening belongs here and nowhere else: the -// solver's predicate also decides which programs are valid, so treating -// indexing as failable there would legalize `arr[9] || -1`. The cost is that a -// statically safe read like `arr[0]` also suppresses a real dead-store warning. + return cfg.hasRangeExpr(expr) || + cfg.callRootMaySkip(expr) || + treeCanFail(expr, cfg.nodeMayNotYield) +} + +// nodeMayNotYield classifies one node for diagnostics. Beyond conditions it +// counts an array read, whose out-of-bounds case preserves the destination the +// same way. That widening belongs here and nowhere else: the solver's predicate +// also decides which programs are valid, so treating indexing as failable +// there would legalize `arr[9] || -1`. The cost is that a statically safe read +// like `arr[0]` also suppresses a real dead-store warning. +// +// A script has been typed already, so its solver classification is exact. A +// .pt function body is validated before any specialization exists, so nothing +// is cached and the syntactic shape is the only signal; erring toward "may +// fail" there keeps the diagnostic conservative. func (cfg *CFG) nodeMayNotYield(expr ast.Expression) bool { if _, ok := expr.(*ast.ArrayRangeExpression); ok { return true } - return cfg.conditionMayFail(expr) -} - -// conditionMayFail classifies one node. A script has been typed already, so its -// solver classification is exact. A .pt function body is validated before any -// specialization exists, so nothing is cached and the syntactic shape is the -// only signal; erring toward "may fail" there keeps the diagnostic conservative. -func (cfg *CFG) conditionMayFail(expr ast.Expression) bool { if cfg.ScriptCompiler != nil { c := cfg.ScriptCompiler.Compiler if info := c.ExprCache[key(c.FuncNameMangled, expr)]; info != nil { From 899fbeb0dae2ac1d7d065370ac9b36d49bc77535 Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 27 Jul 2026 22:39:30 +0530 Subject: [PATCH 38/60] docs(ir-plan): record range domain ownership for write effects A Range parameter is bound by the caller and drives the whole specialization, so its possibly-empty domain is one shared effect at the function boundary; a locally created range suspends only the statements it drives. Template-time CFG has neither distinction, which is the known false dead-store on local empty-range bodies, and typed per-specialization effects resolve it. Also record the caching requirement that follows: effects, binding types, and validation results must be cached atomically per mangled variant, since the existing FuncCache/BindingTypes lifetime split is what produced issue #71. Co-Authored-By: Claude Opus 5 --- docs/Pluto IR Plan.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index 2eef72a7..efd9bce4 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -55,6 +55,20 @@ before the final commit; for example, `x = arr[i] > 0 || 0` is `MustWrite`, whil `x = arr[i] > 0 || other[j] > 0` remains `MayWrite`. This matches the compiler's existing solver-then-CFG order, so migration requires no pass reordering. +Inside a function body, range domain ownership decides which statements an +empty domain can skip. A Range parameter is bound by the caller and drives the +whole specialization, so its possibly-empty domain contributes one shared +effect at the function boundary rather than making every statement that reads +the parameter independently conditional. A locally created range owns only the +statements it drives, so its empty domain suspends exactly those slots. +Template-time CFG has neither distinction — it misreports a body like +`i = 0:n` / `y = 10` / `y = i + 1` as a dead store — and typed effects +computed once per mangled specialization are what resolve it. Any such +per-specialization cache must bundle write effects, binding types, and +validation results atomically: FuncCache is already shared across the scripts +of one run while BindingTypes is rebuilt per script, and that split is exactly +what produced issue #71's compile-order-dependent wrong output. + This per-target `WriteEffect` is not a public function-ABI classifier. Every exported direct `I64`/`F64` return keeps its final hidden seed parameter. Collapsing statement effects into a function-level `MustWrite`/`MayWrite` From 718286f93a1e9683367cda1330d28897afc209b7 Mon Sep 17 00:00:00 2001 From: Tejas Date: Mon, 27 Jul 2026 23:10:51 +0530 Subject: [PATCH 39/60] docs(ir-plan): record structural output spans for template analysis A call site must consume exactly len(callee.Outputs) destinations and that arity is fixed by the template declaration, so output spans are derivable before any typing exists. Template analysis can therefore classify the literal slot of a mixed statement as a definite write while the call's slots stay conditional, instead of blanketing every destination; only shapes whose slot count genuinely needs types keep the all-conditional fallback. Also refine the Range-argument wording in the domain-ownership paragraph: the argument establishes a function-level domain whose yielded values drive the body. Co-Authored-By: Claude Opus 5 --- docs/Pluto IR Plan.md | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/Pluto IR Plan.md b/docs/Pluto IR Plan.md index efd9bce4..21234537 100644 --- a/docs/Pluto IR Plan.md +++ b/docs/Pluto IR Plan.md @@ -56,11 +56,12 @@ before the final commit; for example, `x = arr[i] > 0 || 0` is `MustWrite`, whil existing solver-then-CFG order, so migration requires no pass reordering. Inside a function body, range domain ownership decides which statements an -empty domain can skip. A Range parameter is bound by the caller and drives the -whole specialization, so its possibly-empty domain contributes one shared -effect at the function boundary rather than making every statement that reads -the parameter independently conditional. A locally created range owns only the -statements it drives, so its empty domain suspends exactly those slots. +empty domain can skip. A Range argument establishes a function-level domain +whose yielded values drive the whole body, so its possibly-empty domain +contributes one shared effect at the function boundary rather than making every +statement that reads the parameter independently conditional. A locally +created range owns only the statements it drives, so its empty domain suspends +exactly those slots. Template-time CFG has neither distinction — it misreports a body like `i = 0:n` / `y = 10` / `y = i + 1` as a dead store — and typed effects computed once per mangled specialization are what resolve it. Any such @@ -69,6 +70,15 @@ validation results atomically: FuncCache is already shared across the scripts of one run while BindingTypes is rebuilt per script, and that split is exactly what produced issue #71's compile-order-dependent wrong output. +Output spans, unlike write effects, are structural before any typing: a call +site must consume exactly `len(callee.Outputs)` destinations, and that arity +is fixed by the template declaration, so template analysis can place spans for +direct calls and single-value expressions in mixed statements like +`a, b, c = MaybePair(x), 5` — the literal's slot is a definite write even +while the call's slots stay conditional. Only shapes whose slot count +genuinely needs types, such as multi-slot value-position comparisons, keep the +all-conditional fallback. + This per-target `WriteEffect` is not a public function-ABI classifier. Every exported direct `I64`/`F64` return keeps its final hidden seed parameter. Collapsing statement effects into a function-level `MustWrite`/`MayWrite` From 461680025bf8f3e0e4694bc11c1c05ecde2aebdc Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 28 Jul 2026 00:43:28 +0530 Subject: [PATCH 40/60] refactor(compiler): split typed and template CFG analysis Route typed scripts through exact solver-backed write classification and untyped function templates through their syntax-only approximation. Share event processing while removing nullable ScriptCompiler checks and retain focused coverage for typed array-mask writes. --- compiler/cfg.go | 237 ++++++++++++++++++++++--------------------- compiler/cfg_test.go | 15 ++- 2 files changed, 138 insertions(+), 114 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index 4aeadacb..c55f609c 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -158,38 +158,44 @@ func (cfg *CFG) collectSpecifierReads(value string, tok token.Token, runes []run return evs, spec.end } -func (cfg *CFG) extractStmtEvents(stmt ast.Statement) []VarEvent { - var evs []VarEvent // Holds all events for this statement - switch s := stmt.(type) { - case *ast.LetStatement: - // A LetStatement always follows the same order: - // 1. Read all variables used in the Condition(s). - for _, expr := range s.Condition { - evs = append(evs, cfg.collectReads(expr)...) - } - // 2. Read all variables used in the Value(s). - for _, expr := range s.Value { - evs = append(evs, cfg.collectReads(expr)...) +func (cfg *CFG) extractLetEvents(s *ast.LetStatement, kinds []EventType) []VarEvent { + var evs []VarEvent + // A LetStatement always follows the same order: + // 1. Read all variables used in the Condition(s). + for _, expr := range s.Condition { + evs = append(evs, cfg.collectReads(expr)...) + } + // 2. Read all variables used in the Value(s). + for _, expr := range s.Value { + evs = append(evs, cfg.collectReads(expr)...) + } + // 3. Write to the destination variable(s). + for i, lhs := range s.Name { + // Treat '_' as a discard target: do not record writes or liveness. + if lhs.Value == "_" { + continue } - // 3. Write to the destination variable(s). - kinds := cfg.destWriteKinds(s) - for i, lhs := range s.Name { - // Treat '_' as a discard target: do not record writes or liveness. - if lhs.Value == "_" { - continue - } - ve := VarEvent{Name: lhs.Value, Kind: kinds[i], Token: lhs.Tok()} - Put(cfg.Scopes, lhs.Value, ve) - evs = append(evs, ve) - } + ve := VarEvent{Name: lhs.Value, Kind: kinds[i], Token: lhs.Tok()} + Put(cfg.Scopes, lhs.Value, ve) + evs = append(evs, ve) + } + return evs +} +func (cfg *CFG) extractStmtEvents(stmt ast.Statement) []VarEvent { + switch s := stmt.(type) { + case *ast.LetStatement: + return cfg.extractLetEvents(s, cfg.destWriteKinds(s)) case *ast.PrintStatement: + var evs []VarEvent for _, expr := range s.Expression.Arguments { evs = append(evs, cfg.collectReads(expr)...) } + return evs + default: + return nil } - return evs } // destWriteKinds classifies each destination write of a statement. A statement @@ -211,22 +217,12 @@ func (cfg *CFG) destWriteKinds(s *ast.LetStatement) []EventType { return kinds } - spans, known := cfg.valueOutputSpans(s) - if !known { - // Without per-expression arity a failable span cannot be placed, so - // any failable value must suspend every destination. - if cfg.anyValueMaySkip(s.Value) { - for i := range kinds { - kinds[i] = ConditionalWrite - } - } - return kinds - } - + c := cfg.ScriptCompiler.Compiler dest := 0 - for vi, v := range s.Value { + for _, v := range s.Value { maySkip := cfg.valueMaySkip(v) - for j := 0; j < spans[vi]; j++ { + span := c.ExprCache[key(c.FuncNameMangled, v)].ExprLen + for j := 0; j < span; j++ { if maySkip { kinds[dest] = ConditionalWrite } @@ -236,82 +232,83 @@ func (cfg *CFG) destWriteKinds(s *ast.LetStatement) []EventType { return kinds } -// valueOutputSpans reports how many destinations each value expression feeds. -// Scripts are typed before analysis, so ExprLen is exact. A .pt body has no -// typing yet; values pairing one to one with destinations is the only mapping -// that needs no arity, and anything else falls back to statement-wide -// classification. -func (cfg *CFG) valueOutputSpans(s *ast.LetStatement) ([]int, bool) { - spans := make([]int, len(s.Value)) - if cfg.ScriptCompiler == nil { - if len(s.Value) != len(s.Name) { - return nil, false - } - for i := range spans { - spans[i] = 1 +// funcDestWriteKinds classifies writes using the syntax available in an +// untyped .pt function template. Values pair one-to-one with destinations when +// their counts match. Otherwise output arity is unknown, so any syntactically +// skippable value protects every destination. Range effects that depend on +// inferred bindings are unavailable in this pass. +func (cfg *CFG) funcDestWriteKinds(s *ast.LetStatement) []EventType { + kinds := make([]EventType, len(s.Name)) + for i := range kinds { + kinds[i] = Write + } + if len(s.Condition) > 0 { + for i := range kinds { + kinds[i] = ConditionalWrite } - return spans, true + return kinds } - c := cfg.ScriptCompiler.Compiler - total := 0 - for i, v := range s.Value { - info := c.ExprCache[key(c.FuncNameMangled, v)] - if info == nil || info.ExprLen <= 0 { - return nil, false + if len(s.Value) == len(s.Name) { + for i, v := range s.Value { + if cfg.funcValueMaySkip(v) { + kinds[i] = ConditionalWrite + } } - spans[i] = info.ExprLen - total += info.ExprLen - } - if total != len(s.Name) { - return nil, false + return kinds } - return spans, true -} -// anyValueMaySkip reports whether any RHS expression can leave its destination -// unchanged. -func (cfg *CFG) anyValueMaySkip(values []ast.Expression) bool { - for _, v := range values { - if cfg.valueMaySkip(v) { - return true + for _, v := range s.Value { + if !cfg.funcValueMaySkip(v) { + continue + } + for i := range kinds { + kinds[i] = ConditionalWrite } + break } - return false + return kinds } // valueMaySkip reports whether an RHS expression can leave its destination -// unchanged: outside an inline collector, an empty range driver can run no -// iterations; a root call can keep an unwritten output; and a failable value -// can yield nothing. The shared tree traversal finds failures below the root, -// so `y = Square(x < 5) + 5` is recognized even though the call feeds an -// operator. +// unchanged in a typed script: outside an inline collector, an empty range +// driver can run no iterations; a root call can keep an unwritten output; and +// a failable value can yield nothing. The shared tree traversal finds failures +// below the root, so `y = Square(x < 5) + 5` is recognized even though the call +// feeds an operator. func (cfg *CFG) valueMaySkip(expr ast.Expression) bool { return cfg.hasRangeExpr(expr) || cfg.callRootMaySkip(expr) || treeCanFail(expr, cfg.nodeMayNotYield) } -// nodeMayNotYield classifies one node for diagnostics. Beyond conditions it -// counts an array read, whose out-of-bounds case preserves the destination the -// same way. That widening belongs here and nowhere else: the solver's predicate -// also decides which programs are valid, so treating indexing as failable -// there would legalize `arr[9] || -1`. The cost is that a statically safe read -// like `arr[0]` also suppresses a real dead-store warning. -// -// A script has been typed already, so its solver classification is exact. A -// .pt function body is validated before any specialization exists, so nothing -// is cached and the syntactic shape is the only signal; erring toward "may -// fail" there keeps the diagnostic conservative. +// funcValueMaySkip applies the syntax-only approximation available in an +// untyped .pt function template. Calls and syntactically failable values are +// visible here; range effects that depend on inferred bindings are not. +func (cfg *CFG) funcValueMaySkip(expr ast.Expression) bool { + return cfg.callRootMaySkip(expr) || + treeCanFail(expr, cfg.funcNodeMayNotYield) +} + +// nodeMayNotYield uses exact solver metadata to classify one node in a typed +// script. It also counts an array read, whose out-of-bounds case preserves the +// destination. That widening belongs only to diagnostics: applying it to the +// solver's validity predicate would incorrectly legalize `arr[9] || -1`. func (cfg *CFG) nodeMayNotYield(expr ast.Expression) bool { if _, ok := expr.(*ast.ArrayRangeExpression); ok { return true } - if cfg.ScriptCompiler != nil { - c := cfg.ScriptCompiler.Compiler - if info := c.ExprCache[key(c.FuncNameMangled, expr)]; info != nil { - return info.HasCondScalar() || info.HasCondAnd() - } + c := cfg.ScriptCompiler.Compiler + info := c.ExprCache[key(c.FuncNameMangled, expr)] + return info.HasCondScalar() || info.HasCondAnd() +} + +// funcNodeMayNotYield classifies one node in an untyped .pt function template. +// Without specialization metadata, comparison and logical-AND syntax is the +// conservative signal that a scalar specialization may fail to yield. +func (cfg *CFG) funcNodeMayNotYield(expr ast.Expression) bool { + if _, ok := expr.(*ast.ArrayRangeExpression); ok { + return true } if infix, ok := expr.(*ast.InfixExpression); ok { return infix.Token.IsComparison() || infix.IsLogicalAnd() @@ -338,10 +335,6 @@ func (cfg *CFG) callRootMaySkip(v ast.Expression) bool { // position, mirroring the solver's behavior. A bare range literal is a // descriptor value and therefore does not count. func (cfg *CFG) hasRangeExpr(e ast.Expression) bool { - // Only possible when we have ScriptCompiler with ExprCache - if cfg.ScriptCompiler == nil { - return false - } c := cfg.ScriptCompiler.Compiler switch t := e.(type) { @@ -492,7 +485,7 @@ func (cfg *CFG) validateFunc(fn *ast.FuncStatement) { Put(cfg.Scopes, param.Value, ve) } - cfg.forwardPass(fn.Body.Statements) + cfg.funcForwardPass(fn.Body.Statements) // Build set of output names outSet := make(map[string]struct{}, len(fn.Outputs)) @@ -520,26 +513,44 @@ func (cfg *CFG) validateFunc(fn *ast.FuncStatement) { cfg.backwardPass(live) } -// forwardPass checks for use-before-definition and simple write-after-write errors. -// This pass iterates forward through the events. +// processForwardEvents applies one statement's events and records them for the +// backward liveness pass. +func (cfg *CFG) processForwardEvents(stmt ast.Statement, evs []VarEvent, lastWrites map[string]VarEvent) { + for _, e := range evs { + switch e.Kind { + case Read: + cfg.checkRead(lastWrites, e) + case Write, ConditionalWrite: + cfg.checkWrite(lastWrites, e) + default: + panic(fmt.Sprintf("unhandled event type: %v", e.Kind)) + } + } + block := cfg.Blocks[len(cfg.Blocks)-1] + block.Stmts = append(block.Stmts, &StmtNode{Stmt: stmt, Events: evs}) +} + +// forwardPass checks a typed script for use-before-definition and simple +// write-after-write errors. func (cfg *CFG) forwardPass(statements []ast.Statement) { - block := cfg.Blocks[len(cfg.Blocks)-1] // Get the last block lastWrites := make(map[string]VarEvent) + for _, stmt := range statements { + cfg.processForwardEvents(stmt, cfg.extractStmtEvents(stmt), lastWrites) + } +} +// funcForwardPass performs the same checks on an untyped .pt function +// template, using its syntax-only write classification. +func (cfg *CFG) funcForwardPass(statements []ast.Statement) { + lastWrites := make(map[string]VarEvent) for _, stmt := range statements { - evs := cfg.extractStmtEvents(stmt) - for _, e := range evs { - switch e.Kind { - case Read: - cfg.checkRead(lastWrites, e) - case Write, ConditionalWrite: - cfg.checkWrite(lastWrites, e) - default: - panic(fmt.Sprintf("unhandled event type: %v", e.Kind)) - } + var evs []VarEvent + if s, ok := stmt.(*ast.LetStatement); ok { + evs = cfg.extractLetEvents(s, cfg.funcDestWriteKinds(s)) + } else { + evs = cfg.extractStmtEvents(stmt) } - sn := &StmtNode{Stmt: stmt, Events: evs} - block.Stmts = append(block.Stmts, sn) + cfg.processForwardEvents(stmt, evs, lastWrites) } } diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index 60f13afd..50677e7c 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -238,7 +238,11 @@ func runCFGTest(t *testing.T, tc cfgTestCase, expectError bool) { cc := NewCodeCompiler(ctx, "TestCFGAnalysis", "", cp.Parse()) cc.Compile() cfg := NewCFG(nil, cc) - cfg.Analyze(prog.Statements) + cfg.PushBlock() + defer cfg.PopBlock() + PushScope(&cfg.Scopes, BlockScope) + cfg.funcForwardPass(prog.Statements) + cfg.backwardPass(make(map[string]struct{})) if expectError { assertHasExpectedError(t, cfg.Errors, tc.errorContains) @@ -276,6 +280,15 @@ func TestCollectorWriteIsUnconditional(t *testing.T) { assert.Contains(t, errs[0].Msg, `unconditional assignment to "c"`) } +// Typed comparison metadata distinguishes a scalar condition, which may not +// yield, from an array mask, which always materializes an array. The untyped +// function-template fallback intentionally cannot make that distinction. +func TestArrayComparisonWriteIsUnconditional(t *testing.T) { + errs := compileScriptForCFGTest(t, "arrayComparisonWrite", "a = [1 2]\nr = [9 9]\nr = a > 0\nr") + require.NotEmpty(t, errs, "the dead store behind the array mask must be reported") + assert.Contains(t, errs[0].Msg, `unconditional assignment to "r"`) +} + // A ranged gate can admit no iterations, so collector and scalar destinations // both preserve their prior values and both writes stay conditional. func TestRangedGateCollectorWriteIsConditional(t *testing.T) { From ce13031ce692c0fd24a695a2f0170f705b20f99a Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 28 Jul 2026 00:55:00 +0530 Subject: [PATCH 41/60] refactor(compiler): share CFG statement event extraction Collect Let and Print reads through one mode-neutral path, with typed and template passes supplying only their respective Let write kinds. This removes the template pass dependency on typed statement extraction. --- compiler/cfg.go | 59 +++++++++++++++++++++++-------------------------- 1 file changed, 28 insertions(+), 31 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index c55f609c..acac6d9a 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -158,19 +158,29 @@ func (cfg *CFG) collectSpecifierReads(value string, tok token.Token, runes []run return evs, spec.end } -func (cfg *CFG) extractLetEvents(s *ast.LetStatement, kinds []EventType) []VarEvent { - var evs []VarEvent - // A LetStatement always follows the same order: - // 1. Read all variables used in the Condition(s). - for _, expr := range s.Condition { - evs = append(evs, cfg.collectReads(expr)...) +// extractStmtEvents records the reads common to every statement and appends +// destination writes when stmt is a LetStatement. Callers supply write kinds +// only for a LetStatement; a PrintStatement has no destinations. +func (cfg *CFG) extractStmtEvents(stmt ast.Statement, kinds []EventType) []VarEvent { + var reads []ast.Expression + var names []*ast.Identifier + switch s := stmt.(type) { + case *ast.LetStatement: + reads = make([]ast.Expression, 0, len(s.Condition)+len(s.Value)) + reads = append(reads, s.Condition...) + reads = append(reads, s.Value...) + names = s.Name + case *ast.PrintStatement: + reads = s.Expression.Arguments + default: + return nil } - // 2. Read all variables used in the Value(s). - for _, expr := range s.Value { + + var evs []VarEvent + for _, expr := range reads { evs = append(evs, cfg.collectReads(expr)...) } - // 3. Write to the destination variable(s). - for i, lhs := range s.Name { + for i, lhs := range names { // Treat '_' as a discard target: do not record writes or liveness. if lhs.Value == "_" { continue @@ -183,21 +193,6 @@ func (cfg *CFG) extractLetEvents(s *ast.LetStatement, kinds []EventType) []VarEv return evs } -func (cfg *CFG) extractStmtEvents(stmt ast.Statement) []VarEvent { - switch s := stmt.(type) { - case *ast.LetStatement: - return cfg.extractLetEvents(s, cfg.destWriteKinds(s)) - case *ast.PrintStatement: - var evs []VarEvent - for _, expr := range s.Expression.Arguments { - evs = append(evs, cfg.collectReads(expr)...) - } - return evs - default: - return nil - } -} - // destWriteKinds classifies each destination write of a statement. A statement // condition suspends the whole simultaneous assignment. Every other source of // a skipped write belongs to one value expression — an empty driver, a callee @@ -535,7 +530,11 @@ func (cfg *CFG) processForwardEvents(stmt ast.Statement, evs []VarEvent, lastWri func (cfg *CFG) forwardPass(statements []ast.Statement) { lastWrites := make(map[string]VarEvent) for _, stmt := range statements { - cfg.processForwardEvents(stmt, cfg.extractStmtEvents(stmt), lastWrites) + var kinds []EventType + if s, ok := stmt.(*ast.LetStatement); ok { + kinds = cfg.destWriteKinds(s) + } + cfg.processForwardEvents(stmt, cfg.extractStmtEvents(stmt, kinds), lastWrites) } } @@ -544,13 +543,11 @@ func (cfg *CFG) forwardPass(statements []ast.Statement) { func (cfg *CFG) funcForwardPass(statements []ast.Statement) { lastWrites := make(map[string]VarEvent) for _, stmt := range statements { - var evs []VarEvent + var kinds []EventType if s, ok := stmt.(*ast.LetStatement); ok { - evs = cfg.extractLetEvents(s, cfg.funcDestWriteKinds(s)) - } else { - evs = cfg.extractStmtEvents(stmt) + kinds = cfg.funcDestWriteKinds(s) } - cfg.processForwardEvents(stmt, evs, lastWrites) + cfg.processForwardEvents(stmt, cfg.extractStmtEvents(stmt, kinds), lastWrites) } } From e0439e0169ff01ccfb68e7cf254da584b5b3f275 Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 28 Jul 2026 01:05:00 +0530 Subject: [PATCH 42/60] refactor(compiler): avoid redundant write initialization Initialize conditional statement destinations directly as ConditionalWrite, and fill the default Write classification only for statements that continue into per-value analysis. --- compiler/cfg.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index acac6d9a..89f81b86 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -202,9 +202,7 @@ func (cfg *CFG) extractStmtEvents(stmt ast.Statement, kinds []EventType) []VarEv // reported. func (cfg *CFG) destWriteKinds(s *ast.LetStatement) []EventType { kinds := make([]EventType, len(s.Name)) - for i := range kinds { - kinds[i] = Write - } + if len(s.Condition) > 0 { for i := range kinds { kinds[i] = ConditionalWrite @@ -212,6 +210,10 @@ func (cfg *CFG) destWriteKinds(s *ast.LetStatement) []EventType { return kinds } + for i := range kinds { + kinds[i] = Write + } + c := cfg.ScriptCompiler.Compiler dest := 0 for _, v := range s.Value { @@ -234,9 +236,7 @@ func (cfg *CFG) destWriteKinds(s *ast.LetStatement) []EventType { // inferred bindings are unavailable in this pass. func (cfg *CFG) funcDestWriteKinds(s *ast.LetStatement) []EventType { kinds := make([]EventType, len(s.Name)) - for i := range kinds { - kinds[i] = Write - } + if len(s.Condition) > 0 { for i := range kinds { kinds[i] = ConditionalWrite @@ -244,6 +244,10 @@ func (cfg *CFG) funcDestWriteKinds(s *ast.LetStatement) []EventType { return kinds } + for i := range kinds { + kinds[i] = Write + } + if len(s.Value) == len(s.Name) { for i, v := range s.Value { if cfg.funcValueMaySkip(v) { From f97991f11cdfb092b6b943e3caeddd12bf32b413 Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 28 Jul 2026 13:23:52 +0530 Subject: [PATCH 43/60] refactor(compiler): initialize CFG write kinds safely Create fully initialized write-kind slices through one shared helper. This avoids exposing EventType's Read zero value while removing duplicated fill loops in typed and template classification. --- compiler/cfg.go | 32 ++++++++++++-------------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/compiler/cfg.go b/compiler/cfg.go index 89f81b86..50868853 100644 --- a/compiler/cfg.go +++ b/compiler/cfg.go @@ -201,19 +201,11 @@ func (cfg *CFG) extractStmtEvents(stmt ast.Statement, kinds []EventType) []VarEv // expression feeds and a dead store behind an unconditional sibling is still // reported. func (cfg *CFG) destWriteKinds(s *ast.LetStatement) []EventType { - kinds := make([]EventType, len(s.Name)) - if len(s.Condition) > 0 { - for i := range kinds { - kinds[i] = ConditionalWrite - } - return kinds - } - - for i := range kinds { - kinds[i] = Write + return makeWriteKinds(len(s.Name), ConditionalWrite) } + kinds := makeWriteKinds(len(s.Name), Write) c := cfg.ScriptCompiler.Compiler dest := 0 for _, v := range s.Value { @@ -235,19 +227,11 @@ func (cfg *CFG) destWriteKinds(s *ast.LetStatement) []EventType { // skippable value protects every destination. Range effects that depend on // inferred bindings are unavailable in this pass. func (cfg *CFG) funcDestWriteKinds(s *ast.LetStatement) []EventType { - kinds := make([]EventType, len(s.Name)) - if len(s.Condition) > 0 { - for i := range kinds { - kinds[i] = ConditionalWrite - } - return kinds - } - - for i := range kinds { - kinds[i] = Write + return makeWriteKinds(len(s.Name), ConditionalWrite) } + kinds := makeWriteKinds(len(s.Name), Write) if len(s.Value) == len(s.Name) { for i, v := range s.Value { if cfg.funcValueMaySkip(v) { @@ -269,6 +253,14 @@ func (cfg *CFG) funcDestWriteKinds(s *ast.LetStatement) []EventType { return kinds } +func makeWriteKinds(count int, kind EventType) []EventType { + kinds := make([]EventType, count) + for i := range kinds { + kinds[i] = kind + } + return kinds +} + // valueMaySkip reports whether an RHS expression can leave its destination // unchanged in a typed script: outside an inline collector, an empty range // driver can run no iterations; a root call can keep an unwritten output; and From 934ede8e975187079a4ad800d39269c537c4cb60 Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 28 Jul 2026 20:35:20 +0530 Subject: [PATCH 44/60] refactor(compiler): simplify call and output lowering Trust solver-proven call arity and ArrayRange shape during argument lowering. Remove redundant alias-selector filtering and write-flag temporaries without changing generated behavior. --- compiler/compiler.go | 37 ++++++++++--------------------------- 1 file changed, 10 insertions(+), 27 deletions(-) diff --git a/compiler/compiler.go b/compiler/compiler.go index 91e11506..bb41fb51 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -320,8 +320,7 @@ func (c *Compiler) setCallArgAliasSelectors(sig *callSignature, args []callArg, } for paramIndex, arg := range args { - paramABI := sig.ABI.Params[paramIndex] - if arg.Name == "" || (paramABI.Mode == ABIParamDirect && paramABI.AliasSlot < 0) { + if arg.Name == "" { continue } @@ -971,7 +970,6 @@ func (c *Compiler) storeSymbolToSlot(dst *Symbol, src *Symbol, target Type, load if !ok { panic("internal: storeSymbolToSlot requires pointer destination") } - sourceWriteFlag := src.WriteFlag if target.Kind() != ptrType.Elem.Kind() { target = ptrType.Elem } @@ -995,7 +993,7 @@ func (c *Compiler) storeSymbolToSlot(dst *Symbol, src *Symbol, target Type, load coerced := c.coerceSymbolForType(source, target, "") c.createStore(coerced.Val, dst.Val, coerced.Type) - c.markOutputSlotWritten(dst, sourceWriteFlag) + c.markOutputSlotWritten(dst, src.WriteFlag) return coerced } @@ -2953,23 +2951,16 @@ func (c *Compiler) createIfCont(cond llvm.Value, ifName, contName string) (llvm. } func (c *Compiler) compileCallArgs(sig *callSignature, ce *ast.CallExpression) []callArg { - args := []callArg{} - paramIndex := 0 + args := make([]callArg, 0, len(sig.ParamTypes)) for _, callArgExpr := range ce.Arguments { - if paramIndex >= len(sig.ParamTypes) { - panic("internal: call argument count exceeds resolved signature") - } - if arrayRangeType, ok := sig.ParamTypes[paramIndex].(ArrayRange); ok { - arrayRangeExpr, ok := callArgExpr.(*ast.ArrayRangeExpression) - if !ok { - panic(fmt.Sprintf("internal: ArrayRange parameter received %T", callArgExpr)) + if arrayRangeExpr, ok := callArgExpr.(*ast.ArrayRangeExpression); ok { + if arrayRangeType, ok := sig.ParamTypes[len(args)].(ArrayRange); ok { + args = append(args, callArg{ + Expr: callArgExpr, + Symbol: c.compileArrayRangeCallArg(arrayRangeExpr, arrayRangeType), + }) + continue } - args = append(args, callArg{ - Expr: callArgExpr, - Symbol: c.compileArrayRangeCallArg(arrayRangeExpr, arrayRangeType), - }) - paramIndex++ - continue } if ident, ok := callArgExpr.(*ast.Identifier); ok { @@ -2977,25 +2968,17 @@ func (c *Compiler) compileCallArgs(sig *callSignature, ce *ast.CallExpression) [ Expr: callArgExpr, Name: ident.Value, }) - paramIndex++ continue } res := c.compileExpression(callArgExpr, nil) for _, r := range res { - if paramIndex >= len(sig.ParamTypes) { - panic("internal: compiled call argument count exceeds resolved signature") - } args = append(args, callArg{ Expr: callArgExpr, Symbol: r, }) - paramIndex++ } } - if paramIndex != len(sig.ParamTypes) { - panic(fmt.Sprintf("internal: compiled %d call arguments for %d parameters", paramIndex, len(sig.ParamTypes))) - } return args } From cdb2627376f0179baec044ad0f4bb57637633134 Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 28 Jul 2026 20:54:26 +0530 Subject: [PATCH 45/60] test(compiler): shorten alias test names Keep the regression intent in focused comments while making the related test identifiers easier to scan. --- compiler/compiler_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 11cc0726..e498654f 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -196,7 +196,7 @@ func verifyCompiledFunctions(t *testing.T, moduleName, codeSrc, scriptSrc string require.NotZero(t, verified, "expected at least one defined function to verify") } -func TestAliasSelectorSkipsMismatchedOutputs(t *testing.T) { +func TestAliasSelectorTypeGaps(t *testing.T) { // The accumulator leads in the first group, so it is reached through // selector 1. In the second group the mismatched output leads, so the // accumulator is selector 2 and the skipped slot must stay a numbering gap. @@ -222,7 +222,7 @@ func TestAliasSelectorSkipsMismatchedOutputs(t *testing.T) { // 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 TestPointerPromotionSkipsMismatchedOutputSlot(t *testing.T) { +func TestPromotedAliasTypeGap(t *testing.T) { code := `half, res = Rev(a, x) "count-a%n chars" half = x * 0.5 @@ -242,7 +242,7 @@ h, r` "the mismatched leading output must never be selectable as the parameter's slot") } -func TestCollectorOverRangeSelectionRegistersScalarVariant(t *testing.T) { +func TestRangeCollectorScalarVariant(t *testing.T) { code := `res = Scale(x) res = x * 3` script := `arr = [10 20 30] From be081aeaf114184bba75242b147c9b07e32054de Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 28 Jul 2026 22:42:11 +0530 Subject: [PATCH 46/60] test(compiler): clarify shared-driver fallback Rename the lowering test and document that caller-side scalar iteration is the current correctness fallback until callee specializations encode shared driver identity. --- compiler/compiler_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index e498654f..73a7f1b2 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -366,7 +366,9 @@ row` "a rank-two selection should be iterated inside one callee invocation") } -func TestSharedArrayRangeDriverUsesCallerScalarVariant(t *testing.T) { +// Shared drivers must advance in lockstep. Until callee specializations encode +// driver identity, keep the shared loop at the caller and invoke a scalar call. +func TestSharedDriverUsesScalarCall(t *testing.T) { code := `out = Add(x, y) out = x + y` script := `i = 0:3 From cd06fefadec7344c1044743bc61fd58d2d872559 Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 28 Jul 2026 23:04:00 +0530 Subject: [PATCH 47/60] refactor(compiler): simplify format marker scan Trust the specifier parser's forward cursor contract and derive marker validity solely from resolved main markers. --- compiler/format.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/compiler/format.go b/compiler/format.go index 6634df9f..16ba75c2 100644 --- a/compiler/format.go +++ b/compiler/format.go @@ -911,15 +911,13 @@ func formatMarkerIdentifiers(value string, isDefined func(string) bool) (mains, specs = append(specs, specID) } } - if spec.end > end { - i = spec.end - 1 - } + i = spec.end - 1 } return mains, specs } // hasValidMarkers checks if a format string contains a resolved marker. func hasValidMarkers(value string, isDefined func(string) bool) bool { - mains, specs := formatMarkerIdentifiers(value, isDefined) - return len(mains)+len(specs) > 0 + mains, _ := formatMarkerIdentifiers(value, isDefined) + return len(mains) > 0 } From c6681204acfc4fdb91d070ef495cbca314b49641 Mon Sep 17 00:00:00 2001 From: Tejas Date: Tue, 28 Jul 2026 23:25:01 +0530 Subject: [PATCH 48/60] refactor(compiler): centralize range symbol invariant Make range symbol extraction total for its lowering callers and report invalid driver types in one place. This removes duplicate success checks and caller-side invariant panics. --- compiler/loop.go | 37 +++++++++++++++---------------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/compiler/loop.go b/compiler/loop.go index 8553378d..ad4a3f7e 100644 --- a/compiler/loop.go +++ b/compiler/loop.go @@ -13,11 +13,11 @@ type Loop struct { Exit *llvm.BasicBlock } -// extractRangeSymbol loads a range aggregate from a symbol when needed. -func (c *Compiler) extractRangeSymbol(sym *Symbol, name string) (*Symbol, bool) { +// rangeSymbol loads a range aggregate from a symbol when needed. +func (c *Compiler) rangeSymbol(sym *Symbol, name string) *Symbol { switch t := sym.Type.(type) { case Range: - return sym, true + return sym case Ptr: rangeType, ok := t.Elem.(Range) if ok { @@ -27,34 +27,27 @@ func (c *Compiler) extractRangeSymbol(sym *Symbol, name string) (*Symbol, bool) FuncArg: sym.FuncArg, Borrowed: true, ReadOnly: sym.ReadOnly, - }, true + } } } - return nil, false + + panic(fmt.Sprintf("internal: %q is not a Range during lowering (got %s)", name, sym.Type.String())) } func (c *Compiler) rangeAggregateFromSymbol(sym *Symbol, name string) llvm.Value { - if rangeSym, ok := c.extractRangeSymbol(sym, name); ok { - return rangeSym.Val - } - - panic(fmt.Sprintf("internal: %q is not a Range during lowering (got %s)", name, sym.Type.String())) + return c.rangeSymbol(sym, name).Val } func (c *Compiler) iterOverDriverSymbol(sym *Symbol, name string, body func(*Symbol)) { - if rangeSym, ok := c.extractRangeSymbol(sym, name); ok { - c.iterOverRange(rangeSym.Type.(Range), rangeSym.Val, func(iter llvm.Value, iterType Type) { - body(&Symbol{ - Val: iter, - Type: iterType, - FuncArg: rangeSym.FuncArg, - Borrowed: true, - }) + rangeSym := c.rangeSymbol(sym, name) + c.iterOverRange(rangeSym.Type.(Range), rangeSym.Val, func(iter llvm.Value, iterType Type) { + body(&Symbol{ + Val: iter, + Type: iterType, + FuncArg: rangeSym.FuncArg, + Borrowed: true, }) - return - } - - panic(fmt.Sprintf("internal: %q is not a Range during lowering (got %s)", name, sym.Type.String())) + }) } // rangeAggregateForRI builds the {start,stop,step} aggregate for a driver. From c1d3ed366c0547c10e93b8070d48b17a9f45638a Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 29 Jul 2026 00:40:15 +0530 Subject: [PATCH 49/60] refactor(compiler): reuse print argument range collection Route every non-descriptor print argument through the ordinary expression collector while preserving bare Range descriptors until sibling drivers are known. This keeps array selections and other computations on the same path as call arguments without changing print semantics. --- compiler/solver.go | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/compiler/solver.go b/compiler/solver.go index bbc10f48..794c290a 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -489,7 +489,7 @@ func (ts *TypeSolver) HandleCallRanges(call *ast.CallExpression) (ranges []*Rang // Print is a sink rather than an operation, so its bare descriptor // arguments print as values and must not be rewritten into loop iterators. if call.Function.Value == Print { - ranges, args, changed = ts.collectPrintArgRanges(call) + ranges, args, changed = ts.collectPrintArgRanges(call.Arguments) } else { ranges, args, changed = ts.collectExprRanges(call.Arguments) } @@ -528,27 +528,30 @@ func (ts *TypeSolver) HandleCallRanges(call *ast.CallExpression) (ranges []*Rang return } -// collectPrintArgRanges collects drivers for a print statement, deciding -// descriptor versus driver before any argument is rewritten. Computations -// contribute drivers exactly as in collectExprRanges. A bare descriptor -// argument whose name a sibling binds stays a driver; any other bare -// descriptor keeps its original expression — rewriting it first would leave -// the print loop referencing an iterator no loop binds — and prints as a -// value, mirroring resolveBareRangeAssignment at assignment roots. -func (ts *TypeSolver) collectPrintArgRanges(call *ast.CallExpression) (ranges []*RangeInfo, args []ast.Expression, changed bool) { - args = make([]ast.Expression, len(call.Arguments)) - for i, arg := range call.Arguments { +// collectPrintArgRanges keeps bare Range descriptors out of the ordinary +// argument pass until sibling drivers are known. Every other argument uses +// collectExprRanges exactly as it does for an ordinary call. A bare descriptor +// whose name a sibling binds then joins that driver; any other bare descriptor +// keeps its original expression and prints as a value. +func (ts *TypeSolver) collectPrintArgRanges(exprs []ast.Expression) (ranges []*RangeInfo, args []ast.Expression, changed bool) { + args = append([]ast.Expression(nil), exprs...) + + var ordinaryArgs []ast.Expression + var ordinaryIndexes []int + for i, arg := range exprs { if ts.bareRangeDescriptorArg(arg) { - args[i] = arg continue } - argRanges, rew := ts.HandleRanges(arg) - args[i] = rew - changed = changed || rew != arg - ranges = mergeUses(ranges, argRanges) + ordinaryArgs = append(ordinaryArgs, arg) + ordinaryIndexes = append(ordinaryIndexes, i) + } + + ranges, rewrites, changed := ts.collectExprRanges(ordinaryArgs) + for i, argIndex := range ordinaryIndexes { + args[argIndex] = rewrites[i] } - for i, arg := range call.Arguments { + for i, arg := range exprs { if !ts.bareRangeDescriptorArg(arg) { continue } @@ -563,7 +566,6 @@ func (ts *TypeSolver) collectPrintArgRanges(call *ast.CallExpression) (ranges [] info.Ranges = nil info.HasRanges = false info.Rewrite = nil - args[i] = arg } return ranges, args, changed } From 6bfa8229b33b2e803575c93c5d175284d3bf2baa Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 29 Jul 2026 01:02:07 +0530 Subject: [PATCH 50/60] refactor(compiler): centralize array range classification Make callScopedArrayRangeType the single authority for bare array-selection eligibility and reuse its result during range-driver classification. Preserve the diagnostic path where an invalid array source leaves its index untyped. --- compiler/solver.go | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/compiler/solver.go b/compiler/solver.go index 794c290a..b8d05866 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -592,14 +592,8 @@ func (ts *TypeSolver) isBareRangeExpr(expr ast.Expression) bool { case *ast.Identifier, *ast.RangeLiteral: return true case *ast.ArrayRangeExpression: - arrInfo := ts.ExprCache[key(ts.FuncNameMangled, e.Array)] - idxInfo := ts.ExprCache[key(ts.FuncNameMangled, e.Range)] - return arrInfo != nil && - !arrInfo.HasRanges && - idxInfo != nil && - len(idxInfo.OutTypes) == 1 && - idxInfo.OutTypes[0].Kind() == RangeKind && - ts.isBareRangeExpr(e.Range) + _, _, ok := ts.callScopedArrayRangeType(e) + return ok default: return false } @@ -2353,29 +2347,29 @@ func callArgsShareRangeDriver(exprs []ast.Expression, cache map[ExprKey]*ExprInf return false } -// callScopedArrayRangeType returns the internal parameter type for an immediate -// bare array selection and the yielded type seen by the function body. +// callScopedArrayRangeType returns the internal parameter type for a bare array +// selection and the yielded type seen by the function body. func (ts *TypeSolver) callScopedArrayRangeType(expr ast.Expression) (ArrayRange, Type, bool) { ax, ok := expr.(*ast.ArrayRangeExpression) - if !ok || !ts.isBareRangeExpr(ax) { + if !ok { return ArrayRange{}, nil, false } arrInfo := ts.ExprCache[key(ts.FuncNameMangled, ax.Array)] idxInfo := ts.ExprCache[key(ts.FuncNameMangled, ax.Range)] - if arrInfo == nil || idxInfo == nil || len(arrInfo.OutTypes) != 1 || len(idxInfo.OutTypes) != 1 { + // An invalid array source can stop before its index is typed. + if idxInfo == nil { return ArrayRange{}, nil, false } - - arrType, ok := arrInfo.OutTypes[0].(Array) - if !ok { + if arrInfo.HasRanges || len(arrInfo.OutTypes) != 1 || len(idxInfo.OutTypes) != 1 { return ArrayRange{}, nil, false } - rangeType, ok := idxInfo.OutTypes[0].(Range) - if !ok { + + arrType, arrayOK := arrInfo.OutTypes[0].(Array) + rangeType, rangeOK := idxInfo.OutTypes[0].(Range) + if !arrayOK || !rangeOK || !ts.isBareRangeExpr(ax.Range) { return ArrayRange{}, nil, false } - return ArrayRange{Array: arrType, Range: rangeType}, arrayIndexResultType(arrType), true } From b8dc92579947feeec967438fa957f7eeaa7fd7a5 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 29 Jul 2026 12:50:38 +0530 Subject: [PATCH 51/60] refactor(compiler): simplify bare range resolution Rely on identifier typing and range handling to populate the cached range metadata before assignment resolution. When a statement condition binds the descriptor as an iterator, update only the returned and cached output types while keeping their slices independent. --- compiler/solver.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/compiler/solver.go b/compiler/solver.go index b8d05866..9970b00f 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -674,14 +674,8 @@ func (ts *TypeSolver) resolveBareRangeAssignment(expr ast.Expression, types []Ty info.Rewrite = nil return } - if len(info.Ranges) == 0 { - info.Ranges = []*RangeInfo{{Name: e.Value}} - } - info.OutTypes = []Type{rangeType.Iter} - info.ExprLen = 1 - info.HasRanges = true - info.Rewrite = e types[0] = rangeType.Iter + info.OutTypes[0] = rangeType.Iter case *ast.RangeLiteral: info.Ranges = nil info.HasRanges = false From fbf3055d7202e1cc284fb04741e457ab7ee2cc07 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 29 Jul 2026 12:57:26 +0530 Subject: [PATCH 52/60] refactor(compiler): simplify range driver checks Rely on statement-condition typing to populate the root and array-source expression cache entries before range-driver classification. Keep only the range and source-dependency checks that affect classification. --- compiler/solver.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compiler/solver.go b/compiler/solver.go index 9970b00f..a7889a82 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -769,7 +769,7 @@ func (ts *TypeSolver) isRangeDriverCond(expr ast.Expression, condTypes []Type) b return false } info := ts.ExprCache[key(ts.FuncNameMangled, expr)] - if info == nil || len(info.Ranges) == 0 { + if len(info.Ranges) == 0 { return false } @@ -778,7 +778,7 @@ func (ts *TypeSolver) isRangeDriverCond(expr ast.Expression, condTypes []Type) b return true case *ast.ArrayRangeExpression: arrInfo := ts.ExprCache[key(ts.FuncNameMangled, e.Array)] - return arrInfo != nil && !arrInfo.HasRanges && ts.isBareRangeExpr(e.Range) + return !arrInfo.HasRanges && ts.isBareRangeExpr(e.Range) default: return false } From 95640665e5a39607335efe2876ccc675abbf55bb Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 29 Jul 2026 13:39:59 +0530 Subject: [PATCH 53/60] refactor(compiler): narrow expression cache guards Rely on completed typing for statement, logical operand, lowering, and call-argument cache entries. Retain and document the condition propagation guard needed while diagnostics walk partially typed invalid expressions. --- compiler/solver.go | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/compiler/solver.go b/compiler/solver.go index a7889a82..c7738ac1 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -814,6 +814,8 @@ func treeCanFail(expr ast.Expression, nodeFails func(ast.Expression) bool) bool // out-of-bounds read, must not be folded in. func (ts *TypeSolver) conditionPropagates(expr ast.Expression) bool { info := ts.ExprCache[key(ts.FuncNameMangled, expr)] + // An invalid composite can stop typing before all descendants are cached; + // logical validation still walks that partial tree to report diagnostics. return info != nil && (info.HasCondScalar() || info.HasCondAnd()) } @@ -858,7 +860,7 @@ func (ts *TypeSolver) validateStatementCondition(expr ast.Expression, condTypes // A condition that carries a comparison but can never fail (an unconditional // || fallback like `a > 0 || b`, which always yields b) does not gate. - if info := ts.ExprCache[key(ts.FuncNameMangled, expr)]; info != nil && info.HasCondExpr() { + if info.HasCondExpr() { ts.Errors = append(ts.Errors, &token.CompileError{ Token: expr.Tok(), Msg: "statement condition can never fail (its || fallback always yields a value)", @@ -1862,7 +1864,7 @@ func (ts *TypeSolver) typeLogicalOrExpression(expr *ast.InfixExpression, left, r ts.ExprCache[key(ts.FuncNameMangled, expr)] = &ExprInfo{ OutTypes: types, ExprLen: len(types), - HasRanges: (leftInfo != nil && leftInfo.HasRanges) || (rightInfo != nil && rightInfo.HasRanges), + HasRanges: leftInfo.HasRanges || rightInfo.HasRanges, CompareModes: compareModes, } return types @@ -1923,7 +1925,7 @@ func (ts *TypeSolver) typeLogicalAndExpression(expr *ast.InfixExpression, left, ts.ExprCache[key(ts.FuncNameMangled, expr)] = &ExprInfo{ OutTypes: types, ExprLen: len(types), - HasRanges: (leftInfo != nil && leftInfo.HasRanges) || (rightInfo != nil && rightInfo.HasRanges), + HasRanges: leftInfo.HasRanges || rightInfo.HasRanges, CompareModes: compareModes, } return types @@ -1982,7 +1984,8 @@ func (ts *TypeSolver) TypeInfixExpression(expr *ast.InfixExpression) (types []Ty // with it (solver and compiler share the same ExprCache map). func treeHasLogicalCond(cache map[ExprKey]*ExprInfo, funcNameMangled string, expr ast.Expression) bool { if infix, ok := expr.(*ast.InfixExpression); ok && (infix.IsLogicalOr() || infix.IsLogicalAnd()) { - if info := cache[key(funcNameMangled, expr)]; info != nil && (info.HasFallbackOr() || info.HasCondAnd()) { + info := cache[key(funcNameMangled, expr)] + if info.HasFallbackOr() || info.HasCondAnd() { return true } } @@ -2321,10 +2324,6 @@ func callArgsShareRangeDriver(exprs []ast.Expression, cache map[ExprKey]*ExprInf owner := make(map[string]int) for argIndex, expr := range exprs { info := cache[key(funcNameMangled, expr)] - if info == nil { - continue - } - seenInArg := make(map[string]struct{}) for _, driver := range info.Ranges { if _, seen := seenInArg[driver.Name]; seen { From 0541948f116eeedc6bd2035fc0b4e1eb58306e96 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 29 Jul 2026 14:30:10 +0530 Subject: [PATCH 54/60] refactor(compiler): make print iteration ownership explicit --- compiler/solver.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compiler/solver.go b/compiler/solver.go index c7738ac1..7b468bab 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -2240,9 +2240,9 @@ func (ts *TypeSolver) TypeCallExpression(ce *ast.CallExpression, isRoot bool) [] break } } - // Print has no callee body that can own iteration, so driver arguments are - // always expanded at the statement and printed as yielded scalar values. - if ce.Function.Value == Print && hasRanges { + // Print has no callee body that can own iteration. Any driver arguments are + // expanded at the statement and printed as yielded scalar values. + if ce.Function.Value == Print { loopInside = false args = innerArgs } From 24e727bf0ee59b995d9432ba1a5edee41fc6db06 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 29 Jul 2026 15:18:40 +0530 Subject: [PATCH 55/60] refactor(compiler): simplify shared range detection --- compiler/solver.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/compiler/solver.go b/compiler/solver.go index 7b468bab..18f498fb 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -2324,13 +2324,7 @@ func callArgsShareRangeDriver(exprs []ast.Expression, cache map[ExprKey]*ExprInf owner := make(map[string]int) for argIndex, expr := range exprs { info := cache[key(funcNameMangled, expr)] - seenInArg := make(map[string]struct{}) for _, driver := range info.Ranges { - if _, seen := seenInArg[driver.Name]; seen { - continue - } - seenInArg[driver.Name] = struct{}{} - if previousArg, exists := owner[driver.Name]; exists && previousArg != argIndex { return true } From 586196495b1bd5b519a40a0de495c3fdcdd09868 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 29 Jul 2026 15:32:43 +0530 Subject: [PATCH 56/60] refactor(compiler): remove redundant array range arity guard --- compiler/solver.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compiler/solver.go b/compiler/solver.go index 18f498fb..c33aded6 100644 --- a/compiler/solver.go +++ b/compiler/solver.go @@ -2368,7 +2368,7 @@ func (ts *TypeSolver) collectCallArgs(ce *ast.CallExpression, isRoot bool) (args // Build args and innerArgs from outer types // If loopInside=false, ALL range args become their inner type (loop outside) for argIndex, outerTypes := range outerTypesPerArg { - if loopInside && len(outerTypes) == 1 { + if loopInside { if arrayRangeType, yieldedType, ok := ts.callScopedArrayRangeType(ce.Arguments[argIndex]); ok { args = append(args, arrayRangeType) innerArgs = append(innerArgs, yieldedType) From b4792f44cf917ead9f878aba0cbc4c64d55452b6 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 29 Jul 2026 15:47:11 +0530 Subject: [PATCH 57/60] docs: align ABI entry point table --- docs/Pluto ABI Optimization Plan.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/Pluto ABI Optimization Plan.md b/docs/Pluto ABI Optimization Plan.md index 5c23014e..9e68b17d 100644 --- a/docs/Pluto ABI Optimization Plan.md +++ b/docs/Pluto ABI Optimization Plan.md @@ -63,11 +63,11 @@ Concretely, it hooks between `TypeLetStatement` / `TypeExpression` (which resolv The current compiler emits one classified mangled entry point used by both Pluto calls and direct C callers. There is not yet a separate stable wrapper: -| Entry point | Consumers | Convention | Stability | -| --- | --- | --- | --- | +| Entry point | Consumers | Convention | Stability | +| ---------------------- | ----------- | ------------------------- | ----------------------------------------------- | | Current mangled symbol | Pluto and C | Documented classified ABI | Write-effect-independent within the ABI version | -| Future private clone | Pluto only | Internally optimized | May vary freely | -| Future public wrapper | C | Documented wrapper ABI | Stable or explicitly versioned | +| Future private clone | Pluto only | Internally optimized | May vary freely | +| Future public wrapper | C | Documented wrapper ABI | Stable or explicitly versioned | Name mangling encodes semantic types, not physical ABI. Changing `I64` from pointer-passed to value-passed therefore does not change the name, even though From 7f750f7cc750b213212d8846096ff5fc23dc54a8 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 29 Jul 2026 16:19:16 +0530 Subject: [PATCH 58/60] docs: consolidate range semantics Remove repeated descriptor, final-value, collector, and statement-gate explanations while keeping each rule in one canonical section. Link dynamic formatting behavior to its dedicated specification and correct the final ABI table alignment; no language semantics change. --- docs/Pluto ABI Optimization Plan.md | 2 +- docs/Pluto Range Semantics.md | 147 ++++------------------------ 2 files changed, 20 insertions(+), 129 deletions(-) diff --git a/docs/Pluto ABI Optimization Plan.md b/docs/Pluto ABI Optimization Plan.md index 9e68b17d..0fb2d699 100644 --- a/docs/Pluto ABI Optimization Plan.md +++ b/docs/Pluto ABI Optimization Plan.md @@ -67,7 +67,7 @@ Pluto calls and direct C callers. There is not yet a separate stable wrapper: | ---------------------- | ----------- | ------------------------- | ----------------------------------------------- | | Current mangled symbol | Pluto and C | Documented classified ABI | Write-effect-independent within the ABI version | | Future private clone | Pluto only | Internally optimized | May vary freely | -| Future public wrapper | C | Documented wrapper ABI | Stable or explicitly versioned | +| Future public wrapper | C | Documented wrapper ABI | Stable or explicitly versioned | Name mangling encodes semantic types, not physical ABI. Changing `I64` from pointer-passed to value-passed therefore does not change the name, even though diff --git a/docs/Pluto Range Semantics.md b/docs/Pluto Range Semantics.md index 2d6af13d..eb589749 100644 --- a/docs/Pluto Range Semantics.md +++ b/docs/Pluto Range Semantics.md @@ -32,17 +32,9 @@ This keeps descriptor copying, collection, and final-value selection separate. ## Migration From Bare-Range Finalization -Previously, assigning a bare named Range kept its final yield. It now copies -the descriptor: - -```pluto -i = 0:5 -copy = i -last = i + 0 -``` - -`copy` is a Range descriptor; `last` is the scalar `4`. Use an operation such -as `+ 0` when migrating code that intended the old final-value behavior. +Previously, assigning a bare named Range kept its final yield. Code that relied +on that behavior should use an operation such as `last = i + 0`; for +`i = 0:5`, `last` becomes `4`, while `copy = i` now copies the descriptor. This change can be silent for a fresh destination. A later call, index, or collector consumes the copied Range and runs its whole domain, while print and @@ -55,11 +47,8 @@ unchanged because indexing is already a ranged computation. ## Ranges And Drivers A range identifier consumed by an operator, array index, collector, statement -condition, or function argument contributes an iteration driver. Print -arguments and main interpolation markers are display positions, not -consumers: a bare Range there formats its descriptor. A width or precision -operand is consumed as a number, so a named Range in a specifier still -drives. A range-indexed array access is itself a ranged computation. +condition, or function argument contributes an iteration driver. A +range-indexed array access is itself a ranged computation. Multiple distinct drivers form a nested iteration domain in source order: the first distinct driver is outermost and the last is innermost. Repeated use of the same driver name refers to the same loop, not a nested copy. @@ -67,27 +56,6 @@ Driver identity belongs to the binding name, not to descriptor equality. Substituting one Range name for another can therefore change a shared loop into a cartesian domain. -Example: - -```pluto -i = 0:5 -x = i + 1 -``` - -This iterates `i` over `0, 1, 2, 3, 4` and the root assignment keeps the final -value, so `x = 5`. - -A complete bare Range expression is a descriptor value: - -```pluto -i = 0:5 -copy = i -last = i + 0 -``` - -`copy` is another Range descriptor and an independent named driver with the -same bounds, while `last` becomes `4`. Copy a Range to bind another driver with -the same bounds, or write another range literal to define different bounds. Range `start`/`stop`/`step` fields are not part of the language. The bound values are captured when the range is constructed, so later changes to the source variables do not mutate the existing range. Functions that need @@ -127,7 +95,11 @@ failed cells are zero-filled to preserve collection shape, as described below. Print is a sink, not an operation: a bare Range argument or main marker formats the descriptor as `start:stop` (or `start:stop:step`), exactly as the value it is. Computations still drive the print loop, and a bare name bound as -a driver by a sibling computation prints its per-iteration scalar: +a driver by a sibling computation prints its per-iteration scalar. A width or +precision marker consumes its Range operand as a number, so that operand still +drives; see +[Pluto String and Formatting Semantics](Pluto%20String%20and%20Formatting%20Semantics.md#interpolation-markers) +for the formatting rules and examples. For ordinary print arguments: ```pluto i = 0:2 @@ -252,7 +224,8 @@ expression. Once the literal has materialized, the result is just an ordinary array value. Binding freezes that value, so later statements treat it like any other named -array. +array. With no active drivers, a collector evaluates once and produces a +singleton array. ### Collectors And Binding @@ -289,16 +262,6 @@ This produces: because `y` is collected as `[0 0 0 0 0]` under the admitted `i` domain. -By contrast: - -```pluto -i = 0:5 -y = [0] -res = i + y -``` - -also produces `[4]`. - Example: ```pluto @@ -476,13 +439,9 @@ range and validate that ownership before LLVM lowering. ## Statement Conditions And Tuples -Statement conditions are shared across the whole assignment. -They determine the admitted outer iteration domain for every output in the -statement. - -Sibling RHS expressions do not share their local value drivers with each -other. -Each RHS adds only the extra drivers mentioned inside that expression. +For tuple assignments, the statement-wide gate described above applies to +every output. Each RHS adds only the local drivers mentioned inside that +expression; sibling RHS expressions do not share those drivers. Examples: @@ -547,61 +506,10 @@ The statement condition admits `i = 3 4 5`. The outer expression then continues with the frozen array value, so the final result is `[8 9 10]`. -Sibling expression ranges still do not cross into nested collectors: - -```pluto -i = 0:5 -res = i + [([0] + 1)[0]] -``` - -`[0]` is a singleton because it has no internal range and no statement gate. -`[([0] + 1)[0]]` is also a singleton, and the outer `i` finalizes to `4`, so -the result is `[5]`. - -This is a semantic materialization boundary. -The compiler may later hoist or fuse loops as an optimization, but that does -not change the language meaning. - -## Final-Value Contexts - -Outside `[]`, ranged computations remain per-iteration values until the root -assignment or statement consumes them. Complete Range expressions remain -descriptors. - -Examples: - -```pluto -i = 0:5 -x = i + 1 -``` - -`x` becomes `5`. - -```pluto -copy = i -``` - -`copy` is another descriptor. To request the final yielded iterator, use an -operation such as `last = i + 0`; `last` then becomes `4`. - -```pluto -arr = [i + 1] -``` - -`arr` becomes `[1 2 3 4 5]`. - -For a range-indexed array, the same boundary chooses between one final value -and an explicit collection: - -```pluto -data = [10 20 30 40] -i = 1:4 -last = data[i] # 40 -many = [data[i]] # [20 30 40] -``` - -With a matrix, `last = matrix[i]` is the final owned row while -`many = [matrix[i]]` stacks all yielded rows. +This is the same semantic materialization boundary described for all +collectors: sibling expression ranges do not cross it. The compiler may later +hoist or fuse loops as an optimization, but that does not change the language +meaning. ## Self-Reference: Fold @@ -642,20 +550,3 @@ n = 0:5 Both produce `[0 1 1.41421 1.73205 2]`. Streams and materialized arrays agree wherever both readings exist; the difference is only *when* the array comes into being. - -## Singleton Arrays - -If no range drivers are open inside `[]`, the literal evaluates once and -produces a singleton array. - -Example: - -```pluto -x = 7 -[x] -``` - -produces `[7]`. - -This is not a special array-literal mode. -It is the same collector rule applied to an expression with no active drivers. From 64bdf8b7aedeb571e4f020210bcdb94746aa5caa Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 29 Jul 2026 16:48:42 +0530 Subject: [PATCH 59/60] test(compiler): strengthen blocked rank-N collector case Seed the rank-N destination with a distinct matrix so any mistakenly admitted iteration changes the observable result. --- tests/array/cond_accum.exp | 4 ++-- tests/array/cond_accum.spt | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/array/cond_accum.exp b/tests/array/cond_accum.exp index eeadd7fd..df8e41e9 100644 --- a/tests/array/cond_accum.exp +++ b/tests/array/cond_accum.exp @@ -67,8 +67,8 @@ GatedRows: [ 20 21 ] GatedRowsBlocked: [ - 10 11 - 20 21 + 90 91 + 92 93 ] GatedNestedRows: [ 11 12 diff --git a/tests/array/cond_accum.spt b/tests/array/cond_accum.spt index 87e6c493..e6e86f82 100644 --- a/tests/array/cond_accum.spt +++ b/tests/array/cond_accum.spt @@ -369,6 +369,11 @@ domainMatrix = [ i = 0:3 gatedRows = i < 2 [domainMatrix[i]] "GatedRows: -gatedRows" +# A distinct seed makes any accidentally admitted row observable. +gatedRows = [ + 90 91 + 92 93 +] i = 0:3 gatedRows = i < 0 [domainMatrix[i]] "GatedRowsBlocked: -gatedRows" From 88cbcf29e03b6ba279e3e1d609cb41a112e66146 Mon Sep 17 00:00:00 2001 From: Tejas Date: Wed, 29 Jul 2026 18:22:49 +0530 Subject: [PATCH 60/60] test(compiler): consolidate range regression coverage Fold overlapping branch-added fixtures into the existing array, condition, and math suites, and trim redundant unit assertions. Keep focused IR checks plus both written and unwritten gated rank-reset paths while reducing the E2E executable count from 70 to 63. --- compiler/cfg_test.go | 17 +---- compiler/compiler_test.go | 25 +------ compiler/mangle_test.go | 47 ------------ compiler/solver_test.go | 100 ------------------------- compiler/types.go | 16 ++-- tests/array/array_func.exp | 7 ++ tests/array/array_func.pt | 4 + tests/array/array_func.spt | 24 ++++++ tests/array/array_range.exp | 1 - tests/array/array_range.spt | 3 - tests/array/collector_variant.exp | 2 - tests/array/collector_variant.pt | 7 -- tests/array/collector_variant.spt | 15 ---- tests/array/cond_accum.exp | 5 ++ tests/array/cond_accum.spt | 28 +++++++ tests/array/cond_ranged_reset.exp | 6 -- tests/array/cond_ranged_reset.pt | 5 -- tests/array/cond_ranged_reset.spt | 20 ----- tests/cond/expr_forms.exp | 1 + tests/cond/expr_forms.spt | 6 ++ tests/cond/mixed_ranged_gate.exp | 9 --- tests/cond/mixed_ranged_gate.spt | 39 ---------- tests/cond/nested_cond_write.exp | 6 -- tests/cond/nested_cond_write.pt | 12 --- tests/cond/nested_cond_write.spt | 33 -------- tests/cond/nested_range_driver.exp | 1 - tests/cond/nested_range_driver.spt | 4 - tests/cond/skipped_call_keeps_dest.exp | 5 -- tests/cond/skipped_call_keeps_dest.pt | 10 --- tests/cond/skipped_call_keeps_dest.spt | 22 ------ tests/math/acc.exp | 2 +- tests/math/acc.pt | 6 ++ tests/math/acc.spt | 8 +- tests/math/acc_fmt.exp | 4 - tests/math/acc_fmt.pt | 8 -- tests/math/acc_fmt.spt | 5 -- tests/math/alias_mixed_outputs.exp | 4 - tests/math/alias_mixed_outputs.pt | 20 ----- tests/math/alias_mixed_outputs.spt | 22 ------ tests/range_finalize.exp | 6 +- tests/range_finalize.spt | 18 +---- 41 files changed, 100 insertions(+), 483 deletions(-) delete mode 100644 tests/array/collector_variant.exp delete mode 100644 tests/array/collector_variant.pt delete mode 100644 tests/array/collector_variant.spt delete mode 100644 tests/array/cond_ranged_reset.exp delete mode 100644 tests/array/cond_ranged_reset.pt delete mode 100644 tests/array/cond_ranged_reset.spt delete mode 100644 tests/cond/mixed_ranged_gate.exp delete mode 100644 tests/cond/mixed_ranged_gate.spt delete mode 100644 tests/cond/nested_cond_write.exp delete mode 100644 tests/cond/nested_cond_write.pt delete mode 100644 tests/cond/nested_cond_write.spt delete mode 100644 tests/cond/nested_range_driver.exp delete mode 100644 tests/cond/nested_range_driver.spt delete mode 100644 tests/cond/skipped_call_keeps_dest.exp delete mode 100644 tests/cond/skipped_call_keeps_dest.pt delete mode 100644 tests/cond/skipped_call_keeps_dest.spt delete mode 100644 tests/math/alias_mixed_outputs.exp delete mode 100644 tests/math/alias_mixed_outputs.pt delete mode 100644 tests/math/alias_mixed_outputs.spt diff --git a/compiler/cfg_test.go b/compiler/cfg_test.go index 50677e7c..464bc0ef 100644 --- a/compiler/cfg_test.go +++ b/compiler/cfg_test.go @@ -298,9 +298,8 @@ func TestRangedGateCollectorWriteIsConditional(t *testing.T) { func TestGateArrayWriteKinds(t *testing.T) { tests := []struct { - name string - input string - errorContains string + name string + input string }{ { name: "empty ranged gate preserves collector", @@ -319,17 +318,7 @@ func TestGateArrayWriteKinds(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { errs := compileScriptForCFGTest(t, tt.name, tt.input) - if tt.errorContains == "" { - require.Empty(t, errs) - return - } - - require.NotEmpty(t, errs) - msgs := make([]string, len(errs)) - for i, err := range errs { - msgs[i] = err.Msg - } - assert.Contains(t, strings.Join(msgs, "\n"), tt.errorContains) + require.Empty(t, errs) }) } } diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 73a7f1b2..5e07acd6 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -55,23 +55,11 @@ out` require.Less(t, division, falseLabel, "the second condition must not escape the lazy RHS block") } -func TestDirectScalarABIAlwaysHasDestinationSeed(t *testing.T) { - for _, outType := range []Type{I64, F64} { - abi := classifyFuncABI([]Type{I64}, []Type{outType}) - require.Equal(t, ABIReturnDirect, abi.Return.Mode) - require.True(t, TypeEqual(outType, abi.Return.DirectType)) - require.Equal(t, 1, abi.DirectReturnSeedParamIndex()) - } - +func TestDirectReturnSeedIndex(t *testing.T) { zeroArg := classifyFuncABI(nil, []Type{I64}) require.Equal(t, ABIReturnDirect, zeroArg.Return.Mode) require.Equal(t, 0, zeroArg.DirectReturnSeedParamIndex()) - indirect := classifyFuncABI([]Type{I64}, []Type{I64, I64}) - require.Equal(t, ABIReturnIndirect, indirect.Return.Mode) - require.Nil(t, indirect.Return.DirectType) - require.Equal(t, -1, indirect.DirectReturnSeedParamIndex()) - stringReturn := classifyFuncABI(nil, []Type{StrG{}}) require.Equal(t, ABIReturnIndirect, stringReturn.Return.Mode) require.Equal(t, -1, stringReturn.DirectReturnSeedParamIndex()) @@ -197,19 +185,12 @@ func verifyCompiledFunctions(t *testing.T, moduleName, codeSrc, scriptSrc string } func TestAliasSelectorTypeGaps(t *testing.T) { - // The accumulator leads in the first group, so it is reached through - // selector 1. In the second group the mismatched output leads, so the - // accumulator is selector 2 and the skipped slot must stay a numbering gap. const accFirst = "s = 1\nq, r = Mixed(s, 0:4)\nq, r" - const accSecond = "s = 1\nq, s = Mixed(s, 0:4)\nq, s" cases := []struct{ name, code, script string }{ {"float sibling", "sum, other = Mixed(a, x)\n sum = a + x\n other = x * 0.5", accFirst}, {"string sibling", "sum, other = Mixed(a, x)\n sum = a + x\n other = \"n\"", accFirst}, {"array sibling", "sum, other = Mixed(a, x)\n sum = a + x\n other = [x x]", accFirst}, - {"float sibling first", "other, sum = Mixed(a, x)\n other = x * 0.5\n sum = a + x", accSecond}, - {"string sibling first", "other, sum = Mixed(a, x)\n other = \"n\"\n sum = a + x", accSecond}, - {"array sibling first", "other, sum = Mixed(a, x)\n other = [x x]\n sum = a + x", accSecond}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -324,8 +305,6 @@ res` } mangled := Mangle(MangleDirPath(moduleName, ""), "Square", []Type{arrayRange}) - require.Contains(t, mangled, "ArrayRange_t2_Array_t1_I64_Range_t1_I64", - "the specialization must encode the complete array and range schemas") require.Contains(t, scriptIR, "define noundef i64 @"+mangled+"(ptr noundef nonnull \"captures\"=\"none\" %0, i64 noundef %1)", "the ArrayRange direct variant should receive its descriptor plus the hidden output seed") @@ -352,8 +331,6 @@ row` } mangled := Mangle(MangleDirPath(moduleName, ""), "Identity", []Type{arrayRange}) - require.Contains(t, mangled, "ArrayRange_t2_Array_t1_Array_t1_I64_Range_t1_I64", - "the specialization must retain the rank-two source schema") require.Contains(t, scriptIR, "define void @"+mangled+"(", "the rank-reduced row result should use the indirect array-return ABI") require.Contains(t, scriptIR, "array_subarray_start", diff --git a/compiler/mangle_test.go b/compiler/mangle_test.go index a5996bc1..0c058d85 100644 --- a/compiler/mangle_test.go +++ b/compiler/mangle_test.go @@ -290,17 +290,6 @@ func TestMangle(t *testing.T) { args: []Type{Range{Iter: I64}}, expected: "Pt_4iter_p_3sum_f1_Range_t1_I64", }, - { - name: "with array range type", - modName: "iter", - relPath: "", - funcName: "sum", - args: []Type{ArrayRange{ - Array: Array{ElemType: I64, Rank: 2}, - Range: Range{Iter: I64}, - }}, - expected: "Pt_4iter_p_3sum_f1_ArrayRange_t2_Array_t1_Array_t1_I64_Range_t1_I64", - }, { name: "with array type", modName: "arr", @@ -387,45 +376,14 @@ func TestArrayRangeMangleIsStructural(t *testing.T) { }, } - seen := make(map[string]string, len(tests)) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mangled := tt.typ.Mangle() assert.Equal(t, tt.expected, mangled) - if other, exists := seen[mangled]; exists { - t.Errorf("ArrayRange mangle collision between %q and %q: %s", other, tt.name, mangled) - } - seen[mangled] = tt.name }) } } -func TestArrayRangeTypeIdentityIsStructural(t *testing.T) { - base := ArrayRange{ - Array: Array{ElemType: I64, Rank: 1}, - Range: Range{Iter: I64}, - } - same := ArrayRange{ - Array: Array{ElemType: Int{Width: 64}, Rank: 1}, - Range: Range{Iter: Int{Width: 64}}, - } - - assert.True(t, TypeEqual(base, same)) - assert.True(t, TypeEqual(base, base.Key())) - assert.False(t, TypeEqual(base, ArrayRange{ - Array: Array{ElemType: I64, Rank: 2}, - Range: Range{Iter: I64}, - })) - assert.False(t, TypeEqual(base, ArrayRange{ - Array: Array{ElemType: F64, Rank: 1}, - Range: Range{Iter: I64}, - })) - assert.False(t, TypeEqual(base, ArrayRange{ - Array: Array{ElemType: I64, Rank: 1}, - Range: Range{Iter: F64}, - })) -} - func TestMangleDistinguishesModuleFromSubdir(t *testing.T) { // This is the key test: ensure module path vs module+subdir produce different results // Module: github.com/user/math/stats (as a single module) @@ -574,11 +532,6 @@ func TestDemangle(t *testing.T) { mangled: "Pt_4iter_p_3sum_f1_Range_t1_I64", expected: "iter.sum(Range_t1_I64)", }, - { - name: "with array range type", - mangled: "Pt_4iter_p_3sum_f1_ArrayRange_t2_Array_t1_Array_t1_I64_Range_t1_I64", - expected: "iter.sum(ArrayRange_t2_Array_t1_Array_t1_I64_Range_t1_I64)", - }, { name: "with func type", mangled: "Pt_3hof_p_5apply_f1_Func_t2_I64_F64", diff --git a/compiler/solver_test.go b/compiler/solver_test.go index 28795255..289b2dc5 100644 --- a/compiler/solver_test.go +++ b/compiler/solver_test.go @@ -1131,106 +1131,6 @@ arr[i]`) require.Equal(t, []Type{I64}, printInfo.CallParamTypes) } -func TestArrayCollectorCreatesScalarVariantForArrayRangeCall(t *testing.T) { - ctx := llvm.NewContext() - code := mustParseCode(t, `out = Double(x) - out = x * 2`) - moduleName := "arrayRangeCollector" - cc := NewCodeCompiler(ctx, moduleName, "", code) - require.Empty(t, cc.Compile()) - - program := mustParseScript(t, `arr = [10 20 30] -values = [Double(arr[0:3])] -i = 0:3 -[i], [0:3]`) - sc := NewScriptCompiler(ctx, program, cc, make(map[string]*Func), make(map[ExprKey]*ExprInfo)) - ts := NewTypeSolver(sc) - ts.Solve() - require.Emptyf(t, ts.Errors, "unexpected type errors: %v", ts.Errors) - - scalarMangled := Mangle(MangleDirPath(moduleName, ""), "Double", []Type{I64}) - require.Contains(t, ts.ScriptCompiler.Compiler.FuncCache, scalarMangled, - "the surrounding array collector invokes a scalar callee per selected element") -} - -func TestRankTwoSelectionSpecializesOverFullArraySchema(t *testing.T) { - ctx := llvm.NewContext() - code := mustParseCode(t, `out = Identity(x) - out = x`) - cc := NewCodeCompiler(ctx, "rankTwoCallScopedArrayRange", "", code) - require.Empty(t, cc.Compile()) - - program := mustParseScript(t, `rows = 0:2 -matrix = [ - 1 2 - 3 4 -] -row = Identity(matrix[rows])`) - sc := NewScriptCompiler(ctx, program, cc, make(map[string]*Func), make(map[ExprKey]*ExprInfo)) - ts := NewTypeSolver(sc) - ts.Solve() - require.Emptyf(t, ts.Errors, "unexpected type errors: %v", ts.Errors) - - call := program.Statements[2].(*ast.LetStatement).Value[0].(*ast.CallExpression) - info := ts.ExprCache[key("", call)] - require.True(t, info.LoopInside) - require.Equal(t, []Type{Array{ElemType: I64, Rank: 1}}, info.ScalarCallParamTypes) - require.Equal(t, []Type{ArrayRange{ - Array: Array{ElemType: I64, Rank: 2}, - Range: Range{Iter: I64}, - }}, info.CallParamTypes) - - rowType, ok := ts.GetIdentifier("row") - require.True(t, ok) - require.Equal(t, Array{ElemType: I64, Rank: 1}, rowType) -} - -func TestCallRangePlacementPreservesSharedDriverIdentity(t *testing.T) { - ctx := llvm.NewContext() - code := mustParseCode(t, `out = Identity(x) - out = x - -left, right = Keep(a, b) - left = a - right = b`) - cc := NewCodeCompiler(ctx, "callRangePlacement", "", code) - require.Empty(t, cc.Compile()) - - program := mustParseScript(t, `i = 0:3 -j = 1:3 -arr = [10 20 30] -rangeValue = Identity(j) -distinctLeft, distinctRight = Keep(arr[i], j) -sharedLeft, sharedRight = Keep(arr[i], i)`) - sc := NewScriptCompiler(ctx, program, cc, make(map[string]*Func), make(map[ExprKey]*ExprInfo)) - ts := NewTypeSolver(sc) - ts.Solve() - require.Emptyf(t, ts.Errors, "unexpected type errors: %v", ts.Errors) - - rangeCall := program.Statements[3].(*ast.LetStatement).Value[0].(*ast.CallExpression) - rangeInfo := ts.ExprCache[key("", rangeCall)] - require.True(t, rangeInfo.LoopInside, "a bare Range argument should remain callee-iterated") - require.Equal(t, []Type{Range{Iter: I64}}, rangeInfo.CallParamTypes) - require.Equal(t, []Type{I64}, rangeInfo.ScalarCallParamTypes) - - distinctCall := program.Statements[4].(*ast.LetStatement).Value[0].(*ast.CallExpression) - distinctInfo := ts.ExprCache[key("", distinctCall)] - require.True(t, distinctInfo.LoopInside, - "distinct Range and ArrayRange drivers may form the callee's cartesian loop") - require.IsType(t, ArrayRange{}, distinctInfo.CallParamTypes[0]) - require.Equal(t, Range{Iter: I64}, distinctInfo.CallParamTypes[1]) - require.Equal(t, []Type{I64, I64}, distinctInfo.ScalarCallParamTypes) - - sharedCall := program.Statements[5].(*ast.LetStatement).Value[0].(*ast.CallExpression) - sharedInfo := ts.ExprCache[key("", sharedCall)] - require.False(t, sharedInfo.LoopInside, - "a driver reused by arr[i] and i must advance once at the caller") - require.Equal(t, []Type{I64, I64}, sharedInfo.CallParamTypes) - require.Equal(t, []Type{I64, I64}, sharedInfo.ScalarCallParamTypes) - require.Len(t, sharedInfo.Ranges, 1) - require.Equal(t, "i", sharedInfo.Ranges[0].Name) -} - func TestArrayIndexRejectsI1(t *testing.T) { ctx := llvm.NewContext() cc := NewCodeCompiler(ctx, "arrayIndexI1", "", ast.NewCode()) diff --git a/compiler/types.go b/compiler/types.go index 04ed2319..70b167fe 100644 --- a/compiler/types.go +++ b/compiler/types.go @@ -596,8 +596,8 @@ func CanRefineType(oldType, newType Type) bool { newTable, ok := newType.(Table) return ok && canRefineTable(old, newTable) case ArrayRange: - newArrayRange, ok := newType.(ArrayRange) - return ok && canRefineArrayRange(old, newArrayRange) + newSlice, ok := newType.(ArrayRange) + return ok && canRefineArrayRange(old, newSlice) case Ptr: newPtr, ok := newType.(Ptr) return ok && CanRefineType(old.Elem, newPtr.Elem) @@ -694,9 +694,8 @@ func canRefineTable(oldTable, newTable Table) bool { return true } -func canRefineArrayRange(oldArrayRange, newArrayRange ArrayRange) bool { - return CanRefineType(oldArrayRange.Array, newArrayRange.Array) && - CanRefineType(oldArrayRange.Range, newArrayRange.Range) +func canRefineArrayRange(oldSlice, newSlice ArrayRange) bool { + return CanRefineType(oldSlice.Array, newSlice.Array) && CanRefineType(oldSlice.Range, newSlice.Range) } func canRefineFunc(oldFunc, newFunc Func) bool { @@ -806,10 +805,9 @@ func eqTable(a, b Type) bool { } func eqArrayRange(a, b Type) bool { - aArrayRange := a.(ArrayRange) - bArrayRange := b.(ArrayRange) - return TypeEqual(aArrayRange.Array, bArrayRange.Array) && - TypeEqual(aArrayRange.Range, bArrayRange.Range) + aar := a.(ArrayRange) + bar := b.(ArrayRange) + return eqArray(aar.Array, bar.Array) && eqRange(aar.Range, bar.Range) } func eqStruct(a, b Type) bool { diff --git a/tests/array/array_func.exp b/tests/array/array_func.exp index 26c8deae..9e6b2292 100644 --- a/tests/array/array_func.exp +++ b/tests/array/array_func.exp @@ -48,6 +48,13 @@ RangedRankResetEmpty: [ 5 6 7 8 ] +GatedRangedRankResetWritten: [ +] +GatedRangedRankResetEmpty: [ + 31 32 + 33 34 +] +RangedArrayKept: [4 5] ConditionalRankResetBefore: [ 11 12 13 14 diff --git a/tests/array/array_func.pt b/tests/array/array_func.pt index c726fbfc..1b075a48 100644 --- a/tests/array/array_func.pt +++ b/tests/array/array_func.pt @@ -32,6 +32,10 @@ res = ConcatArrays(a, b) res = ResetArray(x) res = x > -1 [] +# A ranged collector can leave an indirect output unwritten. +res = KeepRangedArray(i) + res = i < 0 [i] + # A header-only table can conditionally reset an established concrete table. res = ResetTable(x) res = x > -1 [ diff --git a/tests/array/array_func.spt b/tests/array/array_func.spt index f4ad45ad..54b492d2 100644 --- a/tests/array/array_func.spt +++ b/tests/array/array_func.spt @@ -107,6 +107,30 @@ emptyResetDriver = 0:0 keptMatrix = ResetArray(emptyResetDriver) "RangedRankResetEmpty: -keptMatrix" +# A statement condition stages a ranged call through a synthetic destination. +# A non-empty driver must commit the rank-1 [] result into the rank-2 slot. +gatedWrittenMatrix = [ + 41 42 + 43 44 +] +gatedResetDriver = 0:2 +gatedWrittenMatrix = 1 > 0 ResetArray(gatedResetDriver + 0) +"GatedRangedRankResetWritten: -gatedWrittenMatrix" + +# With an empty driver, the staged slot must retain its rank-2 value. +gatedKeptMatrix = [ + 31 32 + 33 34 +] +gatedEmptyResetDriver = 0:0 +gatedKeptMatrix = 1 > 0 ResetArray(gatedEmptyResetDriver + 0) +"GatedRangedRankResetEmpty: -gatedKeptMatrix" + +# A blocked ranged collector inside a function leaves its caller's output intact. +rangedArrayKept = [4 5] +rangedArrayKept = KeepRangedArray(0:3) +"RangedArrayKept: -rangedArrayKept" + # An ordinary conditional [] return keeps an established rank-N destination # when the callee leaves its output unwritten. conditionalResetMatrix = [ diff --git a/tests/array/array_range.exp b/tests/array/array_range.exp index c3456318..5c0014fc 100644 --- a/tests/array/array_range.exp +++ b/tests/array/array_range.exp @@ -14,7 +14,6 @@ UnevenRange: 50 EmptyRangeFresh: 0 EmptyRangeExisting: 77 OOBLastValid: 50 -AllOOBFresh: 0 AllOOBExisting: 88 PrefixRange: -40 LiteralIterChain: [1 1 0 0 0 0] diff --git a/tests/array/array_range.spt b/tests/array/array_range.spt index aaa88292..90e2caee 100644 --- a/tests/array/array_range.spt +++ b/tests/array/array_range.spt @@ -49,9 +49,6 @@ emptyExisting = arr[2:2] lastValid = arr[-2:7] "OOBLastValid: -lastValid" -allOOBFresh = arr[8:10] -"AllOOBFresh: -allOOBFresh" - allOOBExisting = 88 allOOBExisting = arr[8:10] "AllOOBExisting: -allOOBExisting" diff --git a/tests/array/collector_variant.exp b/tests/array/collector_variant.exp deleted file mode 100644 index 49ee7f7a..00000000 --- a/tests/array/collector_variant.exp +++ /dev/null @@ -1,2 +0,0 @@ -Scaled: [30 60 90] -Offset: [110 121 132] diff --git a/tests/array/collector_variant.pt b/tests/array/collector_variant.pt deleted file mode 100644 index 730cf4ca..00000000 --- a/tests/array/collector_variant.pt +++ /dev/null @@ -1,7 +0,0 @@ -# Helpers for collector_variant.spt. Each is called from exactly one collector -# so the script never instantiates their scalar variants through another call. -res = ScaleCell(x) - res = x * 3 - -res = OffsetCell(x) - res = x + 100 diff --git a/tests/array/collector_variant.spt b/tests/array/collector_variant.spt deleted file mode 100644 index 55916e74..00000000 --- a/tests/array/collector_variant.spt +++ /dev/null @@ -1,15 +0,0 @@ -# A call over a range-indexed array selection inside a collector must -# instantiate the scalar callee variant, even though promoting the argument to -# an internal ArrayRange leaves the argument list syntactically unchanged. -# -# Neither helper may be called anywhere else in this script: any other call to -# the same function registers that scalar variant and masks the regression. -arr = [10 20 30] - -i = 0:3 -scaled = [ScaleCell(arr[i])] -"Scaled: -scaled" - -j = 0:3 -offset = [OffsetCell(arr[j]) + j] -"Offset: -offset" diff --git a/tests/array/cond_accum.exp b/tests/array/cond_accum.exp index df8e41e9..3bce1d8f 100644 --- a/tests/array/cond_accum.exp +++ b/tests/array/cond_accum.exp @@ -8,6 +8,11 @@ RangeMixedTupleA: [0 1 2 3]. RangeMixedTupleB: 13 RangeSelectionMixedTupleA: [8 8 8]. RangeSelectionMixedTupleB: 40 NestedDrivers: [0 1 2 10 11 12] MixedDrivers: [9 9 9 9 9 9] +NestedRangeSelectionDriver: [1 1] +MixedGateCollectorBlocked: [90] +MixedGateScalarBlocked: 30 +MixedGateAdmitted: [0 1 2 3 4] +MixedGateLoopCarried: 3 DriverScalar: 5 [0 1 2] FalseCondBlocked: [10 20 30] diff --git a/tests/array/cond_accum.spt b/tests/array/cond_accum.spt index e6e86f82..adea5774 100644 --- a/tests/array/cond_accum.spt +++ b/tests/array/cond_accum.spt @@ -48,6 +48,34 @@ i = 0:2 gridView = i && driverSrc[1:4] [9] "MixedDrivers: -gridView" +# A nested range selection can itself own a statement's iteration domain. +nestedDriverIdx = [0 1] +nestedDriverData = [10 20] +nestedDriverOut = nestedDriverData[nestedDriverIdx[0:2]] [1] +"NestedRangeSelectionDriver: -nestedDriverOut" + +# Scalar conjuncts remain per-iteration guards when another conjunct supplies +# the range, regardless of their order or destination shape. +mixedGateDriver = 0:7 +mixedGateFalse = 0 +mixedGateTrue = 1 + +mixedGateCollector = [90] +mixedGateCollector = mixedGateFalse > 0 && mixedGateDriver < 5 [mixedGateDriver] +"MixedGateCollectorBlocked: -mixedGateCollector" + +mixedGateScalar = 30 +mixedGateScalar = mixedGateDriver < 5 && mixedGateFalse > 0 mixedGateDriver + 7 +"MixedGateScalarBlocked: -mixedGateScalar" + +mixedGateAdmitted = mixedGateTrue > 0 && mixedGateDriver < 5 [mixedGateDriver] +"MixedGateAdmitted: -mixedGateAdmitted" + +# The scalar guard must observe loop-carried updates rather than being hoisted. +mixedGateLoopCarried = 1 +mixedGateLoopCarried = mixedGateLoopCarried < 3 && mixedGateDriver mixedGateLoopCarried + 1 +"MixedGateLoopCarried: -mixedGateLoopCarried" + # Bare range driver with scalar RHS keeps last iteration value i = 0:5 driverScalar = i i + 1 diff --git a/tests/array/cond_ranged_reset.exp b/tests/array/cond_ranged_reset.exp deleted file mode 100644 index aeb812d6..00000000 --- a/tests/array/cond_ranged_reset.exp +++ /dev/null @@ -1,6 +0,0 @@ -Written: [ -] -Preserved: [ - 5 6 - 7 8 -] diff --git a/tests/array/cond_ranged_reset.pt b/tests/array/cond_ranged_reset.pt deleted file mode 100644 index e7a16c6f..00000000 --- a/tests/array/cond_ranged_reset.pt +++ /dev/null @@ -1,5 +0,0 @@ -# Conditionally reset an array through a ranged call. The staged slot must take -# the destination's element flavor rather than this call's empty-array output, -# or an empty range cannot preserve what the destination already holds. -res = CondReset(x) - res = x > -1 [] diff --git a/tests/array/cond_ranged_reset.spt b/tests/array/cond_ranged_reset.spt deleted file mode 100644 index 5e460080..00000000 --- a/tests/array/cond_ranged_reset.spt +++ /dev/null @@ -1,20 +0,0 @@ -# A statement condition over a ranged call stages through a synthetic slot, and -# the destination is an established rank-2 owner whose flavor differs from the -# call's output. A non-empty driver writes the reset through. -written = [ - 1 2 - 3 4 -] -driver = 0:3 -written = 1 > 0 CondReset(driver + 0) -"Written: -written" - -# An empty driver yields nothing, so the destination keeps its own matrix. Taking -# the call's flavor here instead empties the destination and leaks its payload. -kept = [ - 5 6 - 7 8 -] -empty = 0:0 -kept = 1 > 0 CondReset(empty + 0) -"Preserved: -kept" diff --git a/tests/cond/expr_forms.exp b/tests/cond/expr_forms.exp index 9d3cd551..71af2633 100644 --- a/tests/cond/expr_forms.exp +++ b/tests/cond/expr_forms.exp @@ -10,3 +10,4 @@ CondPrefixFalseNew: 0 CondPrefixRangeSelectionTrue: -40 CondPrefixRangeSelectionFalseExisting: -40 CondPrefixRangeSelectionFalseNew: 0 +NestedCallUnderOperator: 10 diff --git a/tests/cond/expr_forms.spt b/tests/cond/expr_forms.spt index 746db536..f274f805 100644 --- a/tests/cond/expr_forms.spt +++ b/tests/cond/expr_forms.spt @@ -34,3 +34,9 @@ pr = 1 > 2 0 + -arr[1:2] "CondPrefixRangeSelectionFalseExisting: -pr" k = 1 > 2 0 + -arr[1:4] "CondPrefixRangeSelectionFalseNew: -k" + +# A failed condition nested under a call and an operator makes the whole value +# optional, so the established destination remains live and unchanged. +nestedCallKeep = 10 +nestedCallKeep = Double(15 < 10) + 5 +"NestedCallUnderOperator: -nestedCallKeep" diff --git a/tests/cond/mixed_ranged_gate.exp b/tests/cond/mixed_ranged_gate.exp deleted file mode 100644 index 45f3b1ad..00000000 --- a/tests/cond/mixed_ranged_gate.exp +++ /dev/null @@ -1,9 +0,0 @@ -CollectorScalarFirstFalse: [90] -CollectorScalarFirstTrue: [0 1 2 3 4] -CollectorRangeFirstFalse: [80] -CollectorRangeFirstTrue: [0 1 2 3 4] -ScalarScalarFirstFalse: 10 -ScalarScalarFirstTrue: 11 -ScalarRangeFirstFalse: 30 -ScalarRangeFirstTrue: 11 -LoopCarried: 3 diff --git a/tests/cond/mixed_ranged_gate.spt b/tests/cond/mixed_ranged_gate.spt deleted file mode 100644 index fc9bdbde..00000000 --- a/tests/cond/mixed_ranged_gate.spt +++ /dev/null @@ -1,39 +0,0 @@ -# When any statement-condition conjunct supplies a range, scalar conjuncts -# remain per-iteration guards regardless of their order. -driver = 0:7 -falseFlag = 0 -trueFlag = 1 - -# Inline collectors preserve their old values when a scalar conjunct rejects -# every iteration, and collect once per iteration when it admits the range. -collectorScalarFirstFalse = [90] -collectorScalarFirstFalse = falseFlag > 0 && driver < 5 [driver] -"CollectorScalarFirstFalse: -collectorScalarFirstFalse" -collectorScalarFirstTrue = trueFlag > 0 && driver < 5 [driver] -"CollectorScalarFirstTrue: -collectorScalarFirstTrue" -collectorRangeFirstFalse = [80] -collectorRangeFirstFalse = driver < 5 && falseFlag > 0 [driver] -"CollectorRangeFirstFalse: -collectorRangeFirstFalse" -collectorRangeFirstTrue = driver < 5 && trueFlag > 0 [driver] -"CollectorRangeFirstTrue: -collectorRangeFirstTrue" - -# Scalar destinations keep their old value when no iteration is admitted and -# take the final admitted iteration's value otherwise. -scalarScalarFirstFalse = 10 -scalarScalarFirstFalse = falseFlag > 0 && driver < 5 driver + 7 -"ScalarScalarFirstFalse: -scalarScalarFirstFalse" -scalarScalarFirstTrue = 20 -scalarScalarFirstTrue = trueFlag > 0 && driver < 5 driver + 7 -"ScalarScalarFirstTrue: -scalarScalarFirstTrue" -scalarRangeFirstFalse = 30 -scalarRangeFirstFalse = driver < 5 && falseFlag > 0 driver + 7 -"ScalarRangeFirstFalse: -scalarRangeFirstFalse" -scalarRangeFirstTrue = 40 -scalarRangeFirstTrue = driver < 5 && trueFlag > 0 driver + 7 -"ScalarRangeFirstTrue: -scalarRangeFirstTrue" - -# A scalar guard can observe a destination updated by earlier range iterations; -# it must remain inside the loop rather than being hoisted ahead of it. -loopCarried = 1 -loopCarried = loopCarried < 3 && driver loopCarried + 1 -"LoopCarried: -loopCarried" diff --git a/tests/cond/nested_cond_write.exp b/tests/cond/nested_cond_write.exp deleted file mode 100644 index 409fb50d..00000000 --- a/tests/cond/nested_cond_write.exp +++ /dev/null @@ -1,6 +0,0 @@ -UnderOperatorFalse: 10 -NoCallFalse: 10 -UnderOperatorTrue: 14 -NoCallTrue: 8 -OutOfBounds: 10 -InBodyFalse: 10 diff --git a/tests/cond/nested_cond_write.pt b/tests/cond/nested_cond_write.pt deleted file mode 100644 index 6425712d..00000000 --- a/tests/cond/nested_cond_write.pt +++ /dev/null @@ -1,12 +0,0 @@ -# Always writes its output, so any optionality at a call site comes from the -# caller's own argument, not from this body. -res = Sq(v) - res = v * v - -# The same nested-condition shape inside a function body. A .pt template is -# validated before any specialization exists, so this path has no typed -# classification to consult and relies on the syntactic fallback. -out = NestedInBody(x) - y = 10 - y = (x < 5) + 5 - out = y diff --git a/tests/cond/nested_cond_write.spt b/tests/cond/nested_cond_write.spt deleted file mode 100644 index 5262af2e..00000000 --- a/tests/cond/nested_cond_write.spt +++ /dev/null @@ -1,33 +0,0 @@ -# A condition nested below the value root still makes the write optional: the -# whole right-hand side yields nothing, so the destination keeps its value. The -# earlier write is therefore live and must not be reported as a dead store. -x = 7 -underOperator = 10 -underOperator = Sq(x < 5) + 5 -"UnderOperatorFalse: -underOperator" - -# No call involved at all — the comparison alone can fail to yield. -noCall = 10 -noCall = (x < 5) + 5 -"NoCallFalse: -noCall" - -# Same shapes when the condition holds, so the write actually lands. -y = 3 -underOperatorTrue = 10 -underOperatorTrue = Sq(y < 5) + 5 -"UnderOperatorTrue: -underOperatorTrue" - -noCallTrue = 10 -noCallTrue = (y < 5) + 5 -"NoCallTrue: -noCallTrue" - -# An out-of-bounds read fails its lanes, so the destination survives untouched. -arr = [1] -oob = 10 -oob = arr[9] -"OutOfBounds: -oob" - -# The same nested condition inside a function body, which is validated without -# any type information available. -inBody = NestedInBody(7) -"InBodyFalse: -inBody" diff --git a/tests/cond/nested_range_driver.exp b/tests/cond/nested_range_driver.exp deleted file mode 100644 index 6d64dadb..00000000 --- a/tests/cond/nested_range_driver.exp +++ /dev/null @@ -1 +0,0 @@ -[1 1] diff --git a/tests/cond/nested_range_driver.spt b/tests/cond/nested_range_driver.spt deleted file mode 100644 index 99404fa5..00000000 --- a/tests/cond/nested_range_driver.spt +++ /dev/null @@ -1,4 +0,0 @@ -idx = [0 1] -data = [10 20] -out = data[idx[0:2]] [1] -out diff --git a/tests/cond/skipped_call_keeps_dest.exp b/tests/cond/skipped_call_keeps_dest.exp deleted file mode 100644 index 344e5218..00000000 --- a/tests/cond/skipped_call_keeps_dest.exp +++ /dev/null @@ -1,5 +0,0 @@ -ScalarKept: 7 -ScalarWritten: 42 -ArrayKept: [1 2 3] -ArrayWritten: [7 8] -RangedArrayKept: [4 5] diff --git a/tests/cond/skipped_call_keeps_dest.pt b/tests/cond/skipped_call_keeps_dest.pt deleted file mode 100644 index 5b3e9c46..00000000 --- a/tests/cond/skipped_call_keeps_dest.pt +++ /dev/null @@ -1,10 +0,0 @@ -# Outputs written only when the condition holds, so a call to either may leave -# the caller's destination untouched. -res = KeepScalar(x) - res = x > 0 42 - -res = KeepArray(x) - res = x > 0 [7 8] - -res = KeepRangedArray(i) - res = i < 0 [i] diff --git a/tests/cond/skipped_call_keeps_dest.spt b/tests/cond/skipped_call_keeps_dest.spt deleted file mode 100644 index 64d59a7a..00000000 --- a/tests/cond/skipped_call_keeps_dest.spt +++ /dev/null @@ -1,22 +0,0 @@ -# A call whose callee may skip its output write is not an unconditional write. -# The destination keeps its previous value, so that previous assignment is live -# and dead-store analysis must not reject these statements. -scalarKept = 7 -scalarKept = KeepScalar(-1) -"ScalarKept: -scalarKept" - -scalarWritten = 7 -scalarWritten = KeepScalar(1) -"ScalarWritten: -scalarWritten" - -arrayKept = [1 2 3] -arrayKept = KeepArray(-1) -"ArrayKept: -arrayKept" - -arrayWritten = [1 2 3] -arrayWritten = KeepArray(1) -"ArrayWritten: -arrayWritten" - -rangedArrayKept = [4 5] -rangedArrayKept = KeepRangedArray(0:3) -"RangedArrayKept: -rangedArrayKept" diff --git a/tests/math/acc.exp b/tests/math/acc.exp index 85323eda..ea9e9b29 100644 --- a/tests/math/acc.exp +++ b/tests/math/acc.exp @@ -3,6 +3,6 @@ 25 10 10 -12 13 16 3 +ReversedHalf: 1.5 ReversedSum: 7 diff --git a/tests/math/acc.pt b/tests/math/acc.pt index c971eeee..f55dfba5 100644 --- a/tests/math/acc.pt +++ b/tests/math/acc.pt @@ -14,3 +14,9 @@ res = ConditionalAcc(a, x) sum, last = AccPair(a, x) sum = a + x last = x + +# Sibling first, so the accumulator sits at the second output and its selector +# keeps the skipped first output as a numbering gap. +half, sum = ReversedAcc(a, x) + half = x * 0.5 + sum = a + x diff --git a/tests/math/acc.spt b/tests/math/acc.spt index 4ae15c16..3ea406e3 100644 --- a/tests/math/acc.spt +++ b/tests/math/acc.spt @@ -18,9 +18,6 @@ res = 10 res = ConditionalAcc(res, -1) res -res = ConditionalAcc(res, 2) -res - res = 10 res = ConditionalAcc(res, -2:3) res @@ -28,3 +25,8 @@ res sum = 10 sum, last = AccPair(sum, 1:4) sum, last + +reversedHalf = 0.0 +reversedSum = 1 +reversedHalf, reversedSum = ReversedAcc(reversedSum, 0:4) +"ReversedHalf: -reversedHalf ReversedSum: -reversedSum" diff --git a/tests/math/acc_fmt.exp b/tests/math/acc_fmt.exp index b5780d98..d16913d3 100644 --- a/tests/math/acc_fmt.exp +++ b/tests/math/acc_fmt.exp @@ -2,7 +2,3 @@ count chars count chars count chars 8 -count chars -count chars -count chars -RevHalf: 1.5 RevRes: 8 diff --git a/tests/math/acc_fmt.pt b/tests/math/acc_fmt.pt index 59c4a863..8949a6e4 100644 --- a/tests/math/acc_fmt.pt +++ b/tests/math/acc_fmt.pt @@ -1,11 +1,3 @@ res = AccFmt(a, x) "count-a%n chars" res = a + x - -# Sibling output first, so the accumulator is the second output and its selector -# is 2. Writing through %n promotes the parameter to memory, which picks the -# aliased slot by pointer rather than by value. -half, res = AccFmtRev(a, x) - "count-a%n chars" - half = x * 0.5 - res = a + x diff --git a/tests/math/acc_fmt.spt b/tests/math/acc_fmt.spt index c3fad08f..833789ba 100644 --- a/tests/math/acc_fmt.spt +++ b/tests/math/acc_fmt.spt @@ -1,8 +1,3 @@ res = 10 res = AccFmt(res, 1:4) res - -revHalf = 0.0 -revRes = 10 -revHalf, revRes = AccFmtRev(revRes, 1:4) -"RevHalf: -revHalf RevRes: -revRes" diff --git a/tests/math/alias_mixed_outputs.exp b/tests/math/alias_mixed_outputs.exp deleted file mode 100644 index fe482c85..00000000 --- a/tests/math/alias_mixed_outputs.exp +++ /dev/null @@ -1,4 +0,0 @@ -MixedAccSum: 7 MixedAccHalf: 1.5 -MixedStrSum: 7 MixedStrNote: n -ReversedHalf: 1.5 ReversedSum: 7 -FloatNote: n FloatTotal: 7 diff --git a/tests/math/alias_mixed_outputs.pt b/tests/math/alias_mixed_outputs.pt deleted file mode 100644 index 3d35a603..00000000 --- a/tests/math/alias_mixed_outputs.pt +++ /dev/null @@ -1,20 +0,0 @@ -# Loop-carried accumulators whose sibling output lowers to a different type -# than the accumulated parameter. The hidden alias selector must reach the -# matching output only; the sibling cannot back an I64 parameter's slot. -sum, half = MixedAcc(a, x) - sum = a + x - half = x * 0.5 - -sum, note = MixedStr(a, x) - sum = a + x - note = "n" - -# Sibling first, so the accumulator sits at the second output and its selector -# is 2. The skipped slot has to stay a numbering gap rather than shift. -half, sum = ReversedAcc(a, x) - half = x * 0.5 - sum = a + x - -note, total = FloatAcc(b, x) - note = "n" - total = b + x diff --git a/tests/math/alias_mixed_outputs.spt b/tests/math/alias_mixed_outputs.spt deleted file mode 100644 index 49085944..00000000 --- a/tests/math/alias_mixed_outputs.spt +++ /dev/null @@ -1,22 +0,0 @@ -# Each accumulator must still alias its own output across iterations, so the -# sums advance 1 -> 1 -> 2 -> 4 -> 7 rather than restarting from the argument. -s = 1 -h = 0.0 -s, h = MixedAcc(s, 0:4) -"MixedAccSum: -s MixedAccHalf: -h" - -u = 1 -n = "z" -u, n = MixedStr(u, 0:4) -"MixedStrSum: -u MixedStrNote: -n" - -# The accumulator is the second output here, so it is reached through selector 2. -rh = 0.0 -rs = 1 -rh, rs = ReversedAcc(rs, 0:4) -"ReversedHalf: -rh ReversedSum: -rs" - -fn = "z" -ft = 1.0 -fn, ft = FloatAcc(ft, 0:4) -"FloatNote: -fn FloatTotal: -ft" diff --git a/tests/range_finalize.exp b/tests/range_finalize.exp index 20b3139e..49591e22 100644 --- a/tests/range_finalize.exp +++ b/tests/range_finalize.exp @@ -1,13 +1,9 @@ AscendingCopy: [0 1 2 3 4] -ParenthesizedCopy: [0 1 2 3 4] DescendingCopy: [6 4 2] -UnevenCopy: [1 5 9] EmptyRangeFresh: [] -EmptyRangeBefore: [0 1] -EmptyRangeExisting: [] +EmptyRangeExisting: [0 1] -> [] EmptyComputeFresh: 0 EmptyComputeExisting: 91 -Collected: [0 1 2 3 4] CallFinal: 20 ReturnedRange: [2 3 4] MarkerFinal: Marker 0:3 diff --git a/tests/range_finalize.spt b/tests/range_finalize.spt index 89f5ba0c..aff7855d 100644 --- a/tests/range_finalize.spt +++ b/tests/range_finalize.spt @@ -4,22 +4,11 @@ ascending = ascendingRange ascendingValues = [ascending] "AscendingCopy: -ascendingValues" -# Parentheses do not change a complete descriptor expression into a computation. -parenthesized = (ascendingRange) -parenthesizedValues = [parenthesized] -"ParenthesizedCopy: -parenthesizedValues" - descendingRange = 6:0:-2 descending = descendingRange descendingValues = [descending] "DescendingCopy: -descendingValues" -# Uneven descriptors keep their exact traversal after copying. -unevenRange = 1:10:4 -uneven = unevenRange -unevenValues = [uneven] -"UnevenCopy: -unevenValues" - # Empty descriptors still assign: both destinations become empty ranges. emptyRange = 3:3 freshRange = emptyRange @@ -28,10 +17,9 @@ freshRangeValues = [freshRange] existingRange = 0:2 existingRangeBefore = [existingRange] -"EmptyRangeBefore: -existingRangeBefore" existingRange = emptyRange existingRangeValues = [existingRange] -"EmptyRangeExisting: -existingRangeValues" +"EmptyRangeExisting: -existingRangeBefore -> -existingRangeValues" # Empty ranged computations yield no value: fresh scalar destinations use zero, # while existing scalar destinations retain their seed. @@ -42,10 +30,6 @@ existing = 91 existing = emptyRange + 1 "EmptyComputeExisting: -existing" -# Brackets remain the explicit materialization boundary. -collected = [ascendingRange] -"Collected: -collected" - # A copied descriptor remains consumable by functions. callFinal = useShadow(ascending) "CallFinal: -callFinal"