Skip to content

Milestone 3b: the vector arm stops scanning the corpus, and says what that costs - #11

Merged
skyoo2003 merged 20 commits into
mainfrom
m3b-ivf
Aug 17, 2026
Merged

Milestone 3b: the vector arm stops scanning the corpus, and says what that costs#11
skyoo2003 merged 20 commits into
mainfrom
m3b-ivf

Conversation

@skyoo2003

@skyoo2003 skyoo2003 commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Pull Request

Summary

scorer/vector scored every document in the corpus for every query. On the
171,332-document evaluation index that is 171,332 cosine similarities per query and
626.6 MiB of docs touched — the vector arm was the reason a query cost what it cost.

This is milestone 3b. It adds an IVF-flat partition to the segment format, exposes it
as engine.Index.Nearest, and rewires scorer/vector's loop to read candidates
instead of the whole corpus. It also discharges the obligation FORMAT.md §7.6 placed
on a version 3, and it brings an instrument — weft-eval recall — because nDCG cannot
tell a good approximation from a bad one.

What changes for a caller: vector results are now approximate. recall@10 against an
exact scan is 0.992, text+vector nDCG@10 moved 0.6233 → 0.6211, and a query returns
4.6× faster. The scorer's contract is unchanged — zero norms, non-finite queries,
ErrDimMismatch and context polling all behave as before, and all twelve of its
existing tests pass unmodified.

One consequence is not buried: the vector scorer's contribution over text alone was
+0.0407 [+0.0010, +0.0798] and is now +0.0386 [−0.0015, +0.0779]. That interval
crossed zero. The honest statement after this milestone is that the vector arm's
contribution is not distinguishable from zero on this benchmark. See docs/EVAL.md
§5.14.

Changes

  • Format v3, section ivf — spherical k-means centroids plus the segment-local
    DocIDs assigned to each list, every inverted list sealed with a CRC-32C seeded with
    its own list number. No RNG: initial centroids are chosen deterministically, so the
    same segment always partitions the same way. v2 indexes are read without conversion
    (a v2 segment reports no partition); a v2 generation becomes v3 when Index.Merge
    rewrites it, which is not an upgrade a caller can count on — Merge returns nil
    below nine segments and weft never calls it for the caller, so a v2 index can stay
    v2 indefinitely and answer vector queries by exact full scan. v1 is still refused.
  • engine.Index.Nearest(v []float32, k int) []DocID — the DocIDs worth scoring
    exactly, ascending and free of repeats, at least k when the index holds that many.
    It computes no score: the geometry is the engine's, the metric stays with the caller
    (D-008). The result is deliberately wider than the answer — an unpartitioned segment
    offers every id it holds, so a caller still skips what it cannot score.
  • scorer/vector reads Nearest instead of scanning — 30,549 candidates of
    171,332 per query. A segment with no partition is still scored exactly, which is how
    the exact path stays reachable without a flag.
  • weft-eval recall and make recall — overlap with a brute-force scan, candidates
    scored per query, latency both ways, and the working set a query reaches in bytes and
    in distinct 4 KiB pages.
  • nprobe 8 → 64, chosen against the pre-registered bar rather than by taste, with
    the full sweep published including the part that is inconvenient (the curve is not
    monotone — 128 scores below 64).
  • Docs: FORMAT.md §7 for the ivf section, DECISIONS.md D-008, EVAL.md §5.14,
    FINDINGS.md, the TDD plan under docs/testing/, and changie fragments for the
    format bump, the API addition, the behaviour change and the new subcommand.

