@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.
- package/README.md +2 -0
- package/dist/build-info.json +3 -3
- package/dist/cli.js +525 -121
- package/dist/component-env.js +52 -4
- package/dist/doctor-client.js +46 -1
- package/dist/hook-install.js +52 -4
- package/dist/install/clients.js +318 -9
- package/dist/lib/auth-resolve.js +34 -3
- package/dist/lib/mcp-enable.js +134 -26
- package/dist/resources/AgentSeed.js +2 -0
- package/dist/resources/Memory.js +24 -5
- package/dist/resources/MemoryFeed.js +3 -0
- package/dist/resources/MemoryMaintenance.js +11 -2
- package/dist/resources/bm25-index-service.js +257 -0
- package/dist/resources/bm25-index.js +631 -0
- package/dist/resources/bm25.js +31 -1
- package/dist/resources/embeddings-boot.js +45 -3
- package/dist/resources/memory-read-scope.js +2 -0
- package/dist/resources/semantic-retrieval-core.js +93 -22
- package/dist/version-check.js +59 -13
- package/docs/claude-code.md +10 -3
- package/docs/deployment.md +11 -1
- package/docs/integrations.md +25 -4
- package/docs/mcp-clients.md +18 -0
- package/docs/notes/mcp-oauth-model2.md +31 -13
- package/docs/quickstart.md +9 -9
- package/docs/standalone-local.md +3 -0
- package/package.json +3 -2
|
@@ -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
|
+
}
|
package/dist/resources/bm25.js
CHANGED
|
@@ -80,7 +80,37 @@ export function buildBM25(docs) {
|
|
|
80
80
|
}
|
|
81
81
|
return { id: d.id, score: s };
|
|
82
82
|
});
|
|
83
|
-
|
|
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; } };
|