fold/docs/internal/loop-summarization-research.md
trvon dd98730988
docs: remaining loop-IR work + cross-frontend architecture finding
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>
2026-05-31 13:44:48 -04:00

15 KiB
Raw Permalink Blame History

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), and interprocedural-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:

  1. 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. (See perf-audit.md: 89% of wall is recursive control-flow walking; the cost is symbolic execution ∝ statements × control-nesting depth.)

  2. Clang coupling blocks a core fix. Loop handling is on clang::ForStmt/WhileStmt/DoStmt directly (81 clang:: sites in security_walker.cpp), and ir/cfg_walker.hpp still exposes clang::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 common while equivalents); (b) non-affine / irreducible / data-dependent (while(1) with breaks, 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 forms v_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)

  1. 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).
  2. Value abstractions in the summary may be widened (less precise) — this can only add heuristic/over-approximated findings, which fold's confidence tags (proved vs heuristic vs over-approximated) already communicate.
  3. When affine classification is uncertain, fall back to bounded unroll. Never summarize a loop whose induction/stride/exit we cannot establish.
  4. 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 + new include/fold/ir/loop.hpp — abstract types (B1).
  • src/symbolic/*_clang.cpp (new builder, mirroring ir_expr_clang.cpp) — clang→ir lowering; reuse analyzeForLoopRange. Rust: rust_ir_encoder.cpp; binary: binary_bb_encoder.cpp.
  • src/detect/security_walker.cpp unrollLoop → thin dispatcher: summarize (a) or fallback (b). Reuse widenLoopRange, octagon_, cachedCollectWrittenVars.
  • Carry the shipped fallback guards (WalkOptions::walk_deadline, BatchSafetyObserver flush 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.cpp cold-cache completes under budget, no watchdog quarantine; the 8 int-overflow findings persist; --profile shows reduced control_walk_ms + query count.
  • Comparator: bench/run_esbmc_on_juliet.py on 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.cpp is 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 CFG API specifics before B1 coding; otherwise rely on clang-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.cpp encodes MIR expression nodes; no loop/unroll/CFG.
  • src/symbolic/binary_bb_encoder.cpp encodes 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)

  1. Clang-free CFG + shared loop-walk core (B1-full). Make ir::FunctionCFG/CFGWalker stop exposing clang::CFG*/CFGBlock*; move the ~81 clang:: sites in security_walker.cpp onto IR; extract the loop dispatch (unroll vs summarize) into a frontend-agnostic core that consumes ir::Loop + an ir::CFG. Prerequisite for the other two. Large, mechanical, precision-sensitive — guard every step with the drift gate.
  2. 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").
  3. 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.