Validation

  • make vet testgo vet ./... and go test -race ./..., all packages green.
  • make lint — golangci-lint clean; the exclusions it needs each carry a reason
    beside them in .golangci.yaml.
  • Reproducer-first throughout: every commit adding behaviour is preceded by a commit
    adding the test that fails without it. New coverage is
    pkg/engine/ivf_test.go (765 lines), pkg/engine/nearest_test.go (325),
    cmd/weft-eval/recall_test.go, and pkg/scorer/vector/narrow_test.go.
  • Both ivf decoder entry points are fuzzed (make fuzz) — the section decoder and
    the per-list reader, so a corrupt list number cannot reach a slice index.
  • pkg/engine/testdata/engine_api.txt records the one-line API addition; the six read
    methods a scorer calls are untouched.
  • Measured, not asserted: make eval-full re-ran all five frozen arms and make recall
    produced the recall table. Numbers and how to re-derive them are in docs/EVAL.md
    §5.14 and §7.

Review Focus

  • Nearest's contract (pkg/engine/index.go, ivf.go) — the "wider than the
    answer" promise is what lets the unpartitioned, pending, and small-segment cases share
    one code path. Worth checking that the fallback really is the same branch and not
    three near-copies.
  • The v2/v3 reader (pkg/engine/persist.go, segment.go) — a v2 segment must read
    correctly with no partition, and a v3 segment must not be readable as v2.
  • Per-list CRC seeding — each list's checksum is seeded with its own list number so
    two identical lists cannot be swapped undetected. Confirm the seed is what the
    decoder verifies against, not just what the writer computed.
  • The partition floor — 16,384 documents before a segment is partitioned, and the
    candidate floor tracks nprobe. Both are constants; check they are consistent between
    writer and reader.
  • Whether the approximation is disclosed loudly enough. Nearest's doc comment, the
    changie fragment and EVAL.md §5.14 all say the results are approximate. If a caller
    could still be surprised, that is a bug in the prose.

Risks / Notes

  • On-disk format bumps to v3. v2 indexes are read without conversion, so no
    migration is required; v1 is still refused with ErrBadVersion. An index written by
    this branch cannot be read by an earlier build.
  • Behaviour change for existing callers of scorer/vector: results are approximate
    where they were exact. Nothing in the scorer's error or cancellation contract moved.
  • nprobe is not configurable. One screw, fixed at a measured value — exposing it would
    invite tuning against a 50-query benchmark that cannot support the distinction.
  • The vector arm's contribution over text is now inside its own confidence interval. That
    says as much about a 50-query benchmark as about the scorer, but it is a real change to
    what milestone 4's numbers can claim, and EVAL.md §5.14 states it rather than
    rounding past it.
  • make recall needs the built evaluation corpus (make eval-data); it is not part of
    the default test run.

Post-review changes (598ceac)

A review of this branch raised fifteen findings; twelve are applied, three are recorded
as not taken. Everything below is on top of the branch as originally described — the
published numbers did not move, and the one that was at risk was re-measured to prove it.

  • Five defects a reader or a query can reach. parseIVF's centroid fitness check
    multiplied in uint64 and wrapped, so a hostile header passed the check and panicked
    in make — the bound is a division now, with TestIVFHeaderIsRefusedRatherThanTrusted
    failing against the multiply. dim == 0 with nlist > 0 was accepted and called
    healthy by Scrub; refused. segSectionsFor's [:len-1] would misclassify v2 the
    moment a v4 appends a section, and its result shared a backing array with the
    package-level table. segment.close released the partition after the unmap. And
    ivfNormalizeSum's sq += c*c fused to one FMADDD on arm64, making the centroid
    bytes on disk architecture-dependent on the only float64 multiply in the path
    FINDINGS publishes a sha256 for.
  • nprobe counted lists, not lists with members — an unclaimed centroid stays where
    it was seeded, so it ranks high near that document and spent a probe returning nothing.
    Fixed, then re-measured: 30,549.5 candidates per query, identical before and after,
    with recall@10 0.9920 and the 210.1 MiB working set unchanged. No empty list entered a
    query's probed set on this corpus, which is a fact about 148,232 vectors over 414 lists
    rather than a verdict on the guard. FINDINGS §2 says so beside the column.
  • Four fixes in the instrument, because its output is published: recordExtents
    claimed a cross-check against the docs section on disk and never performed one, and
    started record 0 at offset 0 where encodeDocs writes a count uvarint inside a framed
    section. The partitioned pass now runs before the brute-force pass that was warming
    the page cache for it, and rssBefore is sampled before the walk that decodes the
    corpus rather than after.
  • Seven document corrections. The per-query cost model in ivf.go, index.go and
    FINDINGS §4.3 was still arithmetic from nprobe = 8 (2.7e6 MACs and 42× where the
    shipped constants give 1.79e7 and 6.4×). §4.2's "2% at ten million" floors at 6.25%,
    because ivfMaxList pins nlist at 1,024. FORMAT no longer presents Merge as a
    background converter, no longer calls nlist unstored reader policy when it is the
    section's first uvarint, and states the vector-less segment exception to "every id is a
    candidate".
  • Four tests that passed against a mutation. The -1 in the page count and
    buildIVF's empty-sample guard had no witness — deleting the latter panics inside
    Commit with a divide by zero, reachable from Add. ivfFile joins the
    version-mismatch table, and TestNearestNarrowsTheScan now commits the 65,536
    documents its comment claims instead of 262,144.

