@tpsdev-ai/flair 0.48.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,631 @@
1
+ // ─── Persistent, incrementally-maintained BM25 index (flair#1357) ────────────
2
+ //
3
+ // THE DEFECT THIS REPLACES: `retrieveCandidates()`'s hybrid leg used to fetch
4
+ // the ENTIRE scoped corpus out of Harper and call `buildBM25()` on it — for
5
+ // EVERY query. Tokenizing N documents and allocating N per-doc term maps per
6
+ // recall makes retrieval latency linear in store size: measured 5.6s p50 at
7
+ // 60k rows, 28.7s at 180k (flair#1357, LongMemEval take-6 latency journal),
8
+ // extrapolating past 30s at 250k. Vector-only recall over the same stores
9
+ // moved 2.4s → 4.7s, so the whole gap was the per-query index rebuild.
10
+ //
11
+ // Kern's ruling (2026-08-23) picked direction (a): a persistent index built on
12
+ // WRITE and maintained incrementally, over (b) cached-corpus + dirty-tracking
13
+ // (still pays a full scan+tokenize on the first cold query per scope, and its
14
+ // invalidation is a consistency problem in its own right) and (c) Harper-side
15
+ // lexical scoring (Harper has no native full-text/BM25 — its custom-index
16
+ // registry exports only HNSW, and its comparators are
17
+ // equals/in/contains/starts_with/ends_with/between, none of them ranked).
18
+ //
19
+ // ── THE HARD CONSTRAINT: ranking-identical, latency-only ────────────────────
20
+ // Recall is the product FLOOR and hybrid is default-on, so this module is NOT
21
+ // allowed to improve, degrade, or otherwise perturb ranking. It must return
22
+ // the SAME ids in the SAME order as `buildBM25(corpus).rank(q)` filtered to
23
+ // score>0 and sliced to SEM_LIMIT. Everything below that looks like a
24
+ // restriction exists to keep that promise:
25
+ //
26
+ // 1. BM25 statistics are CORPUS-SCOPED. `idf` reads N and df; the length
27
+ // normalization reads avgdl. All three are computed over the set of
28
+ // documents matching the query's `conditions[]` + temporal filters —
29
+ // NOT over the whole store. A global-statistics index would return a
30
+ // different ORDER, which is precisely what we may not do. So this index
31
+ // holds every document once and derives per-query scoped statistics
32
+ // (see `Aggregates` below).
33
+ // 2. Arithmetic is performed in the SAME ORDER as `buildBM25`, so the
34
+ // doubles are bit-identical: query terms are accumulated in
35
+ // `[...new Set(tokenize(q))]` order, and `avgdl` is a sum of integers
36
+ // (exact in a double regardless of summation order) divided by N.
37
+ // 3. TIE-BREAKING IS EXPLICIT AND SHARED (flair#1363). Equal-scoring
38
+ // documents used to come back from `buildBM25().rank()` in Harper's
39
+ // corpus ITERATION order (stable sort over the fetched corpus) — a
40
+ // query-planner artifact, measured to differ between the multi-agent
41
+ // read-scope OR-group (reader's own rows first) and a tags/subject filter
42
+ // (plain primary-key order) for the same rows and the same query text. No
43
+ // index can reconstruct a planner, so hybrid recall was nondeterministic
44
+ // for tied documents and this work could not have been correct while that
45
+ // remained true. Kern's ruling (2026-08-24): "byte-identical to a
46
+ // nondeterministic source is a contradiction." Both paths now sort by
47
+ // score DESC then ascending `id`, so the tie order is defined, identical,
48
+ // and reproducible without Harper.
49
+ // 4. ANYTHING THIS MODULE CANNOT REPRODUCE EXACTLY MUST NOT BE SERVED FROM
50
+ // THE INDEX. `planQuery()` below is a conservative allowlist: an
51
+ // unrecognised condition attribute, comparator, or shape returns a plan
52
+ // of `null`, and the caller falls back to the legacy per-query corpus
53
+ // scan. Unknown means SLOW, never means WRONG. (Score ties were briefly
54
+ // in this category too — see 3; they no longer are, because the tie order
55
+ // is now defined rather than inherited.)
56
+ //
57
+ // Harper-free on purpose — same rationale as ./bm25.ts and ./scoring.ts: the
58
+ // scoring, the scope evaluation and the statistics derivation are unit-testable
59
+ // against the SHIPPED code with no live Harper. The Harper wiring (lazy build
60
+ // from a table scan, the change-feed subscription, the write hooks) lives in
61
+ // ./bm25-index-service.ts.
62
+ import { tokenize, BM25_K1, BM25_B } from "./bm25.js";
63
+ import { matchesConditions, passesRecordFilters, } from "./bm25-filter.js";
64
+ // ─── What the index stores per document ─────────────────────────────────────
65
+ //
66
+ // SUPPORTED_SCOPE_ATTRS is the CLOSED set of record attributes a query may
67
+ // filter on and still be served from the index. It is closed for a security
68
+ // reason, not a performance one: `matchesConditions` evaluates `not_equal`
69
+ // against `record[attr]`, so a stored record MISSING an attribute the real row
70
+ // carries would PASS a `not_equal` filter it should fail — a scope leak with
71
+ // the shape of a ranking change. `planQuery()` rejects any condition naming an
72
+ // attribute outside this set, so an attribute we do not store can never be
73
+ // evaluated against a record that does not carry it.
74
+ //
75
+ // Every attribute `resources/SemanticSearch.ts` and
76
+ // `resources/MemoryBootstrap.ts` actually put into `conditions[]` is here:
77
+ // agentId + visibility (the read-scope OR-group, resources/memory-read-scope.ts),
78
+ // archived (the always-present exclusion), tags and subject (the optional
79
+ // filters). The rest are stored because they are cheap and a future caller
80
+ // filtering on them should get the fast path rather than a silent fallback.
81
+ export const SUPPORTED_SCOPE_ATTRS = [
82
+ "agentId", "visibility", "archived", "tags", "subject", "durability",
83
+ "source", "sessionId", "promotionStatus", "parentId", "derivedFrom",
84
+ "supersedes", "contentHash", "embeddingModel",
85
+ ];
86
+ // Attributes the TEMPORAL filters read (resources/bm25-filter.ts's
87
+ // passesRecordFilters). Stored alongside the scope attributes; never
88
+ // filterable via `conditions[]` (they have their own parameters).
89
+ export const TEMPORAL_ATTRS = ["createdAt", "expiresAt", "validFrom", "validTo"];
90
+ /** The `select` a corpus scan needs in order to feed this index. */
91
+ export const INDEX_SELECT = ["id", "content", ...SUPPORTED_SCOPE_ATTRS, ...TEMPORAL_ATTRS];
92
+ const SUPPORTED_ATTR_SET = new Set(SUPPORTED_SCOPE_ATTRS);
93
+ function emptyPosting() {
94
+ return { slots: new Int32Array(4), tfs: new Int32Array(4), len: 0 };
95
+ }
96
+ function pushPosting(p, slot, tf) {
97
+ if (p.len === p.slots.length) {
98
+ const slots = new Int32Array(p.slots.length * 2);
99
+ slots.set(p.slots);
100
+ const tfs = new Int32Array(p.tfs.length * 2);
101
+ tfs.set(p.tfs);
102
+ p.slots = slots;
103
+ p.tfs = tfs;
104
+ }
105
+ p.slots[p.len] = slot;
106
+ p.tfs[p.len] = tf;
107
+ p.len++;
108
+ }
109
+ function isGroup(c) {
110
+ return c.operator !== undefined && Array.isArray(c.conditions);
111
+ }
112
+ /** Every attribute a condition tree names, or null if the tree has a shape
113
+ * `matchesConditions` would not evaluate the way we assume. */
114
+ function conditionAttrs(c, out) {
115
+ if (isGroup(c)) {
116
+ if (c.operator !== "or" && c.operator !== "and")
117
+ return false;
118
+ for (const sub of c.conditions)
119
+ if (!conditionAttrs(sub, out))
120
+ return false;
121
+ return true;
122
+ }
123
+ const leaf = c;
124
+ if (typeof leaf.attribute !== "string")
125
+ return false;
126
+ // Only the two comparators `matchesConditions` implements. Anything else is
127
+ // fail-closed THERE, which we must not silently reproduce as "in scope".
128
+ if (leaf.comparator !== "equals" && leaf.comparator !== "not_equal")
129
+ return false;
130
+ out.add(leaf.attribute);
131
+ return true;
132
+ }
133
+ /** Does this condition tree restrict a single facet attribute to a value set
134
+ * by `equals` alone (a leaf, or an OR-group of leaves on one attribute)? */
135
+ function asFacet(c) {
136
+ if (!isGroup(c)) {
137
+ const leaf = c;
138
+ if (leaf.comparator !== "equals")
139
+ return null;
140
+ return { attr: leaf.attribute, values: [String(leaf.value)] };
141
+ }
142
+ if (c.operator !== "or" || c.conditions.length === 0)
143
+ return null;
144
+ let attr = null;
145
+ const values = [];
146
+ for (const sub of c.conditions) {
147
+ if (isGroup(sub))
148
+ return null;
149
+ const leaf = sub;
150
+ if (leaf.comparator !== "equals")
151
+ return null;
152
+ if (attr === null)
153
+ attr = leaf.attribute;
154
+ else if (attr !== leaf.attribute)
155
+ return null;
156
+ values.push(String(leaf.value));
157
+ }
158
+ return attr ? { attr, values } : null;
159
+ }
160
+ const PARTITION_ATTRS = new Set(["agentId", "visibility", "archived"]);
161
+ const FACET_ATTRS = new Set(["tags", "subject"]);
162
+ export class Bm25Index {
163
+ slots = [];
164
+ slotOf = new Map();
165
+ freeSlots = [];
166
+ postings = new Map();
167
+ totalPostings = 0;
168
+ deadPostings = 0;
169
+ /** Aggregates over LIVE, NOT-YET-EXPIRED docs, keyed by partition. */
170
+ partitions = new Map();
171
+ /** `${attr}${value}` → partition → aggregate. Covers ONE facet
172
+ * restriction (tags or subject); a query naming both falls back. */
173
+ facets = new Map();
174
+ /** A representative `{agentId, visibility, archived}` per partition key, so
175
+ * a plan's partition conditions can be evaluated once per partition. */
176
+ partitionRep = new Map();
177
+ /** Min-heap of (expiry, slot) for docs with a temporal bound. Entries may be
178
+ * stale (doc re-indexed or removed); validated on pop. */
179
+ expiryHeap = [];
180
+ /** Wall clock the aggregates have been swept to. */
181
+ sweptTo = 0;
182
+ /** Slots retired by the sweep — removed from the aggregates. */
183
+ retired = new Set();
184
+ get size() { return this.slotOf.size; }
185
+ /** Live postings, for the memory-footprint assertions in the tests. */
186
+ get postingCount() { return this.totalPostings - this.deadPostings; }
187
+ get termCount() { return this.postings.size; }
188
+ has(id) { return this.slotOf.has(id); }
189
+ clear() {
190
+ this.slots = [];
191
+ this.slotOf.clear();
192
+ this.freeSlots = [];
193
+ this.postings.clear();
194
+ this.totalPostings = 0;
195
+ this.deadPostings = 0;
196
+ this.partitions.clear();
197
+ this.facets.clear();
198
+ this.partitionRep.clear();
199
+ this.expiryHeap = [];
200
+ this.sweptTo = 0;
201
+ this.retired.clear();
202
+ }
203
+ // ─── Maintenance ──────────────────────────────────────────────────────────
204
+ /**
205
+ * Add or replace a document. Deliberately NOT "diff the content and patch
206
+ * the postings": an upsert always tombstones the old slot and appends a new
207
+ * one. Detecting an unchanged body would need a content fingerprint, and a
208
+ * fingerprint collision is a silently-wrong lexical index — the one failure
209
+ * mode this index may not have. The cost is a dead posting run per update,
210
+ * reclaimed by `compact()` below at O(1) amortized.
211
+ */
212
+ upsert(record) {
213
+ const id = record?.id;
214
+ if (typeof id !== "string" || id.length === 0)
215
+ return;
216
+ this.remove(id);
217
+ const tokens = tokenize(record.content || "");
218
+ const tf = new Map();
219
+ for (const t of tokens)
220
+ tf.set(t, (tf.get(t) || 0) + 1);
221
+ const meta = { id };
222
+ for (const attr of SUPPORTED_SCOPE_ATTRS)
223
+ if (attr in record)
224
+ meta[attr] = record[attr];
225
+ for (const attr of TEMPORAL_ATTRS)
226
+ if (attr in record)
227
+ meta[attr] = record[attr];
228
+ const slot = this.freeSlots.length > 0 ? this.freeSlots.pop() : this.slots.length;
229
+ const entry = {
230
+ id,
231
+ dl: tokens.length,
232
+ nTerms: tf.size,
233
+ meta,
234
+ pkey: partitionKeyOf(meta),
235
+ expiry: expiryOf(meta),
236
+ };
237
+ this.slots[slot] = entry;
238
+ this.slotOf.set(id, slot);
239
+ for (const [term, count] of tf) {
240
+ let p = this.postings.get(term);
241
+ if (!p) {
242
+ p = emptyPosting();
243
+ this.postings.set(term, p);
244
+ }
245
+ pushPosting(p, slot, count);
246
+ }
247
+ this.totalPostings += tf.size;
248
+ // A document whose bound has ALREADY passed is born retired: it can never
249
+ // pass `passesRecordFilters`, so it must not enter the aggregates.
250
+ if (entry.expiry <= this.sweptTo) {
251
+ this.retired.add(slot);
252
+ }
253
+ else {
254
+ this.addToAggregates(entry);
255
+ if (entry.expiry !== Infinity)
256
+ heapPush(this.expiryHeap, { ts: entry.expiry, slot });
257
+ }
258
+ }
259
+ remove(id) {
260
+ const slot = this.slotOf.get(id);
261
+ if (slot === undefined)
262
+ return;
263
+ const entry = this.slots[slot];
264
+ this.slotOf.delete(id);
265
+ this.slots[slot] = null;
266
+ if (entry) {
267
+ if (!this.retired.delete(slot))
268
+ this.removeFromAggregates(entry);
269
+ this.deadPostings += entry.nTerms;
270
+ }
271
+ this.maybeCompact();
272
+ }
273
+ /**
274
+ * Drop tombstoned postings and reclaim their slots. Triggered when a quarter
275
+ * of the postings are dead, so the work is O(1) amortized per update. Slot
276
+ * NUMBERS are only recycled here, after every posting referencing them is
277
+ * gone — recycling earlier would let a stale posting resolve to an unrelated
278
+ * document.
279
+ */
280
+ maybeCompact() {
281
+ if (this.deadPostings * 4 <= this.totalPostings || this.totalPostings === 0)
282
+ return;
283
+ let live = 0;
284
+ for (const [term, p] of this.postings) {
285
+ let w = 0;
286
+ for (let r = 0; r < p.len; r++) {
287
+ const s = p.slots[r];
288
+ if (this.slots[s] === null)
289
+ continue;
290
+ p.slots[w] = s;
291
+ p.tfs[w] = p.tfs[r];
292
+ w++;
293
+ }
294
+ p.len = w;
295
+ live += w;
296
+ if (w === 0)
297
+ this.postings.delete(term);
298
+ }
299
+ this.totalPostings = live;
300
+ this.deadPostings = 0;
301
+ this.freeSlots = [];
302
+ for (let s = 0; s < this.slots.length; s++)
303
+ if (this.slots[s] === null)
304
+ this.freeSlots.push(s);
305
+ }
306
+ // ─── Aggregates ───────────────────────────────────────────────────────────
307
+ addToAggregates(entry) {
308
+ bump(this.partitions, entry.pkey, entry.dl, 1);
309
+ if (!this.partitionRep.has(entry.pkey)) {
310
+ this.partitionRep.set(entry.pkey, {
311
+ id: "",
312
+ agentId: entry.meta.agentId,
313
+ visibility: entry.meta.visibility,
314
+ archived: entry.meta.archived,
315
+ });
316
+ }
317
+ for (const fk of facetKeysOf(entry.meta)) {
318
+ let m = this.facets.get(fk);
319
+ if (!m) {
320
+ m = new Map();
321
+ this.facets.set(fk, m);
322
+ }
323
+ bump(m, entry.pkey, entry.dl, 1);
324
+ }
325
+ }
326
+ removeFromAggregates(entry) {
327
+ bump(this.partitions, entry.pkey, -entry.dl, -1);
328
+ for (const fk of facetKeysOf(entry.meta)) {
329
+ const m = this.facets.get(fk);
330
+ if (m)
331
+ bump(m, entry.pkey, -entry.dl, -1);
332
+ }
333
+ }
334
+ /** Advance the aggregates to `now` by retiring every document whose
335
+ * expiresAt/validTo has passed. Amortized O(1) per document per lifetime. */
336
+ sweep(now) {
337
+ if (now <= this.sweptTo) {
338
+ this.sweptTo = Math.max(this.sweptTo, now);
339
+ return;
340
+ }
341
+ this.sweptTo = now;
342
+ while (this.expiryHeap.length > 0 && this.expiryHeap[0].ts < now) {
343
+ const top = heapPop(this.expiryHeap);
344
+ const entry = this.slots[top.slot];
345
+ if (!entry || entry.expiry !== top.ts)
346
+ continue; // stale heap entry
347
+ if (this.retired.has(top.slot))
348
+ continue;
349
+ this.retired.add(top.slot);
350
+ this.removeFromAggregates(entry);
351
+ }
352
+ }
353
+ // ─── Planning ─────────────────────────────────────────────────────────────
354
+ /**
355
+ * Decide how this query's corpus statistics can be derived, or return null
356
+ * if the index must not serve it at all. Conservative by construction: every
357
+ * branch that cannot be reproduced EXACTLY either downgrades to the linear
358
+ * exact walk or refuses outright.
359
+ */
360
+ planQuery(conditions, timeFilters, isAllowed) {
361
+ const attrs = new Set();
362
+ for (const c of conditions) {
363
+ if (!conditionAttrs(c, attrs))
364
+ return null; // unknown shape/comparator
365
+ }
366
+ for (const a of attrs)
367
+ if (!SUPPORTED_ATTR_SET.has(a))
368
+ return null; // unstored attribute
369
+ // `sinceDate`/`asOf` restrict the corpus by createdAt/validFrom/validTo,
370
+ // which the aggregates do not model. Exact walk.
371
+ const temporallyFiltered = Boolean(timeFilters?.sinceDate || timeFilters?.asOf);
372
+ // An isAllowed we cannot legally hoist to the partition level.
373
+ const hoistable = !isAllowed || isAllowed.scopableOnly === true;
374
+ const partitionConditions = [];
375
+ let facet = null;
376
+ let exact = temporallyFiltered || !hoistable;
377
+ for (const c of conditions) {
378
+ const cAttrs = new Set();
379
+ conditionAttrs(c, cAttrs);
380
+ if ([...cAttrs].every((a) => PARTITION_ATTRS.has(a))) {
381
+ partitionConditions.push(c);
382
+ continue;
383
+ }
384
+ const f = asFacet(c);
385
+ if (f && FACET_ATTRS.has(f.attr) && facet === null) {
386
+ facet = f;
387
+ continue;
388
+ }
389
+ // Mixed-attribute group, a second facet, or a non-equals restriction on
390
+ // a facet attribute: the aggregates cannot express it.
391
+ exact = true;
392
+ partitionConditions.push(c);
393
+ }
394
+ return {
395
+ partitionConditions,
396
+ facetKey: exact ? null : facet ? facet.attr : null,
397
+ facetValues: exact ? null : facet ? facet.values : null,
398
+ exact,
399
+ };
400
+ }
401
+ /**
402
+ * N and avgdl over the scoped corpus — the two statistics `buildBM25` derives
403
+ * from the whole fetched corpus. Returns the EXACT values the legacy path
404
+ * would have computed.
405
+ */
406
+ stats(plan, conditions, timeFilters, isAllowed, now) {
407
+ if (plan.exact) {
408
+ // Linear in stored documents, but over in-memory metadata only: no
409
+ // Harper fetch, no tokenization, no per-doc allocation. This is the
410
+ // fallback for sinceDate/asOf/tag+subject-together queries.
411
+ let N = 0, sumDl = 0;
412
+ for (const entry of this.slots) {
413
+ if (!entry)
414
+ continue;
415
+ if (!matchesConditions(conditions, entry.meta))
416
+ continue;
417
+ if (!passesRecordFilters(entry.meta, { ...timeFilters, now }))
418
+ continue;
419
+ if (isAllowed && !isAllowed(entry.meta))
420
+ continue;
421
+ N++;
422
+ sumDl += entry.dl;
423
+ }
424
+ return { N, sumDl };
425
+ }
426
+ // Aggregate path: O(#partitions), independent of store size.
427
+ const source = plan.facetKey
428
+ ? mergeFacetAggregates(this.facets, plan.facetKey, plan.facetValues)
429
+ : this.partitions;
430
+ let N = 0, sumDl = 0;
431
+ for (const [pkey, agg] of source) {
432
+ if (agg.count === 0)
433
+ continue;
434
+ const rep = this.partitionRep.get(pkey);
435
+ if (!rep)
436
+ continue;
437
+ if (!matchesConditions(plan.partitionConditions, rep))
438
+ continue;
439
+ if (isAllowed && !isAllowed(rep))
440
+ continue;
441
+ N += agg.count;
442
+ sumDl += agg.sumDl;
443
+ }
444
+ return { N, sumDl };
445
+ }
446
+ /**
447
+ * The lexical leg: the ids `buildBM25(scopedCorpus).rank(q)` would place in
448
+ * the top `limit` after dropping score-0 documents. Returns null when the
449
+ * index declines the query (see `planQuery`), in which case the caller MUST
450
+ * run the legacy corpus scan.
451
+ *
452
+ * FULLY SYNCHRONOUS on purpose. A Harper component worker is single-threaded
453
+ * JavaScript, so a body with no `await` in it cannot observe a write landing
454
+ * part-way through: every query sees one consistent index snapshot, the same
455
+ * guarantee the old single-pass corpus fetch gave.
456
+ */
457
+ rank(params) {
458
+ const { q, conditions, limit } = params;
459
+ const timeFilters = params.timeFilters ?? {};
460
+ const isAllowed = params.isAllowed;
461
+ const now = params.now ?? timeFilters.now ?? Date.now();
462
+ const plan = this.planQuery(conditions, timeFilters, isAllowed);
463
+ if (!plan)
464
+ return null;
465
+ this.sweep(now);
466
+ const qToks = [...new Set(tokenize(q))];
467
+ if (qToks.length === 0)
468
+ return [];
469
+ const effectiveFilters = { ...timeFilters, now };
470
+ // Scope decisions are memoized per slot: a slot reached through several
471
+ // query terms is evaluated once, and the df pass and the scoring pass
472
+ // agree by construction.
473
+ const inScope = new Map();
474
+ const check = (slot) => {
475
+ let v = inScope.get(slot);
476
+ if (v !== undefined)
477
+ return v;
478
+ const entry = this.slots[slot];
479
+ v = !!entry
480
+ && matchesConditions(conditions, entry.meta)
481
+ && passesRecordFilters(entry.meta, effectiveFilters)
482
+ && (!isAllowed || isAllowed(entry.meta));
483
+ inScope.set(slot, v);
484
+ return v;
485
+ };
486
+ // ── Pass 1: df(t) over the SCOPED corpus ────────────────────────────────
487
+ const df = new Array(qToks.length).fill(0);
488
+ const lists = new Array(qToks.length);
489
+ for (let i = 0; i < qToks.length; i++) {
490
+ const p = this.postings.get(qToks[i]);
491
+ lists[i] = p;
492
+ if (!p)
493
+ continue;
494
+ let n = 0;
495
+ for (let r = 0; r < p.len; r++)
496
+ if (check(p.slots[r]))
497
+ n++;
498
+ df[i] = n;
499
+ }
500
+ const { N, sumDl } = this.stats(plan, conditions, timeFilters, isAllowed, now);
501
+ const avgdl = sumDl / (N || 1);
502
+ const lengthNorm = avgdl || 1;
503
+ // ── Pass 2: score ───────────────────────────────────────────────────────
504
+ // Terms are visited in `qToks` order and contributions are accumulated in
505
+ // that order per document — the same addition sequence `buildBM25().rank()`
506
+ // uses, so the resulting doubles are bit-identical.
507
+ const scores = new Map();
508
+ for (let i = 0; i < qToks.length; i++) {
509
+ const p = lists[i];
510
+ if (!p)
511
+ continue;
512
+ const n = df[i];
513
+ const idf = Math.log(1 + (N - n + 0.5) / (n + 0.5));
514
+ for (let r = 0; r < p.len; r++) {
515
+ const slot = p.slots[r];
516
+ if (!check(slot))
517
+ continue;
518
+ const f = p.tfs[r];
519
+ const dl = this.slots[slot].dl;
520
+ const numer = f * (BM25_K1 + 1);
521
+ const denom = f + BM25_K1 * (1 - BM25_B + BM25_B * (dl / lengthNorm));
522
+ scores.set(slot, (scores.get(slot) || 0) + idf * (numer / denom));
523
+ }
524
+ }
525
+ // ── Rank ────────────────────────────────────────────────────────────────
526
+ // score>0 only — `buildBM25`'s caller drops zeroes, and a document can only
527
+ // score 0 here by containing no query term at all (the +1 IDF variant is
528
+ // strictly positive for every term, so a match always contributes).
529
+ const out = [];
530
+ for (const [slot, score] of scores) {
531
+ if (score > 0)
532
+ out.push({ id: this.slots[slot].id, score });
533
+ }
534
+ // Score DESC, ties by ascending id — CHARACTER-FOR-CHARACTER the comparator
535
+ // `buildBM25().rank()` uses (resources/bm25.ts). That shared comparator is
536
+ // the whole reason this index can serve the lexical leg: tie order used to
537
+ // be inherited from Harper's corpus-scan order, which is a query-plan
538
+ // artifact an index cannot reconstruct (flair#1363). If one of these two
539
+ // comparators is ever changed, change BOTH — they are one contract written
540
+ // in two places, and test/unit/bm25-index-latency-1357.test.ts asserts
541
+ // top-N equality between the two implementations.
542
+ out.sort((a, b) => (b.score - a.score) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
543
+ return out.slice(0, limit).map((r) => r.id);
544
+ }
545
+ }
546
+ // ─── helpers ────────────────────────────────────────────────────────────────
547
+ function bump(map, key, dl, count) {
548
+ const a = map.get(key);
549
+ if (a) {
550
+ a.count += count;
551
+ a.sumDl += dl;
552
+ return;
553
+ }
554
+ map.set(key, { count, sumDl: dl });
555
+ }
556
+ function partitionKeyOf(meta) {
557
+ return `${meta.agentId ?? ""}${meta.visibility ?? ""}${meta.archived === true ? "1" : "0"}`;
558
+ }
559
+ function* facetKeysOf(meta) {
560
+ const tags = meta.tags;
561
+ if (Array.isArray(tags))
562
+ for (const t of tags)
563
+ yield `tags${String(t)}`;
564
+ if (meta.subject !== undefined && meta.subject !== null)
565
+ yield `subject${String(meta.subject)}`;
566
+ }
567
+ function mergeFacetAggregates(facets, attr, values) {
568
+ // A record carries ONE subject and is counted once per tag it holds, and a
569
+ // tag-equals filter names a single tag — so summing across `values` never
570
+ // double-counts a document.
571
+ if (values.length === 1)
572
+ return facets.get(`${attr}${values[0]}`) ?? new Map();
573
+ const merged = new Map();
574
+ for (const v of values) {
575
+ const m = facets.get(`${attr}${v}`);
576
+ if (!m)
577
+ continue;
578
+ for (const [pkey, agg] of m)
579
+ bump(merged, pkey, agg.sumDl, agg.count);
580
+ }
581
+ return merged;
582
+ }
583
+ function expiryOf(meta) {
584
+ let e = Infinity;
585
+ const exp = meta.expiresAt ? Date.parse(meta.expiresAt) : NaN;
586
+ if (!Number.isNaN(exp))
587
+ e = Math.min(e, exp);
588
+ const vt = meta.validTo ? Date.parse(meta.validTo) : NaN;
589
+ if (!Number.isNaN(vt))
590
+ e = Math.min(e, vt);
591
+ return e;
592
+ }
593
+ // Binary min-heap on `.ts`.
594
+ function heapPush(h, v) {
595
+ h.push(v);
596
+ let i = h.length - 1;
597
+ while (i > 0) {
598
+ const parent = (i - 1) >> 1;
599
+ if (h[parent].ts <= h[i].ts)
600
+ break;
601
+ const tmp = h[parent];
602
+ h[parent] = h[i];
603
+ h[i] = tmp;
604
+ i = parent;
605
+ }
606
+ }
607
+ function heapPop(h) {
608
+ if (h.length === 0)
609
+ return undefined;
610
+ const top = h[0];
611
+ const last = h.pop();
612
+ if (h.length > 0) {
613
+ h[0] = last;
614
+ let i = 0;
615
+ for (;;) {
616
+ const l = 2 * i + 1, r = l + 1;
617
+ let m = i;
618
+ if (l < h.length && h[l].ts < h[m].ts)
619
+ m = l;
620
+ if (r < h.length && h[r].ts < h[m].ts)
621
+ m = r;
622
+ if (m === i)
623
+ break;
624
+ const tmp = h[m];
625
+ h[m] = h[i];
626
+ h[i] = tmp;
627
+ i = m;
628
+ }
629
+ }
630
+ return top;
631
+ }
@@ -80,7 +80,37 @@ export function buildBM25(docs) {
80
80
  }
81
81
  return { id: d.id, score: s };
82
82
  });
