@tpsdev-ai/flair 0.48.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/bridges/runtime/roundtrip.js +91 -2
- package/dist/build-info.json +3 -3
- package/dist/cli.js +903 -226
- package/dist/component-env.js +52 -4
- package/dist/deploy.js +20 -3
- package/dist/doctor-client.js +105 -32
- package/dist/federation/scheduler.js +24 -3
- package/dist/hook-install.js +96 -16
- package/dist/install/clients.js +318 -9
- package/dist/lib/auth-resolve.js +34 -3
- package/dist/lib/mcp-enable.js +134 -26
- package/dist/lib/scheduler-platform.js +132 -10
- package/dist/lib/scratch-owner.js +49 -0
- package/dist/rem/scheduler.js +23 -5
- package/dist/resources/AgentSeed.js +2 -0
- package/dist/resources/Memory.js +24 -5
- package/dist/resources/MemoryBootstrap.js +8 -4
- package/dist/resources/MemoryFeed.js +3 -0
- package/dist/resources/MemoryMaintenance.js +11 -2
- package/dist/resources/bm25-index-service.js +257 -0
- package/dist/resources/bm25-index.js +631 -0
- package/dist/resources/bm25.js +31 -1
- package/dist/resources/embeddings-boot.js +45 -3
- package/dist/resources/health.js +52 -7
- package/dist/resources/mcp-tools.js +1 -0
- package/dist/resources/memory-read-scope.js +2 -0
- package/dist/resources/search-readiness.js +100 -0
- package/dist/resources/semantic-retrieval-core.js +102 -23
- package/dist/resources/sort-comparators.js +45 -0
- package/dist/src/lib/scheduler-platform.js +132 -10
- package/dist/src/rem/scheduler.js +23 -5
- package/dist/version-check.js +59 -13
- package/docs/auth.md +5 -0
- package/docs/claude-code.md +10 -3
- package/docs/deepseek-harness.md +1 -1
- package/docs/deployment.md +11 -1
- package/docs/hosted-on-fabric.md +2 -0
- package/docs/integrations.md +78 -5
- package/docs/mcp-clients.md +85 -15
- package/docs/notes/mcp-oauth-model2.md +31 -13
- package/docs/quickstart-fabric.md +1 -1
- package/docs/quickstart.md +9 -9
- package/docs/standalone-local.md +3 -0
- package/docs/troubleshooting.md +25 -0
- package/package.json +4 -3
|
@@ -84,6 +84,7 @@
|
|
|
84
84
|
* Harper boot), registration is skipped and logged — Harper falls back to
|
|
85
85
|
* keyword-only search, matching the pre-existing degrade contract.
|
|
86
86
|
*/
|
|
87
|
+
import { availableParallelism } from "node:os";
|
|
87
88
|
import { resolveModelsDir } from "./embeddings-provider.js";
|
|
88
89
|
const LOGICAL_NAME = "default";
|
|
89
90
|
const MODEL_NAME = "nomic-embed-text";
|
|
@@ -148,6 +149,43 @@ const EMBEDDING_POOLING = "mean";
|
|
|
148
149
|
function benchModelPathOverride() {
|
|
149
150
|
return process.env.FLAIR_RECALL_HARNESS_MODEL_PATH || undefined;
|
|
150
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* llama.cpp CPU thread count passed to HFE `register({config:{threads}})`.
|
|
154
|
+
*
|
|
155
|
+
* HFE's own default is a fixed 6 (see harper-fabric-embeddings' `init()`
|
|
156
|
+
* table). flair never used to pass `threads`, so every host inherited that
|
|
157
|
+
* 6: an 8-vCPU ingest box left cores idle (flair#1330), a 4-core laptop
|
|
158
|
+
* oversubscribed. We always pass an explicit value.
|
|
159
|
+
*
|
|
160
|
+
* Default (unset / empty / non-integer / <1): `max(1, cores - 1)`. Safer
|
|
161
|
+
* than `min(6, cores)` — that still leaves the 8-vCPU case idle at 6 —
|
|
162
|
+
* and safer than using every core: Harper's JS worker (`THREADS_COUNT=1`)
|
|
163
|
+
* and the OS keep one. `availableParallelism()` (not `os.cpus().length`)
|
|
164
|
+
* so a cgroup CPU quota (Docker / k8s / Fabric) is what we count.
|
|
165
|
+
*
|
|
166
|
+
* Override: `FLAIR_EMBED_THREADS` — a positive integer, env-only. A
|
|
167
|
+
* config.yaml key would go through Harper's models-config persist path,
|
|
168
|
+
* which is exactly the class of state embeddings-boot exists to avoid
|
|
169
|
+
* (flair#694). Invalid values fall through to the host-aware default.
|
|
170
|
+
*/
|
|
171
|
+
export function resolveEmbedThreads(env = process.env, cores = availableParallelism()) {
|
|
172
|
+
const parsed = parsePositiveInt(env.FLAIR_EMBED_THREADS);
|
|
173
|
+
if (parsed !== undefined)
|
|
174
|
+
return parsed;
|
|
175
|
+
const safeCores = Number.isFinite(cores) && cores >= 1 ? Math.floor(cores) : 1;
|
|
176
|
+
return Math.max(1, safeCores - 1);
|
|
177
|
+
}
|
|
178
|
+
function parsePositiveInt(raw) {
|
|
179
|
+
if (raw == null)
|
|
180
|
+
return undefined;
|
|
181
|
+
const trimmed = raw.trim();
|
|
182
|
+
if (trimmed === "")
|
|
183
|
+
return undefined;
|
|
184
|
+
const n = Number(trimmed);
|
|
185
|
+
if (!Number.isInteger(n) || n < 1)
|
|
186
|
+
return undefined;
|
|
187
|
+
return n;
|
|
188
|
+
}
|
|
151
189
|
let registered = false;
|
|
152
190
|
/**
|
|
153
191
|
* Register the embedding backend. Idempotent within a process (mirrors
|
|
@@ -161,12 +199,16 @@ export async function registerEmbeddingsBackend() {
|
|
|
161
199
|
try {
|
|
162
200
|
const { register } = await import("harper-fabric-embeddings");
|
|
163
201
|
const modelPath = benchModelPathOverride();
|
|
202
|
+
const threads = resolveEmbedThreads();
|
|
164
203
|
await register({
|
|
165
204
|
logicalName: LOGICAL_NAME,
|
|
166
205
|
kind: "embedding",
|
|
167
|
-
config:
|
|
168
|
-
|
|
169
|
-
|
|
206
|
+
config: {
|
|
207
|
+
...(modelPath
|
|
208
|
+
? { modelPath, pooling: EMBEDDING_POOLING }
|
|
209
|
+
: { modelName: MODEL_NAME, modelsDir: resolveModelsDir(), pooling: EMBEDDING_POOLING }),
|
|
210
|
+
threads,
|
|
211
|
+
},
|
|
170
212
|
});
|
|
171
213
|
}
|
|
172
214
|
catch (err) {
|
package/dist/resources/health.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Resource, databases } from "harper";
|
|
1
|
+
import { Resource, databases, server, logger } from "harper";
|
|
2
2
|
import { promises as fsp, existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { homedir, platform } from "node:os";
|
|
4
4
|
import { join, dirname } from "node:path";
|
|
@@ -8,6 +8,9 @@ import { resolveBuildInfo } from "./build-info.js";
|
|
|
8
8
|
import { getMigrationStatusSnapshot } from "./migrations/status.js";
|
|
9
9
|
import { resolveMigrationDataDirForRead } from "./migrations/data-dir.js";
|
|
10
10
|
import { REM_DEDUP_STATS_PATH } from "./dedup-cluster.js";
|
|
11
|
+
import { hybridEnabled } from "./bm25.js";
|
|
12
|
+
import { bm25IndexEnabled, bm25IndexStatus } from "./bm25-index-service.js";
|
|
13
|
+
import { buildPublicHealthBody, resolveSearchReadiness } from "./search-readiness.js";
|
|
11
14
|
const db = databases;
|
|
12
15
|
const redactHome = (p) => {
|
|
13
16
|
const home = homedir();
|
|
@@ -52,7 +55,7 @@ function resolveVersion() {
|
|
|
52
55
|
return process.env.npm_package_version ?? "dev";
|
|
53
56
|
}
|
|
54
57
|
/**
|
|
55
|
-
* Health endpoint — truly public
|
|
58
|
+
* Health endpoint — truly public. Identity-free; no auth, no role check.
|
|
56
59
|
*
|
|
57
60
|
* `allowRead() { return true }` opens Harper's role gate for anonymous GETs,
|
|
58
61
|
* which is what makes /Health work for callers outside `authorizeLocal`'s
|
|
@@ -66,8 +69,15 @@ function resolveVersion() {
|
|
|
66
69
|
*
|
|
67
70
|
* Same pattern as `FederationPair.allowCreate(){ return true }` (PR #299):
|
|
68
71
|
* declare the Resource anonymously-accessible at the Harper layer; let the
|
|
69
|
-
* handler itself enforce whatever it needs
|
|
70
|
-
*
|
|
72
|
+
* handler itself enforce whatever it needs.
|
|
73
|
+
*
|
|
74
|
+
* flair#1326: `ok: true` used to mean only "this resource answered." That
|
|
75
|
+
* is a green light that lies when search routes are not mounted yet, or
|
|
76
|
+
* when the hybrid BM25 index is still cold (first search after restart
|
|
77
|
+
* scans the corpus; the lag grows with store size). `searchReady` is
|
|
78
|
+
* always present. When search cannot be served at all, this endpoint
|
|
79
|
+
* returns HTTP 503 and `ok: false`. When the process is live but recall
|
|
80
|
+
* is still cold, it stays 200 and names the lag on `searchReadyReason`.
|
|
71
81
|
*
|
|
72
82
|
* Rich stats (memory counts, agent names, etc.) are behind /HealthDetail
|
|
73
83
|
* which requires authentication. This prevents information leakage on
|
|
@@ -96,13 +106,39 @@ export class Health extends Resource {
|
|
|
96
106
|
// a 40-hex sha when the build ran in a git work tree, an honest null
|
|
97
107
|
// otherwise (tarball builds) — never omitted, never fabricated (Sherlock).
|
|
98
108
|
const build = resolveBuildInfo();
|
|
99
|
-
|
|
100
|
-
|
|
109
|
+
const readiness = currentSearchReadiness();
|
|
110
|
+
const body = buildPublicHealthBody(readiness, {
|
|
101
111
|
version: build?.version ?? resolveVersion(),
|
|
102
112
|
buildCommit: build?.commit ?? null,
|
|
103
|
-
};
|
|
113
|
+
});
|
|
114
|
+
if (readiness.status !== 200) {
|
|
115
|
+
return new Response(JSON.stringify(body), {
|
|
116
|
+
status: readiness.status,
|
|
117
|
+
headers: { "content-type": "application/json" },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return body;
|
|
104
121
|
}
|
|
105
122
|
}
|
|
123
|
+
/** Same sources /Health and /HealthDetail consult so they cannot disagree. */
|
|
124
|
+
let _warnedMissingRegistry = false;
|
|
125
|
+
function currentSearchReadiness() {
|
|
126
|
+
// Shipped Harper launch: this Resource is already registered, so
|
|
127
|
+
// server.resources is populated. The null skip is a stated fail-open
|
|
128
|
+
// (Sherlock on #1406) for the injectable/test path — not an accident.
|
|
129
|
+
const resources = server.resources ?? null;
|
|
130
|
+
if (!resources && !_warnedMissingRegistry) {
|
|
131
|
+
_warnedMissingRegistry = true;
|
|
132
|
+
logger.warn?.("Health: server.resources is absent — skipping the search-route mount check (table-only fail-open). Shipped Harper launch always exposes the registry.");
|
|
133
|
+
}
|
|
134
|
+
return resolveSearchReadiness({
|
|
135
|
+
resources,
|
|
136
|
+
memoryTable: db.flair?.Memory,
|
|
137
|
+
bm25: bm25IndexStatus(),
|
|
138
|
+
hybridEnabled: hybridEnabled(),
|
|
139
|
+
bm25IndexEnabled: bm25IndexEnabled(),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
106
142
|
/**
|
|
107
143
|
* Authenticated health detail — returns memory/agent/soul stats + process info.
|
|
108
144
|
* Requires Ed25519 agent auth or admin basic auth.
|
|
@@ -127,6 +163,15 @@ export class HealthDetail extends Resource {
|
|
|
127
163
|
const stats = { ok: true };
|
|
128
164
|
const nowMs = Date.now();
|
|
129
165
|
const warnings = [];
|
|
166
|
+
// flair#1326: same search-ready signal as public /Health. HealthDetail
|
|
167
|
+
// stays HTTP 200 (it is a stats dump, not a traffic gate); the field
|
|
168
|
+
// and a warning name the lag so `flair status` / operators can see it.
|
|
169
|
+
const readiness = currentSearchReadiness();
|
|
170
|
+
stats.searchReady = readiness.searchReady;
|
|
171
|
+
if (readiness.searchReadyReason) {
|
|
172
|
+
stats.searchReadyReason = readiness.searchReadyReason;
|
|
173
|
+
warnings.push({ level: "warn", message: readiness.searchReadyReason });
|
|
174
|
+
}
|
|
130
175
|
const ctx = this.getContext?.();
|
|
131
176
|
// #614 fix: resolve identity via the shared three-way verdict
|
|
132
177
|
// (internal/agent/anonymous — agent-auth.ts) instead of reading
|
|
@@ -811,6 +811,7 @@ export const TOOLS = {
|
|
|
811
811
|
{ count: "memoriesIncluded", containers: ["memories", "predicted"] }, // #1199
|
|
812
812
|
{ count: "teammateFindingsIncluded", containers: ["teammateFindings"] }, // #1199
|
|
813
813
|
{ count: "sections.events", containers: ["events"] }, // #1206
|
|
814
|
+
{ count: "sections.soul", containers: ["soul"] }, // #1371
|
|
814
815
|
],
|
|
815
816
|
// present + typed even when empty — never a bare {} / missing key (#1182).
|
|
816
817
|
selfDescribingEmpty: [
|
|
@@ -95,5 +95,7 @@ export async function resolveReadScope(authAgentId) {
|
|
|
95
95
|
return false;
|
|
96
96
|
return record.agentId === authAgentId || !isPrivateVisibility(record.visibility);
|
|
97
97
|
};
|
|
98
|
+
// See ReadScope.isAllowed's doc: agentId + visibility, nothing else.
|
|
99
|
+
isAllowed.scopableOnly = true;
|
|
98
100
|
return { allowedOwners: [authAgentId], condition, isAllowed };
|
|
99
101
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search-readiness.ts — the honest /Health search-ready signal (flair#1326).
|
|
3
|
+
*
|
|
4
|
+
* Harper's process can answer /health (and Flair's /Health resource can
|
|
5
|
+
* answer {ok:true}) while search is still unusable:
|
|
6
|
+
*
|
|
7
|
+
* 1. Boot window — jsResources register incrementally. /Health can be up
|
|
8
|
+
* while /Memory and /SemanticSearch still 404 from Harper's catch-all
|
|
9
|
+
* (documented in packages/adk-flair-js/test/helpers/boot-harper.mjs).
|
|
10
|
+
* 2. Cold BM25 index — hybrid retrieval's persistent index is lazy-built
|
|
11
|
+
* on the first search, not at component start (bm25-index-service.ts).
|
|
12
|
+
* That first-query corpus scan grows with store size. /Health answering
|
|
13
|
+
* is not "recall is warm."
|
|
14
|
+
*
|
|
15
|
+
* This module is Harper-free so the decision is unit-testable against the
|
|
16
|
+
* shipped function. Callers inject the registry / table / index status they
|
|
17
|
+
* already have.
|
|
18
|
+
*
|
|
19
|
+
* Two layers, on purpose:
|
|
20
|
+
* - Routes/table not mounted → not healthy (ok:false, HTTP 503). A
|
|
21
|
+
* traffic-gating probe that only looks at status must not get a green
|
|
22
|
+
* light for a node whose search routes are not serving.
|
|
23
|
+
* - Routes up but index still cold → process is live (ok:true, HTTP 200)
|
|
24
|
+
* and searchReady:false names the lag. We do NOT 503 on a cold index:
|
|
25
|
+
* the index builds on the first search, and a health check must not
|
|
26
|
+
* trigger that scan (bm25-index-service.ts: "Eager building would add a
|
|
27
|
+
* full corpus scan to every boot including … health checks"). 503-until-
|
|
28
|
+
* warm would deadlock — health waits for the index, the index waits for
|
|
29
|
+
* a search that never comes.
|
|
30
|
+
*/
|
|
31
|
+
function routeMounted(resources, name) {
|
|
32
|
+
const entry = resources.get?.(name) ?? resources.getMatch?.(name);
|
|
33
|
+
return Boolean(entry?.Resource);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Decide whether search is actually usable, and whether /Health should claim
|
|
37
|
+
* the process is healthy.
|
|
38
|
+
*
|
|
39
|
+
* `resources` is optional. Stated fail-open (Sherlock on #1406): when the
|
|
40
|
+
* registry is missing we skip the route-mount check rather than 503 forever.
|
|
41
|
+
* A table handle can exist while `/Memory` and `/SemanticSearch` still 404,
|
|
42
|
+
* so this is weaker than the primary defense. In the shipped launch path
|
|
43
|
+
* `/Health` is served by this Resource after Harper has registered us, so
|
|
44
|
+
* `server.resources` is populated; the skip is the injectable/test path
|
|
45
|
+
* (and a theoretical export without a registry). Health.ts logs once if
|
|
46
|
+
* the live call site actually takes it.
|
|
47
|
+
*/
|
|
48
|
+
export function resolveSearchReadiness(opts) {
|
|
49
|
+
if (opts.resources) {
|
|
50
|
+
const memoryMounted = routeMounted(opts.resources, "Memory");
|
|
51
|
+
const searchMounted = routeMounted(opts.resources, "SemanticSearch");
|
|
52
|
+
if (!memoryMounted || !searchMounted) {
|
|
53
|
+
const missing = [
|
|
54
|
+
!memoryMounted ? "Memory" : null,
|
|
55
|
+
!searchMounted ? "SemanticSearch" : null,
|
|
56
|
+
].filter(Boolean).join(", ");
|
|
57
|
+
return notServing(`search routes not mounted (${missing})`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (!opts.memoryTable || typeof opts.memoryTable.search !== "function") {
|
|
61
|
+
return notServing("memory table not queryable");
|
|
62
|
+
}
|
|
63
|
+
// Hybrid + the persistent index are default-on. A cold or in-flight BM25
|
|
64
|
+
// index means the first search will pay a full corpus scan — the #1326
|
|
65
|
+
// lag. Name it; do not fail liveness.
|
|
66
|
+
//
|
|
67
|
+
// `disabled` (feed/build failure) and `FLAIR_BM25_INDEX=false` both fall
|
|
68
|
+
// back to the per-query scan. The kill switch never calls ensureReady, so
|
|
69
|
+
// status stays `empty` for the life of the process — that is serving, not
|
|
70
|
+
// cold. Treating it as lag would make searchReady false forever and refuse
|
|
71
|
+
// a node that is already answering recall.
|
|
72
|
+
const indexInPath = opts.hybridEnabled !== false && opts.bm25IndexEnabled !== false;
|
|
73
|
+
if (indexInPath && opts.bm25) {
|
|
74
|
+
if (opts.bm25.state === "building") {
|
|
75
|
+
return namesLag("bm25 index building — first search is still scanning the corpus");
|
|
76
|
+
}
|
|
77
|
+
if (opts.bm25.state === "empty") {
|
|
78
|
+
return namesLag("bm25 index not built (cold boot; first search scans the corpus)");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { searchReady: true, ok: true, status: 200 };
|
|
82
|
+
}
|
|
83
|
+
function notServing(searchReadyReason) {
|
|
84
|
+
return { searchReady: false, ok: false, status: 503, searchReadyReason };
|
|
85
|
+
}
|
|
86
|
+
function namesLag(searchReadyReason) {
|
|
87
|
+
return { searchReady: false, ok: true, status: 200, searchReadyReason };
|
|
88
|
+
}
|
|
89
|
+
/** Public /Health JSON body. `searchReady` is always present (never omitted). */
|
|
90
|
+
export function buildPublicHealthBody(readiness, identity) {
|
|
91
|
+
const body = {
|
|
92
|
+
ok: readiness.ok,
|
|
93
|
+
version: identity.version,
|
|
94
|
+
buildCommit: identity.buildCommit,
|
|
95
|
+
searchReady: readiness.searchReady,
|
|
96
|
+
};
|
|
97
|
+
if (readiness.searchReadyReason)
|
|
98
|
+
body.searchReadyReason = readiness.searchReadyReason;
|
|
99
|
+
return body;
|
|
100
|
+
}
|
|
@@ -59,6 +59,8 @@ import { cosineSimilarity } from "./dedup.js";
|
|
|
59
59
|
import { compositeScore } from "./scoring.js";
|
|
60
60
|
import { buildBM25, fuseRrfNormalized, SEM_LIMIT } from "./bm25.js";
|
|
61
61
|
import { isAllowedBm25Candidate } from "./bm25-filter.js";
|
|
62
|
+
import { indexedBm25Ids } from "./bm25-index-service.js";
|
|
63
|
+
import { byRecencyThenId } from "./sort-comparators.js";
|
|
62
64
|
// Convert HNSW cosine distance (1 - similarity) to similarity score.
|
|
63
65
|
function distanceToSimilarity(distance) {
|
|
64
66
|
return 1 - distance;
|
|
@@ -154,23 +156,60 @@ export async function retrieveCandidates(params) {
|
|
|
154
156
|
semIds.push(record.id);
|
|
155
157
|
}
|
|
156
158
|
}
|
|
157
|
-
// ── (b) BM25
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
159
|
+
// ── (b) The BM25 lexical leg ─────────────────────────────────────────
|
|
160
|
+
//
|
|
161
|
+
// flair#1357. This used to be unconditional: fetch the WHOLE scoped corpus
|
|
162
|
+
// out of Harper, then `buildBM25()` it — per query. That made retrieval
|
|
163
|
+
// latency linear in store size (5.6s p50 at 60k rows, 28.7s at 180k). The
|
|
164
|
+
// lexical leg is now served from a persistent, incrementally-maintained
|
|
165
|
+
// index (resources/bm25-index.ts) whose contract is RANKING-IDENTICAL:
|
|
166
|
+
// same ids, same order, byte for byte. The legacy scan below is still the
|
|
167
|
+
// reference implementation AND the fallback — the index returns null for
|
|
168
|
+
// any query it cannot reproduce exactly, and for a query with no text
|
|
169
|
+
// there is no lexical leg to serve at all.
|
|
162
170
|
const allowedById = new Map();
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
171
|
+
let bm25Ids = [];
|
|
172
|
+
// Only the no-signal listing branch (d) still needs the full scoped
|
|
173
|
+
// corpus materialised; every other branch resolves records by id.
|
|
174
|
+
const needCorpusListing = !q && !qEmb;
|
|
175
|
+
let servedFromIndex = false;
|
|
176
|
+
if (q) {
|
|
177
|
+
const fromIndex = await indexedBm25Ids({
|
|
178
|
+
q: String(q),
|
|
179
|
+
conditions: conditions,
|
|
180
|
+
timeFilters: { sinceDate, asOf },
|
|
181
|
+
isAllowed,
|
|
182
|
+
limit: SEM_LIMIT,
|
|
183
|
+
ctx,
|
|
184
|
+
});
|
|
185
|
+
if (fromIndex) {
|
|
186
|
+
bm25Ids = fromIndex;
|
|
187
|
+
servedFromIndex = true;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (!servedFromIndex && (q || needCorpusListing)) {
|
|
191
|
+
// ── Legacy path: scoped corpus scan + per-query buildBM25() ──────────
|
|
192
|
+
const corpusQuery = conditions.length > 0
|
|
193
|
+
? { conditions, select }
|
|
194
|
+
: { select };
|
|
195
|
+
const corpusResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(corpusQuery));
|
|
196
|
+
const bm25Docs = [];
|
|
197
|
+
for await (const record of corpusResults) {
|
|
198
|
+
// Defense-in-depth: re-check the SAME conditions[] + temporal filters
|
|
199
|
+
// in-process. Even if a Harper query change ever let an out-of-scope
|
|
200
|
+
// record through, it is dropped here BEFORE it can be BM25-scored/fused.
|
|
201
|
+
if (!isAllowedBm25Candidate(record, conditions, { sinceDate, asOf }))
|
|
202
|
+
continue;
|
|
203
|
+
if (!passesAllowed(record))
|
|
204
|
+
continue;
|
|
205
|
+
allowedById.set(record.id, record);
|
|
206
|
+
bm25Docs.push({ id: record.id, content: record.content });
|
|
207
|
+
}
|
|
208
|
+
if (q) {
|
|
209
|
+
const bm25 = buildBM25(bm25Docs);
|
|
210
|
+
const ranked = bm25.rank(String(q));
|
|
211
|
+
bm25Ids = ranked.filter(r => r.score > 0).slice(0, SEM_LIMIT).map(r => r.id);
|
|
212
|
+
}
|
|
174
213
|
}
|
|
175
214
|
// Carry semantic candidates that survived their temporal gate into the
|
|
176
215
|
// allowed map too (so a fused id always resolves to a record). Semantic
|
|
@@ -181,12 +220,45 @@ export async function retrieveCandidates(params) {
|
|
|
181
220
|
allowedById.set(r.id, rest);
|
|
182
221
|
}
|
|
183
222
|
}
|
|
184
|
-
// ── (
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
223
|
+
// ── (b2) Resolve the index-served BM25 candidates ─────────────────────
|
|
224
|
+
// On the indexed path there is no corpus map to read from, so a BM25-only
|
|
225
|
+
// rescue is point-looked-up and projected down to `select`. The projection
|
|
226
|
+
// reproduces Harper's own `search({select})` shape exactly — the keys of
|
|
227
|
+
// `select` that the row actually carries, in `select` declaration order
|
|
228
|
+
// (measured; pinned by test/integration/bm25-index-scan-order-1357.test.ts).
|
|
229
|
+
//
|
|
230
|
+
// The freshly-read row is then re-checked against the SAME conditions[] +
|
|
231
|
+
// temporal filters + scope predicate before it is allowed into the fusion.
|
|
232
|
+
// The index already applied all three to its own copy of the row; this is
|
|
233
|
+
// the Sherlock gate applied to the row we are actually about to return, so
|
|
234
|
+
// a stale index entry can only ever REMOVE a candidate, never smuggle an
|
|
235
|
+
// out-of-scope record into the union.
|
|
236
|
+
if (servedFromIndex) {
|
|
237
|
+
const resolved = [];
|
|
238
|
+
for (const id of bm25Ids) {
|
|
239
|
+
if (allowedById.has(id)) {
|
|
240
|
+
resolved.push(id);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const full = await withDetachedTxn(ctx, () => databases.flair.Memory.get(id));
|
|
244
|
+
if (!full)
|
|
245
|
+
continue;
|
|
246
|
+
if (!isAllowedBm25Candidate(full, conditions, { sinceDate, asOf }))
|
|
247
|
+
continue;
|
|
248
|
+
if (!passesAllowed(full))
|
|
249
|
+
continue;
|
|
250
|
+
const projected = {};
|
|
251
|
+
for (const key of select)
|
|
252
|
+
if (key in full)
|
|
253
|
+
projected[key] = full[key];
|
|
254
|
+
allowedById.set(id, projected);
|
|
255
|
+
if (qEmb) {
|
|
256
|
+
const storedEmbedding = Array.isArray(full.embedding) ? full.embedding : [];
|
|
257
|
+
semSimById.set(id, cosineSimilarity(qEmb, storedEmbedding));
|
|
258
|
+
}
|
|
259
|
+
resolved.push(id);
|
|
260
|
+
}
|
|
261
|
+
bm25Ids = resolved;
|
|
190
262
|
}
|
|
191
263
|
// ── (d) No retrieval signal at all → full scoped listing ────────────
|
|
192
264
|
if (!q && !qEmb) {
|
|
@@ -413,7 +485,14 @@ export async function retrieveCandidates(params) {
|
|
|
413
485
|
// retrieval lives in the fused ORDER (a BM25 rank-1 rescue outranks weak
|
|
414
486
|
// semantic hits), while `_score` carries the honest absolute evidence for
|
|
415
487
|
// each result — the two can disagree, and that is correct.
|
|
416
|
-
|
|
488
|
+
//
|
|
489
|
+
// Ties at this sort are cross-leg RRF identities (flair#1412): no
|
|
490
|
+
// within-leg tie-break can prevent `1/(k+3)+1/(k+5) === 1/(k+5)+1/(k+3)`.
|
|
491
|
+
// Break them by createdAt DESC (null → oldest, never NaN) then id ASC.
|
|
492
|
+
// Determinism is the id ASC total order. createdAt is best-effort recency
|
|
493
|
+
// within an exact `_rank` tie — federated writer clocks can skew, and
|
|
494
|
+
// that cannot reintroduce nondeterminism (see byRecencyThenId).
|
|
495
|
+
filteredResults.sort((a, b) => (b._rank - a._rank) || byRecencyThenId(a, b));
|
|
417
496
|
for (const r of filteredResults)
|
|
418
497
|
delete r._rank;
|
|
419
498
|
return filteredResults;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic sort comparators (flair#1412).
|
|
3
|
+
*
|
|
4
|
+
* Retrieval and selection sorts used to key on one number (score, rank,
|
|
5
|
+
* priority) and then inherit `Array.prototype.sort`'s input order on ties.
|
|
6
|
+
* That input order is Harper scan order, which is free to change across
|
|
7
|
+
* restarts. The family below makes the tie-break the default thing to
|
|
8
|
+
* reach for: each helper has its own primary key, and they share the
|
|
9
|
+
* `compareKey` tail — not one comparator forced onto every site.
|
|
10
|
+
*
|
|
11
|
+
* Do not collapse this into a single `byScoreThenId`. cosine.ts keys on
|
|
12
|
+
* corpus `.index`; MemoryBootstrap (flair#1409, not this PR) keys on the
|
|
13
|
+
* soul `key`. The shared part is the tail.
|
|
14
|
+
*
|
|
15
|
+
* Locale-independent: `compareKey` is code-unit / numeric `<`/`>`, never
|
|
16
|
+
* `localeCompare`. ISO-8601 UTC strings compare chronologically that way.
|
|
17
|
+
*/
|
|
18
|
+
/** Total-order tail. Works for ids, corpus indexes, soul keys. */
|
|
19
|
+
export function compareKey(a, b) {
|
|
20
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
21
|
+
}
|
|
22
|
+
/** Score / rank descending, then `id` ascending. */
|
|
23
|
+
export function byNumberDescThenId(getNumber) {
|
|
24
|
+
return (a, b) => (getNumber(b) - getNumber(a)) || compareKey(a.id, b.id);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* `createdAt` DESC (null → oldest, never NaN) then `id` ASC.
|
|
28
|
+
*
|
|
29
|
+
* Determinism comes from `id` ASC — ids are unique, so this is a total
|
|
30
|
+
* order no matter what `createdAt` does. Do not date-arithmetic a
|
|
31
|
+
* possibly-null field: `new Date(null).getTime()` is 0, but subtracting
|
|
32
|
+
* NaN (unparseable / missing-after-coercion) is undefined behaviour for
|
|
33
|
+
* `Array.prototype.sort`. Empty-string fallback keeps the comparison in
|
|
34
|
+
* string space; an empty value is less than any ISO-8601 UTC timestamp,
|
|
35
|
+
* so it sorts last under DESC.
|
|
36
|
+
*
|
|
37
|
+
* Clock-skew caveat: `createdAt` is writer-stamped. Across federated
|
|
38
|
+
* writers this is best-effort recency within an exact `_rank` tie, not a
|
|
39
|
+
* correctness claim. Skew can misorder rows the ranker already called
|
|
40
|
+
* equivalent; it cannot reintroduce nondeterminism, because `id` ASC
|
|
41
|
+
* still resolves every remaining tie.
|
|
42
|
+
*/
|
|
43
|
+
export function byRecencyThenId(a, b) {
|
|
44
|
+
return compareKey(b.createdAt ?? "", a.createdAt ?? "") || compareKey(a.id, b.id);
|
|
45
|
+
}
|