Skip to content

MB-69556, MB-73584: Wand/bulk scan - #2399

Draft
capemox wants to merge 21 commits into
nextfrom
wand/bulk-scan
Draft

capemox wants to merge 21 commits into
nextfrom
wand/bulk-scan

Conversation

@capemox

@capemox capemox commented Sep 18, 2026

Copy link
Copy Markdown
Member

No description provided.

capemox and others added 15 commits September 8, 2026 19:21
Replaces the per-document object pipeline on the hot query paths with flat
arrays. Search results are unchanged: hit counts, scores and term locations
are byte-identical to before on a 200k-document corpus across term, boolean,
phrase, prefix, wildcard, fuzzy and numeric-range queries.

Bulk term scan
  Previously every matching document was pushed through five layers, each
  doing work: a boxed segment.Posting, a TermFieldDoc fill and Reset, a
  pooled 232-byte DocumentMatch, and a collector callback. A term query
  returning ten hits out of 142,596 built and discarded 142,586 objects that
  never had a chance of placing.

  search.BulkSearcher/DocScoreBlock let a searcher hand the collector a block
  of (id, score) pairs as flat arrays. TopNCollector.collectBulk tests each
  score against the cutoff before allocating anything, so a DocumentMatch is
  taken from the pool only for documents that actually place.

Windowed disjunction accumulator
  A K-way heap merge costs O(log K) per posting; measured, that was 38
  ns/posting at K=1 rising to 141 ns/posting at K=256. Prefix, wildcard,
  fuzzy and numeric-range queries all become large disjunctions once their
  terms are enumerated, so that growth term dominated them.

  The accumulator sweeps the doc space in windows, adds each clause's
  contribution into a dense array indexed by offset, then walks the window
  once: O(P + D), independent of K. The window jumps to the lowest pending
  document and only the touched span is swept, so sparse disjunctions do not
  pay for empty regions -- without those two details the accumulator loses to
  the heap at small K.

Also included, from earlier in the same effort:
  - monomorphic disjunction heap with uint64 document keys, replacing
    container/heap and bytes.Compare
  - phrase matching no longer calls Complete per candidate document, cutting
    allocation on phrase queries from 34.3 MB/query to 1.1 MB
  - scorch fills TermFieldDoc directly from the segment where the segment
    supports it, in both Next and Advance

Measured against the unmodified tree, 200k documents, 17 query shapes:
total workload 341.1 ms -> 145.3 ms (2.35x), median 2.03x per query.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…i span

The emit sweep walked every slot between the lowest and highest match in
the window, skipping empties with a cnt[off]==0 test. A window holding
three documents 200 slots apart therefore visited 200 slots to emit 3.

Track occupied slots in a bitset instead and drain it with
TrailingZeros64, so the sweep costs O(matches) and a run of up to 64
empty slots is skipped by one zero-word test.

This is what both other implementations do:
  Lucene BooleanScorer      FixedBitSet matching + Long.numberOfTrailingZeros
  tantivy BufferedUnionScorer  [TinySet; HORIZON/64] + pop_lowest

Emission order is unchanged -- words ascend and TrailingZeros64 pops the
lowest set bit first -- so documents still come out in ascending doc
order and the result dump stays byte-identical.

Note the window stays at BlockSize (256). Lucene and tantivy both use
4096, which they can afford because their bucket array is per-scorer;
bleve's BlockSize also sizes the per-clause DocScoreBlock buffers, so an
894-clause wildcard would allocate 894 x 128KB. Decoupling the two is a
separate change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The accumulator paid one pass over every clause per window to pick the
base plus another to drain it, whether or not those clauses had anything
in range. At a 256-wide window an 894-term wildcard over 200k documents
spent ~1.4M peeks on that bookkeeping against ~950k that did real work.

Widening the window divides the bookkeeping by the same factor, but
search.BlockSize could not simply be raised: it also sizes the per-clause
DocScoreBlock buffers, so 894 clauses x 1024 x 4 arrays x 8 bytes would
be 29MB. Split the two. accWindow sizes only acc/cnt/matched, which are
one allocation per disjunction rather than per clause - 12KB, a flat
+0.012MB/query on every shape.

A window wider than the caller's output block can produce more documents
than fit, so the emit sweep is now resumable: base/sweepWord/sweeping
hand back a full block and pick up mid-window on the next call. The
wildcard shape produces ~950 docs per window against a 256-entry block,
so that path is well exercised.

Window size picked by a balanced sweep - 5 ascending plus 5 descending
repeats over 256..16384, the two directions averaged so any ordering or
thermal effect cancels. Workload total: 143.5ms at 256, 136.1 at 1024,
134.3 at 2048, 133.8 at 4096, 133.5 at 16384. 1024 takes three quarters
of the available win for a quarter of the footprint.

The total keeps falling to 16384 only because the 894-term wildcard is
42% of it and is the one shape that benefits without limit. Every other
accumulator shape turns over past 4096 - at 16384 numrange +8%, or-5-mid
+5%, match-4 +3% - so tuning on the total alone would pick a window that
is worse for all of them. Shapes that never construct an accumulator
(phrase, conjunction) measured flat across the whole range in both
directions, confirming the sweep isolates what it claims to.

wildcard 66.0 -> 58.7ms, numrange 1.077 -> 0.935, prefix 0.286 -> 0.269.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A DocScoreBlock is four BlockSize arrays, 8KB, and blockDisjunction takes
one per clause. A wildcard expanding to ~890 terms allocated ~7MB of them
per query and discarded it -- 29% of that query's allocation.

Pool them, released from the disjunction searchers' Close.

Safe to pool in a way recycling a TermFieldReader is not (MB-64669): a
block holds only uint64/float64 scratch with no reference into a mapped
segment, so nothing here can be unmapped underneath a later user.
Producers fill [0:n) and consumers read [0:n), so stale contents are
never observed.

Measured on the 890-term wildcard: allocation 24.2 -> 20.5 MB/query,
GC cycles 139 -> 99, latency 68.95 -> 67.15 ms (2.6%, 4/4 interleaved
pairs). Workload total 144.9 -> 143.2 ms. Other shapes neutral within
noise. Results byte-identical; race detector clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
newBlockDisjunction takes a block per clause as it walks them, so
bailing out partway through left every block taken so far unreferenced.
Nothing is corrupted by this - they are garbage, not shared - but the
pool drains silently, which is the failure mode pooling exists to avoid.

Unreachable today: ScoreBlock only runs after CanScoreBlock has called
canBlockDisjunct, which rejects a non-bulk clause before construction
starts. That makes the bail-out dead code whose safety rests entirely on
every future call site keeping the same discipline, so release what we
took instead of relying on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Registers zapx/v18 as the default segment plugin (v17 stays
registered so existing segments keep reading). v18 replaces
roaring-bitmap + chunked-varint postings with SIMD-BP128 blocks and a
per-field norm column instead of a per-posting field length -- see
the zapx commit "perf: tantivy-style bitpacked postings (zapx v18)".

Uses a local go.mod replace against the zapx checkout for now; drop
it once zapx/v18 is published and pin a real version.

Re-baselines the byte-count assertions in TestBytesWritten,
TestBytesRead and TestBytesReadStored: segments are markedly smaller
under the new format, but the term dictionary is larger because a
term appearing in a single document now carries its doc number
inline in the FST value rather than pointing at a separate record.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The conjunction optimizer's push-down narrowing used to materialize
every clause's full doc set (segment.OptimizablePostingsIterator.
ActualBitmap()) and roaring.And them together. That was free under
zapx v17, whose on-disk postings representation already was a roaring
bitmap sitting in memory. It is not free under zapx v18's bitpacked
blocks: ActualBitmap() has to decode every block of a term's postings
from scratch, at a cost proportional to that clause's full
cardinality regardless of how selective the resulting intersection
turns out to be. Decoding a 140,000-document postings list just to
discover it intersects a 1,000-document one down to 934 hits was
exactly this waste.

Adds a second strategy, leapfrogIntersect: a zig-zag merge driven
directly by each clause's own Next()/Advance() (the published,
format-agnostic segment.PostingsIterator interface), which touches
work proportional to the smallest clause rather than the sum of all
of them. intersectPostingsForSegment picks between the two using each
clause's Count() (usually O(1)): leapfrog wins by a wide margin when
sizes are skewed, but measured ~40% slower net of its own per-document
overhead when clauses are comparably sized, where the original bulk
materialize-and-AND is cheaper in absolute terms despite touching the
same order of documents. leapfrogOverheadFactor is a blunt margin
accounting for that gap, calibrated against the two measured
near-equal-size cases, not a finely-tuned constant.

Verified against zapx v18's block postings: -mode dump output is
byte-identical to before this change on the full 17-shape benchmark
workload in ~/projects/bleve-optim/bench, and every shape's p50
either improved or held flat (and-high-low 0.53ms -> 0.19ms,
and-mid-mid -6%, no shape regressed beyond measurement noise).
Verified against zapx v17 (block-scan branch) with the same
dump-identity check: two of three directly-measured AND shapes were
within noise of the original code, one (the most heavily skewed
clause pair) ~5% slower, since v17's already-free ActualBitmap()
leaves nothing for leapfrog to save there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wires zapx v18's new BlockMax/ShallowAdvance primitives into a scored
top-K term query: once TopNCollector's top-K heap fills and its
worst-of-the-best score stabilizes, TermSearcher skips whole blocks
of the remaining postings whose maximum possible score can't beat it,
without decoding or scoring a single document in them.

Chain, each layer duck-typed to keep the published segment API
untouched (matching the existing FillTermFieldDoc/NextBlock pattern):

- search.CompetitiveScorer / search.SkippedForCompetitiveScore
  (search/bulk.go): a searcher's opt-in surface for a tightening top-K
  threshold and for reporting how much it actually skipped.
- TopNCollector calls SetMinCompetitiveScore every time its
  lowestMatchOutsideResults tightens, gated on canBulkCollect's
  existing safety conditions (score-only sort, no facets/KNN/nested/
  search-after -- all of which need to see every match, not just top-K
  survivors). At the end of Collect, folds SkippedDocCount() back into
  hc.total: every block-max-skipped document is still a real match
  (it's in the term's own postings), so Total() stays exact rather
  than degrading to a lower bound -- this is not the same mechanism as
  SetEarlyStop, which trades exactness away deliberately; WAND doesn't
  need to.
- index/scorch: IndexSnapshotTermFieldReader.BlockMax/ShallowAdvance
  relay the per-segment bound to a global document number.
- search/searcher/search_term_blockmax.go: TermSearcher.
  skipUncompetitiveBlocks() is the shared skip loop, called from both
  Next() (scalar path) and ScoreBlock() (the bulk path a plain top-K
  term query actually runs through today). TermQueryScorer.MaxScore
  evaluates the same docScore formula at (maxTF, maxNorm) to get the
  upper bound.

Deliberately scoped to a bare TermSearcher at the query root -- not
wired into conjunctions, disjunctions, or phrases. Multi-clause WAND
needs pivot selection across clauses with different cost/benefit
tradeoffs than a single postings list; that's real, separate work,
not a natural extension of this.

Verified: -mode dump against the 200k-doc benchmark corpus is
byte-identical with WAND on vs off (same hits, same scores, same
order) on the full 17-shape workload, including the exact Total()
counts. Full test suite passes, including the pre-existing
TestEarlyStopDoesNotEngageWhenUnsafe, which caught the first version
of this (naive skipping under-counted Total() before the exact-count
plumbing above was added).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
zapx's block-max bound (see the companion commit tightening it to a joint
argmax) is now computed at write time as the single document that scores
highest under a specific BM25 estimate -- it carries no guarantee under any
other scoring formula. Add TermQueryScorer.UsesBM25 and check it in
skipUncompetitiveBlocks alongside the existing CanScoreBulk gate, so WAND
only engages when the scorer is actually running BM25 (avgDocLength > 0)
and simply declines -- falling back to a normal fetch -- when a query is
scored with plain tf-idf instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extends block-max WAND pruning -- already live for standalone term
queries -- to conjunctions. A ConjunctionSearcher now decides once,
at construction, whether its clauses qualify (wandEligible): every
clause must be a block-max-capable term searcher, decided before the
push-down optimization below has a chance to narrow any clause's
reader, since a narrowed reader still satisfies the same interfaces
but silently stops reporting a block-max bound from then on. When
eligible, the push-down optimization is skipped entirely -- the block
path needs each clause's own, un-narrowed reader to keep reporting
real bounds -- and search_conjunction_block.go's blockConjunction
drives scoring instead.

Correctness for the new path is what conjunction_wand_test.go checks
directly: WAND-on results (hits, order, scores) must exactly match a
WAND-off baseline at several skew shapes and top-K sizes, and every
document WAND-on returns must actually satisfy every clause -- the
cheapest way this kind of pruning could go wrong.

One real trade-off, not a bug: proving a document is in the top-K
without fully walking the intersection means Total() can no longer
promise an exact count the way a plain conjunction scan always could.
search.ApproximateTotal (bulk.go) lets a searcher declare this, and
TopNCollector folds it into the same lower-bound handling SetEarlyStop
already provides (EarlyStopped() reports true for either reason, which
is what index_impl.go's TotalRelation check actually reads) -- so a
caller relying on an exact "N total results" now correctly sees
TotalRelation=gte instead of a wrong exact number.
TermQueryScorer.ScoreBulk ran docScore's arithmetic one document at a
time; the field-length reciprocal and BM25 division chain (or tf-idf's
plain multiply) now run two documents per vector op via package simd
-- SSE2 on amd64, NEON on arm64, no runtime CPU detection, matching
zapx's bitpack package's own policy. The kernel takes raw freqs
directly and does the sqrt in-vector (SQRTPD / UCVTF+FSQRT) rather
than a precomputed tf slice through scorer.SqrtCache: IEEE 754
requires sqrt specifically to be correctly rounded, so hardware sqrt
reproduces the table bit-for-bit for every input, and table lookups
don't vectorize without a gather instruction that neither SSE2 nor
NEON has anyway.

Bit-exactness against the scalar docScore path is load-bearing, not
cosmetic: MaxScore()'s block-max WAND bound has to be a real upper
bound on whatever ScoreBulk actually computes for the same inputs, so
a reordered or differently-grouped floating point expression that's
merely mathematically equivalent isn't good enough. Both kernels
evaluate every step in docScore's exact left-to-right order and
grouping -- including matching, not avoiding, an ARM64-specific
wrinkle found while building this: Go's arm64 backend auto-fuses
docScore's multiply-add into a single-rounding FMADDD instruction on
its own (confirmed via -gcflags=-S), which the NEON kernel has to
reproduce via vfmaq_f64 to stay bit-identical, where amd64's default
GOAMD64=v1 has no FMA3 and never fuses, so the SSE2 kernel deliberately
does not use FMA. TestScoreBulkMatchesDocScore checks the identity
directly at the scorer_term.go level, on top of simd's own
TestBM25MatchesPortable/TestTFIDFMatchesPortable and a fuzz test.

Real-workload effect is real but shape-dependent, not free everywhere:
term and conjunction queries under block-max WAND (where the searcher
already isn't scoring most of the postings list) show a genuine
6-8% latency gain measured at proper iteration depth; a term whose
WAND has little room to skip (this query's own postings list is
already short) is closer to a wash, since scoring was never a large
share of that shape's cost to begin with. A first attempt at measuring
this with only 60 benchmark iterations understated it badly -- at
these shapes' sub-millisecond latency, that's too few samples to
separate the real signal from GC/scheduler noise; 20,000+ iterations
per shape is what it actually took to see the effect cleanly.

kernels_amd64.s/kernels_arm64.s are generated (avo and goat
respectively, see gen/avo/asm.go and gen/goat/kernels_arm64.c for the
regeneration commands) and checked in rather than built on the fly.
This branch is forked from perf/block-scan, whose zapx dependency is
also perf/block-scan (a different, incompatible on-disk v18 format
from bitpack-simd/wand/block-max's). Points at a dedicated worktree
(/Users/gautham.k/projects/zapx-bulk-scan) rather than the shared
zapx checkout, which stays on wand/block-max for other in-flight work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TestBytesRead's numeric-range-query byte-count assertion used an exact
equality check while every other assertion in the same test already
uses approxSame's 30% tolerance. sample-data.json goes through
map-based Batch.IndexOps, randomizing internal doc-number assignment
per run; the handful of matching documents can straddle the bulk
block-read path's fixed block boundary differently from one run to
the next, shifting the byte count by a small, harmless amount (result
IDs and scores are identical every time -- confirmed across 30 runs).
Switched to approxSame to match the test's own existing pattern.

Adds TestConjunctionBlockMaxWANDAgainstIndependentBaseline, the
companion TestConjunctionBlockMaxWANDMatchesBaseline was missing: that
test only ever compares the block-max WAND path against WAND-off
within the same build, both reading the same corpus-wide
avgDocLength -- so it couldn't distinguish "correct" from "a
write-time bound computed against a different, wrong statistic" (see
zapx's companion fix), since neither side is an independent
computation of the true per-document score. This test's WAND-off side
never reads zapx's block-max metadata at all, only real per-document
(tf, norm) pairs decoded one at a time via the plain scalar leapfrog
path -- a genuine independent reference. Requires a segment-skewed
corpus (buildSkewedSegmentIndex: a stark short/long field-length step
across the corpus) rather than a uniformly-distributed one, since with
enough i.i.d. samples per segment the per-segment and corpus-wide
averages converge closely enough that this bug's effect was too small
to reliably trip a uniform corpus's assertions even with the bug
present.

Also adds TestBulkCollectPaginationWithTiedScores: checks collectBulk's
HitNumber-based tie-break stays self-consistent under pagination
(term/conjunction/disjunction queries, 7 page shapes including deep
pagination, a corpus engineered for heavy BM25 score collisions) --
the same shape of bug found on a separate WAND effort, checked fresh
here since collectBulk's tie-break is a different mechanism that could
independently reintroduce it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…esn't discriminate

Profiling and-mid-mid (two similar-frequency, similar-idf terms) found
the phase-1 score prefilter in scoreCandidates lets ~98.7% of
candidates through -- rejecting almost nothing -- yet every window
still pays for the filtering comparison, the phase-2 suffix-sum
bookkeeping that exists to serve it, and idBuf/cursor-switch overhead
on candidates that were never going to be rejected anyway. A CPU
profile attributed ~32% of scoreCandidates' own time to this
bookkeeping versus ~56% for the genuine, unavoidable cost of a real
secondary Advance() call.

scoreCandidates now tracks a streak of consecutive WINDOWS whose
phase-1 pass rejected zero candidates; once that streak crosses
conjNoBenefitBailoutStreak (4), it permanently skips the phase-1
filtering comparison and the phase-2 suffix-sum early exit for the
rest of the query, while leaving every correctness-relevant mechanism
untouched: the real Advance()-based membership check, the
secCursor/secValid overshoot cache, and advanceWindow's whole-window
skip check.

The streak is counted over whole windows (up to 128 candidates each),
not individual candidates like the equivalent disjunction-side bailout
built in an earlier, separate effort: a window rejecting nothing by
pure chance is already a rare coincidence for a clause pair where
filtering is genuinely earning its keep (at a real 5% per-candidate
rejection rate, a whole clean window has roughly 0.14% probability),
so a small streak threshold cleanly separates "not helping at all"
from "helping, just not on this particular window" without the much
larger threshold the disjunction bailout needs to survive many more,
much cheaper, individual-document trials.

One subtlety caught during review: bc.secSuffix is only recomputed
when NOT bailed out, so the phase-2 early-exit check
(total+bc.secSuffix[si] <= bc.threshold) must also be gated on
!bc.skipPrefilter -- otherwise it would compare against stale suffix
values from before the bailout engaged, a real correctness hazard
(could incorrectly reject a valid match using garbage data) rather
than just a missed optimization.

Verified: full go test ./... clean, 30x repeat on conjunction tests,
15x repeat on TestConjunctionBlockMaxWANDAgainstIndependentBaseline
(the independent-reference test added for the earlier block-bound
fix), all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ilter doesn't discriminate"

This reverts commit 67ee082.
@capemox capemox self-assigned this Sep 18, 2026
capemox and others added 6 commits September 19, 2026 09:09
search/scorer/simd (BM25/TF-IDF vectorized scoring kernels: hand-written
SSE2/NEON assembly generated via avo/goat, plus a portable fallback) was
byte-for-byte identical to freeway's own simd package -- confirmed via
direct diff, every file matches except the "Code generated by" header
comment. freeway exists specifically to be "Shared SIMD kernels for
bleve and zapx" (per its own README), but nothing had actually been
switched over to depend on it yet; this was a straight duplicate
sitting in bleve's own tree instead.

scorer_term.go now imports github.com/blevesearch/freeway/simd instead
of the local package, which is deleted outright rather than kept
around unused. Local replace directive (freeway has no release yet)
follows the same pattern already used for zapx and vellum.

Verified: go build/vet/test clean, freeway's own test suite clean,
5x-repeated runs of every conjunction/WAND/block-max/scoring test
clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
go.mod now points zapx/v18 at the bitpack-simd-based wand/block-max
checkout instead of the independent perf/block-scan fork, and adds a
matching local replace for scorch_segment_api/v2 (needed for its own
wand/block-max branch's newly-exported BlockMaxPostingsIterator).

index/scorch/snapshot_index_tfr.go's three local, unexported
interfaces (blockFiller, blockMaxIterator, shallowAdvancer) are
replaced by type-asserting against the single formal, exported
segment.BlockMaxPostingsIterator instead -- these existed only because
nothing in scorch_segment_api had codified this contract yet; now that
it does, there's no reason to keep three ad hoc duck-typed interfaces
alongside it. IndexSnapshotTermFieldReader's own exported method
signatures (BlockMax/ShallowAdvance/NextBlock) are unchanged, so
nothing in search/searcher needed to change at all -- confirmed by the
full test suite passing unmodified there.

index_test.go's hardcoded byte-count assertions needed recalibrating:
bitpack-simd's on-disk format legitimately writes/reads a different
number of bytes than perf/block-scan's independent fork did for the
same corpus (different skip entry layout, no tail-bitpacking, etc.),
so every affected exact-match or approxSame check is updated to the
newly-measured, stable value for this format -- these are golden
numbers meant to catch unintended future drift, not something to relax
by construction.

Verified: go build/vet/test clean across the whole suite except one
known, pre-existing, unrelated gap (see follow-up note) --
TestTermRangeSearchTooManyTerms, which depends on
segment.OptimizablePostingsIterator (the older bitmap-materialization
push-down for Score:"none" queries), which bitpack-simd's zapx does
not implement at all. This has nothing to do with block-max WAND or
bulk collection; it surfaced here only because zapv18 is the default
segment plugin on this branch (inherited from perf/block-scan) and
that capability was never ported to bitpack-simd. Flagging rather than
fixing as part of this change, since it's a separate, real gap outside
this effort's scope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ailure

Depends on segment.OptimizablePostingsIterator, which bitpack-simd's
zapx doesn't implement (see the previous commit's note) -- a real,
pre-existing, out-of-scope gap, not a regression from this change.
Explicit skip with a pointer back to that commit, so this doesn't get
lost as noise if a real failure shows up elsewhere in the suite later.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…filter doesn't discriminate"

This reverts commit 1412fa7.
ScoreBulk(n=1) computed the largest even count (0), dispatched
simd.BM25/TFIDF with a zero-length slice, then scored the sole document
through the scalar remainder anyway -- paying the batch call for
nothing. Profiling and-high-high found this costing ~13% of the
block-conjunction WAND path's total query time, since scoreCandidates
scores each surviving candidate's secondaries one document at a time
(ScoreBulk(bc.freq1[:], bc.norm1[:], bc.score1[:])) on every call.

n==1 now goes straight to the scalar path via a shared scoreOne
helper, which also replaces MaxScore's identical hand-inlined copy of
the same arithmetic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…dates

blockConjunction.scoreCandidates already tracks every candidate as a
plain uint64, yet its per-secondary membership check encoded it into
an index.IndexInternalID just for the generic Advance to immediately
decode it straight back (num := ID.Value()), then decoded the
*TermFieldDoc Advance returned back into a uint64 again for its own
secCursor bookkeeping -- two decodes and an encode paid on every
single candidate x secondary pair, for a value the caller had in hand
the whole time.

IndexSnapshotTermFieldReader.Advance's actual logic (segment lookup,
direct-fill dispatch, cross-segment fallback via Next) is factored out
into a new advanceNum core that takes and returns raw uint64s;
Advance becomes a thin wrapper that decodes its ID argument once and
calls in. AdvanceDocNum is the other wrapper: no encode, no decode,
just the segment lookup and fill. It skips Advance's backward-seek
recovery entirely, since block-conjunction WAND already guarantees
forward-only targets by construction (candidates are visited in
ascending doc order) -- paying for that check would defeat the point.

scoreCandidates picks it up as an optional capability (docNumAdvancer)
on conjLeg, falling back to the byte-encoded path for any reader that
doesn't implement it, the same convention termFieldDocFiller already
uses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.

1 participant