# 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/--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` 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(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` 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 != */`. 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 `; 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`: 1. **Commit/verify scan-runner wiring for C2 + C5** if those local hunks are still present. 2. **Design a native `use-after-free` detector** (the real capability gap found while retiring C4's `ConsumedAnalysis` framing). 3. **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 `ConsumedAnalysis` wording. - **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_cast` before using C1 / AST matchers. ## Out of scope - Implementing any of the 20 rows. Each row is a separate session. - Bulk replacement of `RecursiveASTVisitor` with AST matchers (C1 applies to specific isolated sites). - Wholesale swap of `ir::ProjectCallGraph` for `clang::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.