Not taken, and recorded rather than dropped. Commit holds the write lock for the
whole 68-second training pass, so every Search, Doc and Nearest waits on it; the
repayment is parallelising two per-document argmax loops, which is a design change owing
its own determinism evidence, so §4.3 records the ceiling instead. The partition floor
counts documents while lists hold only vector-bearing ones, and nprobe is a per-segment
budget rather than an index-wide one — both are what FORMAT specifies, and changing
either re-measures every figure above.

Validation: gofmt, go vet, golangci-lint (0 issues), go test ./..., -race, and
FuzzSegmentDecoding to 5.5M execs with no panic.

RED: go test -run TestIVF ./pkg/engine/ fails to build —
undefined: ivfMinDocs, buildIVF, ivfOrder, ivfNProbe, ivfNList.
The compile failure is the intended signal: ivf.go does not exist,
so the tests reference the code path that has to.
GREEN: go test -run TestIVF ./pkg/engine/ — 6 tests pass,
recall@10 = 1.000 at nprobe=8 of nlist=91 on the clustered corpus.

Determinism comes from the absence of a generator rather than a fixed
seed: the training sample and the initial centroids are both taken on a
fixed stride, so two builds of one corpus agree bit for bit.
RED: go test -run 'TestIVF|TestAV2|TestAV3|TestTheTwo|TestASegmentMixing|TestADamagedIVF|TestAnUnpartitioned' ./pkg/engine/
fails to build — undefined: ivfFile, segment.ivf, segment.nearest.

Covers: the section round-trips centroid bits, an unpartitioned segment
still writes a constant-size section, a v2 generation opens under the v3
reader and answers exactly, the two readers rank the same, a segment
mixing frame versions is refused, a v3 segment missing ivf is refused,
and a damaged list costs speed rather than availability.
GREEN: go test ./... all packages pass, including the seven new
reproducers and every milestone 2/3a format test unchanged.

The version in a segment's meta frame chooses its section list, and
every other section has to agree with it. v3 is v2 plus ivf, so a
v2 generation opens, reports no partition and offers every id — the
same fallback a pending segment and a sub-4096 segment already need,
which is what made the two-version reader cheaper than a converter.
RED: go test -run 'TestNearest|TestMergeUpgrades|TestMergeIsByteDeterministicWithVectors|TestMergeRefusesAPartition' ./pkg/engine/
fails to build — Index.Nearest undefined.

Covers: at least k candidates whatever the partition's shape, the scan
actually narrows, every candidate resolves and none repeats, pending
documents are never skipped, a wrong-width query gets everything so the
scorer can report ErrDimMismatch, and a merge rewrites a v2 run as v3
deterministically.
…he scorer

