Skip to content

fix(origins): widen loop-carried values instead of exhausting the step budget - #85

Merged
dweekly merged 1 commit into
mainfrom
fix/origins-dataflow-convergence
Sep 19, 2026
Merged

dweekly merged 1 commit into
mainfrom
fix/origins-dataflow-convergence

Conversation

@dweekly

@dweekly dweekly commented Sep 19, 2026

Copy link
Copy Markdown
Owner

Plain English

When a Lua function contains a loop, luad's argument-origin analysis could never finish working out what the values were. It kept building a bigger and bigger expression on each pass round the loop, ran until it hit its internal step limit, and then gave up on the whole function, reporting every call in it as analysis-limit — including the simple ones it had already worked out on the first pass.

This bounds the loop instead, so a function containing a loop still resolves everything outside the loop.

The mechanism

The origin lattice has unbounded height. For i = i + 1:

merge at the loop head   ->  Alternatives[0, ADD(0, 1)]
transfer of i = i + 1    ->  ADD(Alternatives[0, ADD(0, 1)], 1)
next merge               ->  Alternatives[0, ADD(0, 1), ADD(Alternatives[..], 1)]

Each pass is strictly larger than the last, so state != out_states[block] never stops firing, the worklist runs until MAX_TRANSFER_STEPS, and analyze_proto_tree falls into enumerate_calls_with_reason(.., AnalysisLimit) for the entire prototype.

MAX_ALTERNATIVES and the expression bounds truncate an individual expression but do not bound the iteration: each pass simply builds a different expression below those limits.

The fix

After a block's outgoing state has changed MAX_BLOCK_REVISITS (8) times, the slots still moving are widened to the top of the lattice. Slots that already converged keep their exact expression. 37 lines, one function, one documented constant.

Evidence

A 524-instruction, 116-block prototype from extracted router firmware, which 0.2.0 resolved and 0.3.0 did not:

proto:0/31:pc:232 analysis-limit in file time
0.2.0 CONCAT("mkdir -p ", "/tmp/subconfig_sync/", field(params, "opcode")) 0
0.3.0 unknown:AnalysisLimit 117 15.85s
this branch CONCAT("mkdir -p ", "/tmp/subconfig_sync/", field(params, "opcode")) 0 0.19s

Across a 260-file corpus: analysis-limit 144 → 17, and no argument lost an expression it previously had. A downstream consumer's machine-confirmed count went 15 → 23, the eight recovered being exactly the calls in that prototype.

Diagnosis was instrumented, not guessed: the exhausting prototypes had a maximum expression size of 12 and 3 nodes respectively while burning 1,000,001 steps, which ruled out expression growth and pointed at the iteration. Two earlier hypotheses (evidence-sensitive state comparison, evidence-sensitive option ordering) were tested and discarded because neither fixed it.

Testing

bash scripts/check.sh passes.

New: test_loop_carried_values_widen_rather_than_growing_without_bound with a dedicated fixture, tests/fixtures/origins_loop_widening.lua.

It asserts the mechanism rather than the symptom: a loop-carried accumulator must widen to ControlFlowConflict rather than grow until ExpressionDepthLimit truncates it. I could not build a synthetic fixture that exhausts the step budget outright at a reasonable size, and the firmware that does reproduce it is not a public sample, so asserting the symptom directly would have produced a test that cannot fail. Verified to fail without this change and pass with it.

tests/fixtures/origins.lua is deliberately untouched so the pinned origin-matrix cardinality gate still holds.

Note on scope

docs/NEXT-SPRINT.md authorizes no new feature implementation. This is a regression fix against behaviour that worked in 0.2.0, not a feature, but it does change an analysis result shape (a loop-carried slot now reports control-flow-conflict where it previously reported a depth/node limit or nothing at all), so it is your call whether it belongs in this checkpoint.

…p budget

The origin lattice has unbounded height. A loop-carried `i = i + 1` merges at the
loop head to `Alternatives[0, ADD(0, 1)]`; the transfer then yields
`ADD(Alternatives[..], 1)`, which the next merge adds as a further option, one
nesting level per pass. Every iteration is strictly larger than the last, so
`state != out_states[block]` never stops firing and the worklist terminates only
by exhausting MAX_TRANSFER_STEPS. `analyze_proto_tree` then reports *every* call
in that prototype as `analysis-limit`, including the arguments that converged on
the first visit.

Widening bounds the iteration: after a block's outgoing state has changed
MAX_BLOCK_REVISITS times, the slots still moving are raised to the top of the
lattice. Slots that already converged keep their exact expression, so a prototype
containing a loop still resolves everything outside it.

Measured on a 524-instruction, 116-block prototype from extracted router
firmware, where 0.2.0 resolved the arguments and 0.3.0 did not:

  before  proto=0/31 steps=1000001 exhausted=true   117 analysis-limit, 15.85s
  after   proto=0/31 converges                        0 analysis-limit,  0.19s

and across a 260-file corpus, `analysis-limit` falls from 144 to 17 with no
argument losing an expression it previously had.

The regression test asserts the mechanism rather than the symptom: a loop-carried
accumulator must widen to `ControlFlowConflict`, not grow until
`ExpressionDepthLimit` truncates it. A synthetic fixture large enough to exhaust
the step budget outright was not reproducible at a reasonable size, and the
firmware that does reproduce it is not a public sample. The test fails without
this change and passes with it. `tests/fixtures/origins.lua` is untouched so the
pinned origin-matrix cardinality gate still holds.
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.71429% with 3 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@ab173e5). Learn more about missing BASE report.

Files with missing lines Patch % Lines
crates/luad-analysis/src/origins.rs 85.71% 3 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main      #85   +/-   ##
=======================================
  Coverage        ?   85.11%           
=======================================
  Files           ?       60           
  Lines           ?    21430           
  Branches        ?        0           
=======================================
  Hits            ?    18240           
  Misses          ?     3190           
  Partials        ?        0           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@dweekly
dweekly merged commit 49ad8b7 into main Sep 19, 2026
14 checks passed
@dweekly
dweekly deleted the fix/origins-dataflow-convergence branch September 19, 2026 03:51
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.

2 participants