fix(origins): widen loop-carried values instead of exhausting the step budget - #85
Merged
Merged
Conversation
…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 Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:Each pass is strictly larger than the last, so
state != out_states[block]never stops firing, the worklist runs untilMAX_TRANSFER_STEPS, andanalyze_proto_treefalls intoenumerate_calls_with_reason(.., AnalysisLimit)for the entire prototype.MAX_ALTERNATIVESand 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:232analysis-limitin fileCONCAT("mkdir -p ", "/tmp/subconfig_sync/", field(params, "opcode"))unknown:AnalysisLimitCONCAT("mkdir -p ", "/tmp/subconfig_sync/", field(params, "opcode"))Across a 260-file corpus:
analysis-limit144 → 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.shpasses.New:
test_loop_carried_values_widen_rather_than_growing_without_boundwith a dedicated fixture,tests/fixtures/origins_loop_widening.lua.It asserts the mechanism rather than the symptom: a loop-carried accumulator must widen to
ControlFlowConflictrather than grow untilExpressionDepthLimittruncates 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.luais deliberately untouched so the pinned origin-matrix cardinality gate still holds.Note on scope
docs/NEXT-SPRINT.mdauthorizes 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 reportscontrol-flow-conflictwhere it previously reported a depth/node limit or nothing at all), so it is your call whether it belongs in this checkpoint.