GREEN: go test ./... all packages pass; make arch green.

Nearest returns DocIDs and no score. The engine knows which documents
are worth scoring because the partition is a section of the format; how
close each one is stays in scorer/vector, so its rules on zero norms,
non-finite components and mixed widths are untouched. D-008.

Merge doubles as the format converter: it rewrites the run it collapses
with the current writer, so a v2 generation becomes v3 through the
maintenance an index already performs.

One intended golden edit: method Index.Nearest([]float32, int) []DocID.
RED: go test -run TestTheScanIsNarrowedByTheIndex ./pkg/scorer/vector/
fails — 3,669,728 B allocated per query against 2,097,152 B of corpus
vectors (175.0%). ponytail:36 said the brute-force scan was milestone
3's to remove; this is the first test in the package that can see it,
because an uncommitted index carries no partition and every existing
test uses one.
GREEN: go test ./... all pass. The narrowing reproducer goes from
3,669,728 B/query (175.0% of corpus vector bytes) to 338,620 B (16.1%),
and all twelve existing contract tests pass unmodified — which is the
evidence the repayment changed nothing a caller can observe.

The price tag, non-comment lines: 4 removed, 3 added, all in the loop
header. Every rule about zero norms, non-finite queries, ErrDimMismatch
and context polling stayed exactly where it was, because the metric
never left this file.

  pkg/fusion  0 lines changed
make fuzz green: FuzzSegmentDecoding and FuzzParseSection, 30s each,
6.3M execs, no panics. The section's header sizes two tables and its
offset table addresses a payload, so it is three more numbers that come
off disk and get acted on before anything vouches for them.
make eval on the pre-existing format v2 index: text+vector 0.6233,
identical to the milestone 3a figure, over 171,332 documents. That is
the two-version reader working on real data and the repayment leaving
the exact path bit-identical.

recall measures what nDCG cannot see — a partition that drops
neighbours the qrels never judged holds nDCG steady while being wrong.
It prints overlap with a brute-force scan, candidates per query,
latency both ways, and the per-query working set in bytes and in
distinct 4 KiB pages, beside the whole docs section for scale.
The plan fixed the quality bar before anything was built and registered
what to do on a miss: raise nprobe and re-measure. nprobe=8 scored
text+vector 0.6003 against a 0.6233 baseline. The curve:

  nprobe    8      16      32      64     128     256
  nDCG   0.6003  0.6095  0.6174  0.6211  0.6205  0.6233

At 64: recall@10 0.9920 (was 0.8500), 30,549 candidates per query
(17.8%), 4.6x faster than brute force (was 31.2x).

Determinism holds: two independent builds of the 148,232-vector
partition produce byte-identical ivf sections,
sha256 bc042260...b2b6d89.

Both narrowing tests move to 65,536-document corpora, because a
constant nprobe cannot narrow a segment with fewer lists than nprobe —
that is what makes the constant a constant, and a small corpus cannot
show it.
FORMAT.md becomes version 3: the ivf section spec, the version-decides-the-
section-list rule, four new rejection rows, and section 7.7 — append a
section rather than changing one, and the old version stays readable by
construction. Three rows milestone 3a had already made false are removed
from the limits table.

FINDINGS milestone 3b reports both halves. The scan is gone: recall@10
0.992, 4.6x faster, quality inside the bar. The working set is not: 210 MiB
per query against a 12 MiB prediction, and the measurement names why —
a candidate costs a whole record (1.4x) and a record costs whole pages
(1.7x). The plan's registered repayment, a vectors section, would only
remove the first. What the second names is centroid-ordered docs, which
is a milestone, and the price of it (positional DocIDs, TopK's tiebreak,
merge as concatenation) is written down now rather than discovered later.

One interval crossed zero and is reported rather than buried:
text+vector - text is now +0.0386 [-0.0015, +0.0779].

D-008 records both open decisions and what would falsify each.

docs/testing/weft-m3b.tdd.md is the TDD evidence report, including the
plan's own internal contradiction about the pass line and how it was
resolved.
…reen

GOOS=windows go build ./... caught it: syscall.Getrusage does not exist
there. Same shape as pkg/engine's mmap split — unix does the real thing,
everything else stays buildable and reports 0 rather than substituting a
differently-defined number a reader would compare against EVAL's figures.

GOOS=windows go build, GOOS=linux GOARCH=386 go build, go vet and the
weft-eval tests all pass.
…probe

Four findings, all inside the partition.

The training stride divided down, so a 39,999-vector corpus trained on
documents 0..19,999 and nothing after it — the comment promised an even
sample and the arithmetic did not deliver one. Ceiling division now,
written 1+(count-1)/ivfSample so a 32-bit build cannot wrap the sum.

decodeList sized its output from a count it had just read off disk. A
damaged counts table naming 100 million ids makes every list(0) allocate
400 MB and then throw it away on the checksum, and one query decodes up
to nprobe lists. Clamped to what the remaining payload can hold, which
is the rule decodeTermIndex and decodeDocRecord already follow.

scrubSegment handed meta's unverified docCount to scrubIVF, which
allocates a seen bitmap of that size. A 20-byte file claiming 2^32-1
documents allocated 4 GB before the docoff cross-check two calls later
would have refused it. The check moves above the allocation.

ivfMinDocs is derived rather than chosen: 4*nprobe^2, not 4096. The two
constants are one decision and drifted apart when nprobe went 8 -> 64.
At the old floor nlist = 64 = nprobe, so a query probed every list:

  count    4,096   8,192  16,384  32,768  65,536
  nlist       64      91     128     182     256
  share     100%     76%     48%     41%     27%

100% of the segment offered as candidates, for a commit that pays a
training pass and an assignment pass. The new floor is the size at which
a query first touches half a segment or less. Nothing published moves —
the evaluation corpus is one segment of 171,332 documents and partitions
under either floor. Test suite pays 2.2 s for the bigger fixtures.

And a segment holding no vectors now offers no candidates rather than
every id it holds. scorer/vector skips those documents before it
compares widths, so they can produce neither a score nor
ErrDimMismatch — only a decode of every record in the segment. One
text-only batch between two vector ones would otherwise reinstate, on
every vector query for as long as the segment lives, the full scan the
partition exists to remove.
The extent derivation models the index as one contiguous docs section.
That is exact for one segment and wrong for more: DocIDs run on across a
directory's segments but the docs files do not, each starting its own
payload at offset zero, so a second segment's records would be laid out
on top of the first's, counted as sharing pages they cannot share, and
the working set would print under the truth. `build` publishes one
commit and so one segment; anything else is now refused rather than
measured wrong.

Three more the same review found. The brute-force pass rebuilt a
corpus-sized DocID slice per query, inside the very timing the printed
speedup divides by — hoisted, since the index does not change under the
loop. A run where no query produced an exact top-k printed NaN for the
recall and +Inf for the worst query, which is the mixed-embedding-model
case published as a measurement; it is an error now. And MaxRSS is
labelled for what it is: a high-water mark both passes reached, not the
partition's working set.

extents.size was int32 holding an int64 width, which truncates a record
past 2 GiB. int64, and the conversion disappears with it.

`make recall`'s SKIP branch skipped nothing: each recipe line gets its
own shell, so `exit 0` in the first ended that shell and go run followed
anyway. One if/else/fi in one shell, the shape `deps` already uses.

recall itself is split into vectorQueries, measureRecall and a print
method — 73 statements was over the funlen bar and the cyclomatic count
was 22 against a limit of 20.
`make lint` has been red since the ivf section landed, and CI pins the
same golangci version, so this was not a local disagreement.

G115 fires all over ivf.go for the reason the config already records
about its siblings: the finding is backwards, firing precisely where the
guard is. parseIVF sizes the centroid region in uint64 before it slices
anything, decodeList refuses a document id against the segment's own
count, and encodeIVF writes lengths buildIVF derived from a corpus Add
had already capped. The path regex gains `ivf` rather than a new rule.

weft-eval gets its own entry, and a different reason: it measures rather
than parses, so every conversion in the extent derivation is a length or
a timestamp Go itself produced. uvLen is handed what len() returned and
vLen zigzags a Unix time — there is no file on the other side of those
to lie about a width.

unparam joins the test-file exclusions. A helper parameter every caller
passes the same value for is what unparam calls dead flexibility and a
test calls its subject: commitGenerations(t, dir, sizes, dim, 12) says
at the call site what width the corpus is.

maxRSS keeps its two conversions and silences unconvert where the reason
can be read: Maxrss is int64 on the 64-bit targets and int32 on
linux/386 and linux/arm, so unconvert is right about the machine it ran
on and wrong about the build.
FORMAT, DECISIONS and FINDINGS all quoted 4,096 as the size below which
no partition is built, and the CHANGELOG entries quoted it twice more.
The floor is 16,384 now — 4*nprobe^2 — and FINDINGS carries why, because
the move is a second finding about nprobe rather than a tuning change: a
segment at the old floor offered 100% of itself as candidates while its
commit paid for a partition that excluded nothing.

The Nearest entry needed a correction of its own. It promised that
documents with no vector are included, which stops being true for a
segment holding no vectors at all — every candidate it could offer is
one the caller would skip, so offering them buys a decode of the segment
and nothing else.

FORMAT's nlist formula was missing the clamp buildIVF actually applies:
min(ivfNList(count), len(sample)/dim), not ceil(sqrt(count)) alone. A
reader reconstructing bytes from the document would have come out short
on any segment where most documents carry no vector.
A complexity review raised eight; five held and three did not survive being
checked. Nothing here changes observable behaviour, so nothing here had a RED
gate — what stands in for one is named per change in the TDD record.

- ivfNList: a 16-line integer square root becomes math.Ceil(math.Sqrt(...)).
  Exhaustively equal to the old body for every count below 5,000,000 and at the
  powers up to 2^62; IEEE-754 requires a correctly rounded sqrt and maxDocCount
  is 2^32-1, so a perfect square cannot round up. The test table gained 4095,
  2^20, 2^20+1 and maxDocCount.
- ivfOrder: four guards deleted and the precondition moved into the doc comment,
  with segment.nearest's order == nil fallback. parseIVF allocates exactly
  nlist x dim centroids, and nearest has already returned for a segment with no
  vectors, no partition, or a query of another width.
- workingSet: the page set becomes a running counter, which the ascending id
  order Nearest guarantees pays for. A new characterization test pins seven
  layouts and passed against the map before it passed against the counter.
- ivf_test: splitmix goes, math/rand/v2 on a fixed seed replaces it — the same
  stdlib internal/eval already uses. ivf.go still holds no generator at all,
  which is the claim TestIVFTrainingIsDeterministic actually asserts.
- .golangci.yaml: four lines defending the unparam exclusion become one. The
  exclusion stays; both helpers it covers hold their value as a local constant
  at the call site, so dropping the parameter would split one fact in two.

Withdrawn, and recorded rather than dropped: replacing recordExtents with an
average record size cannot produce FINDINGS section 3's numbers (it inverts the
page multiplier, 1.94 modelled against 1.68 measured); /usr/bin/time gives one
peak where the plan asked for before and after; and ivfListLen cannot go while
segWriter streams without a seek.

Re-measured, and every published figure reproduces: recall@10 0.9920, 30,549.5
candidates, 124.1 MiB of records, 210.1 MiB of pages, 123 ms against 570 ms. No
document needed editing.
@skyoo2003 skyoo2003 self-assigned this Aug 17, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 49b4a62af7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread pkg/engine/ivf.go Outdated
Comment thread cmd/weft-eval/recall.go Outdated
Comment thread Makefile Outdated
Comment thread cmd/weft-eval/recall.go
Comment thread pkg/engine/ivf.go Outdated
Comment thread cmd/weft-eval/recall.go
Comment thread pkg/engine/ivf.go
…ed it

