@tpsdev-ai/flair 0.30.0 → 0.31.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.
Files changed (49) hide show
  1. package/README.md +194 -377
  2. package/dist/cli.js +1355 -281
  3. package/dist/deploy.js +212 -24
  4. package/dist/fabric-upgrade.js +16 -1
  5. package/dist/federation/scheduler.js +500 -0
  6. package/dist/install/clients.js +111 -53
  7. package/dist/lib/mcp-spec.js +128 -0
  8. package/dist/lib/safe-snapshot-extract.js +231 -0
  9. package/dist/lib/scheduler-platform.js +128 -0
  10. package/dist/lib/xml-escape.js +54 -0
  11. package/dist/rem/scheduler.js +35 -87
  12. package/dist/rem/snapshot.js +13 -0
  13. package/dist/replication-convergence.js +505 -0
  14. package/dist/resources/MemoryBootstrap.js +7 -8
  15. package/dist/resources/SemanticSearch.js +17 -45
  16. package/dist/resources/abstention.js +1 -1
  17. package/dist/resources/embeddings-boot.js +10 -12
  18. package/dist/resources/embeddings-provider.js +10 -7
  19. package/dist/resources/health.js +24 -19
  20. package/dist/resources/in-process.js +225 -0
  21. package/dist/resources/mcp-tools.js +23 -17
  22. package/dist/resources/migration-boot.js +80 -10
  23. package/dist/resources/migrations/data-dir.js +205 -0
  24. package/dist/resources/migrations/progress.js +33 -0
  25. package/dist/resources/migrations/runner.js +29 -2
  26. package/dist/resources/migrations/state.js +13 -2
  27. package/dist/resources/models-dir.js +18 -9
  28. package/dist/resources/semantic-retrieval-core.js +5 -4
  29. package/dist/src/lib/scheduler-platform.js +128 -0
  30. package/dist/src/lib/xml-escape.js +54 -0
  31. package/dist/src/rem/scheduler.js +35 -87
  32. package/docs/deploying-on-fabric.md +267 -0
  33. package/docs/deployment.md +5 -0
  34. package/docs/embedding-in-a-harper-app.md +299 -0
  35. package/docs/federation.md +61 -4
  36. package/docs/integrations.md +3 -0
  37. package/docs/mcp-clients.md +16 -7
  38. package/docs/quickstart.md +80 -54
  39. package/docs/releasing.md +72 -38
  40. package/docs/supply-chain-policy.md +36 -0
  41. package/docs/troubleshooting.md +24 -0
  42. package/docs/upgrade.md +98 -3
  43. package/package.json +1 -11
  44. package/templates/bin/flair-federation-sync.sh.tmpl +28 -0
  45. package/templates/launchd/dev.flair.federation.sync.plist.tmpl +47 -0
  46. package/templates/systemd/flair-federation-sync.service.tmpl +21 -0
  47. package/templates/systemd/flair-federation-sync.timer.tmpl +16 -0
  48. package/dist/resources/rerank-provider.js +0 -569
  49. package/docs/rerank-provisioning.md +0 -101
