Repository navigation
Add per-stage search timing instrumentation to SearchEngine/SwitchcraftStore - #149
Conversation
SearchEngine.searchHybrid() and SwitchcraftStore.search() now return HybridSearchResult / SearchResult wrapper structs carrying hits plus a ContinuousClock-measured per-stage timing breakdown (flush, cascade compaction, embed, vector, BM25, merge), so callers can diagnose which stage dominates a slow search instead of only seeing total latency. SwitchcraftStoreError.searchTimedOut gained a partialTiming field, populated from a call-local timing var so concurrent search() calls can't race on a shared "last call" property. flushAndClearPending() is now @discardableResult, returning the compaction event and its duration without touching its other call sites. Part of #148, ADR 040.
…ype change Mechanical .hits unwrap at ~77 existing call sites across the test suite following the SearchResult/HybridSearchResult wrapper-struct change. SearchTimeoutTests' two searchTimedOut pattern-match sites updated for the new partialTiming case value; one gained assertions proving flushDuration is populated but later stages are nil at the pre-checkpoint timeout. Part of #148.
…ection New DelayInjectingStorage (wraps InMemoryStorage, injects millisecond delays before buckets(forGeneration:)/searchFullText) and DelayedEmbedder (wraps MockEmbedder, injects delay in encode/ encodeQuery) test doubles make each search() stage's duration independently observable without hardcoded seconds-scale sleeps. SearchTimingTests covers: every timing field populated on a successful search; a slow BM25/vector/embed stage doesn't inflate the others; cascadeCompactionOccurred true/false via IndexerConfig.testing (l0Capacity:) exercised through store.search() itself; and partial timing surviving a forced searchTimedOut. Closes #148.
…CHANGELOG ADR 040 documents the wrapper-struct return-type decision, the race-free partial-timing-on-error design, the cascadeCompactionDuration == flushDuration equivalence, and why SearchEngine.Error was left untouched. Corrects the original issue's ADR 039 citation for the cascade-compaction-cost claim (ADR 039 is about wall-clock test assertions, not cascade compaction) so the error isn't propagated. docs/Plan.md gains a Phase 2 checkbox entry; CHANGELOG.md documents the two breaking API changes (search()/searchHybrid() return types, searchTimedOut arity) under Unreleased. Part of #148.
|
🏭 Fabrik — stage: Implement All implementation work is complete: 4 commits pushed to Implemented per-stage search timing instrumentation per the plan: Used 197/250 turns, 29.5M input (365 raw + 29.2M cache-read + 325k cache-write) / 79k output tokens. |
README.md and SwitchcraftStore's top-of-file doc comment still showed store.search() returning [HybridHit] directly; both now reflect the SearchResult wrapper (issue #148).
There was a problem hiding this comment.
🟡 Changes recommended
The new timing-based assertions in SearchTimingTests rely on tight absolute millisecond ceilings that are likely to be flaky under parallel test contention and should be made more robust before merging.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
Adds structured, per-stage wall-clock timing instrumentation to the hybrid search stack so callers can attribute slow searches to flush/cascade, embed, vector/XTR, BM25/FTS, or merge—returned as data (not logs), including best-effort partial timing on timeout.
Changes:
- Introduces
HybridSearchTiming/HybridSearchResult(core) andSearchTiming/SearchResult(store) wrapper return types carrying hits + timing. - Instruments
SearchEngine.searchHybrid()andSwitchcraftStore.search()withContinuousClockstage timing, and extendssearchTimedOutto includepartialTiming. - Updates tests + adds delay-injecting test doubles, plus ADR/plan/changelog entries documenting the API change.
File summaries
| File | Description |
|---|---|
| Tests/SwitchcraftTests/VacuumTests.swift | Updates search call sites to use .hits after return-type wrapper change. |
| Tests/SwitchcraftTests/SwitchcraftStoreTests.swift | Updates search call sites to use .hits. |
| Tests/SwitchcraftTests/Support/DelayInjectingStorage.swift | Adds test storage wrapper that injects delays into vector/FTS storage calls. |
| Tests/SwitchcraftTests/Support/DelayedEmbedder.swift | Adds test embedder wrapper that injects delay into query encoding. |
| Tests/SwitchcraftTests/StoreSnapshotShutdownTests.swift | Updates search call sites to use .hits. |
| Tests/SwitchcraftTests/SearchTimingTests.swift | Adds coverage validating stage attribution, cascade detection, and partial timing on timeout. |
| Tests/SwitchcraftTests/SearchTimeoutTests.swift | Updates .searchTimedOut pattern matches and .hits usage; asserts partial timing where relevant. |
| Tests/SwitchcraftTests/ProperNounRankingTests.swift | Updates search call sites to use .hits. |
| Tests/SwitchcraftTests/Performance/PerformanceTests.swift | Updates warm-up and timed-loop searches to use .hits. |
| Tests/SwitchcraftTests/Performance/MetalPerformanceTests.swift | Updates warm-up and timed-loop searches to use .hits. |
| Tests/SwitchcraftTests/OrphanTests.swift | Updates search call sites to use .hits. |
| Tests/SwitchcraftTests/NoiseFloorPrecisionTests.swift | Updates search call sites to use .hits. |
| Tests/SwitchcraftTests/HybridSearchTests.swift | Updates SearchEngine.searchHybrid() call sites to use .hits. |
| Tests/SwitchcraftTests/FactsCorpusParityTests.swift | Updates search call sites to use .hits. |
| Tests/SwitchcraftTests/EdgeCasesTests.swift | Updates search call sites to use .hits. |
| Tests/SwitchcraftTests/ConcurrencyTests.swift | Updates concurrent search call sites to use .hits. |
| Sources/SwitchcraftSQLite/SQLiteStorage.swift | Supplies empty SearchTiming() when throwing searchTimedOut from SQLite interrupt path. |
| Sources/SwitchcraftCore/Search/SearchEngine.swift | Changes searchHybrid() to return HybridSearchResult and records vector/BM25/merge durations. |
| Sources/SwitchcraftCore/Search/HybridSearchTiming.swift | Adds core timing/result value types for searchHybrid(). |
| Sources/Switchcraft/SwitchcraftStoreError.swift | Extends searchTimedOut to include partialTiming: SearchTiming. |
| Sources/Switchcraft/SwitchcraftStore.swift | Changes search() to return SearchResult, populates stage timing, and returns flush duration from flushAndClearPending(). |
| Sources/Switchcraft/SearchTiming.swift | Adds store-level timing/result value types, including optional per-stage fields for partial timing. |
| docs/Plan.md | Adds completed plan entry for per-stage search timing instrumentation. |
| CHANGELOG.md | Documents breaking API changes and new timing types under Unreleased. |
| adrs/README.md | Adds ADR 040 to the ADR index. |
| adrs/040-search-stage-timing-instrumentation.md | Documents rationale and decisions for return-type changes and partial timing design. |
Review details
Suppressed comments (3)
Tests/SwitchcraftTests/SearchTimingTests.swift:70
- The independent-observability assertion uses an absolute ceiling (
vectorDuration < 50ms). Under host contention, an unrelated scheduling delay can exceed that ceiling even when attribution is correct, leading to flaky failures. A more robust check is to assert the injected-stage duration exceeds the other stage by a slack margin.
let bm25 = try #require(result.timing.bm25Duration)
let vector = try #require(result.timing.vectorDuration)
#expect(bm25 >= .milliseconds(80), "bm25Duration should reflect the injected delay")
#expect(vector < .milliseconds(50), "vectorDuration must not absorb the FTS delay")
}
Tests/SwitchcraftTests/SearchTimingTests.swift:94
- Same flakiness concern as the BM25 test:
bm25Duration < 50msis an absolute ceiling that can be exceeded under contention even if timing attribution is correct. Prefer a relative check showing the injected vector delay dominates BM25 by a slack margin.
let vector = try #require(result.timing.vectorDuration)
let bm25 = try #require(result.timing.bm25Duration)
#expect(vector >= .milliseconds(80), "vectorDuration should reflect the injected delay")
#expect(bm25 < .milliseconds(50), "bm25Duration must not absorb the vector delay")
}
Tests/SwitchcraftTests/SearchTimingTests.swift:120
flushDuration < 50msis another absolute timing ceiling that can be exceeded under load even if attribution is correct. A relative check (embed dominates flush by a margin) keeps the test focused on mis-attribution rather than wall-clock jitter.
let embed = try #require(result.timing.embedDuration)
let flush = try #require(result.timing.flushDuration)
#expect(embed >= .milliseconds(80), "embedDuration should reflect the injected delay")
#expect(flush < .milliseconds(50), "flushDuration must not absorb the embed delay")
}
- Files reviewed: 27/27 changed files
- Comments generated: 2
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
|
🏭 Fabrik — stage: Review Review FindingsFixed
Verified
BlockingNone. Reviewed the per-stage search timing instrumentation implementation. Found and fixed one issue: two public-facing doc examples (SwitchcraftStore's doc comment and README.md's usage sample) still showed the pre-change Used 48/50 turns, 5.2M input (93 raw + 5.1M cache-read + 114k cache-write) / 11k output tokens. |
|
🏭 Fabrik — stage: Validate Branch is clean, rebased, and pushed. Now writing the final validation report. Validation ReportRequirements: 7/7 verified against issue #148
Test Suite
Regressions: None from this PRI independently reproduced the release-mode failure in an isolated worktree checked out to Issues FoundPer this repo's Verdict: BLOCKED — not ready to mergeEverything in scope for #148 itself is correct and complete (types, actor-boundary timing, cascade detection, partial-timing-on-error, test coverage, ADR 040, All 7 requirements verified and correctly implemented; debug suite is 376/376 clean. However, Used 34/50 turns, 2.4M input (49 raw + 2.3M cache-read + 92k cache-write) / 16k output tokens. Stage incomplete. |
…sertion Addresses Copilot PR review feedback: mark the suite .serialized (matches the existing precedent for every other wall-clock-sensitive suite, e.g. PerformanceTests/SQLiteStorageConcurrencyTests per ADR 012/023) so its own duration assertions aren't contended by sibling tests within the suite, and unwrap flushDuration via #require in the cascade test for a clearer failure message on nil.
|
🏭 Fabrik — stage: Validate No stray TODO/FIXME markers. Diff scope matches what was already reviewed (with the added Validation ReportRequirements: 7/7 verified against issue #148
Test Suite
Regressions: None detectedKnown non-blocking flake (documented, not silently waived)
Verdict: READY TO MERGEAll 7 requirements verified, both local suites pass clean in this validation pass, CI is green, branch is rebased/clean/pushed, and the one previously-blocking local flake is fully root-caused, documented, and non-reproducing right now — closing the loop opened by the earlier Validate pass. All 7 requirements verified; Used 25/50 turns, 4.1M input (39 raw + 3.9M cache-read + 196k cache-write) / 19k output tokens. |
Closes #148
Summary
Add per-stage wall-clock timing instrumentation to
SwitchcraftStore.search()andSearchEngine.searchHybrid(), exposed as data returned to the caller (not log lines — the consuming repo owns logging/gating decisions). A caller ofSwitchcraftStore.search()should get back, alongside the existing hits, a breakdown of flush/cascade/embed/vector/BM25/merge durations for that single search call.Problem
Parent investigation totalslacker/WebBrain#344 ("Switchcraft search consistently exceeds 5s Release deadline on typical queries") found a live operator repro: Switchcraft search times out at 5s on typical queries while NomicBert (a separate, simpler backend in a sibling repo) completes the same query in 2-3s. Both engines pay the same CoreML embedder cost, so the delta is Switchcraft-specific and occurs around/after the embedder call.
WebBrain#344's Research stage inspected this package at its pinned revision (
b327b7bf) and found no per-stage timing exists today insideSearchEngine.searchHybridorSwitchcraftStore.search. Without that data, the parent investigation cannot tell whether the excess time is spent in:SwitchcraftStore.search()callsflushAndClearPending()on every search —Sources/Switchcraft/SwitchcraftStore.swift:568-633), which per ADR 039 can synchronously trigger an L0 cascade compaction once the L0 embedding count crossesIndexerConfig.l0Capacity(default 1024,Sources/SwitchcraftCore/Indexer/IndexerConfig.swift:20-21) — a pure-Switchcraft cost a read-only vector search never pays elsewhere,SearchEngine.searchHybrid(Sources/SwitchcraftCore/Search/SearchEngine.swift:608-747), which does XTR-Warp centroid-based retrieval (per-query-token centroid scan, residual bucket decode, per-token MaxSim) — a structurally different algorithm class than a dense-vector ANN KNN query, and therefore a plausible source of the delta on its own.Tests/SwitchcraftTests/SearchLatencyBenchmark/SearchLatencyBenchmarkTests.swiftexists but bypasses both the CoreML embedder and the flush/cascade path, so it cannot diagnose either hypothesis. This issue closes the observability gap so the parent investigation can identify which stage(s) are actually slow.Approach
Approach
Add two new
Sendablevalue types —HybridSearchTiming/HybridSearchResultinSwitchcraftCoreandSearchTiming/SearchResultinSwitchcraft— and changeSearchEngine.searchHybrid()/SwitchcraftStore.search()to return the...Resultwrapper instead of[HybridHit]. Each stage brackets its work with a plain locallet start = ContinuousClock.now/ContinuousClock.now - startpair at the call site (per Research: no generic timing wrapper is viable across actor hops). Partial timing on the timeout path is carried by extending the existingSwitchcraftStoreError.searchTimedOutcase with apartialTiming: SearchTimingfield, populated from a call-localvar timinginsidesearch()— deliberately not a shared actor property, sinceSwitchcraftStorepermits reentrancy atawaitpoints and a shared "last timing" var would let a concurrentsearch()call overwrite another call's diagnostic data before it's read. A local variable is correct under reentrancy by construction and needs no extra synchronization.cascadeCompactionDurationis measured externally (mirrorsflushDurationwhenever the returnedCompactionEvent.trigger == .cascade) rather than adding adurationfield toCompactionEvent— Research confirmed both give numerically identical values sinceperformFlush()has no internal boundary separating cascade time from ordinary flush time, and this avoids any change toIndexer/CompactionEvent.New/Modified Files
Sources/SwitchcraftCore/Search/HybridSearchTiming.swiftHybridSearchTiming(vectorDuration/bm25Duration/mergeDuration: Duration, all non-optional — always fully measured on any successful return) andHybridSearchResult(hits: [HybridHit],timing: HybridSearchTiming).Sources/SwitchcraftCore/Search/SearchEngine.swiftsearchHybrid()return type →HybridSearchResult. Bracket the vector stage (L637-654), BM25 stage (L656-670), and merge stage (union build L672 → filter/score → sort/prefix, all 3 return points) each with their ownContinuousClockpair.SearchEngine.Erroris left untouched (see Key Decisions).Sources/Switchcraft/SearchTiming.swiftSearchTiming(all 7 fields optional exceptcascadeCompactionOccurred: Bool = false) andSearchResult(hits: [HybridHit],timing: SearchTiming).Sources/Switchcraft/SwitchcraftStoreError.swiftsearchTimedOut(elapsed: Duration)→searchTimedOut(elapsed: Duration, partialTiming: SearchTiming).Sources/Switchcraft/SwitchcraftStore.swiftflushAndClearPending()→@discardableResult ... -> (event: CompactionEvent?, duration: Duration)(its other 5 call sites need zero edits thanks to@discardableResult).search()builds a localvar timing = SearchTiming(), populates it at each checkpoint, attaches it to everysearchTimedOutthrow, and returnsSearchResult(hits:timing:)on success.Tests/SwitchcraftTests/Support/DelayInjectingStorage.swiftInMemoryStorage(same shape asCountingBucketStorage), with configurableTask.sleepinjected beforebuckets(forGeneration:)(slows vector) and/orsearchFullText(...)(slows BM25) independently.Tests/SwitchcraftTests/Support/DelayedEmbedder.swiftEmbedderwrapper aroundMockEmbedderinjecting aTask.sleepinsideencodeQuery, to isolateembedDuration.Tests/SwitchcraftTests/SearchTimingTests.swiftTests/SwitchcraftTests/HybridSearchTests.swift.hits.Tests/SwitchcraftTests/SearchTimeoutTests.swiftcase .searchTimedOut(let elapsed)→case .searchTimedOut(let elapsed, _)(or assert onpartialTimingwhere useful).Tests/SwitchcraftTests/*.hitswherestore.search(...)'s result is consumed as[HybridHit].adrs/0NN-search-stage-timing-instrumentation.mddocs/Plan.md### Phase 2: Production Optimization, cross-referencing #148 and the new ADR.CHANGELOG.md[Unreleased]entry for the new public types and thesearchTimedOutcase-shape change.Key Decisions
.hitsedit; that's the accepted cost of requirement 5's "minimal edits," not zero edits.SearchTiminglives inSwitchcraft, notSwitchcraftCore. UnlikeSearchDeadlineContext(threaded into bothSwitchcraftCoreandSwitchcraftSQLite),SearchTimingis only produced/consumed insideSwitchcraftStore— no cross-module sharing, so ADR 020's "shared types live in SwitchcraftCore" rule doesn't apply.HybridSearchTimingdoes cross theSwitchcraftCore→Switchcraftboundary, so it stays inSwitchcraftCore.search()calls could race). AttachingpartialTimingdirectly to theSwitchcraftStoreError.searchTimedOutpayload is race-free by construction and directly matches requirement 6's own example (SwitchcraftStoreError.searchTimedOut, which already carrieselapsed).SearchEngine.Error.deadlineExceededis left unchanged. Requirement 6 scopes partial-timing to thesearchTimedOutpath and explicitly doesn't require full error-path coverage.SwitchcraftStore.search()'s localtimingvar already has flush/cascade/embed durations captured by the time it catches and translatesSearchEngine.Error.deadlineExceeded, which covers the stated primary use case (diagnosing 5s store-level timeouts) without wideningSearchEngine's public error surface or its own 0 known pattern-match sites.cascadeCompactionDuration=flushDurationwhen cascade occurred, noCompactionEventchanges. Simpler, zero production changes toIndexer, numerically identical to the alternative per Research.SearchTimingfields exceptcascadeCompactionOccurredareDuration?. Needed because the two earliest throw points insearch()(post-flush deadline check, post-embed deadline check) genuinely predate later stages —nilis the honest value, not a sentinel.Task Checklist
Sources/SwitchcraftCore/Search/HybridSearchTiming.swift(HybridSearchTiming,HybridSearchResult).SearchEngine.searchHybrid()to measure vector/BM25/merge stages withContinuousClockand returnHybridSearchResult; update all internal early-return points (empty-input fallback,topK <= 0guard,union.isEmpty, final prefix) to constructHybridSearchTimingconsistently.Tests/SwitchcraftTests/HybridSearchTests.swiftcall sites to.hits.Sources/Switchcraft/SearchTiming.swift(SearchTiming,SearchResult).SwitchcraftStoreError.searchTimedOutto carrypartialTiming: SearchTiming; updateSearchTimeoutTests.swift's 2 pattern-match sites.SwitchcraftStore.flushAndClearPending()to@discardableResult ... -> (event: CompactionEvent?, duration: Duration).SwitchcraftStore.search()to build the localSearchTiming, populate it at each checkpoint (flush/cascade, embed, vector/BM25/merge fromsearchHybrid's result), attach it to both directsearchTimedOutthrows and the translatedSearchEngine.Error.deadlineExceededcatch, and returnSearchResult.store.search(...)call sites acrossTests/SwitchcraftTests/*to.hits; spot-check no assertion accidentally drops its unwrap.Tests/SwitchcraftTests/Support/DelayInjectingStorage.swiftandTests/SwitchcraftTests/Support/DelayedEmbedder.swift.Tests/SwitchcraftTests/SearchTimingTests.swift:DelayInjectingStorage) doesn't inflatevectorDuration, and vice versa; a slow embed (viaDelayedEmbedder) doesn't inflateflushDuration/other stages.cascadeCompactionOccurred == true+cascadeCompactionDuration != nilwhenstore.search()itself triggers the cascade (smallIndexerConfig.testing(l0Capacity:lsmFanout:), pattern fromCompactionEventTests.cascadeTriggerReturnValuebut exercised throughsearch()instead ofindexer.flush()directly).cascadeCompactionOccurred == false+cascadeCompactionDuration == nilfor an ordinary (non-cascading) search.searchTimedOut(reuseSearchTimeoutTests.swift'sdeadline: .seconds(0)pattern),partialTiming.flushDurationis populated and later fields arenilas appropriate.l0Capacity.adrs/0NN-search-stage-timing-instrumentation.md— confirm next free number at implementation time) documenting: the wrapper-struct return-type change, the race-free partial-timing-on-error design (vs. a shared actor property), thecascadeCompactionDuration=flushDurationequivalence, and whySearchEngine.Errorwas left untouched.docs/Plan.md(new checkbox under Phase 2) andCHANGELOG.md([Unreleased]entry), following the existing Instrument and fix persistSnapshot/WAL-checkpoint shutdown cost (blocks WebBrain#254) #144/P0: performanceSmoke / safariUnfuckerRegressionTest wall-clock threshold failures under host load (found during #144 Validate) #146-style entry format.swift testandswift test -c releaseclean before the final commit, per repo hard rule.Risks
.hitscould silently change a test's assertion target rather than fail to compile (e.g., comparingSearchResultEquatable/Hashable-lessness would fail to compile, which is actually a safety net here sinceSearchResultwon't conform to those). Do this as its own commit, reviewed as a diff of mechanical-only changes.SwitchcraftStoreError.searchTimedOut's arity change is source-breaking for any external consumer (WebBrain, SemanticHistory) pattern-matching the old 1-value case. This is a deliberate, scoped exception to "keep additive" — it's a one-line fix on their side (case .searchTimedOut(let elapsed, _):) and is the only race-free way to deliver requirement 6. Flag this explicitly in the PR description so consuming repos aren't surprised.searchHybrid()(empty union, sorted+prefixed, sorted+full) — easy to miss one when adding theContinuousClockbracket. Cover this directly with the new independent-observability tests rather than relying on review alone.Used 32/50 turns, 1.4M input (3k raw + 1.3M cache-read + 86k cache-write) / 32k output tokens.
Verification
Implemented per-stage search timing instrumentation per the plan:
SearchEngine.searchHybrid()andSwitchcraftStore.search()now return wrapper structs (HybridSearchResult/SearchResult) carrying hits plus aContinuousClock-measured timing breakdown (flush, cascade compaction, embed, vector, BM25, merge).SwitchcraftStoreError.searchTimedOutgained a race-freepartialTimingfield. Updated ~79 call sites, addedDelayInjectingStorage/DelayedEmbeddertest doubles and a newSearchTimingTestssuite (7 tests) proving independent stage observability, cascade detection, and partial timing on timeout. Wrote ADR 040, updateddocs/Plan.md/CHANGELOG.md. Fullswift testandswift test -c releaseboth pass 376/376; encountered and root-caused two flaky-under-load performance/concurrency tests that are pre-existing (verified identical behavior against pre-branchmainin an isolated worktree) and unrelated to this change.Closes #148