24 KiB
Z3 + Clang library-expansion opportunity matrix
A scorecard of Z3 and Clang LibTooling features fold has either adopted,
rejected, or left as evidence-gated opportunities. Companion to
docs/internal/perf-audit.md; inherits the same "measure before optimising"
discipline that audit baked in after nine wrong-hypothesis pivots.
This is now primarily a decision log and routing table. Most rows have
already been audited; use the Status column to distinguish shipped work,
measured false positives, and genuinely unscheduled capability ideas. Active
loose ends live in docs/internal/library-expansion-followups.md.
When a row does ship, update its Status with the measurement artifact link
(e.g. docs/internal/perf-audit/runs/matrix/<row>-<target>-YYYY-MM-DD.json).
Current usage snapshot
Z3 (z3++.h)
| Surface | Files |
|---|---|
| BV-64 arithmetic + overflow predicates | src/symbolic/encoder.cpp, include/fold/symbolic/encoder.hpp |
Boolean ops + ite |
src/symbolic/encoder.cpp |
Arrays (select/store/const_array) for buffer + heap |
src/symbolic/encoder.cpp, include/fold/symbolic/heap_model.hpp |
Quantifiers (forall/exists) — loop-invariant inference only |
src/symbolic/loop_invariant.cpp |
| Uninterpreted functions for heap pointer identity | include/fold/symbolic/heap_model.hpp |
Solver portfolio (QF_BV + simplify→bit-blast→SAT, par_or) |
src/symbolic/tactics.cpp |
Assumption-based solver.check(expr_vector) |
src/detect/{null_deref,div_by_zero,sign_conversion}.cpp |
Unsat-core caching (per-detector, member unsat_cores_ + path-hash gate) |
src/detect/{null_deref,div_by_zero,oob_access,int_overflow,sign_conversion,int_truncation}.cpp |
| Multi-model sampling + model→bytes for fuzz seeds | src/fuzz/model_to_bytes.cpp, src/detect/null_deref.cpp |
z3::fixedpoint for synthesised loop invariants |
src/symbolic/loop_invariant.cpp |
Statistics aggregation (solver.statistics() → SolverStats) |
include/fold/symbolic/theory.hpp |
Shared shared_ptr<z3::context> for cheap env clones |
include/fold/symbolic/encoder.hpp |
Clang LibTooling / AST / Analysis
| Surface | Files |
|---|---|
RecursiveASTVisitor (detectors + frontend) |
src/detect/*.cpp, src/frontend/{ast_visitor,clang_backend}.cpp |
ParentMapContext |
src/detect/{security_walker,null_deref,div_by_zero}.cpp |
clang::CFG::buildCFG + CFGBlock |
src/ir/cfg_walker.cpp, src/detect/dead_statement.cpp |
ASTFrontendAction + ASTConsumer |
src/frontend/{clang_backend,ast_visitor}.cpp |
tooling::CompilationDatabase + ClangTool |
src/cli/scan_runner.cpp, src/frontend/comp_db.cpp |
Type queries (getTypeSizeInChars, Linkage) |
src/symbolic/encoder_type_queries.cpp, src/detect/oob_access.cpp |
SourceManager / SourceLocation (ubiquitous) |
every detector |
Hand-built call graph (Tarjan SCC) — not clang::CallGraph |
src/ir/call_graph.cpp |
Hand-built CTU cache + persistence — not clang::CrossTranslationUnitContext |
src/detect/{cross_tu_cache,cross_tu_cache_io}.cpp |
Custom def-use chains — not clang::dataflow |
include/fold/ir/def_use.hpp |
Opportunity matrix
Columns:
- Feature — the Z3/Clang API
- Insertion site — file (path verified at write time)
- Affects — detectors /
encoder/frontend/infra - Payoff — precision / perf / capability / UX
- Sizing — qualitative S/M/L + the measurement that would confirm it. The audit's lesson is to ALWAYS name that measurement before writing code.
- Risk — semantic-drift / perf-regression / build-fragility
- Deps — other matrix rows or upstream features needed first
- Status —
shipped,unscheduled, or retired with a named false-positive reason plus links to artifacts where available.
Z3 rows
| # | Feature | Insertion site | Affects | Payoff | Sizing | Complexity | Risk | Deps | Status |
|---|---|---|---|---|---|---|---|---|---|
| Z1 | Quantifiers + patterns (forall :pattern) for function-summary preconditions |
src/symbolic/encoder_summary_instantiate.cpp |
encoder, all summary-backed detectors | precision | hypothesis-FP — measured curl scan: 3,052 instantiate calls, 238 ms total = 0.042% of analyze_ms (570 s). Not a hot path. | M | semantic-drift (matching heuristics fire differently), z3 quantifier e-matching typically slower than direct substitution | none | unscheduled — measured FP. Z1 (matrix author's original hypothesis: summary_lookup_ms reduction) was based on the wrong timer — summary_lookup_ms measures SummaryStore::lookup() (hash-map), NOT the parse_string+substitute path that Z1 would replace. Instrumented the parse_string path directly on curl 2026-05-25: 0.042 % of total scan time. No room for a measurable win, and quantifier encoding would add semantic-drift risk. Skip. |
| Z2 | Datatypes / tuples for structured pointer metadata (base, size, freed) |
include/fold/symbolic/heap_model.hpp |
oob-access, null-deref | perf, precision | design-FP — HeapRegion is a C++ struct whose only z3-typed fields (base, size) are already z3 BV exprs; freed/in_loop/alloc_id are C++ scalars. The struct never crosses TUs (cross-TU cache serialises body_smt only), so a z3 tuple sort saves zero bytes. mergeFrom is hashmap union with freed = lhs.freed || rhs.freed — no z3 queries fired, so "alias-query count" is not a real timer. |
L | semantic-drift, build-fragility (Z3 datatypes need careful sort setup) | none | unscheduled — design FP. The real opportunity at this site is path-sensitive freed merge (currently a crude bool-or that loses path-sensitivity), which is a heap-model correctness fix, not a library-expansion row. Carved out as future work in docs/internal/perf-audit/optimization-backlog.md rather than wedged into Z2's framing. |
| Z3 | String / sequence theory for strcpy/strlen and taint flow |
src/symbolic/encoder.cpp:259, 541, 549, 559 |
oob-access, taint-track | capability, precision | hypothesis-FP for the "byte-unroll elimination" framing — encoder doesn't unroll strcpy/strlen byte-by-byte; concrete-length cases use recordKnownStringDestinationLength and symbolic cases bail (clear_dst()). Real opportunity is new capability (symbolic-length string reasoning), not eliminating existing unrolls. |
L | semantic-drift (Z3 strings theory is heavier than BV) | none | unscheduled — current-state FP. Reframable as pure capability addition (symbolic strlen/strcpy) but payoff is speculative findings on string-content patterns; deferred until there's a concrete missed-finding driving it. |
| Z4 | Array lambda for symbolic-length memcpy/memmove modelling | src/symbolic/encoder.cpp:688 (modelMemcpyLikeCall) |
encoder, oob-access | capability, perf | M (shipped) — replaced per-byte store(store(...)) chain with z3::lambda(i, ite(dst_off ≤ i < dst_off + copy_bytes, select(src, ...), select(orig, i))). Dropped the !arg_values[2].is_numeral() bail; symbolic copy_bytes now modelled. Kept kMaxModeledCopyBytes=4096 cap on CONCRETE bounds. z3 simplifies the lambda away at concrete-index selects so QF_BV stays viable — no logic-change cascade. Curl baseline post-Z4: analyze_ms=565020, null-deref wall_ms=344675 with 2,533 queries (see baseline JSON). |
M | none observed — full unit suite (57/57) green; downstream detectors handle the lambda transparently | none | shipped — docs/internal/perf-audit/runs/matrix/Z4-curl-2026-05-26.json, tests/unit/test_memcpy_lambda.cpp |
| Z5 | z3::optimize for minimal counterexample selection |
src/fuzz/minimal_witness.{hpp,cpp} (new), src/detect/null_deref.cpp:1023 |
fuzz seed quality | UX | S (shipped) — measured on synthetic access_at_offset(int *p, int offset) { if (0 < offset && offset < 100) return p[offset]; }: primary witness drops to the path minimum (current direct artifact: offset 64 → 1, 98.4% smaller). divide_by_count(int *p, int count) { if (count > 0) return *p / count; } unchanged (path-min was already 1). Buffer-size framing in the original row was wrong — buffer_bytes is a C++ constant in param_schema.cpp:84, not a z3 var. The real measurable is integer-witness magnitude. |
S | semantic-drift — only the primary witness is refined; recordAlternateWitnesses keeps the original model so the corpus still covers the equivalence class |
none | shipped — opt-in via active recorder presence; no flag needed. Per-finding cost = one z3::optimize.check(). Synthetic primitive artifact: docs/internal/perf-audit/runs/matrix/Z5-synthetic-2026-05-26.json. Curl-scale validation deferred: fuzz-prep on curl crashes with SIGSEGV (pre-existing — verified Z5-OFF baseline also exit 139). |
| Z6 | Fixedpoint engine extension beyond loop invariants (mutual recursion, linear-arithmetic invariants) | src/symbolic/loop_invariant.cpp, src/detect/security_walker.cpp:1604 |
encoder | capability | infrastructure-FP — inferCountedLoopInvariant already runs; its result is only used for spdlog::debug at security_walker.cpp:1607. refineRange/recordRangeInOctagon run unconditionally and do NOT consume the invariant. Extending fixedpoint to mutual recursion before wiring the existing single-loop result back into analysis state would add capability nothing reads. |
L | semantic-drift, perf-regression | Z1 (deferred — measured FP) | unscheduled — infrastructure FP. Reframable as: (a) first wire the existing loop invariant into octagon/range refinement (small fix), then (b) extend to mutual recursion. (a) is a precision-bug fix, not a library-expansion row. (b) without (a) ships dead code. |
| Z7 | Solver push/pop for incremental walkIf paths | src/detect/security_walker.cpp::walkIf (~L1500) |
encoder, all walker-driven detectors | perf | S — env_clone_count reduction (already measured as <1µs each; bar is low) |
M | semantic-drift (incremental solver state must match cloned-env semantics exactly) | none | unscheduled — env_clone wasn't the bottleneck (0ms / 770K clones) |
| Z8 | Generalise unsat-core caching to remaining detectors | src/detect/sign_conversion.cpp, src/detect/int_truncation.cpp |
sign-conversion, int-truncation | perf | S — per-detector wall_ms in --profile |
S | none — pattern is proven across four detectors | none | shipped — applied to sign-conversion + int-truncation; taint-track skipped (uses no solver, dataflow only) |
| Z9 | Proof + Craig interpolant generation for finding witnesses | finding emission in detectors; src/witness/graphml.cpp |
capability, UX | infrastructure-absent FP — z3 4.13+ (fold's pinned version) does not export interpolant APIs. They were a 4.5-era research feature that was removed; Z3_get_interpolant/mk_interpolant are not in the linked headers. Even if available, existing buildCounterexample already emits concrete model values ((e.g., when ptr=0)) which are easier to read than formula slices. |
L | perf-regression (proof generation is expensive) | upstream z3 reintroducing interpolants | unscheduled — infrastructure absent. Reconsider if z3 reintroduces the API; until then concrete-model counterexamples cover the same UX surface. | |
| Z10 | let-bindings / macros for serialised-summary compaction |
src/symbolic/serialize.cpp:73 |
encoder, summary-store size | perf | M — measured smaller serialised bodies on encoder-shaped n=100 corpora via z3::set_param("pp.min-alias-size", 2); current direct artifact: 320.4 KB → 113.7 KB (64.5% smaller). Earlier corpus cited 94.9 KB → 57.0 KB (39.9% smaller). |
S | none | none | shipped — src/symbolic/serialize.cpp, tests/unit/test_serialize_letbindings.cpp, docs/internal/perf-audit/runs/matrix/Z10-serialize-corpus-2026-05-26.json |
Clang rows
| # | Feature | Insertion site | Affects | Payoff | Sizing | Complexity | Risk | Deps | Status |
|---|---|---|---|---|---|---|---|---|---|
| C1 | AST matchers (clang/ASTMatchers) for targeted patterns |
src/detect/dead_branch.cpp, src/detect/security_walker.cpp |
detectors | UX (code clarity); some perf | no-forcing-function — surveyed sites: dead_branch has 7 dyn_cast uses, all single-line type-tag dispatch (dyn_cast<CompoundStmt>(stmt)); security_walker has 48 of the same shape. ASTMatchers would be MORE verbose for type-tag dispatch (require MatchFinder + bind + callback). They win only for pattern-structure queries (regex name + nth arg shape + …), which the existing detectors don't need. |
S–M per call site | none for isolated replacements | none | unscheduled — no measurable benefit-bearing site. Reconsider when a concrete pattern-structure detector lands that would actually benefit (e.g. a "specific calling-convention misuse" check that's awkward to write with dyn_cast). |
| C2 | clang::CallGraph for built-in call resolution |
src/cli/scan_runner.cpp → ir::ProjectCallGraph |
infra | perf, capability (constructor/new/default-init/block edges) | M — virtual-call resolution count; CTU cache hit-rate stability | M | semantic-drift (clang's call graph has different node identity than ir::ProjectCallGraph); breaks cross-TU cache invariants |
careful migration plan | shipped selective adapter — fold's ProjectCallGraph remains canonical; Clang TU-local edges are merged additively and persisted through the existing cross-TU cache contribution format |
| C3 | clang::dataflow::Analysis framework for pre-pass typestate |
src/symbolic/encoder.cpp (env initialisation) |
encoder, null-deref | precision, perf | L (not M as originally sized) — clang ships the framework but NO ready-made pointer-nullability analyzer. Bundled UncheckedOptionalAccessModel is for std::optional, not raw pointers. C3 would require writing a custom DataflowAnalysis<NullPtrLattice> with transfer functions for every pointer-touching expression — comparable cost to a new detector. Original "M — functions_prechecked_safe" sizing was wrong-tracked: that counter measures syntactic sink-absence (precheckSecuritySinks), not nullability. |
L | wrong lattice = silent missed bugs (false-negatives). additive-pre-pass framing only holds if the lattice is genuinely sound. | none | unscheduled — sized wrong + no forcing function. fold already has multiple condition-based non-null pre-passes (enclosingThenBranchProvesNonNull, ternaryArmProvesNonNull, priorFunctionTextProvesNonNull, staticParamContractProvesNonNull, current_debugassert_nonnull_). Reconsider when a concrete null-deref FP catalog says clang dataflow would catch what these miss. |
| C4 | clang::consumed::ConsumedAnalysis for RAII |
src/detect/security_walker.cpp |
use-after-free path (future detector) | capability | wrong-tool-FP — ConsumedAnalysis is intra-procedural typestate for linear types with states (Unconsumed/Consumed/Unknown). It only activates on classes marked [[clang::consumable]] + member functions marked [[clang::callable_when(state)]] + [[clang::return_typestate(state)]]. Real-world C code (curl, yams, kernel-style) has none of these attributes; without auto-injection the analysis is silent. |
M | none — but no useful signal on unannotated code | source annotations or attribute auto-injection | unscheduled — wrong-tool FP. A native UAF detector remains a real opportunity, but it's a CFG-walker shape (track malloc/free on tracked pointers + flag derefs after free), not ConsumedAnalysis. Carved out as a future detector row rather than wedged into C4's framing. |
| C5 | UninitializedValues analyzer → uninit-local-read detector |
src/detect/uninit_local_read.{hpp,cpp} (new), src/symbolic/uninit_pre_pass.{hpp,cpp} (measurement) |
new detector --detectors uninit-local-read |
capability | S (shipped) — measurement wrapper + 5 unit tests on the detector entry point + end-to-end smoke on a synthetic fixture (int x; int y = x; → finding @ line 3; int p; int y = p; → no finding). Original "null-deref FP triage" framing was wrong-hypothesis (clang's analyzer flags LOCAL read-before-write, NOT parameter nullability) — C5 now ships as the local-uninit detector that calibration revealed. Curl baseline: 0 SARIF findings after report filtering, 68 ms detector wall. |
S | none | none | shipped — library function + detector + registry + scan-runner dispatch + SARIF rule id; baseline docs/internal/perf-audit/runs/matrix/C5-curl-2026-05-26.json |
| C6 | clang::Preprocessor macro-expansion hook |
src/frontend/clang_backend.cpp |
dead-statement, dead-branch | precision | current-state-FP — dead_statement.cpp:140 already filters anchor->getBeginLoc().isMacroID() (battle-tested in 2026-05-15 curl audit: doh.c 9,060→0 findings). Remaining FPs are CFG-construction artifacts (range-for desugaring, EH stubs), not preprocessor-induced. fold has no multi-config scan mode, so "variant -D" is not a live failure mode. dead-branch lacks the macro-ID filter but no documented FP drives the need. |
M | build-fragility (Preprocessor hooks fire during parse; lifetime is delicate) | none | unscheduled — current-state FP. If a real dead-branch FP under macros surfaces, the fix is one-line (add the same isMacroID() filter to dead_branch.cpp), not a Preprocessor-hook row. |
| C7 | AvailabilityAttr / deprecation detection |
src/detect/deprecated_api.{hpp,cpp} (new) |
new detector (--detectors deprecated-api) |
UX, capability | S (shipped) — 7 unit tests pin [[deprecated]], __attribute__((deprecated)), message-payload propagation, decl-only-not-flagged, multi-callsite, method-call. Curl baseline: 52 real findings with replacement guidance carried through ("Use curl_mime_init()", "first deprecated in macOS 10.11 — use OpenDirectory Framework"). Per-call rather than per-decl so refactors show monotone progress. |
S | none | none | shipped — baseline docs/internal/perf-audit/runs/matrix/C7-curl-2026-05-26.json |
| C8 | RawCommentList for /*@ requires */ user contracts |
src/symbolic/function_contracts.{hpp,cpp} (new), src/detect/security_walker.cpp (apply hook), src/frontend/comp_db.cpp (-fparse-all-comments) |
encoder, all detectors that consult isDefinitelyNonNull |
capability | S (shipped) — 8 unit tests pin: != null / != NULL / != nullptr shapes, multi-line, unknown-param reject, twin-flag negative. v1 grammar = /*@ requires <param> != <null> */. Suppresses within-function derefs on contracted params; synthetic twin baseline dropped null-deref findings 6 → 1 (5 contracted derefs suppressed; unannotated residual still fires). Does not suppress call-site findings (callers still need to prove non-null at the call). The call-site suppression is the "callee-standalone-context" gap from docs/internal/perf-audit/fold-on-fold.md, separate from C8. |
S | none | none — but requires -fparse-all-comments (added to applyHostToolchain) |
shipped (within-function suppression) — baseline docs/internal/perf-audit/runs/matrix/C8-contracts-synthetic-2026-05-26.json |
| C9 | FixIts for fold --fix-output (unified-diff patch) |
include/fold/report/{fix_it,patch_writer}.hpp (new), src/report/patch_writer.cpp (new), src/detect/{uninit_local_read,deprecated_api}.cpp (consumers), src/cli/main.cpp (--fix-output flag) |
UX (new capability — autopatch) | S (shipped) — 4 patch_writer tests + 4 FixIt-emission tests + end-to-end smoke on synthetic fixture (int x; → int x = 0; patch applies cleanly with git apply --check). Consumers: UninitLocalRead (scalar integer locals only — int x; → int x = 0;; non-scalar deferred); DeprecatedApi (regex on the message for [Uu]se foo() pattern; emits no FixIt when no replacement is parseable, rather than hallucinating). Patch writer now emits one coherent hunk per file so multiple same-file FixIts apply cleanly. Patch-only mode — no in-place rewrites — keeps the safety blast radius small. |
M | semantic-drift mitigated by emitting only when transform is provably sound and never auto-applying | C8 contracts (deferred — could prove additional patch safety later) | shipped — unified-diff output via fold scan --fix-output <path>; baseline docs/internal/perf-audit/runs/matrix/C9-synthetic-2026-05-26.json. Multi-line replacements + non-scalar uninit + non-call-form deprecation replacements remain unscheduled. |
|
| C10 | clang::CrossTranslationUnitContext |
src/detect/cross_tu_cache.cpp |
infra | perf, precision | architectural-mismatch FP — clang CTU is an on-demand callee-AST importer (ASTImporter); fold's CrossTuCache is a property-summary cache (CalleeProperties { derefed_params, divisor_params, ... }). Different paradigms: clang CTU exists for analyses that walk callee STATEMENTS; fold deliberately avoids that (summary_encoder serialises bodies to SMT-LIB strings; detectors consume properties, not AST). Adopting clang CTU would mean redesigning the analysis paradigm, not extending it. |
L | semantic-drift (would invalidate cross_tu_cache.v2 parity) | full analysis-paradigm redesign | unscheduled — architectural-mismatch FP. The matrix's original "selective adoption" framing implied a real selective use case; on inspection there isn't one. fold's summary-property approach is architecturally superior for its design (smaller serialised footprint, O(1) lookup, no per-call AST parse). |
How to use this matrix now
The first sweep has already retired the original top picks (Z8, C5, C8) and several adjacent rows. Treat this file as a compact map of what was learned, not as a fresh backlog ordered by the old hypotheses.
Status meanings
- shipped — implementation exists; prefer follow-up measurement or polish, not another implementation pass.
- unscheduled — measured/design/current-state/etc. FP — the row's original framing was audited and should not be re-opened without new evidence.
- unscheduled — no forcing function / capability gap — still possible, but needs a concrete missed finding, benchmark, or user-facing workflow before it is worth adding complexity.
Active next work
Do not choose from the old Z/C row order by default. The actionable queue is in
docs/internal/library-expansion-followups.md:
- Commit/verify scan-runner wiring for C2 + C5 if those local hunks are still present.
- Design a native
use-after-freedetector (the real capability gap found while retiring C4'sConsumedAnalysisframing). - Capture missing per-row baselines for shipped rows that predate the per-row artifact convention.
Re-opening a retired row
Only re-open a retired row when the session starts with at least one of:
- a concrete false-positive / false-negative example the row would address;
- a profile artifact showing the cited path is now hot;
- an upstream/library change that removes the reason the row was retired;
- a user-facing workflow that justifies a capability row despite no perf win.
If none of those exists, create a follow-up entry instead of expanding the matrix row. This keeps the matrix from accumulating speculative implementation plans that have already failed the audit discipline.
When to pick differently
The follow-up queue assumes you want the least surprising path to completion. If the next session's goal is instead:
- New source detector capability → start from the native UAF follow-up, not
C4's
ConsumedAnalysiswording. - Fuzz-seed quality → Z5 has a direct synthetic primitive baseline; require a fresh fuzz-prep baseline before extending it into curl-scale workflow changes.
- Documentation-quality findings → revisit Z9 only if the pinned Z3 exposes proof/interpolant APIs again, or scope a non-interpolant explanation feature.
- OSS readability → wait for a detector whose pattern structure is actually
awkward with
dyn_castbefore using C1 / AST matchers.
Out of scope
- Implementing any of the 20 rows. Each row is a separate session.
- Bulk replacement of
RecursiveASTVisitorwith AST matchers (C1 applies to specific isolated sites). - Wholesale swap of
ir::ProjectCallGraphforclang::CallGraph(C2 shipped only as selective additive adoption; clang's graph lacks the cross-TU provenance the persistence layer depends on). - Z3 features requiring Z3 > 4.13.4 (the Conan pin). Bumping Z3 is its own decision.