@tpsdev-ai/flair 0.27.0 → 0.28.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/dist/cli.js CHANGED
@@ -8873,6 +8873,28 @@ program
8873
8873
  }
8874
8874
  });
8875
8875
  // ─── flair reembed ────────────────────────────────────────────────────────────
8876
+ //
8877
+ // ROOT-CAUSE GUARD — recall graph correctness (recall-hnsw-graph-heal).
8878
+ // `flair reembed` replaces the stored embedding of many rows IN PLACE (it
8879
+ // clears embedding/embeddingModel, then re-PUTs through Memory.put()'s regen
8880
+ // branch — the same bulk in-place re-embed path resources/migrations/
8881
+ // embedding-stamp.ts uses). Historically, an OLDER (pre-fix) Harper's
8882
+ // INCREMENTAL HNSW update left stale/asymmetric reverse edges under bulk
8883
+ // re-embed, which collapsed prod recall in July. That engine bug is FIXED in
8884
+ // the Harper this ships against (5.1.22) — its update path reconstructs the
8885
+ // prior vector and does the reverse-edge cleanup the old build skipped — so a
8886
+ // bulk re-embed no longer corrupts the graph. DEFENSE-IN-DEPTH RULE (a
8887
+ // prudent, version-independent default, not a workaround for a live bug): pair
8888
+ // any BULK re-embed with a structural graph REBUILD trigger rather than relying
8889
+ // on incremental HNSW updates to converge (today: the
8890
+ // `@indexed(type:"HNSW", M:16)` descriptor bump in schemas/memory.graphql,
8891
+ // which makes Harper clear + rebuild the graph cleanly from the stored vectors
8892
+ // on the next boot — see resources/migrations/graph-heal.ts). Do NOT use
8893
+ // resources/MemoryReindex.ts's `_reindex` for graph correctness (it re-PUTs the
8894
+ // same vector through the incremental path and rebuilds nothing). If a `flair
8895
+ // reembed` run ever materially changes the vector space (e.g. a model swap),
8896
+ // follow it with a deploy that trips the structural reindex (bump the HNSW
8897
+ // descriptor / restart after a schema change).
8876
8898
  program
8877
8899
  .command("reembed")
8878
8900
  .description("Re-generate embeddings for memories with stale or missing model tags")
@@ -60,6 +60,70 @@
60
60
  * known, narrow gap this bounded query cannot see;
61
61
  * resources/migration-boot.ts mitigates the common case by waiting for the
62
62
  * embeddings engine to settle before running migrations at all.
63
+ *
64
+ * flair#807 — completion-gate false-negative on 0.10-era / cross-version
65
+ * stores, root-caused against the installed `@harperfast/harper@5.1.22`
66
+ * source (node_modules/@harperfast/harper/resources/search.ts): for an
67
+ * INDEXED attribute (`embeddingModel` IS `@indexed` — schemas/memory.graphql),
68
+ * a non-negated `not_equal`/`ne` leaf condition takes `searchByIndex`'s
69
+ * `index && !skipIndex` branch, which reconstructs a SYNTHETIC partial record
70
+ * from the SECONDARY INDEX KEY alone (`recordMatcher = { [attribute_name]:
71
+ * key }`, resources/search.ts ~L418) and filters against THAT — never reading
72
+ * the live record. On a store whose rows were written under flair 0.10.0 /
73
+ * Harper 5.0.x and later rewritten via `PUT /Memory` (crossing a Harper
74
+ * 5.0.21→5.1.22 boot), the secondary index for `embeddingModel` can lag the
75
+ * live, on-disk value — the filter then evaluates a STALE key that never
76
+ * equals `getCurrentModelId()`, matching every row regardless of its true
77
+ * (already-current) stamp.
78
+ *
79
+ * The fix: use `comparator: "not_equals"` (the `not_` PREFIX form, distinct
80
+ * from the legacy `not_equal`/`ne` ALIAS — see search.ts's
81
+ * `resolveComparator`/`NEGATABLE_BASE_COMPARATORS`) for the not-current leg.
82
+ * `prepareConditions` resolves this to `{comparator: "equals", negated:
83
+ * true}`, which flips `searchByIndex`'s `skipIndex` flag
84
+ * (`searchCondition.negated && index && !isPrimaryKey`) — this is Harper's
85
+ * OWN documented mechanism for "bypass the secondary index, iterate the
86
+ * primary store directly" (see that file's comment: "we need to consider
87
+ * records whose attribute value is missing from the index... so we bypass
88
+ * the secondary index"). That path reads the LIVE record unconditionally,
89
+ * immune to any index/record divergence regardless of cause (this migration,
90
+ * a future one, or a Harper-internal index-rebuild timing issue). Strictly
91
+ * MORE reliable than the index-assisted path in every case — a genuinely
92
+ * stale row still matches (full scan over live values), so this changes
93
+ * nothing about the "3/3 success" happy path, only closes the false-positive
94
+ * gap on migrated stores. The `equals: null` leg is unaffected (not a
95
+ * negated comparator — no analogous bypass exists or is needed; its
96
+ * behavior was already verified against real Harper per the doc above).
97
+ *
98
+ * `recheckPending()` below is a SEPARATE, generic defense-in-depth layer
99
+ * (wired into resources/migrations/runner.ts's completion gate) — even a
100
+ * migration whose countPending() query is airtight can be defeated by some
101
+ * OTHER, not-yet-understood counting artifact; this lets the runner PROVE
102
+ * (via `.get()`, a primary-key lookup — always live, never index-assisted)
103
+ * that a nonzero countPending() result is real before halting on it.
104
+ *
105
+ * ROOT-CAUSE GUARD — recall graph correctness (recall-hnsw-graph-heal).
106
+ * This migration RE-EMBEDS rows in BULK via `PUT /Memory/:id` — it replaces
107
+ * the stored vector of many rows in place. Historically, an OLDER (pre-fix)
108
+ * Harper's INCREMENTAL HNSW update left stale/asymmetric reverse edges under
109
+ * exactly this kind of bulk re-embed (order-dependent), which collapsed prod
110
+ * recall (p@3 0.25) in July. That engine bug is FIXED in the Harper this ships
111
+ * against (5.1.22): its index() reconstructs the prior vector from the stored
112
+ * node and carries the node's real connections into the reverse-edge cleanup
113
+ * the old build skipped, so a bulk re-embed no longer corrupts the graph.
114
+ * DEFENSE-IN-DEPTH RULE (a prudent, version-independent default — not a
115
+ * workaround for a live bug): pair any BULK re-embed with a structural graph
116
+ * REBUILD trigger rather than relying on incremental HNSW updates to converge
117
+ * — it keeps recall correct regardless of engine version, closes the rare
118
+ * decode-failure edge case, and heals a graph already corrupted on disk by an
119
+ * older engine. Do NOT reach for resources/MemoryReindex.ts's `_reindex` for
120
+ * graph correctness (it re-PUTs the same vector through the incremental path
121
+ * and rebuilds nothing). The lever flair uses is a structural schema-descriptor
122
+ * change: the `@indexed(type:"HNSW", M:16)` bump in schemas/memory.graphql
123
+ * ships alongside this migration precisely so a graph corrupted under an older
124
+ * engine is rebuilt on the upgrade boot (observed/ledgered by
125
+ * resources/migrations/graph-heal.ts). Before enabling any FUTURE bulk
126
+ * re-embed, ship a structural reindex trigger with it.
63
127
  */
64
128
  import { databases } from "@harperfast/harper";
65
129
  import { getModelId } from "../embeddings-provider.js";
@@ -115,16 +179,24 @@ async function regenViaHttpPut(id, existing, fetchImpl) {
115
179
  */
116
180
  export function createEmbeddingStampMigration(getTable = defaultMemoryTable, getCurrentModelId = getModelId, regen = (id, existing) => regenViaHttpPut(id, existing, fetch)) {
117
181
  function staleCondition() {
118
- // OR-combined: `not_equal <current>` catches a stale non-null model
182
+ // OR-combined: `not_equals <current>` catches a stale non-null model
119
183
  // string; `equals null` catches the explicit-null state this
120
184
  // migration's own writes leave behind on a failed regen (see the
121
185
  // module doc above — Harper's index never sees a TRULY ABSENT
122
186
  // property, only an explicit null).
187
+ //
188
+ // "not_equals" (NOT the legacy "not_equal" alias — see the flair#807
189
+ // addendum in the module doc) is load-bearing: it's the `not_` PREFIX
190
+ // form Harper's query engine resolves to a `negated: true` leaf, which
191
+ // bypasses the (on some stores, stale/desynced) secondary index and
192
+ // reads the live record directly. Reverting this to "not_equal" would
193
+ // reopen #807 on any store where the embeddingModel index lags the
194
+ // on-disk value.
123
195
  return [
124
196
  {
125
197
  operator: "or",
126
198
  conditions: [
127
- { attribute: "embeddingModel", comparator: "not_equal", value: getCurrentModelId() },
199
+ { attribute: "embeddingModel", comparator: "not_equals", value: getCurrentModelId() },
128
200
  { attribute: "embeddingModel", comparator: "equals", value: null },
129
201
  ],
130
202
  },
@@ -174,5 +246,37 @@ export function createEmbeddingStampMigration(getTable = defaultMemoryTable, get
174
246
  }
175
247
  return { processed: touchedIds.length, touchedIds };
176
248
  },
249
+ /**
250
+ * flair#807 completion-gate safety net (resources/migrations/runner.ts
251
+ * calls this ONLY when a nonzero countPending() would otherwise halt the
252
+ * migration). Re-derives up to `limit` currently-"pending" ids via the
253
+ * SAME staleCondition() search countPending() uses, then re-checks each
254
+ * one by a DIRECT `.get()` — a primary-key lookup, never index-assisted,
255
+ * so it can't be fooled by the same secondary-index divergence that can
256
+ * make the search-based count wrong. Returns counts only (never ids —
257
+ * matches this codebase's structural-only-disclosure discipline, e.g.
258
+ * resources/migrations/ledger.ts's module doc), so the runner can log a
259
+ * loud, actionable WARN without ever needing to know which rows.
260
+ */
261
+ async recheckPending(limit) {
262
+ const table = getTable();
263
+ const current = getCurrentModelId();
264
+ const ids = [];
265
+ for await (const row of table.search({ conditions: staleCondition(), limit })) {
266
+ const id = String(row.id ?? "");
267
+ if (id)
268
+ ids.push(id);
269
+ }
270
+ let falsePositives = 0;
271
+ for (const id of ids) {
272
+ const existing = await table.get(id);
273
+ // A row that vanished since the search, OR whose live embeddingModel
274
+ // still doesn't match current, is a GENUINE pending row (or a
275
+ // concurrent delete) — never counted as a false positive.
276
+ if (existing && existing.embeddingModel === current)
277
+ falsePositives++;
278
+ }
279
+ return { sampled: ids.length, falsePositives };
280
+ },
177
281
  };
178
282
  }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * graph-heal.ts — the recall graph-heal OBSERVABILITY migration.
3
+ *
4
+ * WHAT ACTUALLY HEALS: nothing in this file. The heal is entirely
5
+ * schema-driven — `schemas/memory.graphql`'s `embedding` field now declares
6
+ * `@indexed(type: "HNSW", M: 16)` (M:16 = Harper's own default, a zero
7
+ * behavior change). On the first boot after upgrade, Harper structurally
8
+ * diffs the persisted per-attribute HNSW descriptor (built with NO options on
9
+ * every pre-this-change install) against the schema and, because
10
+ * `canonicalizeIndexOptions` does NOT inject defaults (`{type:"HNSW"}` and
11
+ * `{type:"HNSW",M:16}` are DIFFERENT canonical keys — verified against the
12
+ * installed @harperfast/harper source, resources/databases.js:
13
+ * canonicalizeIndexOptions + the `indexOptionsChanged` reset of
14
+ * `lastIndexedKey` to undefined), clears the graph store and rebuilds it
15
+ * CLEANLY from the already-correct stored vectors. That rebuild heals the
16
+ * stale/asymmetric reverse-edge corruption that an OLDER (pre-fix) Harper's
17
+ * INCREMENTAL HNSW update left on disk during the July bulk embedding-stamp
18
+ * re-embed — a fresh index of the same vectors finds the true neighbors, the
19
+ * mangled live graph did not. The corruption is HISTORICAL: the installed
20
+ * Harper (5.1.22) already fixes that incremental-update path —
21
+ * HierarchicalNavigableSmallWorld reconstructs the prior vector from the
22
+ * stored node and carries its real connections into the reverse-edge cleanup
23
+ * the old build skipped — so current Harper no longer produces it; it just
24
+ * still persists on disk in stores re-embedded under the old engine. The heal
25
+ * is therefore version-agnostic: it repairs any such store regardless of the
26
+ * Harper now running. No re-embed happens — only the graph is rebuilt from the
27
+ * vectors already on disk.
28
+ *
29
+ * WHY THIS MIGRATION EXISTS ANYWAY: the heal is invisible — Harper triggers
30
+ * it, this code cannot force it, and there is no Harper API to observe it
31
+ * directly. So this migration's ONLY job is to VERIFY + LEDGER that recall is
32
+ * healthy after the boot, so the heal is auditable and version-gated (runs
33
+ * once per upgrade, then the runner's state-file short-circuit — state.ts —
34
+ * skips it). It touches ZERO rows: riskClass 'derived-only', countPending()
35
+ * is always 0, run() writes one structural-only OrgEvent and returns
36
+ * `processed: 0`. The runner (resources/migrations/runner.ts) then records
37
+ * success in the state file, short-circuiting every subsequent boot at this
38
+ * version.
39
+ *
40
+ * NON-THROWING throughout (invariant II — the runner halts-not-bricks): a
41
+ * canary that can't run, an index still mid-rebuild, or a ledger-write
42
+ * failure all degrade to "not verified this boot, retry next boot" or a
43
+ * best-effort skip — never an exception into the boot path.
44
+ *
45
+ * DEFENSE-IN-DEPTH RULE (a prudent, version-independent default — NOT a
46
+ * workaround for a live bug): any BULK re-embed of the corpus should be paired
47
+ * with a structural graph REBUILD (a full runIndexing pass) rather than
48
+ * relying on the engine's incremental `index()` updates to converge. The
49
+ * installed Harper (5.1.22) DOES converge — its update path is fixed — but the
50
+ * rebuild keeps recall correct regardless of engine version, closes the rare
51
+ * decode-failure edge case, and is what heals a store already corrupted on
52
+ * disk by an older engine. The lever flair uses for that rebuild is a
53
+ * structural-schema change (the M:16 descriptor bump that ships with this
54
+ * migration) — see the guard comments in
55
+ * resources/migrations/embedding-stamp.ts and the `flair reembed` path in
56
+ * src/cli.ts. Do NOT use resources/MemoryReindex.ts's `_reindex` for graph
57
+ * correctness — it re-PUTs the same vector through the incremental path and
58
+ * does not rebuild the graph.
59
+ */
60
+ import { databases } from "@harperfast/harper";
61
+ import { existsSync, readFileSync } from "node:fs";
62
+ import { dirname, join } from "node:path";
63
+ import { fileURLToPath } from "node:url";
64
+ export const GRAPH_HEAL_ID = "graph-heal";
65
+ /** How many rows to scan for a canary that carries a real embedding. Bounded — detect() must stay cheap. */
66
+ const CANARY_SCAN_LIMIT = 8;
67
+ function defaultMemoryTable() {
68
+ return databases.flair.Memory;
69
+ }
70
+ function defaultOrgEventTable() {
71
+ return databases.flair.OrgEvent;
72
+ }
73
+ /**
74
+ * Same "resolve the running package's own version" idiom as
75
+ * resources/health.ts / resources/migration-boot.ts. Inlined (not imported
76
+ * from migration-boot) to avoid an import cycle: migration-boot → registry →
77
+ * graph-heal. Purely informational — it only stamps the ledger detail blob;
78
+ * a "dev" fallback is harmless.
79
+ */
80
+ function resolveRunningVersionLocal() {
81
+ try {
82
+ const here = dirname(fileURLToPath(import.meta.url));
83
+ const candidates = [
84
+ join(here, "..", "..", "..", "package.json"), // dist/resources/migrations → root
85
+ join(here, "..", "..", "package.json"),
86
+ ];
87
+ for (const p of candidates) {
88
+ if (existsSync(p)) {
89
+ const pkg = JSON.parse(readFileSync(p, "utf-8"));
90
+ if (pkg.version)
91
+ return String(pkg.version);
92
+ }
93
+ }
94
+ }
95
+ catch {
96
+ /* fall through */
97
+ }
98
+ return process.env.npm_package_version ?? "dev";
99
+ }
100
+ /**
101
+ * Pick a canary row that carries a real embedding vector: scan a bounded
102
+ * handful of rows and `.get()` the first one whose stored `embedding` is a
103
+ * non-empty array. `.get()` (a primary-key read) always returns the full
104
+ * record including `embedding`, independent of any `select`/projection
105
+ * quirk. Returns null when the corpus has no embedded rows at all — a fresh
106
+ * or empty instance, where there is simply nothing to heal or verify.
107
+ */
108
+ async function pickCanary(table) {
109
+ for await (const row of table.search({ select: ["id"], limit: CANARY_SCAN_LIMIT })) {
110
+ const id = String(row.id ?? "");
111
+ if (!id)
112
+ continue;
113
+ const full = await table.get(id);
114
+ const emb = full?.embedding;
115
+ if (Array.isArray(emb) && emb.length > 0)
116
+ return { id, embedding: emb };
117
+ }
118
+ return null;
119
+ }
120
+ /**
121
+ * The id of the HNSW nearest neighbor of `target` — the exact cosine-sort
122
+ * query the retrieval path (resources/semantic-retrieval-core.ts) uses,
123
+ * bounded to rank 1. Throws if the index is unavailable / mid-rebuild; the
124
+ * caller treats a throw as "not verified this boot".
125
+ */
126
+ async function topNeighborId(table, target) {
127
+ for await (const row of table.search({
128
+ sort: { attribute: "embedding", target, distance: "cosine" },
129
+ select: ["id"],
130
+ limit: 1,
131
+ })) {
132
+ const id = row.id;
133
+ return id ? String(id) : null;
134
+ }
135
+ return null;
136
+ }
137
+ /**
138
+ * Canary self-recall: a healthy HNSW graph returns the canary row itself as
139
+ * the rank-1 neighbor of its OWN stored vector. Cheap, read-only, bounded —
140
+ * and the cleanest black-box proof the graph rebuilt and is serving (a
141
+ * mid-rebuild index throws or returns nothing; a store with no embedded rows
142
+ * has no canary). Never throws (invariant II).
143
+ */
144
+ async function recallHealthy(table) {
145
+ try {
146
+ const canary = await pickCanary(table);
147
+ if (!canary)
148
+ return { verified: false, hasData: false };
149
+ const top = await topNeighborId(table, canary.embedding);
150
+ return { verified: top === canary.id, hasData: true };
151
+ }
152
+ catch {
153
+ return { verified: false, hasData: true };
154
+ }
155
+ }
156
+ /** Count rows carrying a real (non-hash-fallback) embedding — the vectors that live in the HNSW graph. Best-effort. */
157
+ async function countEmbeddedVectors(table) {
158
+ try {
159
+ let n = 0;
160
+ for await (const row of table.search({ select: ["embeddingModel"] })) {
161
+ const m = row.embeddingModel;
162
+ if (typeof m === "string" && m.length > 0 && m !== "hash-512d")
163
+ n++;
164
+ }
165
+ return n;
166
+ }
167
+ catch {
168
+ return null;
169
+ }
170
+ }
171
+ /**
172
+ * Structural-only ledger OrgEvent (Sherlock discipline, same as
173
+ * resources/migrations/ledger.ts): counts, outcome, version — NEVER a memory
174
+ * id or content (the canary row's id is deliberately omitted). Written via an
175
+ * internal (no-HTTP-context) put — resolveAgentAuth(undefined) → internal, the
176
+ * same trusted server-internal write path the migration ledger already uses.
177
+ */
178
+ async function writeGraphHealEvent(obs, table) {
179
+ const countNote = obs.embeddedVectorCount != null
180
+ ? ` (${obs.embeddedVectorCount} embedded vector${obs.embeddedVectorCount === 1 ? "" : "s"})`
181
+ : "";
182
+ await table.put({
183
+ id: `migration-graph-heal-verified-${obs.at}`,
184
+ authorId: "flair-migrations",
185
+ kind: "migration",
186
+ scope: "full",
187
+ summary: `HNSW graph-heal: recall ${obs.verified ? "verified healthy" : "unconfirmed"}${countNote}`,
188
+ detail: JSON.stringify({
189
+ migrationId: GRAPH_HEAL_ID,
190
+ verified: obs.verified,
191
+ canaryRank1: obs.verified,
192
+ embeddedVectorCount: obs.embeddedVectorCount,
193
+ runningVersion: obs.runningVersion,
194
+ verifiedAt: obs.at,
195
+ }),
196
+ refId: GRAPH_HEAL_ID,
197
+ createdAt: obs.at,
198
+ });
199
+ }
200
+ /**
201
+ * `getTable`/`getOrgEventTable`/`getVersion`/`now` are injectable so tests can
202
+ * exercise detect/countPending/run against fakes (matching the mocking style
203
+ * of embedding-stamp.ts / the unit tests). The real HNSW self-recall + ledger
204
+ * path is exercised end-to-end in
205
+ * test/integration/hnsw-graph-heal-e2e.test.ts.
206
+ */
207
+ export function createGraphHealMigration(getTable = defaultMemoryTable, getOrgEventTable = defaultOrgEventTable, getVersion = resolveRunningVersionLocal, now = () => new Date()) {
208
+ return {
209
+ id: GRAPH_HEAL_ID,
210
+ riskClass: "derived-only",
211
+ affectsTables: ["Memory"],
212
+ /**
213
+ * True only when recall is confirmed healthy AND there is embedded data
214
+ * to verify — i.e. the graph rebuilt and is serving. Returns false (the
215
+ * runner marks the migration completed for this boot WITHOUT writing a
216
+ * success state entry, so detect() runs again next boot) when the index
217
+ * is still mid-rebuild or the store has nothing embedded yet. This is the
218
+ * enrollment gate: only a confirmed-healthy boot proceeds to run() +
219
+ * ledger + the version short-circuit.
220
+ */
221
+ async detect() {
222
+ const { verified } = await recallHealthy(getTable());
223
+ return verified;
224
+ },
225
+ /** Observe-only — never any pending row work. */
226
+ async countPending() {
227
+ return 0;
228
+ },
229
+ /**
230
+ * Called exactly once (countPending() is 0 → the runner's batch loop runs
231
+ * once and breaks). Re-verifies recall for the ledger snapshot, counts
232
+ * the embedded vectors, writes ONE structural-only OrgEvent, and returns
233
+ * `processed: 0` so the completion gate (count+marker, derived-only)
234
+ * passes and the runner records success → short-circuit next boot.
235
+ */
236
+ async run(_batchSize) {
237
+ const table = getTable();
238
+ const { verified } = await recallHealthy(table);
239
+ const embeddedVectorCount = await countEmbeddedVectors(table);
240
+ try {
241
+ await writeGraphHealEvent({ verified, embeddedVectorCount, runningVersion: getVersion(), at: now().toISOString() }, getOrgEventTable());
242
+ }
243
+ catch {
244
+ // Observability must never brick the runner — a ledger-write failure
245
+ // is swallowed; the heal itself already happened schema-side.
246
+ }
247
+ return { processed: 0, touchedIds: [] };
248
+ },
249
+ };
250
+ }
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * registry.ts — the MigrationRegistry: an ordered list of registered
3
- * migrations. embedding-stamp always registers; the synthetic CI-only
4
- * variant registers ONLY when shouldRegisterSyntheticMigration() is true
5
- * (see synthetic-test-migration.ts's doc for the exact gating rule).
3
+ * migrations. embedding-stamp and graph-heal always register; the synthetic
4
+ * CI-only variant registers ONLY when shouldRegisterSyntheticMigration() is
5
+ * true (see synthetic-test-migration.ts's doc for the exact gating rule).
6
6
  */
7
7
  import { createEmbeddingStampMigration } from "./embedding-stamp.js";
8
+ import { createGraphHealMigration } from "./graph-heal.js";
8
9
  import { createSyntheticTestMigration, shouldRegisterSyntheticMigration } from "./synthetic-test-migration.js";
9
10
  export class MigrationRegistry {
10
11
  migrations = [];
@@ -23,12 +24,17 @@ export class MigrationRegistry {
23
24
  }
24
25
  }
25
26
  /**
26
- * Builds the production registry. Always includes embedding-stamp;
27
- * conditionally includes the CI-only synthetic migration.
27
+ * Builds the production registry. Always includes embedding-stamp and
28
+ * graph-heal; conditionally includes the CI-only synthetic migration.
29
+ *
30
+ * Order note: graph-heal registers AFTER embedding-stamp so that if a boot
31
+ * ever runs both, the (verify-only) recall check ledgers the state AFTER any
32
+ * re-embed work that cycle already applied — never a stale pre-embed reading.
28
33
  */
29
34
  export function buildRegistry(env = process.env) {
30
35
  const registry = new MigrationRegistry();
31
36
  registry.register(createEmbeddingStampMigration());
37
+ registry.register(createGraphHealMigration());
32
38
  if (shouldRegisterSyntheticMigration(env)) {
33
39
  registry.register(createSyntheticTestMigration());
34
40
  }
@@ -44,6 +44,17 @@ import { join } from "node:path";
44
44
  * production deployment (gate off) can never alter the real 100ms throttle.
45
45
  */
46
46
  export const TEST_BATCH_DELAY_ENV = "FLAIR_MIGRATION_TEST_BATCH_DELAY_MS";
47
+ /**
48
+ * flair#807 completion-gate safety net: the maximum `finalRemaining` count
49
+ * the gate will EXHAUSTIVELY re-verify (every claimed-pending row, never a
50
+ * probabilistic subset) via `migration.recheckPending()` before declaring a
51
+ * halt. Bounded so this stays "cheap" (a per-boot gate check, not a bulk
52
+ * job) and so the net can only ever flip a gate from fail→pass when it has
53
+ * proven — not sampled — that the WHOLE remaining set is a counting
54
+ * artifact. A `finalRemaining` above this threshold skips the net entirely
55
+ * and halts exactly as before (a partial recheck is not proof for the rest).
56
+ */
57
+ const GATE_SAFETY_NET_MAX_RECHECK = 50;
47
58
  function resolveTestBatchDelayMs(env = process.env) {
48
59
  if (!shouldRegisterSyntheticMigration(env))
49
60
  return undefined;
@@ -356,6 +367,39 @@ async function runOneMigration(migration, envelope, deps, lock) {
356
367
  }
357
368
  let hashEnvelopeMatch = null;
358
369
  let gateOk = finalRemaining === 0;
370
+ // ── flair#807 safety net: a nonzero finalRemaining can itself be a
371
+ // COUNTING ARTIFACT (e.g. countPending()'s search-based query reading a
372
+ // stale/desynced secondary index on a migrated store — see
373
+ // resources/migrations/embedding-stamp.ts's flair#807 doc for the
374
+ // concrete mechanism that motivated this) rather than real pending work.
375
+ // Only engages when finalRemaining is small enough to verify
376
+ // EXHAUSTIVELY — every claimed-pending row, not a subset — via a DIRECT
377
+ // per-row read (migration.recheckPending(), opt-in per Migration type).
378
+ // If every single one comes back already correct on direct read, the
379
+ // gate's nonzero count was the bug: log loudly and PASS with a WARN
380
+ // instead of halting forever on an artifact a real re-run can never
381
+ // clear (countPending() would report the same wrong number next boot).
382
+ if (!gateOk &&
383
+ finalRemaining > 0 &&
384
+ finalRemaining <= GATE_SAFETY_NET_MAX_RECHECK &&
385
+ typeof migration.recheckPending === "function") {
386
+ try {
387
+ const recheck = await migration.recheckPending(finalRemaining);
388
+ if (recheck.sampled === finalRemaining && recheck.falsePositives === recheck.sampled) {
389
+ console.warn(`[migrations] ${migration.id}: completion gate countPending() reported ${finalRemaining} pending, ` +
390
+ `but a direct per-record read of ALL ${recheck.sampled} claimed-pending rows found every one already ` +
391
+ `correct. Treating this as a counting artifact (search/index divergence), NOT real pending work — ` +
392
+ `passing the gate with this WARN instead of halting.`);
393
+ gateOk = true;
394
+ }
395
+ }
396
+ catch (err) {
397
+ // The safety net itself must never crash or worsen the gate outcome —
398
+ // a failure here just leaves gateOk as countPending() computed it.
399
+ console.warn(`[migrations] ${migration.id}: completion-gate safety net recheckPending() threw (` +
400
+ `${err?.message ?? String(err)}) — proceeding with the original gate result.`);
401
+ }
402
+ }
359
403
  if (gateOk && posture.gate === "count+full-envelope") {
360
404
  try {
361
405
  const postEnvelope = await computeCorpusEnvelope(deps.getTable, deps.now);
@@ -9,21 +9,62 @@
9
9
  *
10
10
  * Two inference modes (selected by FLAIR_RERANK_MODEL):
11
11
  *
12
- * 1. "qwen3-reranker-0.6b-q8" (DEFAULT, quality) — Qwen3-Reranker-0.6B is a
13
- * causal-LM reranker, NOT a rank-pooling cross-encoder. Its GGUF reports
14
- * supportsRanking=false, so node-llama-cpp's createRankingContext() rejects
15
- * it. The correct path is generative: format query+doc into the official
16
- * instruction prompt ending at the assistant turn, evaluate, and read the
17
- * next-token probability of "yes" vs "no":
18
- * score = P(yes) / (P(yes) + P(no))
19
- * Implemented via seq.controlledEvaluate with generateNext probabilities.
20
- * (Validated offline in RERANK-PILOT-RESULTS.md: 4/4 target cases flipped
21
- * positive, p@1 6/8→8/8, mean margin 0.028→0.688, ~1.2s / 16 docs on rockit.)
22
- *
23
- * 2. "jina-reranker-v2" (latency fallback) — jina-reranker-v2 IS a rank-pooling
12
+ * 1. "jina-reranker-v2" (DEFAULT, working) — jina-reranker-v2 IS a rank-pooling
24
13
  * cross-encoder; its gpustack GGUF loads via createRankingContext()/rankAll
25
- * (supportsRanking=true). ~145ms / 16 docs but weaker (7/8, leaves the
26
- * hardest consensus case negative). Selectable where latency is tight.
14
+ * (supportsRanking=true). ~145ms / 16 docs in the offline pilot (7/8,
15
+ * leaves the hardest consensus case negative weaker than qwen3's
16
+ * generative path in that pilot, but it's the path that actually PRODUCES
17
+ * a score inside Harper's process — see #811 / point 2 below).
18
+ *
19
+ * 2. "qwen3-reranker-0.6b-q8" (EXPERIMENTAL, quality-if-it-worked) —
20
+ * Qwen3-Reranker-0.6B is a causal-LM reranker, NOT a rank-pooling
21
+ * cross-encoder. Its GGUF reports supportsRanking=false, so
22
+ * createRankingContext() rejects it; the intended path is generative:
23
+ * format query+doc into the official instruction prompt ending at the
24
+ * assistant turn, evaluate, and read the next-token probability of "yes"
25
+ * vs "no": score = P(yes) / (P(yes) + P(no)), via seq.controlledEvaluate
26
+ * with generateNext probabilities. Validated OFFLINE, standalone, in
27
+ * RERANK-PILOT-RESULTS.md (4/4 target cases flipped positive, p@1
28
+ * 6/8→8/8, mean margin 0.028→0.688, ~1.2s / 16 docs on rockit) — but
29
+ * flair#811 found that INSIDE Harper's resource runtime,
30
+ * controlledEvaluate reliably returns empty logits (no decoded output),
31
+ * so every generative call throws "generative reranker produced no
32
+ * logits" and falls open. Root cause per docs/rerank-provisioning.md's
33
+ * "Known limitation": HFE's embedding engine has already initialized a
34
+ * separate native llama backend in the same process before this
35
+ * provider's own dynamic import runs, and the low-level
36
+ * controlledEvaluate + custom-sampler logit readout the generative path
37
+ * needs doesn't survive that dual-backend residency (ordinary model
38
+ * loading and the jina rank-pooling call both work fine across it — only
39
+ * this specific low-level readout is affected). Kept available and
40
+ * documented for whoever revisits dual-backend isolation; NOT the
41
+ * default until that's fixed.
42
+ *
43
+ * Context-budget truncation (flair#811 point 1): real memory content is
44
+ * routinely far longer than either model's small context window. Every
45
+ * (query, doc) pair is bounded BEFORE it reaches the engine — a cheap char
46
+ * pre-cut (`truncateChars`, avoids tokenizing pathological multi-MB content)
47
+ * followed by an exact token-level cut (`truncateForModel`, uses the loaded
48
+ * model's own tokenizer — the real guarantee, since char/token ratio varies
49
+ * a lot across prose/code/CJK/emoji). Budgets are derived from the context
50
+ * size each mode actually requests (both NUMERIC, not "auto" — node-llama-cpp
51
+ * grants a numeric contextSize exactly as asked, see GENERATIVE_CONTEXT_SIZE/
52
+ * RANK_CONTEXT_SIZE below) minus reserved template + query overhead. This
53
+ * makes the `rankAll`/context-overflow throw effectively unreachable in
54
+ * normal operation instead of guaranteed on any real-length memory.
55
+ *
56
+ * Config re-validated on every use (flair#811 point 3): `ensureInit()` used
57
+ * to cache `_modelKey`/`_state` permanently on first call — a later change to
58
+ * FLAIR_RERANK_MODEL (or a transient first-call failure) had NO effect for
59
+ * the lifetime of the process, since `_state === "ready"` (or `"failed"`)
60
+ * short-circuited every subsequent call without re-reading env. `needsReinit()`
61
+ * now compares the currently-resolved model key against what's actually
62
+ * loaded and re-initializes (disposing old handles first) whenever they
63
+ * differ, so "configured model X, served model Y" can no longer persist
64
+ * silently across calls. This is the most likely, directly-fixable
65
+ * code-level cause of the model-mismatch symptom reported in #811; without a
66
+ * live process repro we can't rule out an additional contributing factor,
67
+ * but this closes the gap regardless of the exact prior trigger.
27
68
  *
28
69
  * Serving path = the same in-process node-llama-cpp the embedding engine ships.
29
70
  * No Ollama (no logprobs, no rerank endpoint — verified), no network hop, no
@@ -33,13 +74,15 @@
33
74
  * embedding GGUF. See docs/rerank-provisioning.md for download sources.
34
75
  */
35
76
  import { join } from "node:path";
36
- // Known reranker models → GGUF filename + inference mode. The default is the
37
- // quality model (Qwen3 generative). jina is the latency model (rank API).
77
+ // Known reranker models → GGUF filename + inference mode. jina is the DEFAULT
78
+ // (working see file header, flair#811): its rank-pooling path completes
79
+ // inside Harper. qwen3 is EXPERIMENTAL: its generative path is validated
80
+ // offline but reliably fails open inside Harper today (empty logits).
38
81
  const MODELS = {
39
- "qwen3-reranker-0.6b-q8": { file: "Qwen3-Reranker-0.6B-q8_0.gguf", mode: "generative" },
40
82
  "jina-reranker-v2": { file: "jina-reranker-v2-base.Q8_0.gguf", mode: "rank" },
83
+ "qwen3-reranker-0.6b-q8": { file: "Qwen3-Reranker-0.6B-q8_0.gguf", mode: "generative" },
41
84
  };
42
- const DEFAULT_MODEL = "qwen3-reranker-0.6b-q8";
85
+ const DEFAULT_MODEL = "jina-reranker-v2";
43
86
  // Official Qwen3-Reranker prompt scaffold (generative yes/no judgement).
44
87
  const QWEN_PREFIX = '<|im_start|>system\nJudge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be "yes" or "no".<|im_end|>\n<|im_start|>user\n';
45
88
  const QWEN_SUFFIX = "<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n";
@@ -47,9 +90,71 @@ const QWEN_INSTRUCT = "Given a web search query, retrieve relevant passages that
47
90
  function buildQwenPrompt(q, doc) {
48
91
  return `${QWEN_PREFIX}<Instruct>: ${QWEN_INSTRUCT}\n<Query>: ${q}\n<Document>: ${doc}${QWEN_SUFFIX}`;
49
92
  }
50
- // Cap per-document content fed to the reranker. Short atomic notes are the norm;
51
- // this bounds context blow-up + latency on the occasional huge memory.
52
- const MAX_DOC_CHARS = 2000;
93
+ // ── Context-budget truncation (flair#811 point 1) ──────────────────────────
94
+ // See the file header for the two-layer rationale. Budgets are derived from
95
+ // the context size each mode actually requests, minus reserved overhead —
96
+ // not a flat guess detached from either number.
97
+ /** Context size requested for the generative (Qwen3) context. NUMERIC (not
98
+ * "auto"), so node-llama-cpp grants exactly this many tokens — verified
99
+ * against node-llama-cpp 3.18.1's resolveContextContextSizeOption: a numeric
100
+ * contextSize is granted as-is (or context creation throws
101
+ * InsufficientMemoryError); only "auto"/object requests get VRAM-adaptive
102
+ * shrinking. */
103
+ const GENERATIVE_CONTEXT_SIZE = 1024;
104
+ /** Context size requested for the rank (jina) context — see
105
+ * createRankingContext() below. Also numeric, same guarantee. */
106
+ const RANK_CONTEXT_SIZE = 2048;
107
+ /** Reserved tokens for the Qwen3 prompt scaffold (QWEN_PREFIX + INSTRUCT +
108
+ * SUFFIX + <|im_start|>/<|im_end|> special tokens). Rough estimate from the
109
+ * literal scaffold text is ~130-140 tokens; 180 leaves real headroom above
110
+ * that estimate rather than sitting right on top of it — we don't have the
111
+ * actual Qwen3-Reranker tokenizer available to measure exactly (no GGUF in
112
+ * this worktree), so this errs generous. Belt-and-suspenders: scoreGenerative
113
+ * also hard-checks the FINAL built prompt against GENERATIVE_CONTEXT_SIZE
114
+ * and throws (fail-open) rather than proceeding if this margin ever isn't
115
+ * enough — see there. */
116
+ const GENERATIVE_TEMPLATE_OVERHEAD_TOKENS = 180;
117
+ /** Rank mode's DEFAULT template (no GGUF `chat_template.rerank` metadata) is
118
+ * just BOS + EOS + SEP + EOS — ~4 tokens. But if the loaded GGUF DOES carry
119
+ * a custom rerank template (LlamaRankingContext prefers it when present),
120
+ * that template's literal text adds more; we can't inspect the actual jina
121
+ * GGUF's metadata from this worktree (no model files here), so this reserves
122
+ * well above the no-template case. If the real template needs more than
123
+ * this, `rankAll()` still throws its own clean, caught error (existing
124
+ * fail-open path) rather than corrupting anything. */
125
+ const RANK_TEMPLATE_OVERHEAD_TOKENS = 64;
126
+ /** Queries are normally a handful of words. Bounding them caps the worst
127
+ * case so a pathologically long query can never eat the whole doc budget or
128
+ * overflow the context on its own. */
129
+ const MAX_QUERY_TOKENS = 128;
130
+ /** Per-mode doc token budgets: context size minus template overhead minus
131
+ * the reserved query budget. This is the number `truncateForModel()` enforces
132
+ * exactly (via the model's own tokenizer) for candidate document text. */
133
+ const GENERATIVE_DOC_BUDGET_TOKENS = GENERATIVE_CONTEXT_SIZE - GENERATIVE_TEMPLATE_OVERHEAD_TOKENS - MAX_QUERY_TOKENS;
134
+ const RANK_DOC_BUDGET_TOKENS = RANK_CONTEXT_SIZE - RANK_TEMPLATE_OVERHEAD_TOKENS - MAX_QUERY_TOKENS;
135
+ // Cheap CHAR pre-cuts — layer 1 (see file header). Deliberately generous
136
+ // (not a tight token-accurate estimate): their only job is keeping us from
137
+ // ever tokenizing a pathological multi-MB memory blob before layer 2
138
+ // (`truncateForModel`'s exact token-level cut) does the real enforcement.
139
+ const GENERATIVE_DOC_CHAR_PRECUT = 2000;
140
+ const RANK_DOC_CHAR_PRECUT = 5000;
141
+ const QUERY_CHAR_PRECUT = 800;
142
+ /** Pure, trivial char-length cap — layer 1 of truncation. Returns `text`
143
+ * UNCHANGED (same reference) when already within budget, so callers can
144
+ * skip unnecessary work; otherwise returns the first `maxChars` characters.
145
+ * Exported for unit testing (see test/unit/rerank-provider.test.ts). */
146
+ export function truncateChars(text, maxChars) {
147
+ if (text.length <= maxChars)
148
+ return text;
149
+ return text.slice(0, Math.max(0, maxChars));
150
+ }
151
+ /** Pure, trivial token-array cap — layer 2's core slice. Exported for unit
152
+ * testing independent of a real tokenizer. */
153
+ export function truncateTokenBudget(tokens, maxTokens) {
154
+ if (tokens.length <= maxTokens)
155
+ return tokens;
156
+ return tokens.slice(0, Math.max(0, maxTokens));
157
+ }
53
158
  let _state = "uninitialized";
54
159
  let _initError;
55
160
  let _warnedOnce = false;
@@ -76,6 +181,31 @@ function resolveModelKey() {
76
181
  return requested;
77
182
  return DEFAULT_MODEL;
78
183
  }
184
+ /**
185
+ * Decide whether `ensureInit()` needs to (re)run the engine init sequence —
186
+ * the flair#811 point-3 fix. Pure so the decision matrix is directly
187
+ * unit-testable without touching the native engine.
188
+ *
189
+ * - Never initialized → always init.
190
+ * - Currently loaded model differs from what's now configured → always
191
+ * (re)init, regardless of whether the PREVIOUS attempt was "ready" or
192
+ * "failed" — a config change deserves a fresh attempt under the new
193
+ * config. This is the fix: the old code short-circuited on `_state ===
194
+ * "ready"`/`"failed"` unconditionally, so a later FLAIR_RERANK_MODEL
195
+ * change (or a transient first-call failure under a config that was since
196
+ * corrected) had NO effect for the life of the process.
197
+ * - Same model, already "ready" → no-op (don't reload a loaded GGUF).
198
+ * - Same model, already "failed" → no-op (don't retry-storm a config that's
199
+ * still broken; avoids hammering a persistently-unavailable engine on
200
+ * every search).
201
+ */
202
+ export function needsReinit(state, cachedModelKey, requestedModelKey) {
203
+ if (state === "uninitialized")
204
+ return true;
205
+ if (cachedModelKey !== requestedModelKey)
206
+ return true;
207
+ return false;
208
+ }
79
209
  /** Discover the platform addon binary the same way embeddings-provider.ts does. */
80
210
  async function findAddonPath() {
81
211
  const { existsSync } = await import("node:fs");
@@ -88,12 +218,19 @@ async function findAddonPath() {
88
218
  return undefined;
89
219
  }
90
220
  async function ensureInit() {
91
- if (_state === "ready")
221
+ const requested = resolveModelKey();
222
+ if (!needsReinit(_state, _modelKey, requested))
92
223
  return;
93
- if (_state === "failed")
94
- return; // already logged once don't thrash
224
+ // Reinitializing under a new config (or first-ever init) — drop any
225
+ // previously loaded engine handles and let a fresh init attempt warn again
226
+ // if IT fails too (a new config's failure is a new fact, not a repeat of
227
+ // the old one).
228
+ if (_state === "ready")
229
+ await disposeHandles().catch(() => { });
230
+ _state = "uninitialized";
231
+ _warnedOnce = false;
95
232
  try {
96
- _modelKey = resolveModelKey();
233
+ _modelKey = requested;
97
234
  const spec = MODELS[_modelKey];
98
235
  _mode = spec.mode;
99
236
  const { existsSync } = await import("node:fs");
@@ -116,7 +253,7 @@ async function ensureInit() {
116
253
  _llama = await _nlc.getLlama();
117
254
  _model = await _llama.loadModel({ modelPath });
118
255
  if (_mode === "generative") {
119
- _ctx = await _model.createContext({ contextSize: 1024 });
256
+ _ctx = await _model.createContext({ contextSize: GENERATIVE_CONTEXT_SIZE });
120
257
  _seq = _ctx.getSequence();
121
258
  // Resolve yes/no token ids (both case variants — the post-</think>
122
259
  // position puts mass on "yes"/"Yes").
@@ -131,9 +268,11 @@ async function ensureInit() {
131
268
  if (!_model.fileInsights?.supportsRanking) {
132
269
  throw new Error(`model ${spec.file} does not support ranking (not a rank-pooling cross-encoder)`);
133
270
  }
134
- // Context must fit query + the longest document (jina concatenates them).
135
- // 512 is too small for real memories; 2048 covers MAX_DOC_CHARS + query.
136
- _rankCtx = await _model.createRankingContext({ contextSize: 2048 });
271
+ // Context must fit query + the longest document (jina concatenates
272
+ // them). 512 is too small for real memories; RANK_CONTEXT_SIZE (2048)
273
+ // is what RANK_DOC_BUDGET_TOKENS/truncateForModel() are derived from —
274
+ // see the file header.
275
+ _rankCtx = await _model.createRankingContext({ contextSize: RANK_CONTEXT_SIZE });
137
276
  }
138
277
  _state = "ready";
139
278
  }
@@ -182,9 +321,51 @@ async function resetSequence() {
182
321
  catch { /* ignore */ }
183
322
  _seq = _ctx.getSequence();
184
323
  }
324
+ /**
325
+ * Bound `text` to at most `maxTokens` tokens using the loaded model's own
326
+ * tokenizer — layer 2 of the truncation scheme (see file header). Layer 1's
327
+ * cheap char pre-cut runs first (`truncateChars`, avoids tokenizing
328
+ * pathological multi-MB content); if the pre-cut string still tokenizes over
329
+ * budget (dense code/CJK/emoji content packs more tokens per char than the
330
+ * pre-cut assumes), the token array itself is cut and detokenized back to
331
+ * text. This is the actual guarantee: whatever this returns tokenizes to
332
+ * AT MOST `maxTokens` tokens (specialTokens=false — plain content text, no
333
+ * markup interpretation), full stop. Not exported/pure (needs `_model`); its
334
+ * two building blocks (`truncateChars`, `truncateTokenBudget`) are each unit
335
+ * tested directly.
336
+ */
337
+ function truncateForModel(text, maxChars, maxTokens) {
338
+ const precut = truncateChars(text, maxChars);
339
+ const tokens = _model.tokenize(precut, false);
340
+ if (tokens.length <= maxTokens)
341
+ return precut;
342
+ const bounded = truncateTokenBudget(Array.from(tokens), maxTokens);
343
+ return _model.detokenize(bounded, false);
344
+ }
185
345
  /** Generative yes/no score for one (query, doc) pair. */
186
346
  async function scoreGenerative(q, doc) {
187
- const tokens = _model.tokenize(buildQwenPrompt(q, doc.slice(0, MAX_DOC_CHARS)), true);
347
+ // Bound query + doc BEFORE building the prompt (flair#811 point 1) — see
348
+ // truncateForModel's doc and the file header for the two-layer rationale.
349
+ // Each is tokenized/bounded independently, then concatenated into the
350
+ // template; GENERATIVE_TEMPLATE_OVERHEAD_TOKENS' margin absorbs the small
351
+ // boundary-tokenization variance a separate-then-concatenate cut can
352
+ // introduce (BPE isn't always compositional across a splice point).
353
+ const boundedQuery = truncateForModel(q, QUERY_CHAR_PRECUT, MAX_QUERY_TOKENS);
354
+ const boundedDoc = truncateForModel(doc, GENERATIVE_DOC_CHAR_PRECUT, GENERATIVE_DOC_BUDGET_TOKENS);
355
+ const tokens = _model.tokenize(buildQwenPrompt(boundedQuery, boundedDoc), true);
356
+ // Belt-and-suspenders: the per-field bounding above reserves
357
+ // GENERATIVE_TEMPLATE_OVERHEAD_TOKENS of margin for the template, but a
358
+ // splice-boundary tokenization surprise is still conceivable. We can't
359
+ // truncate the COMBINED prompt itself (it would cut the required
360
+ // assistant-turn suffix the model needs to answer at the right position),
361
+ // so if it's still over budget here, throw cleanly and let the caller fall
362
+ // open — better than handing an oversized prompt to controlledEvaluate,
363
+ // which doesn't throw on overflow the way rankAll does and could silently
364
+ // context-shift/corrupt instead (a plausible contributor to the "empty
365
+ // logits" symptom in #811, alongside the documented dual-backend issue).
366
+ if (tokens.length > GENERATIVE_CONTEXT_SIZE) {
367
+ throw new Error(`generative reranker prompt (${tokens.length} tokens) exceeds context size (${GENERATIVE_CONTEXT_SIZE}) after truncation`);
368
+ }
188
369
  // Reset the sequence so each pair is scored independently (deterministic) and
189
370
  // a prior eval can't poison this one ("Eval has failed" after a bad state).
190
371
  await resetSequence();
@@ -228,8 +409,15 @@ export async function rerankScores(query, docs) {
228
409
  if (_state !== "ready")
229
410
  throw new Error("reranker not ready");
230
411
  if (_mode === "rank") {
231
- const trimmed = docs.map((d) => String(d ?? "").slice(0, MAX_DOC_CHARS));
232
- return runExclusive(() => _rankCtx.rankAll(query, trimmed));
412
+ // Bound query + every doc BEFORE rankAll (flair#811 point 1) — rankAll
413
+ // computes ALL documents' token lengths up front and throws "The input
414
+ // lengths of some of the given documents exceed the context size" if
415
+ // ANY one exceeds RANK_CONTEXT_SIZE (verified against node-llama-cpp
416
+ // 3.18.1's LlamaRankingContext.rankAll). truncateForModel makes that
417
+ // effectively unreachable instead of routine on real memory content.
418
+ const boundedQuery = truncateForModel(query, QUERY_CHAR_PRECUT, MAX_QUERY_TOKENS);
419
+ const trimmed = docs.map((d) => truncateForModel(String(d ?? ""), RANK_DOC_CHAR_PRECUT, RANK_DOC_BUDGET_TOKENS));
420
+ return runExclusive(() => _rankCtx.rankAll(boundedQuery, trimmed));
233
421
  }
234
422
  // generative — score sequentially under the engine lock (single shared
235
423
  // sequence; deterministic). The whole batch holds the lock for one search so
@@ -13,8 +13,8 @@ turning it on (`FLAIR_RERANK_ENABLED=true`) or running the recall-bench A/B.
13
13
 
14
14
  | `FLAIR_RERANK_MODEL` | GGUF filename (under `models/`) | Source | Inference mode |
15
15
  |---|---|---|---|
16
- | `qwen3-reranker-0.6b-q8` (default) | `Qwen3-Reranker-0.6B-q8_0.gguf` | `Mungert/Qwen3-Reranker-0.6B-GGUF` (q8_0) | generative yes/no |
17
- | `jina-reranker-v2` | `jina-reranker-v2-base.Q8_0.gguf` | `gpustack/jina-reranker-v2-base-multilingual-GGUF` (q8_0) | rank-pooling cross-encoder |
16
+ | `jina-reranker-v2` (**default**, working) | `jina-reranker-v2-base.Q8_0.gguf` | `gpustack/jina-reranker-v2-base-multilingual-GGUF` (q8_0) | rank-pooling cross-encoder |
17
+ | `qwen3-reranker-0.6b-q8` (**experimental** — see Known limitation) | `Qwen3-Reranker-0.6B-q8_0.gguf` | `Mungert/Qwen3-Reranker-0.6B-GGUF` (q8_0) | generative yes/no |
18
18
 
19
19
  Download into `models/` next to the embedding GGUF, e.g.:
20
20
 
@@ -36,11 +36,17 @@ vector order — it never blocks or breaks search.
36
36
  | Env var | Default | Meaning |
37
37
  |---|---|---|
38
38
  | `FLAIR_RERANK_ENABLED` | unset (**OFF**) | Master flag. `"true"` to enable. |
39
- | `FLAIR_RERANK_MODEL` | `qwen3-reranker-0.6b-q8` | Model + inference mode (table above). |
39
+ | `FLAIR_RERANK_MODEL` | `jina-reranker-v2` | Model + inference mode (table above). Re-read on every rerank call — changing it takes effect on the NEXT call (that call pays a one-time model-(re)load cost and can itself fall back to vector order if the load exceeds `FLAIR_RERANK_BUDGET_MS`; subsequent calls run at normal latency). |
40
40
  | `FLAIR_RERANK_TOPN` | `50` | Candidate count fed to the reranker; caps the HNSW fetch. |
41
41
  | `FLAIR_RERANK_BUDGET_MS` | `2500` | Hard latency budget; exceeded → vector order. |
42
42
  | `FLAIR_RERANK_MIN_CANDIDATES` | `2` | Skip rerank below this many candidates. |
43
43
 
44
+ Candidate document (and query) text is truncated to a per-model context budget
45
+ before it ever reaches the engine — see `resources/rerank-provider.ts`'s file
46
+ header ("Context-budget truncation"). This is not operator-configurable
47
+ (the budgets are derived from each mode's fixed context size); flagged here
48
+ only so a truncated rerank input isn't a surprise.
49
+
44
50
  ## Why this serving path (not Ollama, not a microservice)
45
51
 
46
52
  - **In-process node-llama-cpp** is the same engine the embedding engine already
@@ -62,6 +68,34 @@ detects this (`out.length === 0`), throws, and **fails open to vector order** (i
62
68
  never writes corrupt scores). Net effect today: with `FLAIR_RERANK_MODEL=qwen3-...`
63
69
  the rerank stage cleanly no-ops inside Harper.
64
70
 
65
- The `jina-reranker-v2` rank-API path (`createRankingContext()` / `rankAll`) works
66
- inside Harper. See the integration PR for the live recall-bench A/B numbers and the
67
- go/no-go read.
71
+ flair#811 (the live-corpus Phase-1 gate) found the qwen3 path erroring on every
72
+ call in production and root-caused two compounding issues, fixed in that PR:
73
+
74
+ 1. **Context overflow on real documents.** The offline pilot's 16-doc fixture was
75
+ short synthetic prose; real memory content routinely exceeds either model's
76
+ small context window (1024 tokens for the generative context, 2048 for the
77
+ rank context). `resources/rerank-provider.ts` now truncates every (query, doc)
78
+ pair to a budget derived from each mode's actual context size before it ever
79
+ reaches the engine (see that file's header). This doesn't fix the dual-backend
80
+ empty-logits limitation above, but it removes overflow as a SEPARATE, more
81
+ easily hit failure mode — and may have been a contributing cause of the
82
+ empty-logits symptom itself (an overflowing `controlledEvaluate` call doesn't
83
+ throw the way `rankAll` does; it's plausible an oversized prompt silently
84
+ context-shifted rather than decoding cleanly, though we couldn't confirm
85
+ this without a live repro).
86
+ 2. **Config not re-read after first init.** The provider used to cache which
87
+ model was loaded PERMANENTLY on first successful (or failed) init — a later
88
+ change to `FLAIR_RERANK_MODEL` had no effect until the process restarted, so
89
+ "configured model X, served model Y" could persist silently for the life of
90
+ the process. Now re-validated on every call (`needsReinit()`); a config
91
+ change is picked up on the next rerank.
92
+
93
+ **Given the above, `jina-reranker-v2` is now the DEFAULT model** — its rank-API
94
+ path (`createRankingContext()` / `rankAll`) completes inside Harper. `qwen3` stays
95
+ selectable and documented for whoever revisits the dual-backend isolation
96
+ question; it is not the default until the empty-logits limitation is actually
97
+ fixed (truncation alone doesn't fix it — see point 1 above, it only removes a
98
+ compounding cause).
99
+
100
+ See the integration PR / flair#811 for the live recall-bench A/B numbers and the
101
+ go/no-go read on `jina-reranker-v2` as the default.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "packageManager": "bun@1.3.10",
5
5
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
6
6
  "type": "module",
@@ -76,6 +76,9 @@
76
76
  "@node-llama-cpp/win-arm64": "3.18.1",
77
77
  "@node-llama-cpp/win-x64": "3.18.1"
78
78
  },
79
+ "overrides": {
80
+ "react-native-fs": "npm:empty-npm-package@1.0.0"
81
+ },
79
82
  "devDependencies": {
80
83
  "@playwright/test": "1.59.1",
81
84
  "@types/node": "24.11.0",
@@ -4,7 +4,25 @@ type Memory @table(database: "flair") {
4
4
  content: String!
5
5
  contentHash: String @indexed
6
6
  visibility: String
7
- embedding: [Float] @indexed(type: "HNSW")
7
+ embedding: [Float] @indexed(type: "HNSW", M: 16) # M:16 is Harper's own HNSW default
8
+ # (HierarchicalNavigableSmallWorld M default) — declaring it explicitly is a
9
+ # ZERO behavior / graph-quality change. Its ONLY effect is the recall-heal
10
+ # trigger (resources/migrations/graph-heal.ts): Harper persists each HNSW
11
+ # attribute's option descriptor and, on boot, structurally diffs it against
12
+ # the schema (databases.ts canonicalizeIndexOptions — sorts keys, coerces
13
+ # numeric strings, but does NOT inject defaults, so `{type:"HNSW"}` and
14
+ # `{type:"HNSW",M:16}` are DIFFERENT canonical keys). A store whose descriptor
15
+ # was persisted WITHOUT options (every pre-this-change install) therefore sees
16
+ # a structural diff on the first boot after upgrade → runIndexing clears the
17
+ # graph store and rebuilds it CLEANLY from the already-correct stored vectors
18
+ # (lastIndexedKey reset to undefined). This heals graphs left stale by Harper's
19
+ # incremental HNSW update after a bulk in-place re-embed (the July embedding-
20
+ # stamp re-embed corrupted prod's reverse edges; a fresh index of the SAME
21
+ # vectors finds the true neighbors, the incrementally-updated one didn't).
22
+ # NO re-embed — the stored vectors are untouched; only the graph is rebuilt.
23
+ # Fires once per instance (the descriptor is then persisted to match); brief
24
+ # vector-search 503 window during the rebuild (~seconds; BM25/direct keep
25
+ # serving). Do NOT remove the `M: 16` — reverting it removes the upgrade heal.
8
26
  embeddingModel: String @indexed # ops-1l18: previously written (resources/Memory.ts stamps every
9
27
  # write) but never DECLARED in this schema, so it was storable but not
10
28
  # queryable — Harper's Table.search({conditions}) rejects a condition on