@@ -1,569 +0,0 @@
1
- /**
2
- * rerank-provider.ts
3
- *
4
- * In-process cross-encoder reranker for Flair's recall path. Mirrors the
5
- * embeddings-provider.ts shape: dynamic import of node-llama-cpp (deferred to
6
- * first use to dodge Harper 5.x's VM-sandbox linker race), lazy singleton init,
7
- * and graceful passthrough on ANY failure — the reranker is best-effort and must
8
- * NEVER block or break recall.
9
- *
10
- * Two inference modes (selected by FLAIR_RERANK_MODEL):
11
- *
12
- * 1. "jina-reranker-v2" (DEFAULT, working) — jina-reranker-v2 IS a rank-pooling
13
- * cross-encoder; its gpustack GGUF loads via createRankingContext()/rankAll
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.
68
- *
69
- * Serving path = the same in-process node-llama-cpp the embedding engine ships.
70
- * No Ollama (no logprobs, no rerank endpoint — verified), no network hop, no
71
- * new auth boundary.
72
- *
73
- * GGUF files are NOT committed; they are provisioned into the models dir
74
- * alongside the embedding GGUF — resolved via the SAME `resolveModelsDir()`
75
- * the embedding engine uses (FLAIR_MODELS_DIR → <ROOTPATH>/models →
76
- * <cwd>/models → ~/.flair/data/models; flair#815 — this file used to hardcode
77
- * <cwd>/models, so any deployment whose cwd wasn't the models location failed
78
- * init and silently fell open to vector order). See docs/rerank-provisioning.md
79
- * for download sources.
80
- */
81
- import { join } from "node:path";
82
- import { resolveModelsDir } from "./models-dir.js";
83
- // Known reranker models → GGUF filename + inference mode. jina is the DEFAULT
84
- // (working — see file header, flair#811): its rank-pooling path completes
85
- // inside Harper. qwen3 is EXPERIMENTAL: its generative path is validated
86
- // offline but reliably fails open inside Harper today (empty logits).
87
- const MODELS = {
88
- "jina-reranker-v2": { file: "jina-reranker-v2-base.Q8_0.gguf", mode: "rank" },
89
- "qwen3-reranker-0.6b-q8": { file: "Qwen3-Reranker-0.6B-q8_0.gguf", mode: "generative" },
90
- };
91
- const DEFAULT_MODEL = "jina-reranker-v2";
92
- // Official Qwen3-Reranker prompt scaffold (generative yes/no judgement).
93
- 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';
94
- const QWEN_SUFFIX = "<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n";
95
- const QWEN_INSTRUCT = "Given a web search query, retrieve relevant passages that answer the query";
96
- function buildQwenPrompt(q, doc) {
97
- return `${QWEN_PREFIX}<Instruct>: ${QWEN_INSTRUCT}\n<Query>: ${q}\n<Document>: ${doc}${QWEN_SUFFIX}`;
98
- }
99
- // ── Context-budget truncation (flair#811 point 1) ──────────────────────────
100
- // See the file header for the two-layer rationale. Budgets are derived from
101
- // the context size each mode actually requests, minus reserved overhead —
102
- // not a flat guess detached from either number.
103
- /** Context size requested for the generative (Qwen3) context. NUMERIC (not
104
- * "auto"), so node-llama-cpp grants exactly this many tokens — verified
105
- * against node-llama-cpp 3.18.1's resolveContextContextSizeOption: a numeric
106
- * contextSize is granted as-is (or context creation throws
107
- * InsufficientMemoryError); only "auto"/object requests get VRAM-adaptive
108
- * shrinking. */
109
- const GENERATIVE_CONTEXT_SIZE = 1024;
110
- /** Context size requested for the rank (jina) context — see
111
- * createRankingContext() below. Also numeric, same guarantee. */
112
- const RANK_CONTEXT_SIZE = 2048;
113
- /** Reserved tokens for the Qwen3 prompt scaffold (QWEN_PREFIX + INSTRUCT +
114
- * SUFFIX + <|im_start|>/<|im_end|> special tokens). Rough estimate from the
115
- * literal scaffold text is ~130-140 tokens; 180 leaves real headroom above
116
- * that estimate rather than sitting right on top of it — we don't have the
117
- * actual Qwen3-Reranker tokenizer available to measure exactly (no GGUF in
118
- * this worktree), so this errs generous. Belt-and-suspenders: scoreGenerative
119
- * also hard-checks the FINAL built prompt against GENERATIVE_CONTEXT_SIZE
120
- * and throws (fail-open) rather than proceeding if this margin ever isn't
121
- * enough — see there. */
122
- const GENERATIVE_TEMPLATE_OVERHEAD_TOKENS = 180;
123
- /** Rank mode's DEFAULT template (no GGUF `chat_template.rerank` metadata) is
124
- * just BOS + EOS + SEP + EOS — ~4 tokens. But if the loaded GGUF DOES carry
125
- * a custom rerank template (LlamaRankingContext prefers it when present),
126
- * that template's literal text adds more; we can't inspect the actual jina
127
- * GGUF's metadata from this worktree (no model files here), so this reserves
128
- * well above the no-template case. If the real template needs more than
129
- * this, `rankAll()` still throws its own clean, caught error (existing
130
- * fail-open path) rather than corrupting anything. */
131
- const RANK_TEMPLATE_OVERHEAD_TOKENS = 64;
132
- /** Queries are normally a handful of words. Bounding them caps the worst
133
- * case so a pathologically long query can never eat the whole doc budget or
134
- * overflow the context on its own. */
135
- const MAX_QUERY_TOKENS = 128;
136
- /** Per-mode doc token budgets: context size minus template overhead minus
137
- * the reserved query budget. This is the number `truncateForModel()` enforces
138
- * exactly (via the model's own tokenizer) for candidate document text. */
139
- const GENERATIVE_DOC_BUDGET_TOKENS = GENERATIVE_CONTEXT_SIZE - GENERATIVE_TEMPLATE_OVERHEAD_TOKENS - MAX_QUERY_TOKENS;
140
- const RANK_DOC_BUDGET_TOKENS = RANK_CONTEXT_SIZE - RANK_TEMPLATE_OVERHEAD_TOKENS - MAX_QUERY_TOKENS;
141
- // Cheap CHAR pre-cuts — layer 1 (see file header). Deliberately generous
142
- // (not a tight token-accurate estimate): their only job is keeping us from
143
- // ever tokenizing a pathological multi-MB memory blob before layer 2
144
- // (`truncateForModel`'s exact token-level cut) does the real enforcement.
145
- const GENERATIVE_DOC_CHAR_PRECUT = 2000;
146
- const RANK_DOC_CHAR_PRECUT = 5000;
147
- const QUERY_CHAR_PRECUT = 800;
148
- /** Pure, trivial char-length cap — layer 1 of truncation. Returns `text`
149
- * UNCHANGED (same reference) when already within budget, so callers can
150
- * skip unnecessary work; otherwise returns the first `maxChars` characters.
151
- * Exported for unit testing (see test/unit/rerank-provider.test.ts). */
152
- export function truncateChars(text, maxChars) {
153
- if (text.length <= maxChars)
154
- return text;
155
- return text.slice(0, Math.max(0, maxChars));
156
- }
157
- /** Pure, trivial token-array cap — layer 2's core slice. Exported for unit
158
- * testing independent of a real tokenizer. */
159
- export function truncateTokenBudget(tokens, maxTokens) {
160
- if (tokens.length <= maxTokens)
161
- return tokens;
162
- return tokens.slice(0, Math.max(0, maxTokens));
163
- }
164
- let _state = "uninitialized";
165
- let _initError;
166
- let _warnedOnce = false;
167
- let _modelKey = "";
168
- let _mode;
169
- // node-llama-cpp handles (kept alive as a singleton like the embedding engine).
170
- let _nlc = null;
171
- let _llama = null;
172
- let _model = null;
173
- // generative mode handles
174
- let _ctx = null;
175
- let _seq = null;
176
- let _yesToks = [];
177
- let _noToks = [];
178
- // rank mode handle
179
- let _rankCtx = null;
180
- // Diagnostics surfaced via getRerankStatus() / health.ts.
181
- let _lastLatencyMs = null;
182
- let _fallbackCount = 0;
183
- let _rerankCount = 0;
184
- function resolveModelKey() {
185
- const requested = process.env.FLAIR_RERANK_MODEL?.trim();
186
- if (requested && MODELS[requested])
187
- return requested;
188
- return DEFAULT_MODEL;
189
- }
190
- /**
191
- * Absolute path the reranker GGUF is expected at: `<models dir>/<file>`,
192
- * where the models dir is the shared `resolveModelsDir()` (models-dir.ts,
193
- * same resolution the embedding engine uses) — the single documented source
194
- * of truth (FLAIR_MODELS_DIR override, ROOTPATH/models, cwd/models backward
195
- * compat, ~/.flair/data/models default).
196
- * flair#815: this used to be a hardcoded `<cwd>/models/<file>`, which broke
197
- * (fail-open, silently) in any deployment where Harper's cwd wasn't the
198
- * models location — including the recall harness's ephemeral Harpers.
199
- * Exported for unit testing (see test/unit/rerank-provider.test.ts).
200
- */
201
- export function resolveRerankModelPath(file) {
202
- return join(resolveModelsDir(), file);
203
- }
204
- /**
205
- * Decide whether `ensureInit()` needs to (re)run the engine init sequence —
206
- * the flair#811 point-3 fix. Pure so the decision matrix is directly
207
- * unit-testable without touching the native engine.
208
- *
209
- * - Never initialized → always init.
210
- * - Currently loaded model differs from what's now configured → always
211
- * (re)init, regardless of whether the PREVIOUS attempt was "ready" or
212
- * "failed" — a config change deserves a fresh attempt under the new
213
- * config. This is the fix: the old code short-circuited on `_state ===
214
- * "ready"`/`"failed"` unconditionally, so a later FLAIR_RERANK_MODEL
215
- * change (or a transient first-call failure under a config that was since
216
- * corrected) had NO effect for the life of the process.
217
- * - Same model, already "ready" → no-op (don't reload a loaded GGUF).
218
- * - Same model, already "failed" → no-op (don't retry-storm a config that's
219
- * still broken; avoids hammering a persistently-unavailable engine on
220
- * every search).
221
- */
222
- export function needsReinit(state, cachedModelKey, requestedModelKey) {
223
- if (state === "uninitialized")
224
- return true;
225
- if (cachedModelKey !== requestedModelKey)
226
- return true;
227
- return false;
228
- }
229
- /** Discover the platform addon binary the same way embeddings-provider.ts does. */
230
- async function findAddonPath() {
231
- const { existsSync } = await import("node:fs");
232
- const platforms = ["linux-x64", "mac-arm64-metal", "mac-arm64", "win-x64", "linux-arm64", "mac-x64"];
233
- for (const platform of platforms) {
234
- const candidate = join(process.cwd(), "node_modules", "@node-llama-cpp", platform, "bins", platform, "llama-addon.node");
235
- if (existsSync(candidate))
236
- return candidate;
237
- }
238
- return undefined;
239
- }
240
- async function ensureInit() {
241
- const requested = resolveModelKey();
242
- if (!needsReinit(_state, _modelKey, requested))
243
- return;
244
- // Reinitializing under a new config (or first-ever init) — drop any
245
- // previously loaded engine handles and let a fresh init attempt warn again
246
- // if IT fails too (a new config's failure is a new fact, not a repeat of
247
- // the old one).
248
- if (_state === "ready")
249
- await disposeHandles().catch(() => { });
250
- _state = "uninitialized";
251
- _warnedOnce = false;
252
- try {
253
- _modelKey = requested;
254
- const spec = MODELS[_modelKey];
255
- _mode = spec.mode;
256
- const { existsSync } = await import("node:fs");
257
- const modelPath = resolveRerankModelPath(spec.file);
258
- if (!existsSync(modelPath)) {
259
- throw new Error(`reranker GGUF not found: ${modelPath} (provision per docs/rerank-provisioning.md; set FLAIR_MODELS_DIR to override the models dir)`);
260
- }
261
- // Dynamic import — deferred to avoid Harper 5.x VM linker race (same reason
262
- // embeddings-provider.ts defers harper-fabric-embeddings).
263
- if (!_nlc)
264
- _nlc = await import("node-llama-cpp");
265
- // node-llama-cpp finds its prebuilt binary from process.cwd()/node_modules
266
- // when run inside the Flair app dir (Harper's runtime cwd). We don't need to
267
- // pass the addon path explicitly to getLlama() — but we surface it for
268
- // diagnostics and as a guard that a binary exists at all.
269
- const addonPath = await findAddonPath();
270
- if (!addonPath) {
271
- throw new Error("no @node-llama-cpp platform addon found under node_modules");
272
- }
273
- _llama = await _nlc.getLlama();
274
- _model = await _llama.loadModel({ modelPath });
275
- if (_mode === "generative") {
276
- _ctx = await _model.createContext({ contextSize: GENERATIVE_CONTEXT_SIZE });
277
- _seq = _ctx.getSequence();
278
- // Resolve yes/no token ids (both case variants — the post-</think>
279
- // position puts mass on "yes"/"Yes").
280
- _yesToks = [_model.tokenize("yes", false)[0], _model.tokenize("Yes", false)[0]].filter((t) => t != null);
281
- _noToks = [_model.tokenize("no", false)[0], _model.tokenize("No", false)[0]].filter((t) => t != null);
282
- if (_yesToks.length === 0 || _noToks.length === 0) {
283
- throw new Error("failed to resolve yes/no token ids for generative reranker");
284
- }
285
- }
286
- else {
287
- // rank-pooling cross-encoder (jina). Reject if the GGUF isn't a ranking model.
288
- if (!_model.fileInsights?.supportsRanking) {
289
- throw new Error(`model ${spec.file} does not support ranking (not a rank-pooling cross-encoder)`);
290
- }
291
- // Context must fit query + the longest document (jina concatenates
292
- // them). 512 is too small for real memories; RANK_CONTEXT_SIZE (2048)
293
- // is what RANK_DOC_BUDGET_TOKENS/truncateForModel() are derived from —
294
- // see the file header.
295
- _rankCtx = await _model.createRankingContext({ contextSize: RANK_CONTEXT_SIZE });
296
- }
297
- _state = "ready";
298
- }
299
- catch (err) {
300
- _state = "failed";
301
- _initError = err?.message || String(err);
302
- if (!_warnedOnce) {
303
- console.warn(`[rerank] WARN: reranker unavailable, recall falls back to vector order. Error: ${_initError}`);
304
- _warnedOnce = true;
305
- }
306
- // Best-effort cleanup of any partial handles.
307
- await disposeHandles().catch(() => { });
308
- }
309
- }
310
- async function disposeHandles() {
311
- try {
312
- if (_rankCtx) {
313
- await _rankCtx.dispose();
314
- _rankCtx = null;
315
- }
316
- if (_ctx) {
317
- await _ctx.dispose();
318
- _ctx = null;
319
- _seq = null;
320
- }
321
- if (_model) {
322
- await _model.dispose();
323
- _model = null;
324
- }
325
- }
326
- catch { /* ignore */ }
327
- }
328
- /** Reset (or recreate) the shared generative sequence to a clean KV state. */
329
- async function resetSequence() {
330
- try {
331
- if (_seq && typeof _seq.clearHistory === "function") {
332
- await _seq.clearHistory();
333
- return;
334
- }
335
- }
336
- catch { /* fall through to recreate */ }
337
- // clearHistory unavailable or threw — recreate the sequence from the context.
338
- try {
339
- _seq?.dispose?.();
340
- }
341
- catch { /* ignore */ }
342
- _seq = _ctx.getSequence();
343
- }
344
- /**
345
- * Bound `text` to at most `maxTokens` tokens using the loaded model's own
346
- * tokenizer — layer 2 of the truncation scheme (see file header). Layer 1's
347
- * cheap char pre-cut runs first (`truncateChars`, avoids tokenizing
348
- * pathological multi-MB content); if the pre-cut string still tokenizes over
349
- * budget (dense code/CJK/emoji content packs more tokens per char than the
350
- * pre-cut assumes), the token array itself is cut and detokenized back to
351
- * text. This is the actual guarantee: whatever this returns tokenizes to
352
- * AT MOST `maxTokens` tokens (specialTokens=false — plain content text, no
353
- * markup interpretation), full stop. Not exported/pure (needs `_model`); its
354
- * two building blocks (`truncateChars`, `truncateTokenBudget`) are each unit
355
- * tested directly.
356
- */
357
- function truncateForModel(text, maxChars, maxTokens) {
358
- const precut = truncateChars(text, maxChars);
359
- const tokens = _model.tokenize(precut, false);
360
- if (tokens.length <= maxTokens)
361
- return precut;
362
- const bounded = truncateTokenBudget(Array.from(tokens), maxTokens);
363
- return _model.detokenize(bounded, false);
364
- }
365
- /** Generative yes/no score for one (query, doc) pair. */
366
- async function scoreGenerative(q, doc) {
367
- // Bound query + doc BEFORE building the prompt (flair#811 point 1) — see
368
- // truncateForModel's doc and the file header for the two-layer rationale.
369
- // Each is tokenized/bounded independently, then concatenated into the
370
- // template; GENERATIVE_TEMPLATE_OVERHEAD_TOKENS' margin absorbs the small
371
- // boundary-tokenization variance a separate-then-concatenate cut can
372
- // introduce (BPE isn't always compositional across a splice point).
373
- const boundedQuery = truncateForModel(q, QUERY_CHAR_PRECUT, MAX_QUERY_TOKENS);
374
- const boundedDoc = truncateForModel(doc, GENERATIVE_DOC_CHAR_PRECUT, GENERATIVE_DOC_BUDGET_TOKENS);
375
- const tokens = _model.tokenize(buildQwenPrompt(boundedQuery, boundedDoc), true);
376
- // Belt-and-suspenders: the per-field bounding above reserves
377
- // GENERATIVE_TEMPLATE_OVERHEAD_TOKENS of margin for the template, but a
378
- // splice-boundary tokenization surprise is still conceivable. We can't
379
- // truncate the COMBINED prompt itself (it would cut the required
380
- // assistant-turn suffix the model needs to answer at the right position),
381
- // so if it's still over budget here, throw cleanly and let the caller fall
382
- // open — better than handing an oversized prompt to controlledEvaluate,
383
- // which doesn't throw on overflow the way rankAll does and could silently
384
- // context-shift/corrupt instead (a plausible contributor to the "empty
385
- // logits" symptom in #811, alongside the documented dual-backend issue).
386
- if (tokens.length > GENERATIVE_CONTEXT_SIZE) {
387
- throw new Error(`generative reranker prompt (${tokens.length} tokens) exceeds context size (${GENERATIVE_CONTEXT_SIZE}) after truncation`);
388
- }
389
- // Reset the sequence so each pair is scored independently (deterministic) and
390
- // a prior eval can't poison this one ("Eval has failed" after a bad state).
391
- await resetSequence();
392
- const input = tokens.map((t, i) => i === tokens.length - 1 ? [t, { generateNext: { probabilities: true } }] : t);
393
- const out = await _seq.controlledEvaluate(input);
394
- const lastDefined = [...out].reverse().find((x) => x !== undefined);
395
- const probs = lastDefined?.next?.probabilities;
396
- // Defensive: under some runtimes (observed in Harper's resource worker with a
397
- // second native llama backend already resident — HFE's raw-addon embedding
398
- // engine) controlledEvaluate returns an empty result array (no decoded
399
- // logits). Treat that as "can't score" and signal the caller to fall open,
400
- // rather than silently returning 0 (which would corrupt the ranking).
401
- if (out.length === 0 || !probs) {
402
- throw new Error("generative reranker produced no logits (empty controlledEvaluate result)");
403
- }
404
- const pYes = _yesToks.reduce((s, t) => s + (probs.get(t) ?? 0), 0);
405
- const pNo = _noToks.reduce((s, t) => s + (probs.get(t) ?? 0), 0);
406
- return pYes + pNo > 0 ? pYes / (pYes + pNo) : 0;
407
- }
408
- // Serialize all engine work. node-llama-cpp's context/sequence is single-use:
409
- // concurrent controlledEvaluate calls on the shared sequence corrupt the KV
410
- // cache ("Eval has failed"). Harper calls SemanticSearch.post() concurrently,
411
- // so we funnel every rerank through one in-process queue. Each search still
412
- // gets the full engine; they just don't overlap on the hardware. The latency
413
- // budget in rerankCandidates bounds how long any one search waits.
414
- let _engineChain = Promise.resolve();
415
- function runExclusive(fn) {
416
- const next = _engineChain.then(fn, fn);
417
- // Keep the chain alive regardless of this task's outcome.
418
- _engineChain = next.then(() => undefined, () => undefined);
419
- return next;
420
- }
421
- /**
422
- * Score query against a batch of documents. Returns an array of scores in the
423
- * same order as `docs`, in [0,1]. Higher = more relevant. Throws on engine
424
- * error (caller catches and falls back to vector order). Engine access is
425
- * serialized — see runExclusive.
426
- */
427
- export async function rerankScores(query, docs) {
428
- await ensureInit();
429
- if (_state !== "ready")
430
- throw new Error("reranker not ready");
431
- if (_mode === "rank") {
432
- // Bound query + every doc BEFORE rankAll (flair#811 point 1) — rankAll
433
- // computes ALL documents' token lengths up front and throws "The input
434
- // lengths of some of the given documents exceed the context size" if
435
- // ANY one exceeds RANK_CONTEXT_SIZE (verified against node-llama-cpp
436
- // 3.18.1's LlamaRankingContext.rankAll). truncateForModel makes that
437
- // effectively unreachable instead of routine on real memory content.
438
- const boundedQuery = truncateForModel(query, QUERY_CHAR_PRECUT, MAX_QUERY_TOKENS);
439
- const trimmed = docs.map((d) => truncateForModel(String(d ?? ""), RANK_DOC_CHAR_PRECUT, RANK_DOC_BUDGET_TOKENS));
440
- return runExclusive(() => _rankCtx.rankAll(boundedQuery, trimmed));
441
- }
442
- // generative — score sequentially under the engine lock (single shared
443
- // sequence; deterministic). The whole batch holds the lock for one search so
444
- // its sequence resets aren't interleaved with another search's.
445
- return runExclusive(async () => {
446
- const scores = [];
447
- for (const d of docs) {
448
- scores.push(await scoreGenerative(query, String(d ?? "")));
449
- }
450
- return scores;
451
- });
452
- }
453
- /**
454
- * Rerank `candidates` (each must carry `content`) against `query`. Reorders in
455
- * place by rerank score and overwrites `_score` with it (so downstream margin
456
- * measurement reads the rerank score); preserves the original semantic score as
457
- * `_semScore`. `_rawScore` is intentionally NOT touched (recall-bench's raw mode
458
- * must stay reproducible).
459
- *
460
- * FAIL-OPEN: on init failure, timeout, or any throw, returns the input array
461
- * UNCHANGED (caller then applies the existing vector-order sort). Never throws.
462
- */
463
- export async function rerankCandidates(query, candidates, opts) {
464
- const top = candidates.slice(0, Math.max(0, opts.topN));
465
- if (top.length < 2)
466
- return candidates;
467
- const t0 = Date.now();
468
- let timer;
469
- try {
470
- const docs = top.map((c) => String(c.content ?? ""));
471
- const timeout = new Promise((resolve) => {
472
- timer = setTimeout(() => resolve(null), Math.max(1, opts.budgetMs));
473
- });
474
- // Swallow a LATE rejection: if the budget wins the race, the scoring promise
475
- // is still pending and may reject afterwards — attach a no-op catch so it
476
- // doesn't surface as an unhandled rejection.
477
- const scoring = rerankScores(query, docs);
478
- scoring.catch(() => { });
479
- const scores = await Promise.race([scoring, timeout]);
480
- if (timer)
481
- clearTimeout(timer);
482
- if (scores === null) {
483
- // Budget exceeded — abandon rerank, keep vector order. No partial reorder.
484
- _fallbackCount++;
485
- return candidates;
486
- }
487
- // Attach rerank score, preserve the semantic score for diagnostics.
488
- const reranked = top.map((c, i) => {
489
- const sem = c._score;
490
- c._semScore = sem;
491
- c._score = Math.round((scores[i] ?? 0) * 1000) / 1000;
492
- return c;
493
- });
494
- reranked.sort((a, b) => (b._score ?? 0) - (a._score ?? 0));
495
- // Candidates beyond topN (not reranked) keep their vector position AFTER the
496
- // reranked block — they were already the tail of the vector-ordered pool.
497
- const tail = candidates.slice(top.length);
498
- _lastLatencyMs = Date.now() - t0;
499
- _rerankCount++;
500
- return [...reranked, ...tail];
501
- }
502
- catch (err) {
503
- if (timer)
504
- clearTimeout(timer);
505
- _fallbackCount++;
506
- if (!_warnedOnce) {
507
- console.warn(`[rerank] WARN: rerank threw, falling back to vector order. Error: ${err?.message || err}`);
508
- _warnedOnce = true;
509
- }
510
- return candidates;
511
- }
512
- }
513
- /** Is the master flag on? (Default OFF.) */
514
- export function isRerankEnabled() {
515
- return process.env.FLAIR_RERANK_ENABLED === "true";
516
- }
517
- /** Candidate count fed to the reranker. Caps the HNSW fetch. */
518
- export function getRerankTopN() {
519
- const v = Number(process.env.FLAIR_RERANK_TOPN);
520
- return Number.isFinite(v) && v > 0 ? Math.floor(v) : 50;
521
- }
522
- /** Hard latency budget for the whole rerank stage (ms). */
523
- export function getRerankBudgetMs() {
524
- const v = Number(process.env.FLAIR_RERANK_BUDGET_MS);
525
- return Number.isFinite(v) && v > 0 ? Math.floor(v) : 2500;
526
- }
527
- /** Skip rerank below this many candidates (nothing to reorder). */
528
- export function getRerankMinCandidates() {
529
- const v = Number(process.env.FLAIR_RERANK_MIN_CANDIDATES);
530
- return Number.isFinite(v) && v >= 2 ? Math.floor(v) : 2;
531
- }
532
- /** Status surface for health.ts. */
533
- export function getRerankStatus() {
534
- return {
535
- enabled: isRerankEnabled(),
536
- model: _modelKey || resolveModelKey(),
537
- mode: _mode ?? "uninitialized",
538
- state: _state,
539
- topN: getRerankTopN(),
540
- budgetMs: getRerankBudgetMs(),
541
- lastLatencyMs: _lastLatencyMs,
542
- rerankCount: _rerankCount,
543
- fallbackCount: _fallbackCount,
544
- error: _initError,
545
- };
546
- }
547
- // ── Test seam ────────────────────────────────────────────────────────────────
548
- // Exported pure helpers so the deterministic scoring path can be unit-tested
549
- // without loading a 600MB GGUF (mirrors the pilot's deterministic approach:
550
- // given fixed yes/no probabilities, the score + reorder math is exact).
551
- /** P(yes)/(P(yes)+P(no)) — the generative reranker's scoring function. */
552
- export function yesNoScore(pYes, pNo) {
553
- return pYes + pNo > 0 ? pYes / (pYes + pNo) : 0;
554
- }
555
- /**
556
- * Pure reorder used by rerankCandidates: attach scores, overwrite `_score`,
557
- * preserve `_semScore`, leave `_rawScore` untouched, sort desc, append the
558
- * non-reranked tail. Exported for deterministic unit tests.
559
- */
560
- export function applyRerank(candidates, scores, topN) {
561
- const top = candidates.slice(0, Math.max(0, topN));
562
- const reranked = top.map((c, i) => {
563
- c._semScore = c._score;
564
- c._score = Math.round((scores[i] ?? 0) * 1000) / 1000;
565
- return c;
566
- });
567
- reranked.sort((a, b) => (b._score ?? 0) - (a._score ?? 0));
568
- return [...reranked, ...candidates.slice(top.length)];
569
- }
@@ -1,101 +0,0 @@
1
- # Reranker model provisioning
2
-
3
- Flair's optional cross-encoder rerank stage (`resources/rerank-provider.ts`, gated
4
- behind `FLAIR_RERANK_ENABLED`) loads its model GGUF **in-process** via the same
5
- node-llama-cpp engine the embedding engine ships. The GGUF is **not** committed to
6
- the repo (`*.gguf` is gitignored) — it is provisioned into `models/` manually,
7
- exactly like the embedding model.
8
-
9
- The reranker is **OFF by default**. You only need to provision a GGUF if you are
10
- turning it on (`FLAIR_RERANK_ENABLED=true`) or running the recall-bench A/B.
11
-
12
- ## Models
13
-
14
- | `FLAIR_RERANK_MODEL` | GGUF filename (under `models/`) | Source | Inference mode |
15
- |---|---|---|---|
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
-
19
- Download into `models/` next to the embedding GGUF, e.g.:
20
-
21
- ```sh
22
- # quality model (Qwen3 generative path)
23
- huggingface-cli download Mungert/Qwen3-Reranker-0.6B-GGUF \
24
- Qwen3-Reranker-0.6B-q8_0.gguf --local-dir models/
25
-
26
- # latency model (jina rank API)
27
- huggingface-cli download gpustack/jina-reranker-v2-base-multilingual-GGUF \
28
- jina-reranker-v2-base.Q8_0.gguf --local-dir models/
29
- ```
30
-
31
- If the GGUF is missing, the provider logs one warning and recall falls back to
32
- vector order — it never blocks or breaks search.
33
-
34
- ## Config
35
-
36
- | Env var | Default | Meaning |
37
- |---|---|---|
38
- | `FLAIR_RERANK_ENABLED` | unset (**OFF**) | Master flag. `"true"` to enable. |
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
- | `FLAIR_RERANK_TOPN` | `50` | Candidate count fed to the reranker; caps the HNSW fetch. |
41
- | `FLAIR_RERANK_BUDGET_MS` | `2500` | Hard latency budget; exceeded → vector order. |
42
- | `FLAIR_RERANK_MIN_CANDIDATES` | `2` | Skip rerank below this many candidates. |
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
-
50
- ## Why this serving path (not Ollama, not a microservice)
51
-
52
- - **In-process node-llama-cpp** is the same engine the embedding engine already
53
- ships — no new infra, no network hop, no auth boundary. The reranker GGUF lives
54
- next to the embedding GGUF and loads via the same addon-discovery pattern as
55
- `embeddings-provider.ts`.
56
- - **Ollama is out:** it has no rerank endpoint and silently drops next-token
57
- logprobs, so it can serve neither the jina rank path nor the Qwen3 generative
58
- yes/no path. (Verified live against newton's Ollama 0.30.10.)
59
-
60
- ## Known limitation — Qwen3 generative path inside Harper
61
-
62
- The Qwen3 generative path scores `P(yes)/(P(yes)+P(no))` via node-llama-cpp's
63
- `controlledEvaluate` with next-token probabilities. This works standalone and in a
64
- plain Node worker thread, but **inside Harper's resource runtime — where HFE's
65
- embedding engine has already initialized a separate native llama backend —
66
- `controlledEvaluate` returns an empty result (no decoded logits).** The provider
67
- detects this (`out.length === 0`), throws, and **fails open to vector order** (it
68
- never writes corrupt scores). Net effect today: with `FLAIR_RERANK_MODEL=qwen3-...`
69
- the rerank stage cleanly no-ops inside Harper.
70
-
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.