Skip to content

rigor-infer: descend toward the site under non-descending containers — fixes #380 - #389

Merged
zonuexe merged 3 commits into
masterfrom
claude/issue-380-nondescending-closure-mutations
Oct 9, 2026
Merged

zonuexe merged 3 commits into
masterfrom
claude/issue-380-nondescending-closure-mutations

Conversation

@zonuexe

@zonuexe zonuexe commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Closes #380

Summary

entry_descend flat-applied Loop/Case/When subtrees and BeginRescue's else/ensure arms via apply_subtree_effects and never crossed into the site-holding child, so a literal block/lambda nested under one never replayed its own closure_mutations:

while c
  [1].each { h.default ||= 0; h[:a].frobnicate }
  break
end
# ref:  silent in-body
# port (master): call.undefined-method@5:37  →  FP

Fix (two parts):

  1. The flat envelope is unchanged — apply_subtree_effects still applies every contained effect — and afterwards entry_descend_site_child descends into the one flow_children child holding the site, so the FlowEdge::Barrier positional mutation replay runs for the deferred body inside. when/in descend into their body only — the oracle does not evaluate a condition-position block into the read's scope (when [1].each { w; r } still fires in-body).
  2. preserve_env threads through entry_descend/entry_children: on the container-originated descent the barrier/lambda/def captures of flow.env are skipped. flow.env is the end-of-FILE env — installing it resurrected post-statement rebinds (x = "s"; while x; [1].each { x.upcase }; break; end; x = 1 fired upcase for 1; ref + master silent). The top-level, begin main-body and rescue-clause capture paths are unchanged — the same FP family there predates this branch → filed as FP: deferred-body barrier captures flow.env (end-of-file env) — post-statement rebinds leak into earlier blocks #390.

Non-Sequence Statements carriers stay flat by design — probed: the oracle does not evaluate Recovered (x = (each{w;r}) rescue nil), Jump (break each{w;r}), or Inert (END { each{w;r} }) children as call sites; all three fire on both engines.

Probes vs the reference (harness/probe.py, full tuples + exit code)

Suppression rows — all = (byte-identical), in-body silent / post-container read fires:

container row
while / until / modifier-while while c; each{w;r}; break; end
for (lowers to Node::Loop) for i in [1]; each{w;r}; end
case/when, case/in arm body
begin else / ensure arms begin;nil;rescue;nil;else;each{w;r};end
nested while, if-in-while, lambda-under-until composed
rescue clause already descended — kept

Review-regression rows (post-container local rebind must not leak into the earlier block) — all silent on both engines after the preserve_env fix:

container row
while, until, for x="s"; while x; each{x.upcase}; break; end; x=1
case/when same shape
ensure arm, else arm same shape
lambda under while l = -> { x.upcase }
nested if + while composed

Type-drift control: x = 1; while x; [1].each { x.upcase }; break; end; x = "s" now reports upcase for 1 on both engines — the pre-fix head drifted to for "s".

Must-still-fire controls — all =, in-body + post-container both fire:

  • read-before-write inside the block under while
  • x = (each{w;r}) rescue nil (Recovered), break each{w;r} (Jump), END { each{w;r} } (Inert)
  • def body under while (fresh scope, no replay)

Gates

  • harness/gate.sh: pass on the final head (docs_check, cargo test -p rigor-cli + -p rigor-infer, run_snapshot.rb)
  • New regression tests: container_nested_block_attr_write_drops_indexed_narrowing (12 suppression + 4 control rows), container_nested_block_keeps_flat_env (7 silent + composed + drift-control rows)
  • fp_audit --gaps --sweep on the final head: see below

Disclosures (pre-existing, unchanged by this diff — verified on master)

  • FP: deferred-body barrier captures flow.env (end-of-file env) — post-statement rebinds leak into earlier blocks #390 filed: top-level/begin main-body/rescue-clause/loop do barrier captures still install flow.env (end-of-file env) — x = "s"; [1].each { x.upcase }; x = 1 fires for 1 on master and this head alike. Deliberately out of scope (wider blast radius: every barrier site's env).
  • Sibling conditional-arm caller-scope writes still flat-over-apply — same on master.
  • A caller-scope write after the block call in a while body drops the in-body record — same on master.
  • flow.always-truthy-condition on if <pinned> inside a when arm — port doesn't warn; different rule, unrelated.
  • The issue's second note (class_narrowing.rs gates on evaluated only) is deliberately out of scope — coverage-loss direction, not an FP.

Generated with Devin

zonuexe and others added 3 commits October 10, 2026 03:28
…fixes #380

`entry_descend` flat-applied `Loop`/`Case`/`When` subtrees and
`BeginRescue`'s else/ensure arms without ever reaching the deferred-body
barrier, so a literal block/lambda nested under one never replayed its own
`closure_mutations` — `while c; [1].each { h.default ||= 0;
h[:a].frobnicate }; break; end` kept the indexed narrowing and fired a
`call.undefined-method` the oracle doesn't emit.

The flat envelope is unchanged; after it, `entry_descend_site_child`
descends into the one child holding the site so the barrier handling (env
capture + positional mutation replay) still runs. The non-Sequence
`Statements` carriers stay flat: the oracle does not evaluate `Recovered`
(`x = (each{..}) rescue nil`), `Jump` (`break each{..}`), or `Inert`
(`END {}`) children as call sites — all three fire on both engines.

Probe rows vs the oracle: while/until/for/modifier-while, case-when,
case-in, else/ensure arms, nested while, if-in-while, lambda-under-until —
all byte-identical (silent in-body, post-container read fires).
Controls — read-before-write under while, the three carriers above — both
reads fire on both engines.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Probing the oracle showed a block inside a `when`/`in` CONDITION keeps
its writes out of the in-body read's scope — `when [1].each {
h.default ||= 0; h[:a].m }` fires there on the reference. Descending
through `flow_children(When)` (conditions + body) suppressed it; restrict
the descent to `body`.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The barrier capture `*env = flow.env.clone()` is the end-of-FILE env:
reached from a non-descending container it resurrects post-statement
rebinds (`x = "s"; while x; [1].each { x.upcase }; break; end; x = 1`
fired `upcase for 1` where the oracle and master are silent). Thread
`preserve_env` through `entry_descend`/`entry_children`; only the
container-originated descent sets it, so every barrier/def/lambda
capture keeps the flat scope the container pass already built. The
top-level/rescue-clause/main-body capture paths are unchanged — the
same FP family there predates this branch and stays a separate issue.
@zonuexe
zonuexe marked this pull request as ready for review October 9, 2026 19:26
@zonuexe
zonuexe merged commit c75a8c9 into master Oct 9, 2026
5 checks passed
@zonuexe
zonuexe deleted the claude/issue-380-nondescending-closure-mutations branch October 9, 2026 19:29
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.

FP: closure mutations under non-descending containers (while/case/rescue arms) never replay

1 participant