Repository navigation
Loop-head state matching for concrete loop states - #84
Closed
nils-loose wants to merge 7 commits into
Closed
nils-loose wants to merge 7 commits into
nils-loose wants to merge 7 commits into
Conversation
With loopStateMatching set, the instrumentation finds the targets of backward jumps (with an empty operand stack) and probes them with a LOOP_HEAD instruction that carries the values of the locals defined there. The probe goes through the processor, so it is handled after the jump that led to the head has been recorded. LoopStateMatcher hashes the program state at the head: the head, its locals, the static fields of the initialized application classes (every application class now records the start of its initialization in ClassInits), and the heap reachable from them, encoded as a graph. There is no hash unless the head's method is the only application frame, the target runs a single thread, no shadow value is symbolic, and the heap holds only application objects, application enum constants, arrays and plain Objects. The hashes reach the explorer with the next branch (BranchDTO.loopStates). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tree positions each loop-head state of a trace by (branches before it, their outcomes as a binary number, states before it) and keeps the smallest position of every state. dfs() does not offer a branch, nor any branch below it, that follows a state already reached at a smaller position: a smallest violating execution never passes such a state, since cutting out the part between the two occurrences gives a smaller one (see LoopStateMatcher). --no-loop-state-matching turns this off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A run stops hashing once it has spent a second on it, or once a state repeats on its path (the explorer leaves out everything after the repeat anyway). Reporting fewer states only means fewer branches are left out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An Object[] created with a symbolic length kept isSymbolic() false as long as its elements were concrete, and a primitive array only looked at its element formula. Both now also count a symbolic size. Besides loop-state matching, this makes an unmodelled call that receives such an array a symbolic call (context loss), as it should be. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A library call (or an access to a static field of an untracked class) can read or change state outside the hashed state: a System property, the thread interrupt flag, the statics of a class in an excluded package. A run therefore reports no more loop-head states once it makes such a call, apart from the Verifier, Math/StrictMath and the static helpers of Double/Float/Integer/Long (without getInteger/getLong). Every reported state then has a prefix without such calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Six VIOLATION tasks where a loop-state match would hide the violation: a symbolic Object[] length (valid-assert, no-runtime-exception, unbounded loop), a System property, the interrupt flag, and a class in an excluded package. build.sh now compiles a task's sources in package subfolders too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nils-loose
added a commit
that referenced
this pull request
Oct 19, 2026
Tree.py: #84's loop-state positions are registered at the start of #72's iterative Tree.add; the recursive add_recursive is not brought back. Node/Branch/TraceParser/DataTransferObjects.py, BranchDTO/DTOBuilder.java: both the choice flag (#69/#76) and the loop states (#84). SymbolicTraceHandler.java: addChoiceBranch, checkAndSetLibraryBranch and the loop-state collection side by side. StrategyService.py: the walk on the live tree (#82) reports #84's count of pruned subtrees. SVCompDriver.py: #84's --no-loop-state-matching next to the other state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nils-loose
added a commit
that referenced
this pull request
Oct 19, 2026
…tates to every branch Combining #72, #82 and #84: - #84 pruned a subtree below a repeated loop-head state by returning from the recursive dfs. In #72's iterative walk that return ended the whole walk, so the open branches of every later subtree were never offered, and an exhausted search could report SAFE. It now skips that subtree only (regression test added). - The pruned-subtree count is reset for each walk, as #82 walks the persistent tree instead of a copy. - The choice branches (#69/#76) and library-call branches (#75/#90) take the pending loop-head states too, so a state always goes with the branch right after it, which its position assumes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merged
Contributor
Author
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.
What and why
SWAT keeps unrolling loops whose state repeats exactly. In
float_unboundedloopthe loop is guarded bynondetBoolean()and its state is concrete (for example x stays at 9.5 from iteration 10 on). So the path space is infinite, and no run ever reached a verdict.This adds loop-head state matching, sound by construction:
loopStateMatching=true, set insv-comp.cfg):Analyzer). It probes them with aLOOP_HEADinstruction that carries the locals defined there.LoopStateMatcherhashes the state at the head: the head, its locals, the set of initialized application classes (newClassInitsregistry, called from every<clinit>), their static fields, and the reachable heap, encoded as a graph.Objects.Math/StrictMath, and the static helpers ofDouble/Float/Integer/Long(notgetInteger/getLong). It also stops on an invokedynamic, or on an access to a static field of an untracked class. Every reported state therefore has a prefix that leaves JDK-held state (System properties, the interrupt flag, the statics of classes in excluded packages) at its initial value.ObjectArrayValue,AbstractArrayValue).BranchDTO.loopStates).dfs()does not offer a branch, or anything below it, that follows a state already reached at a smaller position.--no-loop-state-matchingturns this off.Soundness. Order executions by (branch count, branch outcomes, number of states passed), and take a smallest violating execution. If it were cut off, it would pass a state S that some executed path reached at a smaller position. That path's prefix up to S, followed by the violating execution after S, is again an execution: the state is equal, and later inputs are fresh. That execution violates the property too and is smaller, which is a contradiction. The same argument holds for the events that downgrade SAFE (context loss etc.). Two conditions carry this argument:
Related tools:
Review
An independent review found 4 wrong-SAFE counterexamples in the first version:
new Object[n]kept a symbolic length thatisSymbolic()ignored;com/google).All four are fixed by the two rules above. They are now VIOLATION tasks in
swat-benchmarks/java/loop-state/:Object[]variant times out, as it does without matching.The rest of the reviewer's programs give no SAFE either.
The
isSymbolicchange also affects context-loss detection: an unmodelled call that receives an array with a symbolic length now counts as symbolic. Over all 482 baseline-correct SAFE runs and all 728 expected-VIOLATION runs, it changed no verdict. I did not check #77's container check, which is not on this branch. Once both are merged, that check also sees such arrays as symbolic.Measured
Competition config (nosa, 300 s, 15 GB,
sv-comp.cfg), compared withbaseline-results.jsonl.The final commit is re-measured on 1262 runs: the 52 gained runs, all 728 expected-VIOLATION runs and all 482 baseline-correct SAFE runs. Result: the same +104, 0 lost, 0 wrong. CPU on runs that finish on both sides is 0.96× the baseline.
The first version was measured on 1381 runs:
float_unboundedloopruns;unbounded-path-space,cpu-timeout-path-explosion,no-loop-state-matchingorproperty-irrelevant-branches-explored.+104 points, 52 runs gained, 0 lost, 0 new wrong verdicts. All gains are
float_unboundedloopSAFE runs: timeout → SAFE.float_unboundedloop(52 runs).Tests
LoopHeadsTest(Spock): finds heads and their locals; skips a head with values on the stack; finds no heads without backward jumps.tests/test_loop_state_matching.py: positions, pruning on one path and across paths, tie order, the off switch.swat-benchmarks/java/loop-state/: the reviewer's counterexamples as VIOLATION tasks.Known limits
float_unboundedloopruns whose state takes too long to repeat (Two-Counter-Lexicographic-Small, Hysteresis-On-Off-with-Deadband NRE, Adjustable-Inner-Count-Halving), plus Adjustable-Inner-Count-Halving's expected violation (valid-assert), which also times out on the baseline.🤖 Generated with Claude Code