Skip to content

fix(compiler): track callee seed reads beside write effects - #102

Closed
thiremani wants to merge 3 commits into
masterfrom
fix-seed-read-effects
Closed

fix(compiler): track callee seed reads beside write effects#102
thiremani wants to merge 3 commits into
masterfrom
fix-seed-read-effects

Conversation

@thiremani

@thiremani thiremani commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Implements §1 of docs/Pluto Effects and Follow-up Plan.md: the seed dependency analysis fix queued ahead of PIR call routing. PIR is untouched.

Problem

The CFG treated an all-MustWrite callee as reading no seed. A body whose last statement always writes can still depend on the incoming output value:

# seed.pt
y = MaybeIncrement(x)
    y = x > 0 x
    y = y + 1
# seed.spt
a = 20
a = MaybeIncrement(-1)
a

Before (master c253781): the script is rejected, even though lowering already computes 21 from the seed. Inserting a print of a before the call made it compile and print 20 then 21.

/seed.spt:2:1:unconditional assignment to "a" overwrites a previous value that was never used. It was previously written at line 1:1
/seed.spt:1:1:value assigned to "a" is never used
error compiling scriptFile .../seed.spt for script seed

After: the script compiles and prints 21; the fresh-target variant (b = MaybeIncrement(-1)) prints 1.

Three facts, kept separate

Fact Representation Consumer
Callee body write effect FuncInfo.BodyOutputEffects (MustWrite/MayWrite, unchanged) yield propagation, call routing
Callee seed-read dependency new FuncInfo.BodySeedEffects (NoSeedRead/MaySeedRead) and per-call-site StatementEffect.CalleeReadsSeed CFG liveness, future scheduling
Caller boundary resolution StatementEffect.ReadsSeed (unchanged meaning: direct MayWrite result resolved at an existing target) keep-old at =

ReadsSeed was not broadened: the body fold still discounts boundary-manufactured MustWrites, so a seed-dependent accumulator stays MustWrite.

  • Body fold (deriveBodySeedEffects): walks statements in order. Explicit reads (conditions, values, print arguments, resolved format markers and their dynamic width/precision operands) and implicit reads through a MaySeedRead callee happen before the statement's writes; a raw MustWrite replaces the output, a boundary-resolved write does not. The fact is sticky, so copying the seed to a local first keeps the dependency, and reads that feed another output count.
  • Composition: a call site records CalleeReadsSeed whatever the ABI, but only when the seed reaches the callee. Indirect outputs read their destination-seeded staging slot just as direct returns read the hidden seed parameter, and lowering seeds that slot from the destination only for identical storage types (makeCallOutputAdapters). A StrH destination for a StrG output gets an ABI-typed zero seed, so no read is recorded and the prior value stays a true unused-overwrite (review round 1 regression, with CFG, effect, and end-to-end tests). Only boundary resolution stays behind the direct-ABI check.
  • SCC settlement: write weakening and seed growth are both monotone toward the conservative side; either requeues callers within the component. Both facts publish and cache together; an unpublished seed fact at a call site is an ICE rather than a silent "no read".
  • CFG: emits the callee seed read as an ordinary pre-write read for defined bindings. A fresh destination has nothing to read, so x = MaybeIncrement(-1) alone is still a dead store. Unused-write diagnostics still fire where the prior value is genuinely unread, e.g. a callee that overwrites before reading (y = x then y = y + 1), or one that resets its output to 0 before a nested seed-reading call.

Public symbols, prototypes, and lowering are unchanged; every public direct scalar return keeps its hidden seed parameter.

Docs

  • docs/Pluto IR Plan.md §15: corrects the assertion that an all-MustWrite callee reads no seed and adds the SeedEffect subsection, fold rules, SCC step wording, and CFG event order.
  • docs/Pluto Effects and Follow-up Plan.md §1: acceptance criteria ticked and the resolving PR recorded, except the PIR half of the consumption criterion: PIR does not route calls yet, so its consumption of the seed facts is owed by Step 4 call routing.

Verification

  • go test -race ./lexer ./parser ./compiler: pass.
  • python3 test.py --leak-check (full suite): 78 passed, 0 failed, no leaks reported.
  • New tests: effect derivation (seed-dependent MustWrite, definite vs conditional overwrite, local copy, condition/print/marker/width reads, nested calls and reset-before-call, cross-output reads, recursive SCC growth, indirect callee composition, function-owned range domain with zero/nonempty/fresh calls), CFG valid and error cases, and tests/seed/ end-to-end fixtures with a second script compiled warm from the shared cache.

🤖 Generated with Claude Code

thiremani and others added 3 commits September 11, 2026 19:05
A body whose last statement always writes can still depend on the
incoming output seed (`y = x > 0 x` then `y = y + 1`). The CFG treated
every all-MustWrite callee as reading no seed, so `a = 20` followed by
`a = MaybeIncrement(-1)` was rejected as an unused overwrite although
lowering already produced 21.

Publish a per-output SeedEffect (NoSeedRead/MaySeedRead) beside
BodyOutputEffects, derived by an order-sensitive fold: explicit reads,
resolved format markers, and calls to seed-reading callees count until
a raw MustWrite replaces the output, and the fact is sticky. Call sites
record CalleeReadsSeed for every named target whatever the ABI; the CFG
turns it into a pre-write read of a defined destination. ReadsSeed keeps
its boundary-resolution meaning, so the write fold still discounts
boundary-manufactured MustWrites and accumulators stay MustWrite. SCC
settlement requeues callers on seed growth as well as write weakening,
and an unpublished seed fact at a call site is an ICE.

Overwrite-before-read callees and fresh targets keep their existing
diagnostics. Lowering, public symbols, and prototypes are unchanged;
PIR is untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Tick the section 1 acceptance criteria, point the backlog row at the
PR, and defer the canonical description to PIR plan section 15.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An indirect callee reads its destination only when lowering seeds the
staging slot from it, which makeCallOutputAdapters does only for
identical storage types. A StrH destination for a StrG output gets an
ABI-typed zero seed, so the callee never observes the caller's value,
yet CalleeReadsSeed recorded a read and suppressed the true
unused-overwrite diagnostic. Gate the composed read on TypeEqual between
the destination's slot type and the callee output, the same authority
destSlotType consults; direct scalar returns always match.

Un-tick the PIR half of the consumption criterion: PIR does not route
calls yet, so its consumption of the seed facts is owed by Step 4.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@thiremani

Copy link
Copy Markdown
Owner Author

Closing as superseded. Review of the seeded-output semantics concluded that the analysis this PR adds should not exist: the language rule will be that declared outputs are write-only inside their template (no reads in values, conditions, arguments, prints, or format markers), so a body can never observe its incoming seed and BodySeedEffects/CalleeReadsSeed become unnecessary. The reproducer here (y = x > 0 x then y = y + 1) becomes a template error at y = y + 1 rather than a caller-side diagnostic to suppress.

The replacement PR starts from master and keeps the ABI unchanged: the hidden seed parameter and destination-seeded staging slots stay as an unobservable keep-old carrier, and the existing write-effect and boundary-resolution (ReadsSeed) facts keep their purpose. It also has to make explicit inputs stable within each scalar iteration of a range-bearing variant. Today out, seen = Fold(current, item) with out = current + item; seen = current prints 13 13 for value, before = Fold(value, 1:3) because the aliased input reads the output slot after the write, and it has to decide feedback for flavor-mismatched aliases (r = FoldStr(r, items[0:2]) with a static r currently prints ac, not abc, because setCallArgAliasSelectors skips mismatched pairs). Only the CFG cases from this PR carry over, as rejection tests.

The branch stays for reference.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant