Skip to content

Promote develop → main for server/v0.14.0 - #268

Merged
dvcdsys merged 33 commits into
mainfrom
develop
Aug 21, 2026
Merged

Promote develop → main for server/v0.14.0#268
dvcdsys merged 33 commits into
mainfrom
develop

Conversation

@dvcdsys

@dvcdsys dvcdsys commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Promotion of developmain for the server/v0.14.0 release. Tags are cut on main, so this is the gate.

Why 0.14.0 and not 0.13.1

vectors_q8 is a new on-disk table with a background backfill, and the workspace BM25 path was rewritten. Behaviour is identical and the upgrade is safe by construction — a collection with no q8_state row stays on the float32 scan until the backfill completes and records it — but a store-format addition is not a patch release.

What is in it

Search performance — the headline. Workspace search on real data is 2.7–4.2x faster.

before after
write a unit test for the parser 1,306 ms 421 ms
where is the HTTP router registered 1,176 ms 315 ms
parse JWT token and validate signature 1,158 ms 326 ms
graceful shutdown on SIGTERM 345 ms 107 ms

Search output is now reproducible. fuseRRF ranged over a map with no tiebreak and the projects panel sorted on a rounded score — both pre-existing, both fixed in #265. Measured on live data: the old build returns up to 3 different orderings in 6 runs of the same query; the new one returns one, every time.

Also in: #260 (in-place repo compaction — the 76 GB git bloat fix), #262 (exact token counting via the model's own BPE tokenizer), plus site, macOS and docs work (#250#258).

Verification

Beyond CI, the develop build was run against a clone of a real 48-project, 392k-chunk install with local (deterministic) embeddings, alongside the current release serving the same data:

  • semantic search: 20/20 comparisons identical, scores to 4 decimals;
  • symbols / definitions / references / files: byte-identical across both projects tested;
  • q8 backfill completed on all 389,905 vectors; vectors.db grew 2.2 → 2.52 GB (+15%);
  • zero ERROR lines through boot, backfill and query.

Security: what this PR DOES check, and what it does not

Runs on this PR (the Security workflow fires on pull_request: branches: [main], which is what this is): govulncheck on the server module, govulncheck on the CLI, and Trivy at HIGH,CRITICAL — but scan-type: fs, a filesystem scan of the repo. That covers our Go dependencies and anything checked in.

Not run: the image scan. The GPU host that builds and scans the CUDA image is unreachable, so the documented gate — no NEW HIGH/CRITICAL versus the prod tag — was skipped. Trivy fs does not see it: OS packages in the runtime layer and the curl-ed cloudflared / ngrok binaries are invisible to both it and Dependabot. That blindspot is on record (#194) and it is exactly the surface left unverified here.

Bounding the risk honestly:

  • server/go.mod is unchanged since server/v0.13.0, and govulncheck on this PR covers it anyway.
  • server/Dockerfile and Dockerfile.cuda are unchanged, so cloudflared (2026.7.3), ngrok and the digest-pinned llama.cpp layers are the same content the v0.13.0 image shipped — and that image passed a gate.
  • But the runtime bases float. gcr.io/distroless/cc-debian13:nonroot and nvidia/cuda:12.8.1-base-ubuntu24.04 are tags, not digests, so this build pulls whatever they point at today. Distroless rebuilds normally move forward onto patches, which makes a rebuild more likely to reduce CVEs than add them — but that is a tendency, not a guarantee, and it is precisely what the scan exists to confirm.

cloudflared is 2026.7.3 against 2026.8.2 upstream. Deliberately not bumped: bumping without a scan trades a surface that was verified for one that is not. Bump it and run the full gate when the host is back — that is the first thing to do, not an optional follow-up.

After merge

  1. git tag -a server/v0.14.0 <main-sha> && git push origin server/v0.14.0
  2. release-server.yml builds CPU multi-arch + CUDA, pushes :v0.14.0, :latest, :v0.14.0-cu128, :cu128, and runs the Hub prune.
  3. After the images land, bump SERVER_VERSION in site/src/shared/versions.js on develop → PR to main.
  4. Prod deploy stays blocked until the GPU host is reachable — and when it is, run the image gate against the pushed :v0.14.0-cu128 before deploying, and bump cloudflared.

dvcdsys and others added 30 commits August 17, 2026 13:03
…broken clones

Context: production hosts ~70 actively-pushed external repos; the data dir
grew to ~76 GB against ~2.3 GB of database. Root cause: the update path in
repocloner is fetch(Depth:1, Force) + hard reset, go-git writes ONE NEW
PACKFILE per fetch, the reset makes the previously fetched tree unreachable,
and nothing ever runs gc (go-git has none; the distroless runtime has no git
binary to shell out to). So every push of every repo permanently added a
pack, forever. Full brief with measurements: loadtests/GIT_STORAGE_CONTEXT.md
(local, gitignored).

Changes in repocloner.CloneOrFetch, reuse path restructured into
cloneFresh/updateExisting/reclone:

1. Packfile budget (maxFetchPacks=20): before reusing a checkout, count
   .git/objects/pack/*.pack; at/over budget, discard the directory and
   shallow-clone fresh. A fresh clone is one pack; real git auto-gcs at 50.
   This both bounds future growth and automatically reclaims the existing
   bloat in production: each over-budget repo re-clones on its next
   webhook/poll fetch. After a re-clone PrevIndexedSHA is unreachable, so
   Changes=nil and the caller lands in reconcile mode (hash-gated, cheap).

2. Self-healing reuse path: failures rooted in on-disk state (PlainOpen,
   pre-fetch Head — the signature of a SIGKILL-mid-clone half-write that
   previously wedged the repo in a forever-failing error loop, remote URL
   mismatch after a github_url change, post-fetch ref resolve, worktree,
   reset) are wrapped with an errLocalState sentinel and recovered by
   nuke + re-clone. Fetch/transport errors deliberately stay fatal-but-
   preserving: a network blip must not cost a healthy clone and force a
   reindex. A cancelled context also never triggers the nuke.

3. Result.RecloneReason (informational) + a log line in repojobs.handleClone
   so operators can see why a fetch turned into a full clone.

4. maintenance.DirSizeBytes now skips unreadable subtrees and keeps counting
   instead of returning (0,false) on any walk error — previously one bad
   directory made the whole "Cloned repositories" row vanish from the
   Resources screen. (0,false) is still returned for a missing/unreadable
   root (keeps "unreadable" vs "empty" distinguishable) and on context
   cancellation (partial numbers from an aborted request are not cached).

Not addressed here (possible follow-ups): a maintenance category for bloat
inside live checkouts (the packfile budget makes it mostly redundant for
actively-pushed repos, but idle bloated repos only shrink on their next
fetch); per-branch checkout duplication (no shared object store across
branches of the same repo); accounting for a moved CIX_REPOS_DIR stranding
the old tree.

Tests: 4 new repocloner tests (half-written clone self-heals; changed remote
URL re-clones; packfile budget re-clones and resets the count; fetch failure
against a dead upstream preserves the local clone) + 4 new DirSizeBytes tests
(sum, missing root, unreadable subtree partial, cancelled context). Full
server suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The voyage provider had no tokenizer, so it guessed token cost as len(bytes)/2
and lived with the consequences: batches sized against a guess, a TPM throttle
metering phantom tokens, and over-long inputs cut into byte windows whose
vectors were then averaged. This adds a pure-Go, count-only implementation of
the model's actual pipeline and wires it in behind an interface, so the
guessing path becomes the fallback rather than the only option.

WHAT THE HEURISTIC ACTUALLY COST, measured against Voyage's own
usage.total_tokens (412 single-input requests) and against the HuggingFace
Rust tokenizer as oracle (20k real chunks from a 45-repo corpus):

  - overestimates by 1.94x on average, so the TPM token bucket subtracts
    roughly twice the tokens actually spent. At the observed indexing rate
    (128 chunks/s, mean 221 tokens/chunk) real usage was ~1.7M TPM while the
    limiter believed 3.3M and throttled against a 3M budget — the run was
    self-limited to about half the tier's real capacity.
  - AND still undercounts 0.5% of chunks, worst case -41%, which is the
    direction that ships an over-limit POST. The safety the constant was
    chosen for is not actually delivered.
  - the "~1.4 bytes/token worst case" in the old comment is optimistic;
    measured worst is ~1.18.

WHY A HAND-ROLLED TOKENIZER RATHER THAN A LIBRARY. Both pure-Go candidates
were evaluated against the same oracle on the same corpus:

  - sugarme/tokenizer cannot load this tokenizer.json at all. It panics in
    regexp.MustCompile: the Qwen2 pre-tokenizer pattern contains \s+(?!\S),
    and Go's RE2 has no lookahead.
  - the ollama tree's pure-Go tokenizer knows about that and rewrites the
    pattern, but its compensation only covers space indentation. Tabs take a
    different branch, so it mismatches HF on 8.19% of real chunks (30.2% of
    chunks containing a tab), 1471 of them undercounts, worst -16.7% on
    tab-indented JSON. It also skips the declared NFC normalizer, so
    decomposed input costs an extra token.

bpecount instead implements the seven alternation branches by hand with Perl
leftmost-first semantics, which is what makes the whitespace precedence come
out right. Validated at 0 mismatches against the HF oracle across 70,412
inputs (412 Voyage-verified, 20k real chunks, 50k fuzz). It needs no new
module — golang.org/x/text was already an indirect dependency — and counts
27.5k chunks/s single-threaded, which is orders of magnitude above any
indexing rate on the 2-vCPU box this targets.

Only the merges array is read; the vocabulary is not needed for a count. New
pre-tokens are merged through a linked list plus a rank heap rather than the
naive pair rescan, because minified and generated files are real: a 30 KB run
of one character took 9s the naive way and takes ~16ms this way.

WHAT CHANGES FOR CALLERS. tokenizer.Budget is one interface, not a mandatory
counter plus an optional splitter, so the chunker takes a single constructor
argument. ExactCounts() is how a caller learns whether it may size to the
limit or must keep the old margin. The doc comment records the cost asymmetry
that makes two methods worth having: both do the same single pass over the
same memo, but CountTokens allocates nothing and runs per chunk (1.9M times on
the reference corpus) while SplitPoints builds an offsets slice and runs only
when an input does not fit (5 times on that same corpus).

SplitPoints is exact, not iterative. Because Split runs with Isolated
behaviour and BPE is applied per pre-token, merges never cross a pre-token
boundary, so token counts are additive over pre-tokens and a single
left-to-right pass yields cut points whose pieces provably sum to the whole.
Only a single pre-token larger than the budget needs a search, and it gets a
binary search on bytes inside that one pre-token.

Batch cap goes 80K -> 115K when counts are exact: the 40K of headroom existed
to absorb the heuristic's undercount against Voyage's 120K hard limit, not to
absorb anything on Voyage's side. An operator's explicit MaxTokensPerRequest
still wins over both.

MEASURED EFFECT, so nobody expects the wrong thing. Simulating both packers
over 200k real chunks: 1724 requests today vs 1568 with exact counts — 1.10x,
not the 2x the 1.94x inflation suggests. Batches are bound by the 128-input
cap of voyage-code-*, not by tokens (mean chunk is 221 tokens, so 128 inputs
is ~28K against an 80K cap). The throughput win is in the TPM throttle, not in
packing; the packing win is real but small.

DELIBERATELY NOT IN THIS CHANGE. The chunker does not yet consume Budget —
splitOversizeInput still byte-windows on the provider side, and only the
provider-side counting is switched over. That integration is the follow-up
this interface exists for, and it is where averaging over byte windows finally
goes away. On the reference corpus 5 chunks in 1.9M exceeded the old 30 KB
threshold, so the damage is bounded and self-healing: those files re-embed
correctly the next time they change, and can be found with
SELECT file_path FROM vector_contents WHERE LENGTH(content) > 30000.

TESTING. The golden counts need the real 7 MB tokenizer.json, which is not in
the repo, so those tests skip on a clean checkout — CI coverage comes from a
synthetic merge table that exercises the splitter, the merge loop and the
additivity property SplitPoints depends on. Packaging that file (embed vs
fetch-and-cache; only ~3.5 MB of it is load-bearing) is still open, which is
why the path is an operator-set config field for now and an unreadable path
degrades to the estimate with a warning rather than failing to start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chunker's size limit was 1500*3 bytes — a token target expressed in the
only unit it had. That ratio holds for dense ASCII code and misses everywhere
else: a comment block in Cyrillic or CJK costs two to three bytes per
character, so a byte-capped chunk carries a third of the tokens intended,
while minified JavaScript packs several times more. The chunker now asks the
active embedding provider what a chunk actually costs, when that provider can
answer.

Wiring: embeddings.Service.TokenBudget() hands out the live provider when it
implements tokenizer.Budget, nil otherwise. The indexer asks PER FILE rather
than caching it, because a provider swap between files is legitimate while
mixing two models' limits inside one file's chunk set is not.
CIX_MAX_CHUNK_TOKENS finally does something: it was parsed into config and
only ever used to size llama's context.

The bound is applied ABOVE the chunking paths, not inside the tree-sitter one.
That placement is the point: minified files and files with no grammar fall
through to chunkFallback's sliding window, and those are precisely the inputs
that blow the model's input limit. A budget that only covered the tree-sitter
path would have missed exactly the cases it exists for — the first draft did
sit inside that path, and the two tests that now cover minified JS and a
grammarless file both failed against it.

With a budget in hand the byte cap is deliberately raised out of the way
(innerMax = MaxInt32) so semantic units arrive whole and are cut once, in
tokens. Without one, nothing changes: nil budget, or a provider that reports
ExactCounts() == false, keeps the byte path byte for byte. An estimating
provider is routed to the byte path on purpose — its numbers are the same
guess, and dressing them as a token budget would hide that from the caller.

splitChunkTokens also closes a hole the byte splitter had. That loop requires
len(currentLines) > 1, so a single line longer than the cap was never split:
on the 45-repo reference corpus that produced a 65 KB chunk, which the voyage
provider then cut into byte windows and averaged the vectors of — a vector
representing neither half, with nothing in the logs to say so. A line that
cannot fit alone is now cut on real token boundaries from Budget.SplitPoints,
and the tail seeds the next chunk so short following lines can still join it.

Attribution rule is unchanged: only the first piece keeps SymbolName and
ChunkType, the rest become `block`, so one long function does not produce N
symbol rows all claiming to be it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e counts

The first token splitter counted each line, accumulated, and re-joined the
lines it had collected. Joining reinserts the newlines, and a newline plus the
next line's indentation is its own pre-token, so the joined text costs more
than the sum the loop was tracking. On real files a 1500-token budget produced
chunks of up to 1546 tokens — verified by counting the largest chunks of two
freshly reindexed repos with the model's own tokenizer.

Cut positions now come from Budget.SplitPoints over the whole content, which
is exact by construction, and the pieces are SUBSTRINGS of that content rather
than reassembled text. The class of error disappears instead of being
compensated for.

Two consequences worth spelling out, both learned from tests that failed:

Snapping a cut back to a line start (so a piece never begins mid-line and its
recorded line range does not lie) shrinks the piece BEFORE the cut and grows
the one after it. Reusing the remaining offsets SplitPoints had returned would
hand the next piece the tokens this one gave up and push it over budget. So
each piece recomputes its cut from the actual new start; the loop is not an
inefficiency, it is the correctness.

The content-preservation invariant is asserted on splitChunkTokens directly
rather than through ChunkFileTokens, because the sliding-window fallback
deliberately overlaps its windows for recall — whole-pipeline output is not
expected to concatenate back to the source, and a test that assumed otherwise
was wrong about the pipeline, not about the splitter.

Re-verified on the file that produced the reference corpus's 65 KB chunk:
48 pieces, largest exactly 1500 tokens, none over. The byte path leaves that
file as 6 chunks whose largest is 65,553 tokens — twice voyage-code-3's 32K
context, which with truncation enabled means the tail was silently dropped
before embedding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n a real corpus

Three bugs shipped into this branch before being caught, and the reason each
one survived is the same: the only thing verifying the token layer was my own
model of it. The tokenizer next door had zero bugs across 70,412 inputs
because it could be checked against HuggingFace's implementation and against
Voyage's billing. Chunk splitting has no such oracle — where to cut a chunk is
our decision, not a spec — so the oracle has to be built out of invariants and
inputs nobody wrote for the test.

fakeBudget was the direct cause of the worst one. It counted
whitespace-separated words and let newlines be free, which made the sum of
per-line counts equal the count of the joined text — exactly the assumption
the implementation got wrong. The double agreed with the bug, the tests
passed, and real files came out 3% over budget. It now charges a token per
newline, like the real tokenizer does, so summing parts no longer equals the
whole unless the pieces are substrings — which is the property the splitter
must have.

The corpus tests run the chunker over real files from the local fixture: 396
files across 45 repositories, 5,199 chunks, largest exactly 1,500 against a
1,500 budget; 132 files large enough to need splitting, all reconstructing
byte for byte with correct line numbers. Those files are what found the
original bugs — a 65 KB single-line Zig literal, minified JavaScript that has
no grammar and so takes the fallback path — and hand-written cases had not.

The fixture is tens of gigabytes and is not in the repository, so the tests
skip unless CIX_TEST_CORPUS_DIR and CIX_TEST_TOKENIZER are set. A clean
checkout and CI stay green; anyone with a fixture gets the coverage. The file
header says how to point them at one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cing budget-reclone

Replaces the maxFetchPacks nuke-and-reclone bound (previous commit) with the
approach validated by the PoC on branch poc/gc-compaction (server/cmd/gc-poc,
run against all 45 loadtests fixtures — spring-boot, grafana, etc — with
byte-level identity vs canonical full clones, git fsck --strict, a 12-round
leak test, and O() scaling measurements; see that branch's commit messages
for the numbers).

Two production changes:

1. Tags:NoTags on both clone and fetch (repocloner.go). go-git's clone
   default is AllTags, and on a shallow clone every tag arrives as a FULL
   tree snapshot: real checkouts measured 2.6-5x the worktree on day zero
   (spring-boot: 391 tags -> 102MB store / 39MB worktree; worst fixture:
   1240 tags, 503MB / 278MB). cix indexes exactly one branch and never
   reads tags. This kills the second bloat driver at the source.

2. In-place compaction (compact.go), run from CloneOrFetch after every
   successful update when needsCompaction() says so: packfileCount >=
   compactPackThreshold (4) — the fetch+reset path persists one
   snapshot-sized pack per fetch and go-git has no gc — OR tag refs
   present. The tag trigger IS the upgrade migration: the first ordinary
   update of every pre-NoTags checkout drops refs/tags/*, collapses the
   store to one branch-snapshot pack and rewrites .git/shallow, with no
   separate migration code. Runs on the NoChanges path too, so cleanup
   does not wait for the repo's next commit.

   Mechanism: reachability walk from non-tag refs + explicitly protected
   commits (PrevIndexedSHA — the base of the next incremental tree-diff,
   unreferenced once the branch moves, kept alive across compactions),
   honouring .git/shallow graft points exactly like git; encode the set
   into one pack via storer.PackfileWriter (window 0 — measured -17% size
   for 2.9x CPU with deltas, not worth it); delete old packs only after
   the new pack is durable (crash mid-compaction leaves extra packs, never
   a broken store); drop all loose objects; keep only still-present
   shallow entries. The walker is go-git's objectWalker with three fixes
   real repos hit immediately: shallow grafts terminate parent walks,
   submodule gitlinks are skipped, symlink-reached blobs are leaves.

   Costs (measured): linear time ~0.2-1.4ms CPU per object + ~0.2s/GB
   emitted; heap ~3x the uncompressed snapshot (go-git's encoder
   materialises content) — package-level compactMu serialises compactions
   across concurrent clone jobs so those peaks never stack on the 8GB
   prod hosts. Failed compaction falls back to nuke+reclone (store state
   unknown; shallow reclone is always correct).

Result gains Compaction *CompactStats; repojobs logs it (bytes before/
after, packs deleted, tag refs dropped, duration) next to the existing
RecloneReason log.

Tests: fresh clone carries zero tag refs; the upgrade scenario end-to-end
(legacy AllTags clone + 2 legacy fetch cycles -> first new-code update
compacts: 2 tag refs dropped, packs 3+ -> 1, objects dir shrinks, the SAME
update still returns the v4->v5 incremental ChangeSet, a later update still
diffs from the protected-but-unreferenced indexed_sha, and a no-op cycle
stays quiet); pack count stays <= threshold across 8 push/fetch cycles with
at least one compaction. Budget-reclone test removed with the mechanism.
Full server suite + vet green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mits

Nine findings from review, grouped by what they were actually about.

CORRECTNESS — offsets into the wrong string (blocking).
SplitPoints normalised its input and returned offsets into that NFC copy while
callers slice the original. NFC changes lengths in both directions: decomposed
input shrinks (e + U+0301 becomes one code point) so cuts landed mid-rune, and
the composition exclusions at U+0958..U+095F DEcompose under NFC, making the
normalised form longer, so an offset could exceed len(raw) and panic the
indexer on the slice expression. Reproduced both ways before fixing. Input that
is already NFC — nearly all source — takes a fast path where the two coincide;
anything else is cut one piece at a time, each cut mapped back to a raw offset
on a normalisation boundary, rounding down so the budget survives the mapping.
Every previous fixture was ASCII, which is why nothing caught it.

CORRECTNESS — an over-budget pre-token left its tail uncounted (blocking in
spirit; latent for today's only caller). After splitInside the accumulator was
reset to zero although the tail after the last inner cut opens the next piece,
so following pre-tokens could stack a full budget on top of it and produce
pieces of nearly twice the budget. The chunker escaped it by consuming only the
first cut and recomputing, but the doc invites consuming the whole slice.

CORRECTNESS — an unvalidated tokenizer could be confidently wrong (blocking).
LoadBytes checked only model.type == "BPE" while the Qwen2 pre-tokenizer regex
and the NFC normalizer are hardcoded here. A GPT-2 or o200k tokenizer.json
parses fine, declares BPE, and would have produced plausible wrong counts with
ExactCounts() reporting true — which then sizes chunks and packs batches. The
declared normalizer and pre-tokenizer are now compared against what this
package implements, and a mismatch fails the load so the caller stays on its
estimate, which is wrong but knows it.

CORRECTNESS — MaxInputTokens was a constant 32000, but the provider's own model
enum offers voyage-code-2 at 16K. It is a per-model table now, with unknown
models falling back to the smaller window: undershooting costs a needless
split, overshooting costs silently truncated input.

CORRECTNESS — token-sized chunks reopened the averaging path. The chunker
lifts the byte cap when it has a budget, but the provider still cut any input
over 30 KB into byte windows and AVERAGED their vectors — the invisible quality
loss this branch exists to remove. At CIX_MAX_CHUNK_TOKENS=20000, legal and
well inside the window, that would have been routine. With a tokenizer the
provider now asks the real question (does this exceed the model's context) and
cuts on token boundaries; byte windows remain only where there is no tokenizer
and therefore no better answer.

CORRECTNESS — the fallback path was still byte-biased. innerMax reached only
the tree-sitter path, so files with no grammar kept the fixed 4000-byte sliding
window, and boundTokens could not repair it: it splits chunks that are too big
and cannot grow ones that are too small. Multi-byte text — which correlates
with "no grammar" more than one would like — produced chunks worth a third of
the budget on exactly the path the description claimed was covered.

ROBUSTNESS — Service.TokenBudget was the only Service method without the
nil/disabled guard, and a typed-nil *Service satisfies the capability
interface the indexer asserts on, so the first indexed file would panic in
RLock. The indexer's inline anonymous assertion is now the named
TokenBudgetSource, hoisted out of the per-file loop (the per-file CALL stays —
a provider swap between files is legitimate): renaming TokenBudget is now a
compile error rather than a silent return to byte chunking.

EFFICIENCY — splitChunkTokens called SplitPoints per piece and used only the
first cut, rescanning the whole remainder each time: on the 66 KB single-line
case that is ~990 suffix scans instead of ~44. The cut list is now reused while
it stays valid and recomputed only when snapping to a line start actually moves
a boundary — which on single-line content never happens. boundTokens also skips
tokenising entirely when len(content) <= budget: a byte-level BPE token cannot
cover less than a byte, so that comparison PROVES the chunk fits, and most
chunks are small. Corpus property tests dropped from 5.4s/7.6s to 3.8s/2.2s.

EFFICIENCY — the contraction branch lowercased the entire remaining suffix for
every apostrophe, though only two bytes can match. A 512 KB file with 5,000
quotes moved over a gigabyte through the allocator on the indexing hot path.

Also: the dead estimating branch of Provider.SplitPoints (unreachable, and it
looped forever when the budget was smaller than one leading multi-byte rune)
is gone; the batch log reported the Config cap while packing used the Provider
cap; CIX_MAX_CHUNK_TOKENS's default now comes from chunker.DefaultMaxChunkTokens
instead of a second copy of 1500; and the /loadtests/ gitignore entry ships
here rather than sitting in a working tree, since committed tests reference
that path.

Tests: NFD and composition-exclusion cases (both previously absent — every
fixture was ASCII), a foreign-pipeline rejection case, an uncounted-tail case,
a fallback-fills-the-budget case, and TestSplitPointsOversizePreToken fixed to
actually enter splitInside — "aB3" repeated pre-tokenizes into 2-byte pieces
that never exceed any budget, so the path it named was never executed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…byte runs

Two blockers from the second review, both confirmed before fixing.

DEADLOCK. splitInside binary-searched over byte offsets and aligned its
midpoint to a rune start by DECREMENTING. When alignment pulled the midpoint
below lo, the next lo = mid+1 did not advance, and the (lo, hi) pair repeated
forever. Any run of multi-byte runes long enough to exceed the budget as one
pre-token reached it — a box-drawing comment separator does, and so does an
arrow run or a run of combining marks. The worker did not error or slow down,
it stopped. Reproduced at budgets 5 and 50 on four inputs, including
already-NFC ones on the fast path, so this was not confined to the new
denormalised route.

The search now runs over rune-start INDICES enumerated once, so every
candidate is a valid boundary by construction and no alignment step exists to
misbehave. A single rune that exceeds the budget is emitted rather than
refused: not advancing is the failure being removed. All four inputs are
tests now, at both budgets, asserting termination AND that no piece is cut
mid-rune.

Strictly this loop predates the previous commit, but the denormalised path
added routes into it.

UNREACHABLE FALLBACK. chunkFallbackTokens was dead code. chunkWithTreesitter
never returns an error — all six of its decline paths called chunkFallback
themselves and returned the byte windows as SUCCESS — so the caller's fallback
branch could not run, and no-grammar, minified, parse-failure and empty-AST
files kept getting 4000-byte windows regardless of any token budget. That is
the finding the previous commit claimed to fix.

Those six sites now return errUseFallback and the caller decides, because the
caller is the one that knows whether a budget is in play. ChunkFile's byte
path is unchanged: it already mapped err to chunkFallback.

The test that was supposed to catch this passed by arithmetic. At budget 200 a
4000-byte window of the fixture is ~358 fake tokens, so boundTokens split every
window into 200+158 and both halves cleared half-budget without the fallback
being token-aware at all. Raised to 800, where a raw window is BELOW half the
budget: verified it now fails against the old routing (7 of 8 chunks under
half budget) and passes against the new.

Also from the review: the split log reported max_input_bytes even when the
split was token-bounded; the provider-level token-split branch had no direct
test (added, covering both pass-through of a large-but-legal input and cutting
one past the window); a doc comment still named the old lowercase constant.

The token fallback drops the byte window's 500-byte overlap. That is
deliberate and now documented at the site: overlap has to be expressed in
tokens to coexist with a token budget — size pieces at budget minus overlap,
then extend each start back into its predecessor — which is a change worth
making on its own rather than smuggling into a correctness fix. The
tree-sitter path has never overlapped, so the two paths are now consistent
rather than the fallback being worse than its neighbours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat: size chunks in tokens — exact counting via the model's own BPE tokenizer
Review (adversarial, 8 CONFIRMED + 2 PLAUSIBLE) accepted in full; every
finding fixed, plus the runner-ups. Mapping:

1. Failed compaction no longer nukes the checkout. Compaction moved out of
   CloneOrFetch entirely into exported MaybeCompact; repojobs calls it after
   the clone section, logs a Warn on error and moves on — the store is
   exactly what the update left (valid), and the trigger re-fires next
   cycle. Nuke+reclone is reserved for the errLocalState taxonomy.

2. No more global-mutex-inside-repo-lock convoy. MaybeCompact acquires the
   global compaction gate FIRST, then takes the per-repo write lock (passed
   in by repojobs as a closure) only around the store mutation. A job queued
   behind another repo's compaction now holds no locks at all, so readers
   of its repo proceed; upgrade-day fleets serialise on the gate without
   stalling each other's reads.

3. NoChanges no longer trusts HEAD: updateExisting performs the hard reset
   on the NoChanges path too (a no-op write on a clean tree), repairing the
   torn-worktree state a crash mid-reset leaves behind (go-git writes HEAD
   before touching files). Test: NoChangesStillRepairsWorktree.

4. Crash-safe trigger ordering: tag refs are now dropped AFTER the new pack
   is durable and BEFORE old packs are deleted (a ref never dangles over a
   missing object). The remaining window — tags dropped, old packs still
   present — is re-armed by a new needsCompaction backstop: store >= 2x
   worktree (>=2 packs, 1MB floor). Test: RatioBackstopRearms simulates
   exactly the crashed state.

5. compactCheckout takes ctx: checked between phases and every 1024 objects
   inside the walk; cancellation aborts cleanly before any deletion.
   MaybeCompact also re-checks after waiting on the gate. Test:
   CancelledContext.

6. Pending index target protected: Result gains PrevHeadSHA (the on-disk
   HEAD before the fetch — the TargetSHA of a possibly still-queued index
   job) and repojobs passes {IndexedSHA, PrevHeadSHA} to the protect set.
   Test: ProtectsPendingIndexTarget reproduces the two-cycle race.

7. Protect probe distinguishes not-found from store errors:
   errors.Is(ErrObjectNotFound) -> skip; anything else -> loud failure.
   Same fix applied to the walker's parent probe.

8. errLocalState gets one retry before the nuke: transient EMFILE/EIO-class
   failures heal on the second attempt, a genuinely broken checkout fails
   identically and still recloses. (Existing half-written-clone test covers
   the broken path through the retry.)

9. DirSizeBytes undercounts are now visible: dirSizeDetail returns a
   skipped-entry count, DiskUsage gains partial:true (openapi.yaml +
   openapi-gen regenerated; maintenance.Usage serialises straight to the
   wire) and computeUsage logs a Warn naming the disk and skipped count.

10. Reachability walk converted from recursion to an iterative worklist —
    stack depth no longer scales with commit-chain length, so a full-history
    clone seeded into the repos dir cannot blow the goroutine stack.

Runner-ups: the seven errLocalState wraps use double-%w so the cause chain
survives errors.Is/As; refs are read in ONE pass (tags collected and roots
seeded together); needsCompaction's ratio walk is gated on pack count >= 2
so the steady state never pays it.

Not taken (documented deliberately): clone-into-temp+rename for reclone —
reclone is now confined to genuinely-unusable-state paths where the old
checkout has no value; the review marked it optional.

Full server suite, vet, and openapi-gen sync green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(server): compact repo checkouts in place (NoTags + reachability repack), self-heal broken clones
Workspace search over the 45-repo load-test fixture took 23.9 s at the median
on a 14-core Mac with NVMe and 36 GB of RAM. Production is an e2-standard-2
(2 vCPU, 8 GB, network PD) behind a Cloudflare tunnel whose 100 s edge timeout
would fire before the answer.

The cause was not the fan-out logic, it was the volume. A 2048-dim float32
embedding is 8192 bytes, past SQLite's 8157-byte local-payload limit on our
8 KiB pages, so every `vectors` row spilled into its own overflow page.
Measured with dbstat on the fixture: 238,727 leaf + 1,909,447 overflow pages
for 1,909,447 vectors = 9,216 bytes read per vector, 17.6 GB per workspace
query. The schema comment still described the 768-dim case ("the scan reads two
rows per page and never follows an overflow chain"), which was true of every
model the store originally shipped with.

Fix: `vectors_q8`, the same vectors at one byte per component plus a per-vector
scale, is what a search now scans. It takes a shortlist, and the shortlist is
rescored against the float32 originals in `vectors`, which stay authoritative
and untouched. Measured on the same fixture: 636,483 leaf pages, zero overflow,
2,731 bytes per vector, 5.2 GB per workspace query — 3.4x less. The file grew
20 GB -> 26.35 GB (+31%).

Why rescoring rather than trusting int8. On 60k vectors of the fixture's
largest collection (ziglang/zig, voyage-code-3 @2048) against 50 REAL
query-side embeddings, recall of the exact float32 top-K:

    shortlist   k=10    k=20
           20   0.998   0.994
           40   0.998   0.999
           60   1.000   1.000
          200   1.000   1.000

int8 alone gives 0.994 at both k. The quantisation misorders near-ties, it does
not lose the documents, so re-reading a few dozen exact vectors recovers all of
them — hence a floor of 64 and 4x the limit above it. An earlier version of
this experiment drew its queries FROM the corpus; a corpus vector is an exact
member of the set being searched, its neighbours are far away, and it made
rescoring look worthless (0.990 either way). Query-side embeddings are the
regime that decides.

Latency, A/B on the same machine and the same warm page cache, back to back,
by flipping CIX_VECTOR_SCAN_QUANT (10 queries, one repeat each):

                    float32 scan     int8 + rescore
    single project    15,899 ms          1,422 ms   p50
                      23,642 ms          2,093 ms   p95
    workspace (45)    23,879 ms         10,544 ms   p50
                      33,366 ms         25,186 ms   p95

The single-project scan improves more than the 3.4x byte reduction because
5.2 GB fits this machine's page cache and 17.6 GB does not. The workspace
number improves less, so something other than the dense scan now dominates the
fan-out — but what, exactly, is not established. BM25 is the obvious suspect
(`chunksfts.SearchProject` matches `chunks_fts` across the WHOLE server and
filters by project afterwards, once per repo), and measured through the
server's own driver it costs 326-542 ms per repo on this fixture, which does
not account for 10 s. The next step is per-phase timing inside the handler
rather than another guess.

A note for whoever measures next: the same BM25 query timed through Python's
system sqlite3 on this Mac takes 18 s, repeatably, against 380 ms through
modernc.org/sqlite. Any conclusion about FTS5 cost drawn with a different
SQLite build than the server's is worthless.

End-to-end check that the approximation is invisible: 20 queries, top-20, on
the full 346k-vector zig collection, captured with the compact scan off and
then on. 20/20 byte-identical, including the scores.

Mechanics:

  - Scores returned to callers are always the exact cosine, never the int8
    estimate. This is load-bearing beyond cosmetics: min_score thresholds on
    it, the workspace fan-out min-max normalises across projects with it, and
    hybrid search blends it with BM25 — an approximate score would move results
    BETWEEN projects in a way no single-project test would catch.
  - Candidates are keyed by doc_id, not rowid. `vectors` has a composite
    PRIMARY KEY so its rowid is implicit, and SQLite only promises to preserve
    implicit rowids across a VACUUM for tables with an INTEGER PRIMARY KEY.
    Survivable while the rowid never leaves one query; fatal once a second
    table keys off it.
  - Readiness is a q8_state row, not a COUNT. Collections created by this code
    are marked at creation (empty, therefore complete) and every upsert
    maintains both tables in one transaction. Collections written by an older
    binary have no flag, keep using the float32 scan, and are converted by a
    background pass at open — largest first, 2000-row transactions, 50% duty
    cycle, free-space checked up front. The flag is written in the same
    transaction as the batch that proves it, so a kill leaves a collection
    unmarked and still correct, never marked and incomplete. On the fixture the
    backfill converted 1,909,447 vectors in 245 s.
  - vectors_q8 carries `language` because that is the only filter any caller
    produces (fetchVectorResults, from the `languages` parameter). Any other
    `where` key falls back to the float32 scan, which has every column.
    `{"language": ""}` is a filter, not the absence of one — chromem compared
    metadata["language"] to "" — and the test for that fails against the
    obvious `if language != ""` version.
  - CIX_VECTOR_SCAN_QUANT=false opts out: the copy is a quarter of the float32
    bytes on top of an already large store. Turning it off also withdraws the
    completion flag from anything written while off, so turning it back on
    rebuilds rather than trusting a stale copy.
  - Deletes reach the copy in both directions, and delete-by-file runs BEFORE
    the vectors delete because its subquery reads file_path from `vectors`. An
    orphaned q8 row is a document the scan keeps shortlisting and the rescore
    can no longer score: it vanishes from results with nothing logged.
  - The legacy chromem import still writes float32 only; it creates its
    collection with raw SQL so nothing marks it complete, and the backfill that
    runs right after picks it up.

Also here, because it is the same query path and it was free: the stale-FTS
probe in workspace search used `SELECT COUNT(*) ... LIMIT 1` per repo, which
walks every matching index entry to answer a yes/no question (the LIMIT bounds
the result rows of an aggregate that always returns one). Measured on the
fixture's 1.95M-row chunks_meta across 46 projects: 53.2 ms as COUNT, 0.2 ms as
EXISTS. It runs serially, before the fan-out, on every workspace query.

Tests. The layout ones assert PAGES, not milliseconds, so they mean the same
thing in CI, on a laptop and on the production box — multiply by that machine's
read throughput and you have its latency. TestScanPackingEfficiency also fails
on the 1024-dim case as float32 (8192 bytes read to obtain 4096: one row per
leaf page, half of it air), which was reachable by an operator halving
output_dimension to save time and getting half the vector quality for 89% of
the I/O. The behaviour tests use a corpus with deliberate near-duplicate
clusters, because random unit vectors in 2048 dimensions are nearly orthogonal
and have no near-ties for a quantiser to confuse — real code corpora are the
opposite. TestScanQuantOffThenOn covers the toggle that would otherwise leave a
collection marked complete and missing every row written while it was off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s, toggle staleness, exactness claims

Addresses the review of 838c923. Findings 1-10 plus the minors; nothing was
waved through, and two of them were only provable by writing a test that fails
against the old code first.

## The backfill was racy, and the comment claiming otherwise was false (1)

backfillBatch reads a batch in one implicit transaction, quantises it in Go,
and writes it in another. It holds no lock across the gap — deliberately, since
the thing most likely to want the write lock is the file watcher reindexing a
file somebody just saved — and on the fixture that gap is open across 245 s of
live server. Two things went wrong in it, neither of which surfaces as an
error:

  - a doc deleted in the gap had its compact row REINSERTED, and nothing could
    ever remove it again: DeleteByFile finds doc_ids through `vectors`, where
    the row no longer is. Every later scan shortlists the orphan and every
    rescore drops it, so the query silently returns fewer results.
  - a doc re-embedded in the gap had its fresh compact row OVERWRITTEN by this
    batch's quantisation of the embedding it had just replaced. The document
    then ranks by a vector it no longer has.

Both are now closed at the statement level rather than by locking:
WHERE EXISTS (do not resurrect) and ON CONFLICT DO NOTHING (never be the later
writer — the backfill only fills gaps). The empty-batch completion no longer
claims to be "in the same transaction as the query that proved it", which was
literally untrue; completeness rests on those two clauses, and the comment now
says so.

TestBackfillSurvivesConcurrentWrites runs the backfill against a churning
collection and asserts invariants rather than an interleaving, so it can only
fail for a real reason. TestBackfillNeverResurrectsOrOverwrites pins the two
clauses deterministically. Both fail against a plain upsert — verified by
reverting the SQL and re-running.

## One failed collection stopped the other forty-two (2)

backfillQ8 returned on the first per-collection error. The realistic cause is a
collection deleted mid-walk (admin project delete, orphan sweep) failing the
next insert's foreign key — after which every remaining collection stayed on
the float32 scan until somebody restarted the server, with one warn line as the
only trace. Now logged per collection and skipped; the completion line reports
how many failed.

## A legacy import could hide documents behind a live flag (3)

The import writes `vectors` directly and creates its collection with INSERT OR
IGNORE, which was justified as "nothing marks it complete". True only when the
collection is NEW. An operator who indexed a project live (ensureCollection
flags it at creation) and then pointed CIX_CHROMA_PERSIST_DIR at a legacy tree
reaches the other case — migration_state is keyed on the legacy collection name
and has never seen it — and the imported docs get no compact rows inside a
collection whose flag says it is complete. The backfill skips flagged
collections, so those documents would never be searchable on the fast path. The
import now withdraws the flag unconditionally.

## Indexing must not fail over a performance hint (4)

ensureCollection returned the error from markCollectionQ8Ready, which opens its
own transaction and can lose a race for the write lock. That aborted a whole
UpsertChunks over a row whose absence costs nothing but a slower scan — and
which self-heals, because the backfill sets it at the next open. Logged now.

## The two scan paths disagreed on a zero query (5)

quantizeInt8 returns scale 0 for an all-zero vector and scanQ8 short-circuited
to empty, while the float32 path scores every row 0 and fills the heap. Same
broken query, different answers depending on whether the collection had been
converted — and in a workspace fan-out, both at once. The short-circuit is
gone: a zero query now scores everything 0 on both paths, which is what
quantizeInt8's own comment always claimed.

## "Exact" was overclaiming (6)

The docs said results and scores "stay exact". Scores do, by construction — the
rescore computes them from the float32 vectors. The SET does not, in the worst
case: the shortlist is a fixed width and topK rejects boundary ties strictly, so
a collection holding more than `shortlist` documents within one quantisation
step of each other truncates the tie in scan order, and the rescore cannot
recover a document it never received. Measured error is zero on every corpus
tried; that is now what the documentation says, with the boundary case named in
q8Shortlist next to the fix that would close it.

## Performance and duplication (7-10)

  - doc_id was scanned into a fresh Go string on every row of both scan loops —
    ~1.9M allocations per workspace query on the fixture, and the
    CIX_VECTOR_SCAN_QUANT=false path paid it too, so the opt-out did not
    actually restore pre-change behaviour. Both loops now read it as RawBytes
    and materialise the string only for a row that enters the heap.
  - The backfill paginated `vectors` in doc_id order. `vectors` is a rowid
    table whose composite primary key is a separate index, so that scattered
    ~9 kB row lookups across the collection's whole rowid span — the same 1.8x
    that made scanSQL pick idx_vec_coll in the first place. Now keyset-paginated
    on rowid through idx_vec_coll.
  - rescore duplicated scan's streaming loop line for line, putting the float32
    decode protocol in three places; both now call streamExact. The IN-list
    batching duplicated hydrate's; both now call docIDInList. The scan-slot
    select was pasted twice; acquireScanSlot.
  - Options.ScanQuant's comment described a design that was not built ("writes
    maintain it either way"). Rewritten to match: the flag governs the whole
    lifecycle.

## Minors

q8_state's comment claimed a dimension column that does not exist. The row-size
expression was pasted three times; sizeExprQ8. q8Ready cached negatives it
never used; positives only, presence is the answer. clearQ8Ready ran per
500-chunk batch; hoisted to once per UpsertChunks, and moved BEFORE the first
write so a crash mid-run cannot leave the flag set over a half-written
collection. Writes with the copy switched off now DELETE the compact rows they
touch — without that, a doc re-embedded while off kept its stale compact row,
and the backfill's new DO NOTHING would have sealed it in on re-enable. Filter
fallback to the exact scan logs at debug, and TestQ8FilterableCoversEveryFilter
fails when a new filter column appears without a decision about it. layout_test
claimed scanTable was derived from the SQL; it now is, via an assertion.

Not done, with reasons: quantizeInt8's per-component division stays a division
— precomputing the reciprocal changes stored values for a loop that runs once
per chunk at index time, and the end-to-end identity check below is worth more
than the microseconds. The maintenance q8 aggregate still walks leaf pages;
it sits behind the maintenance service's TTL cache, and the cheap fix if that
changes is recording the total at completion, noted at the constant.

Verified on the 45-repo fixture, not only in unit tests: one collection's
compact copy was wiped, the server rebuilt it (54,063 vectors, 8 s, zero
orphans in the whole database), and 20 queries x top-20 came back identical
both to the pre-review compact scan and to the exact float32 scan — 20/20,
scores included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
perf(vectorstore): scan an int8 copy and rescore the shortlist exactly
Stage 1 of the three-stage search-perf plan. It builds nothing faster; it
exists so stages 2 and 3 are not guesses. The previous round of work
budgeted a 10.5 s workspace query by measuring the dense scan, the BM25
query and the fan-out's parallel speedup separately and multiplying. The
budget closed, which is not the same as being right, and two
optimisations were about to be built on it. One of the two multiplicands
turned out to be off by 18x.

What lands:

- searchtimings.go: a searchPhases accumulator. Serial phases (embed,
  stale-FTS probe, fan-out wall, fuse) are plain durations; the two
  per-project phases keep a SUM and a MAX behind atomics, because the sum
  is the work the query did and the max is what the user waited for, and
  under sublinear parallelism those are different questions. Reporting
  either one alone hides which.
- The fan-out records dense and BM25 per project (workspacesearch.go).
- projects_scanned / projects_returned, because their ratio is the
  premise of stage 3: the fan-out does full work on every project and
  then thresholds the answer down.

Always collected, conditionally reported. The measurement costs a
handful of time.Now() calls and two atomics against a query that reads
gigabytes, so there is no reason to gate the collection. Where it goes is
gated twice, and the two gates answer different questions:

- the LOG line fires only above slowWorkspaceQuery (2 s). The server
  already writes one http_request line per request carrying the wall
  time, so a second line on every workspace query would be noise added to
  catch the rare slow one. A threshold keeps the property a ?debug flag
  cannot have: nobody needs to have switched anything on before the slow
  query happened. Two seconds is not "wrong" for a fan-out over every
  project in a workspace — it is the point past which the breakdown is
  worth storing, and low enough that a regression on a small workspace
  still trips it.
- the RESPONSE object is attached only for ?timings=true, documented as
  WorkspaceSearchTimings in doc/openapi.yaml. In a response this is a
  debugging aid, not API surface: every existing caller (CLI, MCP tools,
  dashboard) gets byte-identical responses to before.

One deliberate omission versus the spec in the plan: no hydrate_ms. Chunk
payloads are hydrated inside VectorStore.Search and
chunksfts.SearchProject, so hydration is not separable from out here; it
is inside dense_sum_ms/bm25_sum_ms and addDense says so. Everything after
fuse is in-memory slicing. Measured on the fixture, wall minus the four
serial phases is ~19 ms of 9,911 ms, so nothing material is unaccounted
for.

What it measured, on the 45-repo fixture (1.9M chunks, voyage-code-3
@2048, 14-core Mac, int8 scan on), 10 queries against the 43-project
workspace, via loadtests/bench/phases.py — medians:

  wall 9,911 ms | fan-out 9,663 | BM25 sum 93,345 (max 3,210)
  dense sum 26,476 (max 2,428) | embed 218 | stale-FTS 11 | fuse 0

Dense is a constant; BM25 is the variable and wall tracks it — 78% of
the fan-out's work at the median, ranging 15,026-183,177 ms with the
number and length of query terms rather than with repo size, because
MATCH is evaluated over the whole server's chunks_fts and filtered by
project afterwards, once per repo.

Two estimates it disproved:

- a repo's BM25 measures 326-542 ms standalone (loadtests/bench/ftstest,
  through the server's own driver) but up to 5,767 ms inside the
  fan-out. 43 concurrent FTS queries against one index degrade each
  other by roughly an order of magnitude. Stage 2 removes 42/43 of that
  work AND the contention, so it is worth more than it looked, not less.
- the stale-FTS probe costs 11 ms through the server, not the 0.2 ms a
  Python-side measurement suggested.

Tests assert shape and gating, never wall-clock values. Every field
present when asked for; no timings block at all when not asked for, or
on a response that ran no search (zeroes would read as "instant"); the
counters matching the fan-out actually performed; max <= sum per phase,
which is what catches a sum and a max wired to the wrong accumulator;
and both sides of the log threshold, from one captured logger, because a
test that only proves silence would still pass if the line were deleted.
slowWorkspaceQuery is a var rather than a const purely so that test can
cross the threshold without sleeping; nothing at runtime writes it.

Each gate was mutation-checked: removing the opt-in, removing the
threshold, and deleting the log line each fail the suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…early returns, F3 wall coverage

F1 (the one that mattered): projects_returned reported the count the
caller was SHOWN, not the count that survived the relevance threshold.
It was taken from projectPayloads, which is already truncated to
top_projects (default 10, clamped 1..50). So on the 43-project fixture
the scanned:returned ratio read 43:10 whether ten repos were relevant or
forty, and it moved when a client passed a different top_projects — a
request parameter, not a measurement of discarded work. That ratio is
the stated premise of stage 3, so the metric two optimisations were
going to rest on was measuring a UI cap. Now returned is len(surviving),
and the panel count keeps its own field, projects_in_panel, because
"what did the caller get" is a real but different question.

F2: both early returns (no visible members, no indexed projects) passed
a literal nil and never entered the reporter, so the log threshold and
the response opt-in — documented as independent gates — were both off
together on those paths. The query embedding has already been paid for
by then (218 ms median on the fixture), and a hung embedding provider is
exactly what the slow-query line is for. They now report with
requested=false: no timings in the response, because nothing was
searched, but a slow one still writes its line.

F3: wall_ms was documented as "the whole handler" but started after
requireWorkspaceVisible and the parameter clamps, and the unnamed
remainder silently absorbed the membership SQL and
access.AccessibleProjectHostPaths — the one pre-fan-out step that grows
with how many projects the caller can see rather than with the
workspace, and the one the fixture cannot exercise because an admin (and
AuthDisabled) skips the ACL branch entirely. started now sits on the
handler's first line, and the resolve phase gets its own resolve_ms.
The spec now also states what the remainder is rather than implying
there is none.

Also from the review, documentation-only:

- dense_sum_ms/bm25_sum_ms include projects whose query failed. Keeping
  them is correct — the time was spent, and excluding it would put the
  sums permanently below the wall time they exist to explain — but a
  slow failure can own the max, so both the spec and addDense now say so.
- the comment on slowWorkspaceQuery now names the condition its
  test-only mutability depends on: nothing in this package calls
  t.Parallel(), and whoever adds the first parallel test here has to
  move the threshold onto Deps first or hit a data race under -race with
  a non-obvious cause.

Two new tests, both mutation-checked against the bugs they describe:
restoring the panel-capped counter fails the F1 test (reports 10, wants
14), and restoring the nil early return fails the F2 test. Not covered:
an unrecorded resolve_ms still passes, because a shape test cannot tell
an unset duration from a fast one.

go test ./... green (46 packages), go test -race on the httpapi package
green, make openapi-check in sync, go vet and gofmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up nit from the PR #264 review. reportSearchTimings takes the
three project counters as consecutive ints — scanned, returned, panel —
which is the signature where a transposition compiles, produces
plausible numbers, and stays invisible until someone reasons from the
scanned:returned ratio. That ratio is stage 3's premise, and getting it
silently wrong is the exact failure F1 already was once.

The chained inequality is the only relationship that holds
unconditionally: the panel is a cap on what survived, and what survived
is a subset of what was searched. Asserted from both tests that read
timings, via a shared helper, so it applies to any future one too.

Mutation-checked: swapping returned and panel at the call site trips
both the F1 test and the new invariant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
perf(httpapi): measure workspace search per phase, inside the handler
Stage 2 of the search-perf plan, and the change stage 1's timings pointed
at: BM25 was 78-80% of the workspace fan-out's work.

The reason is structural. FTS5 drives the query: it evaluates MATCH over
the WHOLE chunks_fts table — every project on the server — joins each hit
to chunks_meta, and only then discards the rows belonging to other
projects. So a per-project BM25 query costs about the same whichever
project it names, and a 43-project workspace paid for the same global
match 43 times. On top of that the 43 queries contended over one index:
a repo's BM25 measured 326-542 ms standalone but up to 7,000 ms inside
the fan-out.

chunksfts.SearchProjects replaces them with one statement that ranks
within each project via ROW_NUMBER() OVER (PARTITION BY project_path
ORDER BY bm, rowid) and keeps each project's top rows. The IN list is
batched at 500 paths to stay under SQLite's 999-variable ceiling, the
same batch size the vector store already uses. In the handler the query
runs in the fan-out's errgroup alongside the dense scans rather than
before them, so nothing serialises; fusion moves out of the per-project
goroutines because it now needs both sides, and it was under a
millisecond across the whole fan-out anyway.

Measured on the 45-repo fixture (1.9M chunks, 43-project workspace,
10 queries, medians). Both builds were run back to back against the same
already-warm page cache — a process restart does not evict it — with one
warm-up pass discarded each time, because a first comparison across a
cold restart credited this change with twice the improvement it earned:

  phase       develop    stage 2
  wall         10,235      4,650   2.2x
  fan-out       9,961      4,376   2.3x
  BM25        111,210      4,375  25.4x   (summed over 43 -> one query)
  dense sum    15,166     11,136   1.4x
  dense max     2,318      1,123   2.1x

The dense rows are the ones worth pausing on: nothing in the dense path
changed. Removing 43 concurrent FTS queries gave the vector scans back
the CPU and I/O they were contending for, which is worth 1.4x on the work
and 2.1x on the project anyone actually waits for.

BM25 is no longer the dominant term: at 4,375 ms it now sits level with
the fan-out's own wall time, so the single FTS query IS the critical
path. Whatever comes next should start there rather than from the old
78-80% figure.

Correctness, on the fixture, 50 queries, full response captured per query:
the BM25 signal is IDENTICAL in all 500 panel rows, and the project panel
order is identical for all 50 queries. Dense scores wobble by <=0.0015 in
a few percent of rows — but the same binary compared against ITSELF
wobbles at least as much (30 rows vs 38), so that is a pre-existing
property of the fixture, not this change. Its cause is not established;
single-project search repeats bit-identically, and three consecutive
workspace queries repeat bit-identically, so it correlates with machine
load rather than with the query.

Both orderings are (bm ASC, rowid ASC). The rowid is defensive rather
than a fix: bm25 ties are the norm in a trigram index — 14 of 16 hits in
the package's own test corpus share a score — and SQLite happens to
return tied rows in rowid order for both the LIMIT and the window form
today, so they agree without being told to. That is unspecified sorter
behaviour, and naming the tiebreak makes the agreement a property of the
queries instead of a coincidence.

bm25_sum_ms and bm25_max_ms collapse into bm25_ms. The split existed to
separate "work done" from "waited for" across N queries; with one query
they are the same number, and keeping both would imply a fan-out that no
longer happens.

The blast radius grew and the tests say so: BM25 used to fail per
project, and now one failing query costs every project its sparse signal
at once. The fallback is the one a pre-FTS install already lives with —
dense-only results, no failed_repos, no 500 — and
TestWorkspaceSearch_SurvivesBM25Failure drops chunks_fts outright to
prove it.

Tests, each mutation-checked against the bug it describes:
- SearchProjects matches SearchProject per project, same hits, same
  order, same scores, over four queries x two limits x four projects on a
  tie-heavy corpus;
- the IN list does not prefix-match (project paths routinely share
  prefixes: "local:host:/x" vs "local:host:/x/y");
- the map survives the batch boundary (searchProjectsBatch + 7 projects);
- BM25 hits stay in their own project end-to-end through the handler;
- a total BM25 failure still returns dense results.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by clicking the fixture dashboard: "google authentication login
form" took 18.6 s, against a 4.6 s median. The slow-query log said the
whole 18.6 s was the single BM25 statement, so the timings from stage 1
pointed straight at it.

The cause was not the partitioning, it was what the CTE carried. Selecting
file_path, content and the rest inside `hits` makes SQLite materialise all
of it for EVERY matched row before ROW_NUMBER trims to perProject per
project — and the match set is the whole server's index, because that is
how FTS5 evaluates MATCH. That query matched 263,515 rows to return 2,300.

Ranking on (project_path, rowid, bm) alone and joining chunks_meta and
chunks_fts back for the survivors costs one rowid round-trip per returned
row and nothing per discarded row.

Measured with loadtests/bench/ftsab (the server's own driver, serial, so
the old shape's sum is not hidden by the fan-out's concurrency):

  query                              match set   46 queries   payload   rank
                                                              in CTE    first
  google authentication login form     263,515       26,085   15,070    2,798
  parse JWT token and validate sig.    623,913       27,587   29,566    5,492
  rate limiter middleware              166,347       16,904    7,692      993
  websocket upgrade handshake           21,049        3,505    1,011      156
  graceful shutdown on SIGTERM          14,987        3,244      854      424

The JWT row is the one that matters: with a 624k-row match set the query
as shipped was SLOWER than the 46 per-project queries it replaced. The
gain scaled inversely with the match set — exactly backwards — and the
ten-query bench set hid it because the old shape ran concurrently in the
fan-out while these numbers are serial.

End to end on the fixture, same warm cache, 10 queries, medians:

  phase       develop   prev commit   this commit   vs develop
  wall         10,235         4,650         3,048       3.4x
  BM25        111,210         4,375         2,782      40.0x
  dense sum    15,166        11,136        11,649       1.3x
  dense max     2,318         1,123         1,138       2.0x

And the query that started this: 18,623 ms -> 2,711 ms.

No test guards this. It is a property of the query plan, not of the
result, and the equivalence tests pass against both forms — they did, and
that is the point: correctness tests cannot see this class of bug. What
guards it is loadtests/bench/ftsab, which times all three shapes against
the real corpus, and the comment on the query saying why the obvious form
is wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…invariant

N1, the one worth doing: 6a47df7's commit message named
loadtests/bench/ftsab as the guard against the payload-in-CTE regression
returning. /loadtests/ is gitignored, so that guard exists on one machine
and the reference is worse than none in a repo whose commit messages are
written for the next agent.

The in-repo guard asserts the query PLAN, because that is the only place
this bug lives: every equivalence test in the package passes against the
slow form too — verified, not assumed — since both forms return the same
rows. FTS5 reports a rowid lookup as "0:=" and a MATCH scan as "0:M...";
the rank-first form does both, the payload-in-CTE form only scans.
workspaceRankQuery is extracted so the test builds the string production
builds rather than a copy free to drift.

Its mutation check lives in the tree rather than in a shell history:
TestExplainDistinguishesTheTwoQueryShapes builds the slow form and
asserts the plan assertion REJECTS it. Without that, a change in how
SQLite reports plans could quietly turn the guard into a tautology.
Confirmed by mutation: moving the payload back into the CTE fails the
plan test while the equivalence tests still pass.

Also, and where the reasoning differs from the review:

- F2, bm25_ms had no invariant left after sum/max collapsed. Added
  bm25_ms <= fanout_ms. Stated plainly in the comment is what it does NOT
  catch: a dropped assignment, since 0 <= fanout holds. "> 0" is not
  available because an in-memory corpus rounds to 0 ms, and a flaky guard
  is worse than an honest partial one.
- F3, the batch loop hand-copied scanHit's eleven lines and lost the
  sign-flip comment. Both paths now share scanRankedHit, which takes an
  optional leading project_path. This mattered more than it looks: the
  equivalence test compares the two paths against EACH OTHER, so a
  mistake made symmetrically in both would have passed.
- F4, the mutex around bm25ByProject guarded nothing — single writer,
  readers after g.Wait(). Dropped, with a comment saying why, because a
  lock that protects nothing reads like protection to whoever next needs
  those hits inside the fan-out.
- F5, denseHits[i] is released as the fusion loop consumes it. Fusion
  used to free its inputs per goroutine; without this, every project's
  dense hits, BM25 hits and fused copies stay live at once.
- F7, placeholders' n<=0 branch returned "NULL", which matches nothing
  and is indistinguishable from "nothing matched". Removed: IN () is a
  syntax error, which is loud and points at the wrong caller.
- F9, the searchPhases header still said "the fan-out phases keep a SUM
  and a MAX" after this PR left only dense with that shape.

F6/F8 — collapsing SearchProject into SearchProjects([]string{p}) —
NOT done, deliberately. It would make
TestSearchProjects_MatchesPerProjectQueries compare a function to itself,
and that test is the only independent check that the partitioned ranking
matches the known-good per-project one. The per-project BM25 signal feeds
project candidacy, so a divergence re-ranks the projects panel with no
error and no failed_repos. Structural agreement is worth less here than
an oracle. SearchProject's doc comment now says it has no production
caller, why it is kept, and not to delete it as unused — which is the
real fix for F8.

go test ./... green (46 packages), -race green on both changed packages,
go vet and gofmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by chasing "why is the answer different each time". Not caused by
this branch — main has it too — but it is the larger half of the answer,
so it lands here rather than waiting.

fuseRRF built its output slice by ranging over a map. Go randomises map
iteration deliberately, and sort.SliceStable then preserved that
randomness for every pair of chunks with equal RRF. Equal RRF is not a
corner case: a chunk found only by dense at rank r and a chunk found only
by BM25 at the same rank r score identically by construction, which
happens in most queries.

The symptom was invisible from the projects panel — project scores do not
depend on chunk order — so the panel looked stable while the chunk list
underneath it moved. On the fixture, the same query on the same process
and binary returned a different chunk at rank 0 between consecutive
calls.

Sorting by (rrf desc, chunk key asc) gives a total order. Measured on the
43-project fixture, two full 50-query sweeps of one build against itself:

  chunk lists differing   25/50  ->  5/50

The five that remain are not ours. The provider returns a different
vector for a byte-identical request often enough to matter: logging the
exact request body alongside a checksum of the vector it produced, over
two sweeps, 4 of 50 queries got two distinct vectors from identical
bodies (sha of the marshalled request equal, sha of the float32 vector
not). When a query drifts it drifts in ALL ten panel projects at once,
which is the signature of the query vector moving rather than of any
per-collection scan. dense_score shifts by <=0.002 and occasionally flips
a rank. Nothing in cix can make that deterministic; a query-embedding
cache keyed on the text would, and would cut provider spend too, but that
is a separate change with its own trade-offs.

The test asserts across 20 repeats, because with N tied entries a single
run has a 1/N! chance of looking ordered by accident. It also pins that
RRF still dominates the key: a chunk present in both lists outranks
single-list chunks whatever its key sorts like. Mutation-checked —
restoring the SliceStable-without-tiebreak form fails it on run 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…the rounded one

Third review pass. N2 is the same defect 202d979 fixed, one level up.

The panel sorted on ProjectScore — the round4 copy that ships in the
JSON — and then truncated to top_projects on the next line. Rounding
manufactures ties: 0.71234 and 0.71236 both become 0.7123, and the tie
then decided which of two repos the caller saw AT ALL, resolved by
whatever order the projects arrived in, which is workspace membership
order (added_at DESC). The panel was a function of insertion history.

Now sortPanel orders on the raw candidacy with ProjectPath as the
tiebreak, so it is a total order and a function of the query. Extracted
into its own function so the property is testable on a constructed
slice: through the handler it would depend on added_at timestamps a test
cannot control, and that test would be flaky rather than wrong.

Two tests, both mutation-checked. The rounding one uses a 5e-6 gap —
below round4's resolution — with the input in reverse order and the
STRONGER project named last alphabetically, which is what lets it tell
"sorted on the raw value" apart from "fell back to the path tiebreak":
both alternatives would put "aaa" first. Restoring the rounded sort
fails it; removing only the path tiebreak fails the other one.

Writing this the obvious way first also re-broke F1: truncating
`surviving` in place capped projects_returned at top_projects again,
exactly the bug fixed in d511513. TestWorkspaceSearch_ReturnedCountIgnoresThePanelCap
caught it immediately. `panel` now reslices instead, and the comment
says why. That also removed the set-membership filter that rebuilt the
panel for the interleave — `panel` already is that set, in that order.

N3: fuseRRF's tiebreak called key() on both sides of every comparison,
rebuilding a four-part concatenation that had already been computed as
the map key and thrown away. At ~100 chunks per project that is tens of
thousands of throwaway strings per query, on the path this branch exists
to speed up. The key is now carried on the entry. The expression itself
also existed twice — fuseRRF's `key` and interleaveByRank's `dedupKey` —
and since 202d979 it decides ORDER, not just identity, so the two
drifting apart would make fusion and dedup disagree with no error. One
package-level chunkKey now.

N4: scanRankedHit built an 8-element []any and then prepended to it,
allocating and copying a second slice for every row of the workspace
query — ~2,150 rows on the fixture. Built once with the right capacity.

Also corrects a comment I wrote in c495f0b: denseHits[i] = nil does not
avoid keeping "three sets of chunk payloads for the whole workspace"
alive. fuseRRF returns the UNION of both lists, so the Content strings
stay reachable through results[i].FusedChunks either way. What it frees
is ~50 payload structs per project — a few KB, not the chunk text. The
line is still right; the claim was inflated.

go test ./... green (46 packages), -race green on both changed packages,
make openapi-check in sync, go vet and gofmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…under it

Fourth review pass, comment only.

"the projects panel sees every surviving project" was written when
payloads were built for everything and truncated afterwards. 7e03041
moved the truncation above the loop, so the sentence now describes the
opposite of the code three lines below it.

Worth a commit of its own because of WHICH reader it misleads: this is
the comment someone consults when reasoning about projects_in_panel
versus projects_returned, and telling them those are the same number is
exactly how d511513 gets re-broken. It has already been re-broken once,
while refactoring these very lines — so the replacement says so, and
says which of the two must never be capped.

The num_hits half of the original sentence was correct and is kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
perf(chunksfts): rank the whole workspace in one FTS5 query
… function

Stage 2 replaced 43 per-project BM25 queries with one partitioned query and
made BM25 four times cheaper. The remaining cost is not where the plan said
it was, and this commit is the consequence of measuring rather than assuming.

WHAT THE MEASUREMENT SAID

The plan's next stage was to prune the dense fan-out, on the grounds that
dense is 89% of the fan-out's WORK. That is true and it is the wrong lens:
dense is 11.2 s of work spread over 13 workers with a per-project ceiling of
~1.1 s, while BM25 is ONE serial query. Timed from inside the handler on the
43-project fixture, fanout_ms equals bm25_ms to within 2 ms in eight of ten
queries. The user waits on BM25; dense hides behind it.

Inside the BM25 query the cost is not the MATCH either. Walking the statement
up one addition at a time, for a six-term query on that fixture:

  MATCH only (posting-list merge)              186 ms
  + bm25() per matched row                    1889 ms
  + join chunks_meta by rowid                  352 ms
  + project_path IN (46)                       487 ms
  production shape                            2726 ms

The posting merge is 7% of it. The rest is bm25() over the whole match set
and, on top of that, ROW_NUMBER() sorting that same match set to keep fifty
rows per project. The match sets are large because the tokenizer is trigram:
"and" matches a quarter of the corpus through command, handler, standard and
random, and "fault" matches 10% of it, almost all of them the word "default".
A six-term query matched 623,913 rows to keep 2,300; one containing "test"
matched 1,288,739.

WHAT THIS CHANGES

The trim moves out of SQL. The scan streams (project_path, rowid, bm25) with
no ORDER BY, and the caller keeps a bounded per-project heap, so a row that
does not make the cut costs one comparison instead of a place in a sort of
everything. The payload is then fetched by rowid for the ~2,300 survivors,
which is what the previous shape already did.

Results are IDENTICAL, not close: same rows, same order, including the
(score, rowid) tiebreak that ties in a trigram index make routine.

MEASURED, back to back on the same warm page cache, medians of five passes:

  bm25_ms, expensive queries     develop   this
  write a unit test for parser      7747   5988
  type inference for generics       5908   4962
  mock an HTTP client in tests      4602   3727
  parse JWT token and validate      3867   2883
  median of those seven             4471   3782   1.18x
  median of the standard ten        1917   1740   1.10x

Standalone, outside the server, the same substitution is 1.7x (2466 -> 1455
on a 624k match set, 5216 -> 3014 on 1.29M). Most of that does not survive
inside the server and I could not find out why. Ruled out: CPU/IO contention
with the dense scans (the standalone bench measures the same while the server
is saturated), SQLite's per-connection page cache (a cold connection measures
the same as a warm one), and GC pressure (GOGC=600 moves dense_max but not
bm25_ms). A fresh process running this code path converges toward the
standalone number only on its third pass, so something process-level warms
up. That is a lead for whoever looks next, not a blocker: every measured
query is faster or unchanged, and the gap grows with the match set, which is
the class of query that produced the multi-second waits this work began from.

CORRECTNESS

50 fixture queries, the full workspace response captured per query: the BM25
signal is bit-identical in all 500 panel rows and the panel order is
identical for all 50. One chunk list differs, on a query where ten projects
also report different DENSE scores — the provider returns different query
vectors for byte-identical requests, and the same build diffed against ITSELF
shows the same thing on other queries. Only BM25 is bit-stable here, so it is
the only side an "identical results" claim can rest on.

TESTS

TestSearchProjects_MatchesPerProjectQueries already compared the workspace
path against the per-project query as an oracle; it now covers the heap, and
it is what catches a wrong tiebreak. Added: a property test that offers
shuffled rows with deliberately many tied scores and compares the heap
against a full sort, at limits 1, 3 and 50 over 20 seeds; plan guards that
the scan sorts nothing and that the payload fetch is a rowid lookup with no
MATCH; and the in-tree mutation check for the first of those, which builds
the window form and asserts the guard rejects it.

Mutation-checked, each independently against a restored tree: dropping the
rowid tiebreak, never evicting, sorting output on score alone, losing the
bm25 sign flip, and stopping the heap's sift-down after one level all fail
the suite.

The bench harness behind these timings is NOT in this repository (/loadtests/
is gitignored, corpus and tools alike). The workspaceScanQuery doc comment
says how to recreate it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… the comment it outdated

Review of #266 found four things, all confirmed here before being acted on.
Three were holes the change opened and nothing held; the fourth was a comment
that survived the code it describes.

THE TWO UNTESTED PATHS

Both were verified as real by mutating the tree and watching the suite pass:

- fetchPayload's batching loop never ran twice. TestSearchProjects_SpansTheBatch-
  Boundary cannot reach it, and the reason is a coincidence of searchProjectsBatch
  and payloadFetchBatch both being 500: that test seeds one hit per project, so
  the rowid list is at most 500 long and the loop body runs exactly once however
  many projects it is given. Production is 43 projects x 50 hits — five batches.
  The path that always runs in production was the one nothing covered. Making
  fetchPayload `break` after the first batch passed the whole suite.

- collectHits' vanished-row handling had no test at all, which is the ONE
  genuinely new behaviour in #266: ranking and payload fetch are two statements
  now, so a chunk can be deleted between them. Two mutations passed the suite —
  turning the dropped row into an empty Hit carrying a real BM25 score, and
  deleting the `len(hits) > 0` guard so a project whose every survivor vanished
  becomes present-with-an-empty-slice, which contradicts this package's own
  documented contract.

Racing a real delete against a live query is not worth building. The assembly
moved into collectHits(ranked, payload, dst) instead, and TestCollectHits drives
it with a payload map that deliberately omits rows — which is exactly the state
that race produces. All three mutations now fail the suite, named:
TestFetchPayload_SpansTheBatchBoundary, TestCollectHits/one_row_vanished,
TestCollectHits/every_row_vanished.

WHY THE SPLIT IS SAFE, WRITTEN DOWN

The old comment said why a MISSING row is acceptable and never said why a WRONG
row is impossible — and that second fact is the whole reason splitting the
statement is safe. chunks_meta.rowid is INTEGER PRIMARY KEY AUTOINCREMENT
(internal/db/schema.go:430), so SQLite never re-issues a rowid after a delete;
a row can only go missing, never come back pointing at a different chunk. That
guarantee lives in another package and is one schema edit away from silently
becoming false, at which point a reindex could hand project B's chunk back under
project A's score with no error and nothing in failed_repos. collectHits' doc
comment now says so, and says what to do if it ever changes.

THE OUTDATED COMMENT

SearchProjects' doc still explained that "the window function does the
partitioning" and that "both forms order by (bm ASC, rowid ASC)" — of a window
form that #266 deleted. A reader following it to find where the workspace path
orders ties landed on workspaceScanQuery, which has neither ORDER BY nor rowid;
the tiebreak now lives in rankedRow.betterThan. The substance was right and only
the artifact was wrong, so it is repointed rather than removed. This is the same
drift 7cc70a2 fixed one commit earlier in workspacesearch.go.

A dead 14-line comment block for the deleted TestSearchProjects_FetchesPayload-
AfterTheTrim was also still sitting above TestSearchProjects_ScanDoesNotSortThe-
MatchSet, describing a different guard. Its 0:= reasoning already exists,
correctly, on TestSearchProjects_FetchesPayloadByRowid. Deleted.

ONE NIT TAKEN

rids was built by ranging a map, so the payload IN-lists — and the batch
boundaries — differed between two runs of the same query. Results did not
(payload is keyed by rowid, each project is assembled in rank order), but a
statement whose bound parameters come out of Go's map iteration cannot be
compared plan-to-plan between runs, which is the first thing anyone timing this
will want to do. Now sorted. Ascending rowids also probe both B-trees in order
rather than at random. NOT measured as a speedup, and deliberately not tested:
removing the sort passes the suite, because the change has no observable effect
on results. The reason to do it is the determinism.

VERIFIED

- go test ./... green, go vet clean, gofmt clean on the touched files. Three
  files elsewhere in the tree fail gofmt; they fail on develop too and are not
  touched here.
- The five mutations #266 was checked against still fail after the refactor —
  the sign flip in particular now lives in collectHits.
- 50 fixture queries recaptured and diffed against both the pre-review build and
  develop: BM25 signal bit-identical in all 500 panel rows both ways, panel order
  identical 50/50 both ways. Dense scores differ on 6 and 8 of 50 queries
  respectively and every chunk-list difference falls on a query where dense also
  moved — the provider returns different vectors for byte-identical requests, and
  the same build diffed against itself shows it too. BM25 is the only bit-stable
  side and it is the only side this code touches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nshadow rows

Review pass 2 on #266: ship-it verdict with three nits. All three confirmed
here first.

CORRECTION TO 956d657's COMMIT MESSAGE

That message says "Three files elsewhere in the tree fail gofmt". It is SEVEN.
The substance holds — the set is identical at this HEAD and at origin/develop,
and none of the seven is touched by this PR — but the number does not
reproduce, and in this repo a commit message is a report for whoever comes
next, so the wrong number is the part that costs someone else time. It came
from running `gofmt -l internal/ | head -3`: the `head -3` truncated the list
and `internal/` excluded bench/. The real set is

  bench/bench_eval_retrieval.go
  internal/callgraph/eval/eval_test.go
  internal/secrets/secrets.go
  internal/tunnels/ngrok.go
  internal/workspaceprojects/workspaceprojects.go
  internal/workspaceprojects/workspaceprojects_test.go
  internal/workspaces/workspaces.go

Not amended into 956d657 on purpose: that commit is the reviewed head, and
force-pushing over it would invalidate a review that names the OID.

ONE MORE COMMENT THAT NO LONGER MATCHED ITS CODE

TestSearchProjects_SpansTheBatchBoundary's doc named "a batch that overwrote
instead of appending" as the failure mode it guards. Since the previous commit
the implementation deliberately does NOT append — collectHits assigns
dst[pp] = hits, which is safe because the batching slices projectPaths into
disjoint batches, so each project is written exactly once. The test still
guards something real, so only the phrasing is repointed: a batch that replaced
the MAP rather than adding to it, or that dropped its last slice. Verified by
mutation rather than by reading — clearing dst at the top of
searchProjectsBatchInto fails the suite on that test by name.

The same comment now also says what the test does NOT reach: with one hit per
project the rowid list is exactly searchProjectsBatch long, so fetchPayload's
loop runs once. That is the coincidence that hid the batching hole pass 1
found, and it is worth stating next to the test that looks like it covers it.

SHADOWING

`rows := t.sorted()` shadowed the *sql.Rows twenty lines above it. The outer
rows is closed by then and vet is happy, so this is only a hazard for the next
edit: anyone adding a Close() or Err() inside that loop gets confusion at best.
Renamed to `ordered`.

VERIFIED

go test ./... green, go vet ./... clean on the whole module, gofmt clean on the
touched files. Post-rename regression check on the mutation battery: the sign
flip, never-evict and replace-dst mutations all still fail the suite, named.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e bound-variable fact

Review pass 3 on #266 found a test that reads as protection and is not. Every
claim below was reproduced here before being acted on.

THE GUARD THAT COULD NOT FIRE

TestSearchProjects_FetchesPayloadByRowid asserted two substrings of the FTS5
plan: that it contains "VIRTUAL TABLE INDEX 0:=" and that it does NOT contain
"VIRTUAL TABLE INDEX 0:M". The second assertion is unreachable.

FTS5 packs its plan into one idxStr. "0:=" is a bare rowid lookup; a MATCH adds
"M" plus the matched column. A payload fetch that ALSO ran a MATCH reports
"0:=M3" — which still contains "0:=" and does not contain "0:M", because the M
is now preceded by "=". Both assertions are satisfied by exactly the merge the
test exists to catch. Reproduced: adding `AND chunks_fts MATCH 'retry'` to
payloadQuery, arity unchanged, the suite reported ok and that test PASSed.

The mechanical lesson is worth more than the fix. "0:M" was borrowed from the
scan query, which reports "0:M3" because it has no rowid constraint. Add one
and FTS5 records "=" ahead of the M, so the M moves and the prefix stops
matching. The string was not wrong; it was a PREFIX of a packed field whose
earlier characters vary. A substring assertion over planner output is safe when
it matches a complete token whose variable part comes AFTER it — "USE TEMP
B-TREE FOR X" varies in X — and unsafe when it matches a prefix of a packed
field. FTS5's idxStr is documented as an internal encoding, which is the marker
for the second class.

Fixed by comparing the WHOLE idxStr: every FTS5 index in the payload plan must
be exactly "0:=". ftsIndexes() extracts them. TestExplainRejectsThePayloadShapes
is the companion that would have caught this: it builds the merged form and a
join FTS5 cannot serve by rowid, and asserts the guard rejects both.

Verified: the merged-MATCH mutation is now KILLED by
TestSearchProjects_FetchesPayloadByRowid by name, and making ftsIndexes return
nothing — the way to make the new guard vacuous — is killed by both the guard
and its companion, so the replacement is not vacuous either.

THE COMPANION THAT GUARDED THE WRONG THING

TestExplainRejectsTheWindowForm only built the window form, which this PR
DELETED. The regression far more likely to happen is someone adding ORDER BY
back to the scan, and nothing proved the guard would catch that. Now
table-driven as TestExplainRejectsTheSortingForms over both shapes. Verified:
appending `ORDER BY bm25(chunks_fts)` to workspaceScanQuery is killed by
TestSearchProjects_ScanDoesNotSortTheMatchSet by name.

The "TEMP B-TREE" assertion itself is NOT brittle the way the idxStr one was —
review measured every plausible way of putting a sort back (ORDER BY, ORDER BY
with LIMIT, GROUP BY, SELECT DISTINCT) and all report "USE TEMP B-TREE FOR ...".
Kept as is.

A WRONG FACT IN THE CONSTANTS COMMENT

It said SQLite's bound-variable ceiling is 999 and that 500 "leaves room for the
query parameter". Measured through this driver: `rowid IN (...)` takes 32,766
placeholders and fails at 32,767. SQLite raised SQLITE_MAX_VARIABLE_NUMBER from
999 to 32,766 in 3.32.0 and modernc tracks a recent upstream, so the real
headroom is 32,266, not 4. The headroom half of the justification was wrong, and
it was wrong in the one place someone would look before deciding whether 500
could be raised. The half that survives is the real reason: 500 is hydrateBatch
(internal/vectorstore/search.go:442), so both IN-list batchers use one number.
The constant is unchanged — 43 x 50 = 2,150 rowids in five statements is nothing
against a multi-second BM25 scan, so there is nothing to gain by tuning it.

TWO CORRECTIONS INHERITED FROM THE REVIEW LOG, CONFIRMED HERE

- The "IN -> LIKE" mutation quoted in earlier review passes proved nothing: it
  is a row-value misuse, SQLite errors, and the suite dies on a query error
  rather than on prefix leakage. The honest form keeps arity and stays valid
  SQL — `substr(cm.project_path, 1, 4) IN (%s)`, which really does leak
  proj-extended into proj. Ran it: killed by
  TestSearchProjects_DoesNotPrefixMatchProjectPaths by name.
- "heap keeps perProject+1" is killed by TestSearchProjects_MatchesPerProjectQueries,
  not by TestTopHits_MatchesAFullSort as an earlier log said. That is the better
  answer: the property test constructs &topHits{n: n} directly and never sees
  how searchProjectsBatchInto picks n.

SCOPE

No production behaviour changes. The only non-test edit is a comment; the
diff over chunksfts.go contains no non-comment lines. The fixture was not
re-measured for that reason.

go test ./... green, go vet ./... clean, gofmt clean on the touched files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review pass 4 caught that 9087ca4 removed TestFetchPayload_SpansTheBatchBoundary
and TestCollectHits — the two tests 956d657 added to close pass-1's findings 3
and 4. Confirmed here: test function count went 21 -> 20 across that commit
while collectHits stayed in production at chunksfts.go:411 with nothing
exercising it.

Measured rather than inferred. The three mutations those tests were written to
kill all walked through 9087ca4:

  always assign dst[pp]         killed at 46713ff -> SURVIVED at 9087ca4
  payload miss -> empty Hit     killed at 46713ff -> SURVIVED at 9087ca4
  payload fetch: first batch    killed at 46713ff -> SURVIVED at 9087ca4

Coverage was back to its pre-956d657 state: the payload batching loop that
always runs in production uncovered again, and so was the vanished-row handling,
which is the only genuinely new behaviour in this PR.

HOW IT HAPPENED, because the mechanism generalises

956d657 inserted both tests immediately BEFORE the anchor comment
"// TestTopHits_MatchesAFullSort is the property test". 9087ca4 then replaced a
region delimited by index("// TestSearchProjects_FetchesPayloadByRowid guards")
and index(that same anchor) — so the two tests sat inside the replaced span and
went out with it. Editing by string-delimited region is fine for a region you
just read; it is not fine for one that a previous edit has since grown.

Nothing detected it. The suite was green, because deleting a test never fails a
suite. vet and gofmt were clean, because the file was still valid Go. 9087ca4's
own message says "No production behaviour changes ... the only non-test edit is
a comment", which was true and beside the point: the loss was entirely in the
test file. I ran a mutation battery for that commit, but only the mutations
relevant to what I was changing, so the three that regressed were never
re-checked.

The instrument that would have caught it costs one command: diff the list of
test function names against the previous head. A commit that touches only tests
is exactly the commit where the test inventory is the only thing that can see
what happened. Doing that from here on any test-only edit.

RESTORED

Both functions come back verbatim from 46713ff — verified byte-identical to
that head, not retyped — and all three mutations are killed again by name:
TestCollectHits/every_row_vanished, TestCollectHits/one_row_vanished,
TestFetchPayload_SpansTheBatchBoundary. The dangling cross-reference at the end
of TestSearchProjects_SpansTheBatchBoundary's comment, which pointed at a test
that did not exist at 9087ca4, is correct again as a result.

ALSO: the companion subtest no longer derives its query from production

TestExplainRejectsTheSortingForms' second subtest built its query as
workspaceScanQuery(...) + " ORDER BY ...". When the scan itself was mutated to
sort, the concatenation produced two ORDER BY clauses, the SQL went invalid, and
explain's t.Fatalf fired — so the companion failed for a reason unrelated to
what it asserts. The companion is a claim about how SQLite REPORTS a sort, not
about production code, so it should not touch production code. Now a literal.
Verified: with the scan mutated to sort, only
TestSearchProjects_ScanDoesNotSortTheMatchSet fails; the companion stays green,
which is what a companion should do.

go test ./... green, go vet ./... clean, gofmt clean on the touched files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dvcdsys and others added 3 commits August 21, 2026 14:50
perf(chunksfts): rank the workspace with a bounded heap, not a window function
…e comments

TWO UNRELATED THINGS, BOTH SMALL, BOTH USER-VISIBLE OR READER-VISIBLE.

SEARCH FIRED WHILE TYPING

/search debounced the input into the URL after 250ms idle, and a change to the
URL is what runs a search. That is the usual pattern and it is wrong here: a
semantic search embeds the query through the configured provider, so every
pause while typing spent a real API call and a full fan-out to answer a
half-written question. "retry with exponential backoff" typed at a normal pace
fires on "retry", "retry with", "retry with expo" — three searches nobody asked
for and one they did. On a metered provider that is money; on a local sidecar
it is a queue of pointless work in front of the query the user meant.

Typing now changes local state and nothing else. The URL — and therefore the
search — moves only on submit. SearchBar already had the onSubmit path; only
the debounce had to go. The empty state says "press Enter to search" instead of
implying results appear on their own.

Verified through the real component in devmock, which boots the app with a mock
fetch and no login: typing 31 characters one at a time issues ZERO search
requests, and submitting issues exactly one, with no navigation.

A note on how that was verified, because the first attempt was worthless:
driving Enter through the browser-automation key API produced a page
"reload" that looked like a regression. It was not — a keydown listener on the
input recorded NOTHING, so those key events never reached the page and that
test asserted nothing at all. The real check goes through form.requestSubmit(),
which is the exact path a keypress takes.

THREE STALE COMMENTS IN workspacesearch.go

All three are from #265, all three describe code that commit changed:

- projectHits' doc said the two sides "are fused inside the goroutine". They
  are not — fuseRRF runs in the serial loop after g.Wait(), and the comment
  above that loop says so in as many words. The struct doc contradicted a
  comment 650 lines below it.
- the handler doc said "each project runs two queries in parallel: dense and
  sparse". There is one BM25 query for the whole workspace now, which is what
  #265 was.
- BM25Signal's doc explained its normalization but never said it is computed on
  the RAW, unfused list while FusedChunks beside it is post-RRF. That asymmetry
  decides whether a panel reorder means what it appears to mean, and the one
  place a reader would look for it did not mention it.

Same class as 7cc70a2 and as two commits in #266: the code moved and the
comment above it did not.

go test ./... green, go vet clean, gofmt clean on the touched files. Dashboard
built with `npm run build` (tsc -b + vite); dashboard build is not on PR CI, so
it was validated locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(dashboard): search on Enter, not while typing; repoint three stale comments
Comment thread server/internal/tokenizer/bpecount/bpecount.go Dismissed
Comment thread server/internal/embeddings/provider/voyage/voyage.go Dismissed
Comment thread server/internal/embeddings/provider/voyage/voyage.go Dismissed
@dvcdsys
dvcdsys merged commit c4f00ae into main Aug 21, 2026
10 checks passed
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.

2 participants