Measured (scratch, reverted): with the graph-fed cross-TU candidate expansion disabled, finding fingerprints stay exact (fold 0fec78e5..., yams 247523c5..., status ok) and securityGraphCandidateFunctions returns an empty set (expanded=0) for these manifests. The 18.8s graph build is pure overhead for the expansion path here. Remaining graph consumer is summary priming; S0b tests whether its SCC-ordered eager prepass can be dropped in favor of the lazy on-demand summary path.
10 KiB
Optimization Loop — Decision Record
Date: 2026-07-30 Status: accepted (workflow active) Grounding:
project-eval-scaffolding-2026-07-27.md(manifests, gates),PROMPT-eng-codex.md(retrieval contract, execution loop, handoff),PROMPT-yams-cpp-workflow.md(design-first TDD, measurement-first profiling).
Decision
Performance and bug-finding optimization on fold becomes a phased, evidence-first loop driven by the existing benchmark infrastructure, not ad-hoc intuition driven tuning:
measure -> select -> implement -> validate -> checkpoint -> measure ...
Each phase is a persisted workflow state with a declared outcome, so an optimization campaign survives session restarts and rework (validate failure returns to implement, never to measure) without losing evidence.
Phase contracts
1. measure
-
Build release first:
ninja -C build/release. -
Run the profiled harness over the self-hosting manifests:
python3 scripts/benchmarks/run_optimization_harness.py \ --manifest tests/benchmarks/projects/fold_source.json \ --out-dir tests/benchmarks/runs/optimization-<date> python3 scripts/benchmarks/run_optimization_harness.py \ --manifest tests/benchmarks/projects/yams_source.json \ --out-dir tests/benchmarks/runs/optimization-<date> -
Record: per-detector wall time, RSS, finding count, stability (max_churn=0, pairwise Jaccard=1.0), token/report bytes.
-
Outcome
evidence-readywhen at least one run artifact exists with the profile rows intact;evidence-failedif the harness cannot run (fix the environment before optimizing anything).
2. select
- Pick exactly one target per iteration from measured evidence:
- a hotspot (top per-detector wall or RSS row), or
- a bug-finding quality gap (FP churn, missed class, unstable fingerprint).
- State the KPI and the expected direction before touching code (PROMPT-yams-cpp-workflow rule 5: never optimize from intuition).
- Outcome
target-pickedwith KPI written into the task/workflow state; outcomeno-target(terminal) when no measured target clears the improvement bar this round.
3. implement
- Design-first TDD per
PROMPT-yams-cpp-workflow.mdrules 2–4: smallest testable seam, Catch2 behavior test red before the production change, assertions per the fold policy inAGENTS.md/fold-assertion-policy. - One change per iteration. Hybrid/router/backend boundary rules from
AGENTS.mdapply unchanged. - Outcome
change-made.
4. validate
Gates, in order, fastest first:
-
git diff --check -
Focused tests for the touched area (
meson test -C build/release fold:<area>). -
Full suite for CLI/router/backend changes (
meson test -C build/release --print-errorlogs). -
Statistical A/B against baseline:
python3 scripts/benchmarks/run_ab_evaluation.py \ --baseline <prev-release-or-pre-change-fold> --current build/release/src/cli/foldComparison uses
fold_eval.metrics.paired_comparisonwhen paired samples exist (single medians are explicitly labelled median-only, never treated as a regression oracle). -
Stability gate: finding fingerprints unchanged unless the change is a deliberate detector-behavior change (then dispositions must be updated and reviewed in the same iteration).
Hard failure rules: wall/RSS regression beyond the manifest's
wall_regression_pct/rss_regression_pct, churn > 0, or Jaccard < 1.0
without reviewed dispositions → outcome gate-failed → back to implement
(revert or fix; never weaken a gate to pass).
5. checkpoint
- Index changed files and a handoff note into YAMS with the
PROMPT-eng-codex.mdmetadata contract (task=optimization-loop, phase=complete, owner=opencode, source=note). - Commit with measured before/after numbers in the body;
git pushonly with operator confirmation. - Update the baseline artifacts under
tests/benchmarks/projects/baselines/when a sustained win is accepted. - Outcome
checkpointed→ loop tomeasure.
Artifacts per iteration
| Artifact | Location |
|---|---|
| Profiled run evidence | tests/benchmarks/runs/optimization-<date>/*.run.json |
| A/B comparison | harness stdout + run dir summary |
| Decision note | YAMS (source=note) + commit message |
| Baseline update | tests/benchmarks/projects/baselines/ (only on accepted wins) |
Iteration ledger (2026-07-31 campaign)
| Iter | Target | Result |
|---|---|---|
| 1 | sql-injection hotspot profiling | Characterization test fixed (UAF); context-reuse attempt contaminated |
| 2 | Red evaluation gate | Dispositions re-baselined; both manifests pass |
| 3 | Call-graph fallback | Merge duplicate tolerance fix: 24.1s → 1.7s (93%) |
| 4 | Broken schema test | Full suite 137/137 |
| 5 | Summary flake | 3 rig attempts, not reproduced; rig committed |
| 6 | Z3 instrumentation | Solver-setup hypothesis falsified (154 solvers, 6ms) |
| 7 | Scratch context re-attempt | Accepted; all gates green |
| 8 | Summary-path sizing | 66,147 constructions/scan eliminated |
| 9 | Feasibility memo | Measured dead end (3.5% duplicates) |
| 10 | Dual-walk | Measured dead end (0% fallback rate) |
Cumulative: fold self-scan wall 251.7s → 86.2s (66% reduction), finding fingerprints exact at every step.
Deferred: in-pass graph contribution collection (design, 2026-07-31)
The no-index exhaustive call-graph build (~16.9s on the fold manifest) is a full ClangTool re-parse of all TUs (buildProjectCallGraph). The TU ASTs already exist in the main detector pass, and graphToContribution(database, ctx, ...) (internal in source_call_graph_contribution.cpp) builds a complete contribution from a live ASTContext. Planned change (next campaign slice):
- Expose
buildContributionFromContext(...)(public wrapper around graphToContribution) insource_call_graph.hpp. - In the main TU pass (source_tu_runner Consumer::HandleTranslationUnit),
build each TU's contribution post-detectors and accumulate it in
SourceTuRunState; the Consumer needs a
CompilationDatabase&for fingerprinting (makeTranslationUnitFingerprint uses compile commands). - Extend
assembleProjectCallGraphwith an optional prebuilt contributions parameter: when they cover the shard's files AND no project index is open, go straight to mergeTranslationUnitGraphContributions (skipping load+collect+persistence); all other paths unchanged. - Fanout topology: contributions are per-shard; each shard's assembly covers its own files — consistent with today's per-shard exhaustive fallback.
Equivalence argument: graphToContribution is the same function the ClangTool collector calls with the same compilation database and configuration profile; the main-pass ASTs come from the same compile commands, so contributions should be byte-identical. The finding fingerprint gate (exact) decides acceptance; the exhaustive fallback is preserved as the worst case.
Estimated: ~80-150 lines across source_call_graph.{hpp,cpp}, source_tu_runner.{hpp,cpp}, project_graph_orchestrator.{hpp,cpp}, scan_runner.cpp.
INVALIDATED (iteration 12, 2026-07-31): implementation began and
collapsed on an ordering dependency — assembleProjectCallGraph runs
BEFORE the main TU pass because the TU pass consumes the graph
(graph_security_candidates expands security candidates across TUs;
symbolic summaries are primed from the call graph). In-pass collection
cannot serve the same scan: the TU pass needs the graph the
contributions would feed. Reversing the order breaks candidate
expansion (fingerprint changes), and keeping every TU AST alive across
both passes is infeasible (~1.4 GiB RSS for the fold self-scan).
The only sound reductions for the no-index build are persistence
(cache-dir, already exists) or a lighter graph parse — neither
acceptable. The ~16.9s no-index build is the inherent cold-run price
of cross-TU facts; closed as a target.
Non-goals
- No gate weakening, no detector-disabling to reduce runtime, no silent
coverage caps (router rules in
AGENTS.md). - Comparators (Clang SA, sanitizers) stay differential evidence, never ground truth.
- Binary-lane heuristics stay heuristic; performance work on the binary path never converts into source-coverage claims.
Option 2 scoping (2026-08-31, README-documented)
Decision: replace the per-scan double parse (graph pass + detector pass,
call_graph_ms 18.8s on the fold self-scan) with single-pass graph building.
Chosen over PCH/module caching to preserve portability (README 'Performance
architecture'): no clang-version-locked artifacts, no SDK-locked module cache,
no flag-matching bug surface.
Dependency map (who consumes the graph inside the detector pass):
primeCrossTUProperties(scan_runner.cpp) — third parse, only for div-by-zero/null-deref/int-overflow/oob; no-op on the fold manifest.assembleProjectCallGraph->call_graph->graph_security_candidatesfeeding the security walk's candidate expansion (prunes 2711 of 6480 functions — a real perf win that must be preserved).- summary priming consumes the graph for SCC-ordered callee-first passes.
Tension to resolve before implementation: the candidate-expansion closure needs the full cross-TU graph, which exists only after every TU is parsed, so "run detectors in the same single pass" holds only for graph-INDEPENDENT detectors. Graph-dependent detectors require either (A) keeping the ASTs alive across the deferred closure (memory: ~210MB/AST, must stay under the existing 1.68GB peak profile), or (B) re-materializing the walk input from persisted IR — the exact equivalence proof is the work, policed by the finding-fingerprint + gold gates.
Option 2 S0 result (2026-08-31)
Measured (scratch, reverted): disabling the graph-fed cross-TU candidate
expansion on fold + yams produced IDENTICAL finding fingerprints
(fold 0fec78e5..., yams 247523c5..., status ok, gold gates green) with
expanded=0 in every run — securityGraphCandidateFunctions returns an
empty set for these manifests' detector mixes, so the graph's candidate-
expansion consumer is finding-neutral/empty here. The 18.8s graph build is
therefore pure overhead for the expansion path on these corpora.
Remaining graph consumer not yet tested: summary priming (SCC-ordered eager
prepass). S0b: disable the eager primeSymbolicSummaries graph-SCC layering
(rely on the lazy on-demand ensureSymbolicSummary path) and compare
fingerprints — if exact, the graph is fully dispensable on fold+yams and
option 2 collapses to "skip the graph build unless a corpus's detectors
actually consume it" (lazy, equilibrium fallback to the full graph when a
corpus needs expansion or SCC-ordered priming).