Record that rust_ir_encoder and binary_bb_encoder do not route through SecurityWalker/unrollLoop and have no loop handling, so "cross-frontend ir::Loop reuse" is gated on a shared clang-free loop-walk core rather than being a standalone slice. Lay out the realistic sequencing (clang-free CFG foundation -> rust/binary lowering -> recall-preserving while(1) acceleration) and note no safe small gate-validatable slice exists short of starting the foundation. Clang counted+affine summarization is complete. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
15 KiB
Loop Summarization + Frontend-Agnostic Loop IR — Research Synthesis
Generated 2026-05-30. Design research for replacing per-iteration loop unrolling with loop summarization in fold's core, on a frontend-agnostic loop IR. Gates Workstream B of the "precision-drift + IR/loop-perf" plan. Pairs with
perf-audit.md(cost attribution),multi-language-ir-patterns.md(IR direction), andinterprocedural-buffer-threading.md.
1. Problem recap
fold handles loops by bounded unrolling: SecurityWalker::unrollLoop
(src/detect/security_walker.cpp ~1990) walks the body loop_unroll (default 3) times,
encoding statements and firing observer hooks — each hook may issue a Z3 query — then havocs
modified vars and widens ranges. Two consequences:
-
Query-volume blowup. On data-dependent
while(1)scanners (e.g. the dbench JUMBF JPEG marker loops,dbench_jumbf.cpp:279,375) the body is large and nested, so unroll × nesting produces ~170 hard sink queries each burning the per-query budget. Measured: 8+ min uncapped; 52s even at--loop-unroll 1 --per-query-ms 300. The cooperative deadline + flush-deadline + non-progress fixpoint (shipped) bound the fallback but cannot make a query-bound TU finish under budget — it gets watchdog-quarantined. (Seeperf-audit.md: 89% of wall is recursive control-flow walking; the cost is symbolic execution ∝ statements × control-nesting depth.) -
Clang coupling blocks a core fix. Loop handling is on
clang::ForStmt/WhileStmt/DoStmtdirectly (81clang::sites insecurity_walker.cpp), andir/cfg_walker.hppstill exposesclang::CFG*/clang::CFGBlock*. So any loop summarization written here would be clang-only and would have to be re-implemented for the rust (rust_ir_encoder.cpp) and binary (binary_bb_encoder.cpp) frontends. The fix must live on an abstract loop IR.
2. Literature synthesis
| Technique | Source | Relevance to fold |
|---|---|---|
| Loop acceleration → Presburger / recurrence summaries. Accelerate simple loops into closed constraints; summarize nested loops with recurrence relations derived from the inner loop's acceleration (may over-approximate). | Liu & Wahl 2015, arXiv:1505.02637 | Core lever: replace N unrolled bodies + per-iteration queries with one trip-count-parameterized constraint set. Over-approximation is acceptable for fold (refutation, not verification). |
| Loop-modified variables as closed-form functions of the iteration count; steer execution by solving a per-loop constraint system. | Obdrzalek & Trtik 2011, arXiv:1107.1398 | Gives the shape of an affine/counted-loop summary: x_k = x_0 + k·Δ. Matches fold's existing widenLoopRange/octagon value abstractions. |
| k-induction + invariant inference for C verification and refutation. | Alhawi & Cordeiro 2020, doi:10.1007/s10009-020-00564-1 (ESBMC) | Alternative/complement to acceleration; fold already has bench/run_esbmc_on_juliet.py → ESBMC is the natural quantitative comparator for any summarization approach. |
| Path-explosion taxonomy: scope reduction vs guidance heuristics. | Bailey & Nicholas 2025, arXiv:2508.06643 | Frames summarization as scope reduction — the highest-ROI class for fold's per-function bounded model. |
| Incorrectness / under-approximate bug-finding. | Raad et al. 2020 (ISL) | Justifies fold's posture: we may soundly over-approximate reachability (never drop a true bug) and under-approximate only where we already bail — summaries must not introduce false negatives, but may introduce (bounded) false positives that downstream confidence tags already handle. |
| Small core-op IR + shared backend (Infer SIL ~10 ops, Kani MIR→Z3, CodeQL lang-frontends + shared engine). | multi-language-ir-patterns.md, ir-cross-language-research.md |
The encoder should consume a small set of core IR ops (incl. an explicit loop node), not clang Stmt; one summarization pass then serves all frontends. |
3. Chosen approach
Affine/counted-loop closed-form summarization, recurrence-based nested handling, bounded-unroll fallback — implemented once in the core over an abstract loop IR.
- Classify each loop: (a) counted/affine — single integer induction variable with constant
stride and a comparison exit condition (
for (i=a; i<b; i+=c)and the commonwhileequivalents); (b) non-affine / irreducible / data-dependent (while(1)withbreaks, pointer walks, multiple exits). - (a) Summarize: emit one constraint set parameterized by a symbolic trip count
k(bounded by the exit condition), with induction/derived vars as closed formsv_k = v_0 + k·Δ(recurrences for nested loops, conservatively widened). Fire each in-body sink once against the summarized state instead of once per unrolled iteration → collapses the query count from O(unroll × nesting × sinks) to O(sinks). - (b) Fallback: keep the existing bounded unroll (and this session's deadline / flush-deadline / non-progress-fixpoint guards) for loops we cannot summarize soundly.
Rationale: counted/affine loops are where unrolling wastes the most queries and where closed forms
are cheap and exact; while(1) scanners stay on the guarded fallback (correctness over speed).
ESBMC's k-induction is heavier and aimed at proving; acceleration fits fold's bounded refutation
model better and reuses the octagon/range machinery already present.
4. Abstract loop/CFG IR shape (target for B1)
Add to fold::ir (frontend-agnostic; no clang:: in headers, builders in *_clang.cpp /
rust / binary):
ir::FunctionCFG // owns blocks + edges; NO clang::CFG* in the public type
ir::CFGBlock // id, successors/predecessors, ordered ir statements
ir::Loop {
header, latch, exits; // block ids
std::vector<InductionVar> ivs; // { Symbol, init Expr, stride Expr, kind }
Expr exit_condition; // normalized guard
LoopKind kind; // Counted | Affine | General
}
Induction-variable + stride detection lowers from clang (ForStmt analysis already partly exists
as analyzeForLoopRange) and from rust MIR / binary BBs into the same ir::Loop. The encoder/
walker consumes ir::Loop, not clang::ForStmt.
5. Precision contract (non-negotiable)
- A summary's reachability of any in-body sink must be an over-approximation of the real loop — i.e. if the real loop can reach a sink, the summary must too. This guarantees no true finding is dropped (no new false negatives).
- Value abstractions in the summary may be widened (less precise) — this can only add
heuristic/over-approximated findings, which fold's confidence tags (
provedvsheuristicvsover-approximated) already communicate. - When affine classification is uncertain, fall back to bounded unroll. Never summarize a loop whose induction/stride/exit we cannot establish.
- The Workstream-A committed baseline gate is the enforcement mechanism: zero precision/recall regression on Juliet + security-bugs + interproc, or the change does not land.
6. Integration points in fold
include/fold/ir/cfg_walker.hpp+ newinclude/fold/ir/loop.hpp— abstract types (B1).src/symbolic/*_clang.cpp(new builder, mirroringir_expr_clang.cpp) — clang→ir lowering; reuseanalyzeForLoopRange. Rust:rust_ir_encoder.cpp; binary:binary_bb_encoder.cpp.src/detect/security_walker.cppunrollLoop→ thin dispatcher: summarize (a) or fallback (b). ReusewidenLoopRange,octagon_,cachedCollectWrittenVars.- Carry the shipped fallback guards (
WalkOptions::walk_deadline,BatchSafetyObserverflush deadline, non-progress fixpoint).
7. Evaluation plan
- Precision: Workstream-A committed baseline (Juliet 14 CWEs + security-bugs + interproc) — zero regression. Add unit tests: counted loop summarized (query count drops), non-affine falls back, a known loop-dependent finding still fires.
- Perf:
dbench_jumbf.cppcold-cache completes under budget, no watchdog quarantine; the 8 int-overflow findings persist;--profileshows reducedcontrol_walk_ms+ query count. - Comparator:
bench/run_esbmc_on_juliet.pyon the loop-heavy CWEs to sanity-check recall/precision of summarization vs a k-induction tool. - Cross-frontend reuse: one rust loop case + one binary loop case exercise the same core summarization path (advances loop2-priorities #1 "unify binary + source Z3 paths").
8. Risks & open questions
- False negatives from an unsound summary — mitigated by the over-approximation contract (§5)
- baseline gate; default to fallback when unsure.
- Encoder size —
encoder.cppis 2219 lines (over budget). Per CLAUDE.md, extract the summarization pass into its own TU(s); no new monoliths. - Nested-loop recurrences — start with single-level affine; nested recurrence summarization is a follow-up once single-level is proven on the gate.
- context7 not connected this session — provision via the sage hub for Z3 quantifier/array and
LLVM
CFGAPI specifics before B1 coding; otherwise rely onclang-analysis-api-research.md.
9. References
- Liu, Wahl. Unbounded-Thread Reachability via Symbolic Execution and Loop Acceleration. arXiv:1505.02637 (2015).
- Obdrzalek, Trtik. Efficient Loop Navigation for Symbolic Execution. arXiv:1107.1398 (2011).
- Alhawi, Rocha, Gadelha, Cordeiro, Batista. Verification and refutation of C programs based on k-induction and invariant inference. STTT, doi:10.1007/s10009-020-00564-1 (2020).
- Bailey, Nicholas. Symbolic Execution in Practice: A Survey… arXiv:2508.06643 (2025).
- Raad, Berdine, Dang, Dreyer, O'Hearn, Villard. Incorrectness Separation Logic. (2020).
- fold internal:
multi-language-ir-patterns.md,ir-cross-language-research.md,perf-audit.md.
Appendix: drift root-cause #1 — null-deref priorFunctionTextProvesNonNull (fixed 2026-05-30)
Workstream-A triage of the null-deref CWE-476 recall regression (baseline F1 0.583
→ HEAD 0.485) found the cause was not a path-forking bug but a poor optimization:
commit 331a68467 ("Optimize security detectors and add quality gates", 2026-05-23)
added priorFunctionTextProvesNonNull, a whole-function source-text heuristic that
treated the textual presence of p->, *p, p!=0, p!=NULL, p!=nullptr before a
deref as proof the pointer was non-null, suppressing the finding.
The !=-comparison patterns are unsound: a textual p != 0 is a test, not a
guarantee. It suppressed real derefs whenever p != 0 appeared (textually, after the
last assignment) before the deref — e.g. Juliet's binary_if bad case
if ((p != NULL) & (p->intOne == 5)) (bitwise &, both operands evaluated → real
null deref), and any x = (p != 0); … p->m; straight-line pattern. Minimal repros in
/tmp/nd*.c; the distinguisher is encoding order (a deref before the !=0 text was
still flagged).
Fix: removed the heuristic from the suppression chain (null_deref.cpp ~883) and
deleted the function. Legitimate non-null facts remain covered by sound mechanisms:
M20 deref-implies-non-null (markDefinitelyNonNull/isDefinitelyNonNull, encoder),
enclosingThenBranchProvesNonNull (if (p) then-branch),
sourceLineShortCircuitProvesNonNull (p && p->x), staticParamContractProvesNonNull.
Result (CWE-476 null-deref): F1 0.485 → 0.583 (= baseline), recall 0.379 → 0.517 (above baseline 0.497), precision 0.673 → 0.669 (flat). security-bugs null-deref stays P1.0/R1.0; full unit suite green (66/0).
Residual (separate, narrow): (p != 0) && p->x (note !=0, and parenthesized)
still FPs because the sound same-line && recognizer's text patterns don't handle the
!=0/parenthesized form. Right fix is path-sensitive (&& fork actually constraining
the null-deref query), not more text patterns — tracked for the IR/encoder work, not
papered over.
Lesson for Workstream B: prefer path-sensitive/semantic facts over source-text heuristics; text-proof shortcuts trade recall for FP-suppression invisibly. The loop-summarization work must encode guards as constraints, not text patterns.
Appendix: remaining work + architecture finding (2026-05-31)
Status: clang-frontend loop summarization is done — ir::Loop (Counted | Affine |
General) + buildLoopFromClang, wired into SecurityWalker::unrollLoop; counted and
affine (symbolic-bound) loops are summarized (sink fired once against the closed-form
range asserted as a Z3 constraint; findings tagged LoopHavoc/heuristic). Gate-validated,
deterministic baseline.
Key finding that reshapes "cross-frontend reuse": the rust and binary frontends do
not go through SecurityWalker/unrollLoop and have no loop handling today:
src/symbolic/rust_ir_encoder.cppencodes MIR expression nodes; no loop/unroll/CFG.src/symbolic/binary_bb_encoder.cppencodes one basic block at a time (straight-line); no back-edge / loop detection.
So lowering ir::Loop for rust/binary does not by itself enable summarization there —
each frontend would first need loop detection routed through a shared loop-walk core.
That means the three "remaining" items are not independent slices; they are one coupled
effort gated on the same foundation:
Realistic sequencing (each a focused session)
- Clang-free CFG + shared loop-walk core (B1-full). Make
ir::FunctionCFG/CFGWalkerstop exposingclang::CFG*/CFGBlock*; move the ~81clang::sites insecurity_walker.cpponto IR; extract the loop dispatch (unroll vs summarize) into a frontend-agnostic core that consumesir::Loop+ anir::CFG. Prerequisite for the other two. Large, mechanical, precision-sensitive — guard every step with the drift gate. - Rust/binary lower into the shared core. rustc MIR back-edges and r2 BB back-edges →
ir::Loop; route their encoders through the shared loop-walk so counted/affine summarization is reused (the multi-frontend payoff). Advances loop2-priorities #1 ("unify binary + source Z3 paths"). while(1)/ General acceleration. The dbench JUMBF blowup. Must be recall- preserving (additive: keep bounded unroll's concrete-iteration findings AND add a summary pass; union, dedup) — a single-walk replacement traded detection in B2's affine experiment. Research-grade (recurrence/acceleration); highest precision risk. Today the robustness guards (cooperative deadline + flush deadline + non-progress fixpoint) keep it bounded (graceful partial findings), which is acceptable until acceleration lands.
No safe small gate-validatable slice exists short of starting (1). Recommend each as a dedicated session with the drift gate as the guardrail.