@tpsdev-ai/flair 0.47.1 → 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,257 @@
1
+ // ─── Harper wiring for the persistent BM25 index (flair#1357) ───────────────
2
+ //
3
+ // ./bm25-index.ts is the Harper-free data structure. This module owns the one
4
+ // process-wide instance of it and answers the only two questions the retrieval
5
+ // core asks: "can you serve this lexical leg?" and "here is a write you should
6
+ // know about".
7
+ //
8
+ // ── WHERE THE INDEX STATE LIVES, AND WHY ────────────────────────────────────
9
+ // In process memory, per Harper worker, NOT in a Harper table.
10
+ //
11
+ // A Harper-table posting list was considered and rejected on WRITE cost: a
12
+ // memory averages ~26 tokens (the measured live corpus,
13
+ // test/bench/corpus-profiler/profiles), so persisting postings would turn one
14
+ // `Memory.put()` into ~25 additional indexed row writes inside the same
15
+ // transaction — write amplification on the ingestion path in order to speed up
16
+ // the read path. It would also put a Harper round-trip per query TERM back
17
+ // into recall. The in-process structure costs one full corpus scan per worker
18
+ // lifetime, which is exactly ONE instance of what the defect used to charge on
19
+ // EVERY query.
20
+ //
21
+ // Footprint at 250k documents: ~6.5M postings held as paired Int32Arrays
22
+ // (~52MB), the term dictionary (~20MB), and per-document scope metadata with
23
+ // NO content and NO embedding (~50MB) — order 120MB steady state. For scale:
24
+ // the code this replaces allocated a 250k-entry array of per-document term
25
+ // Maps plus the whole projected corpus INCLUDING content, transiently, on
26
+ // every single query.
27
+ //
28
+ // ── COLD BOOT: LAZY ─────────────────────────────────────────────────────────
29
+ // Built on the first hybrid query that carries query text, not at component
30
+ // start. Eager building would add a full corpus scan to every boot including
31
+ // the many processes that never search (CLI verbs, migration boots, health
32
+ // checks), and it would race the embedding engine's own model load. The first
33
+ // query after boot pays what every query used to pay; every one after it pays
34
+ // nothing. Concurrent first queries share a single build promise.
35
+ //
36
+ // ── STAYING CURRENT ─────────────────────────────────────────────────────────
37
+ // Two mechanisms, deliberately overlapping:
38
+ //
39
+ // 1. THE TABLE'S OWN CHANGE FEED (`Memory.subscribe`) is the authority. It
40
+ // is the same audit-log-backed primitive `FeedMemories.connect()` already
41
+ // uses, and it observes the TABLE — so it sees writes that never touch a
42
+ // flair resource at all: operations-API writes, `flair` CLI direct
43
+ // writes, and Harper replication applying federated rows. A scheme built
44
+ // only from hooks in flair's own write paths CANNOT see those, which is
45
+ // why the feed — not the hook list — is the correctness argument.
46
+ // Verified against a stock instance: an operations-API insert and an
47
+ // operations-API delete both arrive (put/delete with the full row).
48
+ //
49
+ // 2. SYNCHRONOUS HOOKS at flair's own write surface (`noteMemoryUpsert` /
50
+ // `noteMemoryDelete`) give READ-YOUR-WRITE. The feed is asynchronous, so
51
+ // without the hooks a store immediately followed by a search would be a
52
+ // race — and the path being replaced had no such race, because it refetched
53
+ // the corpus every query. Both mechanisms are idempotent upserts keyed by
54
+ // id, so seeing a write twice is a no-op.
55
+ //
56
+ // If the feed cannot be established, or delivers an event shape we do not
57
+ // understand (Harper emits a bare `reload` marker when a base copy / resync is
58
+ // applied — precisely when the index CANNOT be patched incrementally), the
59
+ // index marks itself stale and the next query rebuilds it. If subscription
60
+ // fails outright, the index DISABLES itself and every query falls back to the
61
+ // legacy per-query corpus scan. A slow-but-correct recall is acceptable; a
62
+ // silently stale one is not — recall is the product floor.
63
+ //
64
+ // ── MULTI-WORKER ────────────────────────────────────────────────────────────
65
+ // The instance is per worker thread, so in a multi-worker configuration each
66
+ // worker pays its own first-query build and holds its own copy of the index.
67
+ // Both of flair's shipped launch paths pin `THREADS_COUNT=1` (src/cli.ts's
68
+ // launchd plist and its direct-spawn env), as does the integration harness, so
69
+ // the shipped configuration has exactly one worker and "per worker" is "per
70
+ // process". Cross-worker write visibility rides on mechanism (1): the feed is
71
+ // audit-log-backed and the audit store is shared, so a write committed by
72
+ // another worker still arrives. Mechanism (2) is local to the writing worker,
73
+ // which is why it is an immediacy optimisation and never the correctness
74
+ // argument.
75
+ import { databases } from "harper";
76
+ import { withDetachedTxn } from "./table-helpers.js";
77
+ import { Bm25Index, INDEX_SELECT } from "./bm25-index.js";
78
+ /** Kill switch. Default ON; set FLAIR_BM25_INDEX=false/0/off to force every
79
+ * query back onto the legacy per-query corpus scan + buildBM25(). Read
80
+ * per-call so it can be flipped without a rebuild and set per-case in tests. */
81
+ export function bm25IndexEnabled() {
82
+ const v = (process.env.FLAIR_BM25_INDEX ?? "true").toLowerCase();
83
+ return v === "true" || v === "1" || v === "on";
84
+ }
85
+ const index = new Bm25Index();
86
+ let state = "empty";
87
+ let buildPromise = null;
88
+ let pending = null;
89
+ let feedStarted = false;
90
+ let disabledReason = "";
91
+ /** Test seam — resets everything this module owns. */
92
+ export function __resetBm25IndexForTests() {
93
+ index.clear();
94
+ state = "empty";
95
+ buildPromise = null;
96
+ pending = null;
97
+ feedStarted = false;
98
+ disabledReason = "";
99
+ }
100
+ /** Diagnostics, for tests and `flair doctor`-shaped callers. */
101
+ export function bm25IndexStatus() {
102
+ return { state, size: index.size, postings: index.postingCount, terms: index.termCount, reason: disabledReason };
103
+ }
104
+ function project(record) {
105
+ if (!record || typeof record.id !== "string")
106
+ return null;
107
+ const out = { id: record.id };
108
+ for (const k of INDEX_SELECT)
109
+ if (k !== "id" && k in record)
110
+ out[k] = record[k];
111
+ return out;
112
+ }
113
+ function apply(ev) {
114
+ if (ev.kind === "delete")
115
+ index.remove(ev.id);
116
+ else
117
+ index.upsert(ev.record);
118
+ }
119
+ function record(ev) {
120
+ if (state === "disabled" || state === "empty")
121
+ return; // a later build will scan it
122
+ if (state === "building") {
123
+ pending.push(ev);
124
+ return;
125
+ }
126
+ apply(ev);
127
+ }
128
+ /** Read-your-write hook: call immediately after a committed Memory write that
129
+ * changed content or any scope/temporal attribute. Safe to call for writes
130
+ * that changed neither (it is an idempotent re-index of one row). */
131
+ export function noteMemoryUpsert(row) {
132
+ const r = project(row);
133
+ if (r)
134
+ record({ kind: "upsert", record: r });
135
+ }
136
+ /** Read-your-write hook: call immediately after a committed Memory delete. */
137
+ export function noteMemoryDelete(id) {
138
+ if (typeof id === "string" && id.length > 0)
139
+ record({ kind: "delete", id });
140
+ }
141
+ /** Force the next query to rebuild — used when the feed reports a change we
142
+ * cannot express incrementally (a resync/base-copy `reload` marker). */
143
+ export function markBm25IndexStale(reason) {
144
+ if (state === "disabled")
145
+ return;
146
+ disabledReason = reason;
147
+ state = "empty";
148
+ buildPromise = null;
149
+ }
150
+ function disable(reason) {
151
+ state = "disabled";
152
+ disabledReason = reason;
153
+ buildPromise = null;
154
+ pending = null;
155
+ index.clear();
156
+ }
157
+ async function startFeed(ctx) {
158
+ if (feedStarted)
159
+ return;
160
+ feedStarted = true;
161
+ const subscription = await withDetachedTxn(ctx, () => databases.flair.Memory.subscribe({ omitCurrent: true }));
162
+ // Deliberately not awaited: the consumer runs for the life of the process.
163
+ (async () => {
164
+ try {
165
+ for await (const ev of subscription) {
166
+ const type = ev?.type;
167
+ if (type === "delete") {
168
+ record({ kind: "delete", id: String(ev.id) });
169
+ }
170
+ else if (type === "put" || type === "insert" || type === "update" || type === "upsert") {
171
+ const r = project(ev?.value);
172
+ if (r)
173
+ record({ kind: "upsert", record: r });
174
+ else
175
+ markBm25IndexStale(`feed ${type} event carried no usable record`);
176
+ }
177
+ else if (type !== undefined) {
178
+ // Includes Harper's `reload` base-copy/resync marker: the table's
179
+ // contents may have been replaced wholesale with no per-row events.
180
+ markBm25IndexStale(`unhandled feed event type ${String(type)}`);
181
+ }
182
+ }
183
+ disable("change feed ended");
184
+ }
185
+ catch (err) {
186
+ disable("change feed error: " + String(err?.message ?? err));
187
+ }
188
+ })();
189
+ }
190
+ /**
191
+ * Build (or rebuild) the index from one full corpus scan.
192
+ *
193
+ * ORDER IS LOAD-BEARING: the change feed is started BEFORE the scan, and the
194
+ * events it delivers during the scan are buffered and replayed AFTER it. A
195
+ * delete that lands mid-scan for a row the cursor has not reached yet would
196
+ * otherwise be applied first and then undone by the cursor re-adding the row.
197
+ * Replaying after the scan lets the newer event win, whichever order they
198
+ * physically occurred in.
199
+ */
200
+ async function build(ctx) {
201
+ state = "building";
202
+ pending = [];
203
+ index.clear();
204
+ try {
205
+ await startFeed(ctx);
206
+ const results = withDetachedTxn(ctx, () => databases.flair.Memory.search({ select: INDEX_SELECT }));
207
+ for await (const row of results) {
208
+ const r = project(row);
209
+ if (r)
210
+ index.upsert(r);
211
+ }
212
+ }
213
+ catch (err) {
214
+ disable("build failed: " + String(err?.message ?? err));
215
+ return false;
216
+ }
217
+ const buffered = pending ?? [];
218
+ pending = null;
219
+ // `state` may have been knocked back to "empty" by a stale marker that
220
+ // arrived during the scan; in that case do not claim readiness.
221
+ if (state !== "building")
222
+ return false;
223
+ state = "ready";
224
+ for (const ev of buffered)
225
+ apply(ev);
226
+ return true;
227
+ }
228
+ async function ensureReady(ctx) {
229
+ if (!bm25IndexEnabled())
230
+ return false;
231
+ if (state === "disabled")
232
+ return false;
233
+ if (state === "ready")
234
+ return true;
235
+ if (!buildPromise)
236
+ buildPromise = build(ctx).finally(() => { buildPromise = null; });
237
+ return buildPromise;
238
+ }
239
+ /**
240
+ * The lexical leg, served from the index. Returns the BM25 candidate ids
241
+ * (score>0, best-first, sliced to `limit`) — or NULL when the index declines,
242
+ * in which case the caller MUST run the legacy corpus scan + buildBM25(). Null
243
+ * is returned for: the kill switch, a failed/disabled index, and any query
244
+ * whose conditions the index cannot reproduce exactly (see
245
+ * ./bm25-index.ts's `planQuery`).
246
+ */
247
+ export async function indexedBm25Ids(params) {
248
+ if (!(await ensureReady(params.ctx)))
249
+ return null;
250
+ try {
251
+ return index.rank(params);
252
+ }
253
+ catch (err) {
254
+ disable("rank failed: " + String(err?.message ?? err));
255
+ return null;
256
+ }
257
+ }