A max-effort review raised fifteen; twelve are here and three are recorded
rather than taken. Five are defects a reader or a query can reach.

- parseIVF: the centroid fitness check computed nlist*dim*4 in uint64, which
  wraps, so a meta claiming vecDim = 2^52 beside an ivf header claiming
  nlist = 1024 passed the check and panicked in make. The bound is a division
  now — a quotient cannot wrap and nlist >= 1 is already established there.
  TestIVFHeaderIsRefusedRatherThanTrusted pins five payloads and fails with
  makeslice against the multiply.
- parseIVF: dim == 0 with nlist > 0 was accepted, because the width comparison
  was against meta's vecDim rather than against zero. No writer can produce it,
  Scrub called it healthy, and ivfOrder would have tied all 1,024 lists at a dot
  of zero — a probed set chosen arbitrarily rather than ranked. Refused.
- segment.nearest: nprobe counted lists, not lists with members. ivfRefine
  leaves an unclaimed centroid at the direction it was seeded from, so it ranks
  high for a query near that document and spent a probe returning nothing; the
  widening loop is no repair, since it stops at k and k is already met. The
  budget counts members now, and ivf.go's comment claiming the opposite is gone.
- segSectionsFor: segSections[:len-1] encoded "exactly one section was ever
  added", so a fourth version appending one would have demanded ivf of a 3a
  segment and refused a directory FORMAT section 1 promises is readable. It is a
  per-version count, and the returned slice is capped so it no longer shares a
  backing array with the package-level table — one append through the v2 path
  would have overwritten the ivf entry process-wide. The rs[6] index becomes an
  ivfReader lookup by kind.
- segment.close: s.ivf was released after the unmap, against the order its own
  comment and Index.Close document.
- ivfNormalizeSum: sq += c*c fuses to one FMADDD on arm64 and stays MULSD+ADDSD
  on amd64. float64 squared does not fit float64, so the fusion skips a rounding
  and the norm differs by an ULP between architectures — on the only float64
  multiply between a corpus and the bytes FINDINGS publishes a sha256 for. An
  explicit conversion stops it. ivfDot's fusion is harmless for the reason now
  written beside it: two widened float32s have 48 significand bits.

Four are in the instrument, which matters because its output is published.
recordExtents claimed to cross-check its derived total against the docs section
on disk and never did, and started record 0 at offset 0 when encodeDocs writes a
count uvarint inside a framed section — every page boundary was off. The
partitioned pass now runs before the brute-force one, which was warming the page
cache for the measurement that follows it; rssBefore is sampled before the walk
that decodes the corpus rather than after; and make recall measures the
directory it checks.

