Skip to content

SearchEngine centroid scan materializes every bucket's indices+residuals before selection (797 MiB/search on a live index, 95% discarded) - #152

Merged
totalslacker merged 3 commits into
mainfrom
fabrik/issue-151
Aug 6, 2026
Merged

totalslacker merged 3 commits into
mainfrom
fabrik/issue-151

Conversation

@totalslacker

@totalslacker totalslacker commented Aug 6, 2026 •

Copy link
Copy Markdown
Owner

Closes #151

Summary

Split centroid-scan storage access into two phases: a scan phase that reads only what bucket selection needs (centroid + residual token count, not the residual bytes or indices), and a fetch phase that retrieves full indices/residuals only for the buckets selection actually picked. This requires a storage-protocol addition — a scan-shaped record type alongside BucketRecord, plus an id-based fetch — implemented across both existing storage backends (SQLiteStorage, InMemoryStorage), with searchOptimized and searchLegacy in SearchEngine both updated to use the new two-phase flow (both paths must move together — SearchDeterminismTests uses searchLegacy as the equivalence oracle for searchOptimized, so if only one path changes, the oracle stops being valid).

Problem

Every search materializes the entire bucket table before selecting a single candidate.

Parent investigation: totalslacker/WebBrain#344 — "Switchcraft search consistently exceeds 5s Release deadline on typical queries (NomicBert on same query is 2-3s)". Live operator repro, Release build, 2026-08-05: query "go kafka cloud computing" on the Switchcraft backend times out at the 5s interactive deadline, repeatably, with no background maintenance running. The same query on WebBrain's other backend (NomicBert — dense sqlite-vec KNN, a different repo) completes in 2–3s. WB#344's write-up is at docs/investigations/switchcraft-slowness-2026-08-05.md in that repo.

SearchEngine.searchOptimized and SearchEngine.searchLegacy (Sources/SwitchcraftCore/Search/SearchEngine.swift) each independently begin the centroid scan by walking every generation and calling storage.buckets(forGeneration:), then use exactly two fields per returned BucketRecord: center (needed — it's the matrix cblas_sgemm runs the query against) and residuals.count (the blob's length, never its bytes). indices is not touched at this stage at all. Both indices and residuals are needed later, but only for the small subset of buckets selection actually picks.

SQLiteStorage.buckets(forGeneration:) (Sources/SwitchcraftSQLite/SQLiteStorage.swift:664) fetches all three columns (center, indices, residuals) eagerly for every row via SELECT id, generation_id, center, indices, residuals FROM bucket WHERE generation_id = ? ORDER BY id ASC. columnBlob copies each blob out of SQLite into a Swift value, and the full [BucketRecord] array is built and returned before either caller inspects anything.

Measured impact

Against the operator's live production index (read-only sqlite3, 2026-08-06):

Gen Level Embeddings Buckets center indices residuals
124 L3 10,408,657 53,248 27,262,976 52,351,078 666,154,048
574 L2 922,285 18,226 9,331,712 5,125,232 59,026,240
636 L1 223,871 1,226 627,712 910,173 14,327,744
640 L0 8,333 73 37,376 27,629 533,312
Total 11,563,146 72,773 37,259,776 58,414,112 740,041,344

Per search, unconditionally, on any query:

  • 72,773 rows materialized
  • 835,715,232 B (797 MiB) copied out of SQLite into the heap
  • of which ~35.5 MiB (center) is actually used at that point
  • ~762 MiB (95%) is copied and discarded unread, apart from residuals.count
  • ~218,000 blob allocations (three per row), ~145,000 of them pure waste

Index shape for reference: 6,512 documents, 7,152 chunks, dims = 128 (center is 512 B = 128 × Float32; residuals_bytes / (dims/2) == num_embeddings holds exactly per generation, confirming the Q4 two-values-per-byte packing).

Corpus size is not the story: the NomicBert backend indexes 23,148 documents — 3.5× more — and wins by 2–4s. Switchcraft's scale figure is 11.6M token embeddings across 72,773 buckets, because XTR-Warp stores one embedding per token.

Caveat on attribution

This was derived from static analysis of the pinned source against the live index shape, not from a stage timer. WB#344's REQ-1/REQ-2 per-stage instrumentation is not yet wired end-to-end (switchcraft#148 returns the struct; no shipped SemanticHistory revision consumes it), so nothing has yet timed this stage in situ. The waste is arithmetic and certain; its share of the 2–4s gap is not. Other candidates WB#344 keeps explicitly alive: flush/cascade compaction inside SwitchcraftStore.search() (ADR 039 — generation timestamps show cascades firing during the repro window), CoreML embedder contention between add() and encodeQuery(), and WebBrain's perSourceBudget = 200 override. This issue addresses only the materialization waste described above; it is not claimed to be the sole or even primary cause of the 5s timeout.

Approach

Approach

Split the centroid-scan storage read into two phases, exactly as the issue's prior-art section proposes:

  1. Scan phase: a new lightweight record type (BucketScanRecord: id, generationID, center, residualByteCount) fetched via a new protocol method (scanBuckets(forGeneration:)). SQLite's implementation reads residuals via length(residuals) (blob length header only, no payload copy). This replaces storage.buckets(forGeneration:) inside both search paths' centroid-scan loop.
  2. Fetch phase: after selection completes across all generations, a new id-based fetch (buckets(ids:)) retrieves full BucketRecords only for the deduplicated set of selected bucket ids, which feeds the existing (untouched) decodeSelectedBuckets/decodeBucketChunk machinery from ADR 035.

Both searchLegacy and searchOptimized move together in the same commit, since SearchDeterminismTests uses searchLegacy as searchOptimized's equivalence oracle. ORDER BY id ASC stays exactly where it already is — the scan query — and is not required (and will not be added) on the fetch-by-id query, per the issue's explicit requirement.

selected: [BucketRecord] in both search functions becomes selectedIDs: [Int64] during scanning (dedup via the existing seenBuckets set, unchanged logic), then one storage.buckets(ids: selectedIDs) call after the scan loop reconstructs [BucketRecord] in selectedIDs order via an id → record dictionary, before handing off to the unchanged decode step. Reconstructing in selectedIDs order (not fetch-return order) is what keeps decode output byte-identical to today.

Key Decisions

  • scanBuckets/buckets(ids:) are required protocol members, not defaulted. Research confirmed a default no-op doesn't make sense for a correctness-critical read path (unlike e.g. saveLedgerSnapshot's "never incorrect, just slower" pattern). Both SQLiteStorage and InMemoryStorage must implement both.
  • BucketScanRecord carries raw residualByteCount: Int, not a pre-divided token count. The storage layer doesn't track dims; SearchEngine already does the / (dims/2) division today (bucket.residuals.count / (dims / 2)) and keeps doing it, just against residualByteCount instead of residuals.count. No behavior change.
  • Missing ids during fetch are silently dropped, not an error. The two-phase split introduces a real (if narrow) new race window: SQLiteReaderActor holds one persistent read-only connection with no explicit transaction spanning multiple calls (confirmed by reading SQLiteReaderActor.swift — each call is its own implicit read), so a concurrent vacuum/compaction between the scan and the fetch-by-id call could delete a bucket that was just scanned. This is directionally the same race class the code already tolerates today (a generation can already vanish between storage.generations() and storage.buckets(forGeneration:) in the existing scan loop, silently treated as empty) — so buckets(ids:) returning fewer records than requested is handled the same way: selectedIDs.compactMap { byID[$0] }, no new Error case. If everything vanishes, selected is empty and the function returns [] exactly like the existing "no candidate buckets" path.
  • InMemoryStorage.buckets(ids:) uses a linear scan over bucketsByGeneration.values, not a new bucketsByID dictionary. Research flagged both options; a second dict would need consistent bookkeeping across five existing mutation sites (insertBucket, deleteGeneration, replaceGeneration, applyVacuumPlan's three sub-cases, clear) — real risk for a correctness-critical addition, for zero production benefit (InMemoryStorage is test/benchmark-only; the storage design tenet explicitly says it has no I/O cost to save, only a conformance obligation). A linear scan is O(total buckets in the store), which is fine at test/fixture scale and touches zero existing mutation code paths.
  • The new bytes-read benchmark assertion is a timing-ratio test (ADR 035/039 style), not a SQLite VFS byte-counting shim. No I/O-byte instrumentation exists anywhere in this codebase today; building one (Research's option (c)) is disproportionate new infrastructure for this issue. A small dedicated on-disk-SQLiteStorage fixture (two generations, equal bucket count, ~200x different residual-bytes-per-bucket) measures scanBuckets elapsed time staying flat across the two while buckets(forGeneration:) (full fetch) measurably scales — the second half is a control proving the test methodology can actually detect what it's checking scanBuckets is free of.
  • The new benchmark test runs always-on, not gated behind SWITCHCRAFT_SEARCH_LATENCY_BENCHMARK=1. Unlike the existing suite's fixture (hundreds of thousands of 768-dim embeddings, genuinely heavy), this fixture is ~150 buckets with small blobs — cheap enough to run every swift test. The issue's own risk section says this assertion is "the only thing that will actually catch a regression of this specific waste going forward"; gating it behind an opt-in env var most contributors never set would defeat that purpose.
  • SQL IN (...) fetch is batched in chunks (e.g. 500 ids per statement). selectedIDs size is bounded in practice (k=32 centroids × generations × query tokens, deduplicated) but not provably so, and this codebase doesn't control the exact SQLite build's SQLITE_LIMIT_VARIABLE_NUMBER across platforms. Both SQLiteStorage's .inMemory mode and SQLiteReaderActor need this batching independently since both run SQL directly.
  • No new SearchEngine.Error case. Covered above — the missing-bucket race degrades gracefully rather than throwing.

New/Modified Files

File Change
Sources/SwitchcraftCore/Storage/Records.swift Add BucketScanRecord struct (sibling to BucketRecord): id, generationID, center: Data, residualByteCount: Int.
Sources/SwitchcraftCore/Storage/SwitchcraftStorage.swift Add two required protocol methods: scanBuckets(forGeneration:) async throws -> [BucketScanRecord], buckets(ids: [Int64]) async throws -> [BucketRecord]. Doc comments explain the scan/fetch split and the ordering contract (scan preserves id ASC; fetch order is unspecified).
Sources/SwitchcraftCore/Storage/InMemoryStorage.swift Implement both new methods: scanBuckets maps bucketsByGeneration[generationID]; buckets(ids:) is a linear scan across bucketsByGeneration.values filtered by a Set of requested ids, returned in input-id order via compactMap.
Sources/SwitchcraftSQLite/SQLiteStorage.swift Add both methods with a switch mode matching the existing buckets(forGeneration:) shape (lines 664-699): .inMemory runs SQL directly (length(residuals) for scan; batched IN (...) for fetch); .fileBacked delegates to SQLiteReaderActor; both wrap read errors through translateIfInterrupt.
Sources/SwitchcraftSQLite/SQLiteReaderActor.swift Add scanBuckets(forGeneration:) (the length(residuals) query) and buckets(ids:) (batched IN (...) query) alongside the existing buckets(forGeneration:) (line 212).
Sources/SwitchcraftCore/Search/SearchEngine.swift searchLegacy (~108-220) and searchOptimized (~340-465): replace the scan-loop's storage.buckets(forGeneration:) with storage.scanBuckets(forGeneration:); selected: [BucketRecord] → selectedIDs: [Int64] during scan (same dedup logic); one storage.buckets(ids: selectedIDs) call after the scan loop, reconstructing selected: [BucketRecord] in selectedIDs order before the unchanged decode step.
Sources/SwitchcraftStorageTesting/StorageConformance.swift New conformance scenario(s) covering scanBuckets (byte count matches full fetch, id ASC order, empty-generation empty result) and buckets(ids:) (exact requested ids only, missing ids silently omitted, empty input → [], ids spanning multiple generations in one call). Wired into runAll.
Tests/SwitchcraftTests/InMemoryStorageConformanceTests.swift, Tests/SwitchcraftTests/SQLiteStorageConformanceTests.swift No code change expected — both already call StorageConformance.runAll, so new scenarios run automatically against all three existing storage configurations.
Tests/SwitchcraftTests/SearchLatencyBenchmark/BucketScanBytesReadTests.swift (new) The required new benchmark assertion: on-disk SQLiteStorage, two generations with equal bucket counts and very different residual sizes; asserts scanBuckets elapsed time is flat across them while buckets(forGeneration:) (full fetch) scales with residual size (control). Always-on (no env-var gate).
adrs/041-two-phase-centroid-scan.md (new) Documents the scan/fetch split, the missing-id-during-fetch tolerance decision, the InMemoryStorage linear-scan choice, and the IN-clause batching.
docs/Plan.md Check off/annotate the relevant Phase-1 item per project convention — see Documentation task below.

Task Checklist

  • Add BucketScanRecord to Sources/SwitchcraftCore/Storage/Records.swift
  • Add scanBuckets(forGeneration:) and buckets(ids:) to the SwitchcraftStorage protocol in SwitchcraftStorage.swift, with doc comments describing the scan/fetch split and ordering contract
  • Implement both methods in InMemoryStorage.swift (linear-scan buckets(ids:), mapped scanBuckets)
  • Implement both methods in SQLiteReaderActor.swift (length(residuals) scan query; batched IN (...) fetch query, chunk size 500)
  • Implement both methods in SQLiteStorage.swift's .inMemory mode (direct SQL, same query shapes) and wire .fileBacked mode to delegate to SQLiteReaderActor, matching the existing buckets(forGeneration:) error-translation pattern
  • Add conformance scenarios for both new methods to StorageConformance.swift and wire into runAll (covers InMemoryStorage + both SQLiteStorage modes automatically via existing test suites)
  • Add InMemoryStorage-specific regression coverage proving buckets(ids:) stays correct across deleteGeneration, replaceGeneration, and applyVacuumPlan (all three bucketsByGeneration-mutating paths the linear scan reads from)
  • Add SQLiteStorage-specific regression coverage proving scanBuckets's residualByteCount matches buckets(forGeneration:)'s residuals.count for the same generation, in both .inMemory and .fileBacked modes
  • Update searchLegacy in SearchEngine.swift: scan loop uses scanBuckets, selected becomes selectedIDs: [Int64], add the buckets(ids:) fetch + reconstruction step before the decode block
  • Update searchOptimized in SearchEngine.swift identically, placing the buckets(ids:) fetch call right after the existing post-scan try Task.checkCancellation() (no new cancellation checkpoint needed — the existing one already covers this window) and before decodeSelectedBuckets
  • Run SearchDeterminismTests and confirm all three properties (repeated-search byte-identical, searchLegacy/searchOptimized equivalence, concurrent-overlap reentrancy) still pass unchanged
  • Run SearchLatencyBenchmarkTests (SWITCHCRAFT_SEARCH_LATENCY_BENCHMARK=1, both Debug and Release) and confirm the ratio/sustained-workload gates still pass
  • Add Tests/SwitchcraftTests/SearchLatencyBenchmark/BucketScanBytesReadTests.swift: on-disk two-generation fixture (equal bucket count, ~200x residual-size difference), assert scanBuckets time is flat while buckets(forGeneration:) time scales (control assertion included)
  • Create ADR 041 (check adrs/ for the current highest number at implementation time in case a concurrent issue has landed one first): scan/fetch protocol split, missing-id tolerance, InMemoryStorage linear-scan choice, IN-clause batching
  • Update docs/Plan.md: add a new checked entry under the Phase-1 checklist (after the #148/ADR 040 line) following the existing dense-changelog style — issue number, ADR number, one-paragraph summary of the scan/fetch split and measured before/after read volume
  • Run full swift test (debug) and swift test -c release before committing, per project hard rule

Risks

  • Determinism regression: the biggest risk is accidentally changing selected's final order relative to today (e.g., reconstructing from fetch-return order instead of selectedIDs order) — would silently break SearchDeterminismTests' byte-identical guarantee. Mitigated by explicitly reconstructing via selectedIDs.compactMap { byID[$0] }, never trusting buckets(ids:)'s return order.
  • Oracle coupling: partial migration (only one of searchLegacy/searchOptimized) would make SearchDeterminismTests compare two different code shapes for the wrong reason. Both are in the same task-checklist region so Implement should land them together, not split across commits with a broken intermediate state pushed.
  • IN-clause parameter limits: untested against very large selectedIDs sets today because the old code never needed one. The batching requirement above is the mitigation; the new conformance test should include a "many ids, multiple generations" scenario exceeding one batch to exercise it.
  • Benchmark flakiness: wall-clock ratio assertions are inherently host-load-sensitive (ADR 039's own lesson). Use a generous ratio-or-floor tolerance and .serialized, matching ADR 039's precedent, rather than a tight threshold.

Both user-facing and engineering docs are covered above (docs/Plan.md entry + new ADR); no README/config-facing surface changes since this is entirely internal to SwitchcraftStorage/SearchEngine.


Used 32/50 turns, 1.8M input (37 raw + 1.7M cache-read + 104k cache-write) / 25k output tokens.

Verification

Implemented the two-phase centroid-scan split (ADR 041): added BucketScanRecord and two new SwitchcraftStorage protocol methods (scanBuckets(forGeneration:), buckets(ids:)), implemented in InMemoryStorage and both SQLiteStorage modes (SQL length(residuals) avoids reading blob payloads). Migrated both searchLegacy and searchOptimized to scan-then-fetch, preserving byte-identical output. Added conformance/regression tests plus a new always-on BucketScanBytesReadTests benchmark proving scan time is flat vs. bucket count while full fetch scales with residual size. Full swift test (381 tests) passes in both Debug and Release; docs/Plan.md and the Plan-stage checklist are updated.


Closes #151

… 041)

SearchEngine's centroid scan materialized every bucket's full center +
indices + residuals via storage.buckets(forGeneration:) but only ever
used center and residuals.count — on a live index this copied 797 MiB
out of SQLite per search, ~95% discarded unread.

Adds a lightweight BucketScanRecord and two new required
SwitchcraftStorage protocol methods: scanBuckets(forGeneration:) (id-
ASC scan reading center + residual byte count only — SQLite reads
length(residuals), never the payload) and buckets(ids:) (targeted
fetch for exactly the buckets selection picked). Both searchLegacy and
searchOptimized move together, reconstructing selected buckets in scan
order so decode output stays byte-identical. InMemoryStorage and both
SQLiteStorage modes implement both methods; StorageConformance covers
them across all three storage configurations.

Closes #151
@totalslacker

Copy link
Copy Markdown
Owner Author

🏭 Fabrik — stage: Implement
branch: fabrik/issue-151 | commit: dbd0e24 | main: 5170ffb | 2026-08-06 16:14 UTC

Working tree clean, local and remote branches in sync. All implementation work is complete: the two-phase scan/fetch split is implemented across both storage backends and both search paths, all new/existing tests pass in Debug and Release, ADR 041 and docs/Plan.md are updated, and the Plan stage checklist is checked off.

Implemented the two-phase centroid-scan split (ADR 041): added BucketScanRecord and two new SwitchcraftStorage protocol methods (scanBuckets(forGeneration:), buckets(ids:)), implemented in InMemoryStorage and both SQLiteStorage modes (SQL length(residuals) avoids reading blob payloads). Migrated both searchLegacy and searchOptimized to scan-then-fetch, preserving byte-identical output. Added conformance/regression tests plus a new always-on BucketScanBytesReadTests benchmark proving scan time is flat vs. bucket count while full fetch scales with residual size. Full swift test (381 tests) passes in both Debug and Release; docs/Plan.md and the Plan-stage checklist are updated.


Used 134/250 turns, 15.2M input (189 raw + 15.0M cache-read + 276k cache-write) / 52k output tokens.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@totalslacker

Copy link
Copy Markdown
Owner Author

🏭 Fabrik — stage: Review
branch: fabrik/issue-151 | commit: dbd0e24 | main: 5170ffb | 2026-08-06 17:01 UTC

Working tree clean, branch in sync with origin, PR mergeable. Review is complete.

Review Findings

Verified

  • Diff review (git diff origin/main..HEAD, 16 files, +935/-12): implementation matches the Plan stage exactly — BucketScanRecord, scanBuckets(forGeneration:), buckets(ids:) added to the protocol; both SQLiteStorage internal modes and SQLiteReaderActor implement the scan via length(residuals) (blob length header only, confirmed no payload read) and a batched IN (...) fetch (chunk size 500); InMemoryStorage implements both correctly via linear scan.
  • Determinism preserved: both searchLegacy and searchOptimized migrated together; selected: [BucketRecord] → selectedIDs: [Int64] during scan, reconstructed via selectedIDs.compactMap { bucketsByID[$0] } (scan order, not fetch-return order) before the unchanged ADR 035 decode step. ORDER BY id ASC preserved exactly on the scan query only, as required.
  • Test-double correctness: DelayInjectingStorage's injected delay was correctly moved from buckets(forGeneration:) to scanBuckets(forGeneration:) — the call SearchEngine now actually makes — preserving the "slow vector stage" test semantics.
  • Conformance/regression coverage: StorageConformance gains runBucketScanLifecycle, runBucketsByIDs, runBucketsByIDsExceedsBatchSize (>500-id batch boundary); InMemoryStorageBucketsByIDsRegressionTests covers deleteGeneration/replaceGeneration/applyVacuumPlan. All exercise the "missing ids silently omitted" contract.
  • Local test verification (ran independently, not just trusting Implement's report):
    • swift test (Debug): 381/381 passed — 3 clean runs.
    • swift test -c release: 381/381 passed — 2 of 3 runs; 1 run hit a pre-existing, unrelated flaky failure in Q4CodecTests.residualRoundTripMSE (unseeded SystemRandomNumberGenerator, no margin at its 1e-3 threshold). Confirmed not touched by this branch; filed as P0: Q4CodecTests.residualRoundTripMSE is flaky — unseeded RNG, threshold has no margin #153 (P0, following the project's own precedent at P0: Investigate LSM Indexer performanceSmoke failure — 36.5s vs 10s debug limit on Validate run #97) rather than silently absorbing it into this PR's scope.
    • BucketScanBytesReadTests (the new required benchmark): ran 9× total, stable every time — scanRatio ~1.1–1.35x (gate: <3.0x), fetchRatio (control) ~5.1–6.3x (gate: >3.0x). Not flaky.
    • SearchDeterminismTests: all 4 properties pass.
    • SWITCHCRAFT_SEARCH_LATENCY_BENCHMARK=1 swift test --filter SearchLatencyBenchmarkTests: Debug 5.13x speedup, Release 24.15x speedup (gate: ≥3x), sustained-100-query ratio ~1.0x in both. Matches ADR 041's claimed figures closely.
  • ADR 041 and docs/Plan.md: both accurate and thorough against the actual diff.

Fixed

None needed — no correctness, security, or completeness issues found in the diff itself.

Blocking

None from the code. CI (swift test (macOS) debug job, and the "Request GitHub Copilot review" workflow) is currently red, but this is conclusively a GitHub platform issue, not a code problem:

  • https://www.githubstatus.com reports an active, unresolved "Partial System Outage" incident ("Incident with Actions" — investigating) throughout this review.
  • The debug job's own CI log shows Test run with 381 tests passed after 16.758 seconds immediately followed by ##[error]The operation was canceled — the platform killed the runner after all tests had already passed, not because a test failed.
  • Copilot's review bot itself reported: "Copilot encountered an error and was unable to review this pull request" — same outage window.
  • No branch protection rules exist on main (confirmed via API), so this isn't a merge gate regardless.

I re-triggered the debug job twice; it will likely go green on its own once GitHub's incident resolves. Given the job's own log already proves the code passes in CI's exact environment, I'm not blocking stage completion on it.

Reviewed the two-phase centroid-scan implementation (ADR 041) — diff matches the plan exactly, determinism/oracle-coupling constraints preserved, and all required tests pass. Verified locally: full suite 381/381 in Debug and Release, the new BucketScanBytesReadTests benchmark stable across 9 runs, and both gated latency benchmarks (5.1x Debug / 24.2x Release) meet their gates. Found and filed one pre-existing, out-of-scope flaky test (Q4CodecTests, unseeded RNG) as issue #153. CI is currently red only due to a confirmed active GitHub Actions platform outage (githubstatus.com) — the CI job's own log shows all 381 tests passed before the platform cancelled it. PR #152 is ready for human review.


Used 23/50 turns, 4.0M input (45 raw + 3.9M cache-read + 42k cache-write) / 9k output tokens.

totalslacker and others added 2 commits August 6, 2026 10:29
…DR 039)

The new scan-vs-fetch bytes-read benchmark added by this issue ran its
timed operations at 100-600 microsecond absolute scale — two orders of
magnitude below the "tens of milliseconds" baseline ADR 039 already
identified as too small for a pure-ratio wall-clock threshold to survive
Swift Testing's default intra-process parallel scheduling under
full-suite contention. Validate-stage reproduction confirmed a ~37%
failure rate across 8 full-suite runs (both the scanRatio<3.0 assertion
and the fetchRatio>3.0 control failed independently in different runs).

Raise bucketCount 300->2000 to move the absolute baseline into the
low-millisecond range, and switch trial reduction from median to
minimum (contention only ever adds stall time to a trial, so the
minimum across trials is the best available estimate of unstalled
cost). 10/10 full-suite reproductions post-fix pass with wide margins
(scanRatio ~1.4-1.8x vs. the 3.0 threshold; fetchRatio ~10-13x vs. the
3.0 floor).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Reflects the bucketCount/trials/min-vs-median revision made to
BucketScanBytesReadTests during Validate (commit 8c93196) — the ADR and
Plan.md changelog entry still described the pre-fix 300-bucket/7-trial-
median parameters and observed ratios.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
@totalslacker

Copy link
Copy Markdown
Owner Author

🏭 Fabrik — stage: Validate
branch: fabrik/issue-151 | commit: 02dd34f | main: 5170ffb | 2026-08-06 17:39 UTC

Validation Report

Requirements: 10/10 verified

  • Scan-shaped protocol query (id, generation_id, center, residual token-count-derivable field), no indices/residuals materialization — BucketScanRecord + scanBuckets(forGeneration:), Records.swift:143/SwitchcraftStorage.swift:186.
  • Id-based fetch for full BucketRecords — buckets(ids:), SwitchcraftStorage.swift:194, batched (chunk size 500) in SQLiteReaderActor.
  • SQLite scan doesn't read payload bytes — confirmed length(residuals) in both .inMemory (SQLiteStorage.swift:708) and .fileBacked (SQLiteReaderActor.swift:237) modes.
  • InMemoryStorage implements both, passes shared conformance — InMemoryStorage.swift:223,234; runBucketScanLifecycle/runBucketsByIDs/runBucketsByIDsExceedsBatchSize wired into StorageConformance.runAll.
  • Both searchLegacy and searchOptimized updated to scan→select→fetch — verified via diff, selected: [BucketRecord] → selectedIDs: [Int64] in both, identical shape.
  • ORDER BY id ASC preserved on scan phase only — confirmed in SQL text; fetch-by-id has no ordering clause, order reconstructed by caller via selectedIDs.compactMap.
  • ADR 035 parallel-decode structure untouched — decodeSelectedBuckets/decodeBucketChunk receive the same [BucketRecord] shape, no changes to that code.
  • Bit-identical output — SearchDeterminismTests (all 4 properties) pass; reconstruction is explicitly by selectedIDs order, not fetch-return order.
  • SearchDeterminismTests/SearchLatencyBenchmarkTests pass — verified independently (see below), not just trusted from Review.
  • New benchmark demonstrates bytes-read scales with bucket count, not residual size — BucketScanBytesReadTests, root-caused a real flake and fixed it (see Issues Found).

Test Suite: PASSED (after a fix)

  • swift test (Debug): 381/381, 8 consecutive clean full-suite runs post-fix (5 before the fix commit, 3 after the docs-only commit — same commit state as final push).
  • swift test -c release: 381/381, 2 consecutive clean runs at final commit state.
  • SWITCHCRAFT_SEARCH_LATENCY_BENCHMARK=1 gated benchmark: Debug 5.01x speedup, Release 22.5x speedup (gate ≥3x); sustained-100-query ratios ~1.006x/1.018x — both pass.
  • SearchDeterminismTests: 4/4 pass, run in isolation.
  • CI (GitHub Actions, macOS): green for commit 8c93196 (the fix commit) — both debug and release jobs succeeded. The final docs-only commit (02dd34f) hadn't produced a check run as of this report, consistent with the platform's ongoing "Partial System Outage" (confirmed via githubstatus.com throughout this session); the engine re-checks CI after stage completion regardless.
  • Build: clean (swift build). No swift vet equivalent in this Swift project; swift build -v/-c release both clean.

Regressions: None detected

Issues Found — found and fixed during Validate

BucketScanBytesReadTests (the issue's own required new benchmark) was flaky, ~37% failure rate under full-suite contention. Reproduced independently (not just trusted from Review, which had reported it stable across 9 isolated runs — full-suite contention is what exposes it): 3 failures in 8 full-suite swift test runs, both the scanRatio<3.0 assertion and the fetchRatio>3.0 control failed independently in different runs.

Root cause: the timed operations ran at 100–600 microseconds absolute scale — two orders of magnitude below the "tens of milliseconds" baseline ADR 039 (already in this repo) identified as too small for a pure-ratio wall-clock threshold to survive Swift Testing's default intra-process parallel scheduling under host load. This is exactly the anti-pattern ADR 039 exists to prevent; it just wasn't applied to this new test.

Per project policy ("flaky" is not a valid waiver; root-cause, don't skip), fixed following the established ADR 039 precedent rather than treating it as pre-existing/unrelated:

  • bucketCount 300→2,000 (raises absolute baseline into low-millisecond range)
  • Trial reduction: median→minimum over 9 trials (contention only ever adds stall time, never subtracts — minimum is the more robust statistic)
  • Updated ADR 041 §6 and the docs/Plan.md changelog entry, which had described the pre-fix parameters/observed ratios

10/10 full-suite reproductions post-fix passed with wide margins (scanRatio ~1.4–1.8x vs. 3.0 threshold; fetchRatio ~10–13x vs. 3.0 floor). Committed as 8c93196 (fix) and 02dd34f (docs), pushed.

Branch State

Verdict: READY TO MERGE

Validated PR #152: all 10 issue requirements verified against the diff, and independently reproduced the full test suite (381/381, Debug + Release, gated latency benchmarks, determinism tests) rather than trusting prior stage reports. Found the new required BucketScanBytesReadTests benchmark was genuinely flaky (~37% failure rate under full-suite contention) — root-caused it to sub-millisecond timing measurements violating this repo's own ADR 039 precedent, fixed it (larger fixture + min-of-trials instead of median), updated ADR 041/Plan.md accordingly, and pushed. 10/10 reproductions pass post-fix with wide margins; CI is green for the fix commit.


Used 28/50 turns, 4.8M input (53 raw + 4.6M cache-read + 159k cache-write) / 12k output tokens.

@totalslacker
totalslacker merged commit 460a03e into main Aug 6, 2026
@totalslacker
totalslacker deleted the fabrik/issue-151 branch August 26, 2026 20:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SearchEngine centroid scan materializes every bucket's indices+residuals before selection (797 MiB/search on a live index, 95% discarded)

2 participants