83
- scored.sort((a, b) => b.score - a.score);
83
+ // Score DESC, ties broken by ascending id (flair#1363, Kern-ruled
84
+ // 2026-08-24, landed with the flair#1357 index work).
85
+ //
86
+ // This sort used to be `(a, b) => b.score - a.score` alone. Because
87
+ // `Array.prototype.sort` is stable, equal-scoring documents then came back
88
+ // in whatever order Harper's corpus scan had yielded them — and THAT order
89
+ // is a query-plan artifact, not a property of the store. Measured against a
90
+ // live instance (test/integration/bm25-index-scan-order-1357.test.ts): the
91
+ // same rows, for the same query text, iterate in one order under the
92
+ // multi-agent read-scope OR-group (the reader's own agentId-indexed rows
93
+ // lead, everything else follows) and in a DIFFERENT one under a
94
+ // tags/subject filter (plain primary-key order). Harper's planner is
95
+ // cost-based, so which of those applies is a function of data
96
+ // distribution. Hybrid recall was therefore nondeterministic for tied
97
+ // documents, and nothing in the suite could see it — only exact score ties
98
+ // move, and they move at the tail of the result window.
99
+ //
100
+ // Ties are NOT a curiosity: a document matching one rare query term once,
101
+ // with the same token count as another such document, scores identically,
102
+ // and flair's live corpus clusters at 19-33 tokens
103
+ // (test/bench/corpus-profiler/profiles/corpus-v2.json). Measured at
104
+ // realistic corpus shapes, 96-99% of queries have a tie inside the returned
105
+ // window.
106
+ //
107
+ // Making the tie-break EXPLICIT is what lets resources/bm25-index.ts serve
108
+ // the lexical leg at all: an index cannot reproduce a planner, but it can
109
+ // reproduce `id` ascending. Both paths now sort exactly this way, so their
110
+ // output is identical including tie order. The ranking change is confined
111
+ // to documents whose BM25 scores are bit-identical — where there was no
112
+ // defined order to preserve, only an accident to stop inheriting.
113
+ scored.sort((a, b) => (b.score - a.score) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
84
114
  return scored;
85
115
  }
86
116
  return { rank, get N() { return N; }, get avgdl() { return avgdl; } };