Seven are documents. The per-query cost model in ivf.go, index.go and FINDINGS
section 4.3 was arithmetic from nprobe = 8 — 2.7e6 MACs and 42x where the
constants give 1.79e7 and 6.4x. FINDINGS section 4.2's "2% at ten million" stops
at 6.25%, because ivfMaxList pins nlist at 1,024 twelve lines above it. FORMAT
no longer says Merge is the background converter that upgrades v2 (it returns
nil below nine segments and weft does not call it), no longer says nlist is
reader policy and unstored (it is the section's first uvarint), and states the
vector-less segment exception to "every id is a candidate".

Four are tests that passed against a mutation. The -1 in the page count and
buildIVF's empty-sample guard both had no witness — deleting the guard panics in
Commit with a divide by zero, which Add can reach. ivfFile joins the
version-mismatch table, and TestNearestNarrowsTheScan commits the 65,536
documents its comment claims instead of 262,144.

Re-measured on the evaluation index, and the empty-list fix moves nothing:
recall@10 0.9920, worst query 0.8000, 30,549.5 candidates, 124.1 MiB of records,
210.1 MiB of pages, 124 ms against 577 ms. The same run against HEAD gives the
same candidate count, so no empty list entered a query's probed set on this
corpus — a fact about 148,232 vectors over 414 lists, not a verdict on the
guard. FINDINGS section 2 carries that, because a column re-measured after a
correctness fix should say so.

Not taken. Commit holds the write lock for the whole 68-second training pass,
and the repayment is parallelising two per-document argmax loops rather than
threading a context — a design change needing its own determinism evidence, so
section 4.3 records the ceiling instead. The partition floor counts documents
while lists hold only vector-bearing ones, and nprobe is a per-segment budget
rather than an index-wide one; both are what FORMAT specifies, and changing
either re-measures every published figure.
…measurement

An automated review left seven findings on the PR. Three were already answered by
the previous commit and are outdated against it — the wrapping centroid bound, the
frame header missing from the page offsets, and make recall ignoring EVAL_DATA.
Three of the remaining four are here.

- weft-eval recall averaged over queries it could not grade. Two shapes reach the
  loop: a vector of a width the corpus does not carry, which scoreTopK skips every
  document for and which Nearest answers with the whole segment, and an all-zero
  vector, which scorer/vector abstains on before it ever calls Nearest. Both leave
  want untouched, so the guard for a wholly ungradeable run does not fire while one
  good query remains — and both still contribute a candidate list, a latency and a
  working set to three figures printed per query. One wrong-width query pulls a
  corpus-sized candidate count into an average FINDINGS publishes as a percentage of
  the corpus. They are dropped from every accumulator now and counted in a line the
  report prints when it has one.
- ivfTrainingSample walked its stride and skipped a position with no usable vector
  without looking for one that has it. At 40,000 documents the stride is 2, so a
  corpus carrying vectors on odd ids only samples nothing, buildIVF returns no
  partition, and every vector query over that segment runs the full scan this file
  exists to remove — indistinguishable from a segment that legitimately holds none.
  A dense pass now runs when the strided one comes back empty. Only when empty: a
  short sample is the stride weakness already priced in the comment above it, and
  falling back on short would change which vectors train the centroids for every
  corpus with any gap at all. This one cannot change an existing partition, because
  a corpus that reaches it has none.
- parseIVF accepted a centroid of all zeros. It is the NaN rule already in that loop
  with a quieter face: zero scores zero against every query, so it sorts below any
  list with a positive dot and its own documents stop being reachable — a recall
  loss with no error raised, on a section whose checksum is intact. The writer
  cannot produce one, since a centroid with no members keeps its seeded position and
  that seed was a normalized sample vector.

Not taken: the same finding asked for the centroids to be checked against unit
norm, with a tolerance for float32 rounding. Zero is exact and is refused above.
A tolerance is a constant with no measurement behind it, and one chosen a shade
too tight refuses an index that is fine — the failure the check exists to prevent,
pointed the other way. Magnitude alone biases the inner-product ordering; only the
absence of magnitude removes a list from it, and that is the case now covered.

Each fix fails its test without it, checked by reverting the source and running:
the sampler test reports sampling nothing from 20,000 vectors, the centroid test
reports parseIVF accepting a zero centroid, and the recall test pins the graded
count, the candidate sum and the bytes reached rather than the new field alone.

Re-measured on the evaluation index, which still opens: recall@10 0.9920, worst
query 0.8000, 30,549.5 candidates, 124.1 MiB of records, 210.1 MiB of pages, all
50 queries graded. gofmt, go vet, golangci-lint at 0 issues, go test ./... and
-race on engine and weft-eval.
@skyoo2003
skyoo2003 merged commit 9a06096 into main Aug 17, 2026
6 checks passed
@skyoo2003
skyoo2003 deleted the m3b-ivf branch August 17, 2026 21:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant