clawmem 0.13.0 → 0.15.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/AGENTS.md +165 -677
- package/CLAUDE.md +4 -747
- package/README.md +20 -191
- package/SKILL.md +157 -711
- package/docs/clawmem-architecture.excalidraw +2415 -0
- package/docs/clawmem-architecture.png +0 -0
- package/docs/clawmem_hero.jpg +0 -0
- package/docs/concepts/architecture.md +413 -0
- package/docs/concepts/composite-scoring.md +133 -0
- package/docs/concepts/hooks-vs-mcp.md +156 -0
- package/docs/concepts/multi-vault.md +71 -0
- package/docs/contributing.md +101 -0
- package/docs/guides/cloud-embedding.md +134 -0
- package/docs/guides/hermes-plugin.md +187 -0
- package/docs/guides/inference-services.md +144 -0
- package/docs/guides/multi-vault-config.md +84 -0
- package/docs/guides/openclaw-plugin.md +306 -0
- package/docs/guides/setup-hooks.md +146 -0
- package/docs/guides/setup-mcp.md +76 -0
- package/docs/guides/systemd-services.md +332 -0
- package/docs/guides/upgrading.md +566 -0
- package/docs/internals/entity-resolution.md +135 -0
- package/docs/internals/graph-traversal.md +85 -0
- package/docs/internals/intent-search-pipeline.md +103 -0
- package/docs/internals/query-pipeline.md +100 -0
- package/docs/introduction.md +104 -0
- package/docs/quickstart.md +158 -0
- package/docs/reference/cli.md +195 -0
- package/docs/reference/configuration.md +101 -0
- package/docs/reference/mcp-tools.md +336 -0
- package/docs/reference/rest-api.md +204 -0
- package/docs/troubleshooting.md +330 -0
- package/package.json +2 -1
- package/src/clawmem.ts +60 -0
- package/src/health/rerank-golden.json +54 -0
- package/src/health/rerank-health.ts +150 -0
- package/src/mcp.ts +20 -1
- package/src/memory.ts +2 -0
- package/src/search-utils.ts +35 -4
- package/src/store.ts +115 -22
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reranker health probe — asserts the reranker DISCRIMINATES, not just that it responds.
|
|
3
|
+
*
|
|
4
|
+
* Background: the deployed zerank-2 GGUF was mis-converted (no score head), so it returned
|
|
5
|
+
* HTTP 200 + valid JSON + finite positive scores (~1e-11) yet ranked near-randomly and silently
|
|
6
|
+
* collapsed the final ranking to RRF. Liveness checks all passed. This probe instead runs a small
|
|
7
|
+
* golden set of same-topic (query, relevant, hard-negative) triples through the LIVE reranker
|
|
8
|
+
* (cache-bypassed) and asserts three things:
|
|
9
|
+
*
|
|
10
|
+
* 1. coverage — the reranker scored every probe doc (store.rerank throws RerankCoverageError
|
|
11
|
+
* otherwise, before its zero-fill would hide an omitted score);
|
|
12
|
+
* 2. calibration — the best relevant-doc score lands in a sane band (>= CALIB_FLOOR);
|
|
13
|
+
* 3. discrimination — EVERY pair clears score(relevant) - score(hardNegative) >= DISCRIM_MARGIN.
|
|
14
|
+
*
|
|
15
|
+
* See RERANKER-HEALTH-GUARD-DESIGN.md.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync } from "fs";
|
|
18
|
+
import { join } from "path";
|
|
19
|
+
import { DEFAULT_RERANK_MODEL, RerankCoverageError, RerankMalformedResponseError, type Store } from "../store.ts";
|
|
20
|
+
|
|
21
|
+
// Thresholds — LOCKED from a live zerank-2-seq baseline (2026-06-26, 8-pair golden set):
|
|
22
|
+
// relevant scores 0.9233-0.9700, hard-neg max 0.3120, min margin 0.6417, 0/8 inverted.
|
|
23
|
+
// broken zerank-2 GGUF regime: every score <= 8.03e-7 (32-query probe). 5-6 OOM of separation.
|
|
24
|
+
// CALIB_FLOOR is the band that catches the ~0 collapse (kept conservative — the margin does the
|
|
25
|
+
// discrimination, so the band must not false-fail a working-but-lower reranker). DISCRIM_MARGIN is
|
|
26
|
+
// 2.5x below the live min margin (0.64) so healthy never trips, far above the degenerate ~0.
|
|
27
|
+
export const RERANK_CALIB_FLOOR = 0.05; // band: best relevant-doc score across pairs must clear this
|
|
28
|
+
export const RERANK_DISCRIM_MARGIN = 0.25; // per-pair: score(relevant) - score(hardNegative) >= this
|
|
29
|
+
// Default per-probe-request timeout (the production remote fetch is otherwise untimed; a hung
|
|
30
|
+
// reranker must not hang the healthcheck).
|
|
31
|
+
export const RERANK_PROBE_TIMEOUT_MS = 10_000;
|
|
32
|
+
|
|
33
|
+
export interface GoldenTriple {
|
|
34
|
+
query: string;
|
|
35
|
+
relevant: string;
|
|
36
|
+
hardNegative: string;
|
|
37
|
+
note?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RerankHealthResult {
|
|
41
|
+
ok: boolean;
|
|
42
|
+
coverageOk: boolean;
|
|
43
|
+
maxScore: number; // best relevant-doc score across pairs (calibration-band input)
|
|
44
|
+
minMargin: number; // smallest (relevant - hardNegative) margin across pairs
|
|
45
|
+
pairsTotal: number;
|
|
46
|
+
pairsScored: number; // pairs where both docs were scored (full coverage)
|
|
47
|
+
failures: string[]; // human-readable failure reasons (empty iff ok)
|
|
48
|
+
thresholds: { calibFloor: number; discrimMargin: number };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Load the shipped golden set (or an explicit path, for tests). */
|
|
52
|
+
export function loadGoldenSet(path?: string): GoldenTriple[] {
|
|
53
|
+
const p = path ?? join(import.meta.dir, "rerank-golden.json");
|
|
54
|
+
const parsed = JSON.parse(readFileSync(p, "utf-8")) as { triples: GoldenTriple[] };
|
|
55
|
+
return parsed.triples;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Probe the reranker behind `store` for discrimination + calibration. Routes through store.rerank
|
|
60
|
+
* with { noCache, requireLiveCoverage, timeoutMs } so it exercises the FULL production path
|
|
61
|
+
* (remote → local fallback, dedup, intent, 400-char truncation) while bypassing the cache and
|
|
62
|
+
* enforcing live coverage. `store` is structurally typed so tests can pass a fake reranker.
|
|
63
|
+
*/
|
|
64
|
+
export async function probeRerankHealth(
|
|
65
|
+
store: Pick<Store, "rerank">,
|
|
66
|
+
opts: {
|
|
67
|
+
thresholds?: { calibFloor?: number; discrimMargin?: number };
|
|
68
|
+
timeoutMs?: number;
|
|
69
|
+
model?: string;
|
|
70
|
+
triples?: GoldenTriple[];
|
|
71
|
+
} = {},
|
|
72
|
+
): Promise<RerankHealthResult> {
|
|
73
|
+
const calibFloor = opts.thresholds?.calibFloor ?? RERANK_CALIB_FLOOR;
|
|
74
|
+
const discrimMargin = opts.thresholds?.discrimMargin ?? RERANK_DISCRIM_MARGIN;
|
|
75
|
+
const timeoutMs = opts.timeoutMs ?? RERANK_PROBE_TIMEOUT_MS;
|
|
76
|
+
const model = opts.model ?? DEFAULT_RERANK_MODEL;
|
|
77
|
+
const triples = opts.triples ?? loadGoldenSet();
|
|
78
|
+
|
|
79
|
+
const failures: string[] = [];
|
|
80
|
+
let maxScore = 0;
|
|
81
|
+
let minMargin = Infinity;
|
|
82
|
+
let pairsScored = 0;
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < triples.length; i++) {
|
|
85
|
+
const t = triples[i]!;
|
|
86
|
+
const relFile = `golden-${i}-rel`;
|
|
87
|
+
const negFile = `golden-${i}-neg`;
|
|
88
|
+
const docs = [
|
|
89
|
+
{ file: relFile, text: t.relevant },
|
|
90
|
+
{ file: negFile, text: t.hardNegative },
|
|
91
|
+
];
|
|
92
|
+
const label = `pair ${i} ("${t.query.slice(0, 40)}")`;
|
|
93
|
+
|
|
94
|
+
let scored: { file: string; score: number }[];
|
|
95
|
+
try {
|
|
96
|
+
// intent omitted (4th arg undefined); options force a live, coverage-checked call.
|
|
97
|
+
scored = await store.rerank(t.query, docs, model, undefined, {
|
|
98
|
+
noCache: true,
|
|
99
|
+
requireLiveCoverage: true,
|
|
100
|
+
timeoutMs,
|
|
101
|
+
});
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (err instanceof RerankCoverageError) {
|
|
104
|
+
failures.push(`${label}: coverage — reranker did not score ${err.missing.length} doc(s)`);
|
|
105
|
+
} else if (err instanceof RerankMalformedResponseError) {
|
|
106
|
+
failures.push(`${label}: malformed response — ${err.problems.join("; ")}`);
|
|
107
|
+
} else {
|
|
108
|
+
failures.push(`${label}: probe error — ${(err as Error).message}`);
|
|
109
|
+
}
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const scoreMap = new Map(scored.map((s) => [s.file, s.score]));
|
|
114
|
+
const relScore = scoreMap.get(relFile);
|
|
115
|
+
const negScore = scoreMap.get(negFile);
|
|
116
|
+
if (relScore === undefined || negScore === undefined || !Number.isFinite(relScore) || !Number.isFinite(negScore)) {
|
|
117
|
+
// requireLiveCoverage should have thrown already; defensive.
|
|
118
|
+
failures.push(`${label}: missing or non-finite score after rerank`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
pairsScored++;
|
|
123
|
+
maxScore = Math.max(maxScore, relScore);
|
|
124
|
+
const margin = relScore - negScore;
|
|
125
|
+
minMargin = Math.min(minMargin, margin);
|
|
126
|
+
if (margin < discrimMargin) {
|
|
127
|
+
failures.push(
|
|
128
|
+
`${label}: margin ${margin.toFixed(3)} < ${discrimMargin} (rel ${relScore.toFixed(3)} vs neg ${negScore.toFixed(3)})`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (maxScore < calibFloor) {
|
|
134
|
+
failures.push(
|
|
135
|
+
`calibration: max relevant-doc score ${maxScore.toExponential(2)} < floor ${calibFloor} — reranker is inert/degenerate (likely the deprecated zerank-2 GGUF; re-deploy the seq-cls sidecar)`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (minMargin === Infinity) minMargin = 0; // no pair scored
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
ok: failures.length === 0,
|
|
142
|
+
coverageOk: pairsScored === triples.length,
|
|
143
|
+
maxScore,
|
|
144
|
+
minMargin,
|
|
145
|
+
pairsTotal: triples.length,
|
|
146
|
+
pairsScored,
|
|
147
|
+
failures,
|
|
148
|
+
thresholds: { calibFloor, discrimMargin },
|
|
149
|
+
};
|
|
150
|
+
}
|
package/src/mcp.ts
CHANGED
|
@@ -47,6 +47,25 @@ import {
|
|
|
47
47
|
import { listVaults, loadVaultConfig } from "./config.ts";
|
|
48
48
|
import { getEntityGraphNeighbors, searchEntities } from "./entity.ts";
|
|
49
49
|
|
|
50
|
+
// =============================================================================
|
|
51
|
+
// Reranker fallback telemetry
|
|
52
|
+
// =============================================================================
|
|
53
|
+
|
|
54
|
+
// blendRerank silently degrades to RRF order when the reranker is degenerate (the deprecated
|
|
55
|
+
// zerank-2 GGUF emitted ~1e-11 scores that contributed nothing at weight 0.9). This surfaces that
|
|
56
|
+
// otherwise-invisible regression. Rate-limited to at most one stderr line per minute; the running
|
|
57
|
+
// count is included so a persistent failure is obvious. Run `clawmem doctor` for the full probe.
|
|
58
|
+
let rerankFallbackCount = 0;
|
|
59
|
+
let lastRerankFallbackWarnAt = 0;
|
|
60
|
+
function onRerankFallback(reason: string): void {
|
|
61
|
+
rerankFallbackCount++;
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
if (now - lastRerankFallbackWarnAt > 60_000) {
|
|
64
|
+
lastRerankFallbackWarnAt = now;
|
|
65
|
+
console.error(`[clawmem] reranker degraded → RRF fallback (${reason}); ${rerankFallbackCount} occurrence(s) this process. Run 'clawmem doctor' to check the reranker.`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
50
69
|
// =============================================================================
|
|
51
70
|
// Types
|
|
52
71
|
// =============================================================================
|
|
@@ -768,7 +787,7 @@ This is the recommended entry point for ALL memory queries.`,
|
|
|
768
787
|
// prior w·(1/rrfRank) blend made RRF rank-1 immovable by the reranker. Harness-validated
|
|
769
788
|
// 2026-06-25 (NL+KW known-item recall): lifts recall@1-5 + MRR@10 with no material pooled
|
|
770
789
|
// recall@10 regression.
|
|
771
|
-
const blended = blendRerank(candidates, reranked);
|
|
790
|
+
const blended = blendRerank(candidates, reranked, { onFallback: onRerankFallback });
|
|
772
791
|
|
|
773
792
|
// Map to SearchResults for composite scoring — hydrate from DB when needed
|
|
774
793
|
const allSearchResults = [...store.searchFTS(query, 30)];
|
package/src/memory.ts
CHANGED
|
@@ -22,6 +22,7 @@ export const HALF_LIVES: Record<string, number> = {
|
|
|
22
22
|
decision: Infinity,
|
|
23
23
|
deductive: Infinity,
|
|
24
24
|
hub: Infinity,
|
|
25
|
+
antipattern: Infinity,
|
|
25
26
|
};
|
|
26
27
|
|
|
27
28
|
// =============================================================================
|
|
@@ -33,6 +34,7 @@ export const TYPE_BASELINES: Record<string, number> = {
|
|
|
33
34
|
deductive: 0.85,
|
|
34
35
|
preference: 0.80,
|
|
35
36
|
hub: 0.80,
|
|
37
|
+
antipattern: 0.75,
|
|
36
38
|
problem: 0.75,
|
|
37
39
|
research: 0.70,
|
|
38
40
|
milestone: 0.70,
|
package/src/search-utils.ts
CHANGED
|
@@ -7,6 +7,23 @@
|
|
|
7
7
|
import type { Store, SearchResult } from "./store.ts";
|
|
8
8
|
import type { EnrichedResult } from "./memory.ts";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Runtime floor below which the whole reranker output is treated as degenerate (→ RRF fallback).
|
|
12
|
+
* Permissive by design: its only job is to catch the near-zero collapse (the deprecated zerank-2
|
|
13
|
+
* GGUF maxed at 8.03e-7), NOT to grade quality. A working zerank-2-seq scores >= ~0.1, so it never
|
|
14
|
+
* false-trips a healthy reranker. Distinct from the stricter doctor CALIB_FLOOR (rerank-health.ts).
|
|
15
|
+
*/
|
|
16
|
+
export const RERANK_DEGENERATE_FLOOR = 1e-4;
|
|
17
|
+
|
|
18
|
+
export interface BlendRerankOptions {
|
|
19
|
+
/** Reranker dominance in the blend (default 0.9). */
|
|
20
|
+
rerankWeight?: number;
|
|
21
|
+
/** Reranker is treated as unusable (→ RRF fallback) unless some score exceeds this floor. */
|
|
22
|
+
degenerateFloor?: number;
|
|
23
|
+
/** Invoked when the reranker is unusable and the blend silently falls back to pure RRF order. */
|
|
24
|
+
onFallback?: (reason: string) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
10
27
|
// =============================================================================
|
|
11
28
|
// Result Enrichment
|
|
12
29
|
// =============================================================================
|
|
@@ -135,17 +152,31 @@ export function reciprocalRankFusion(
|
|
|
135
152
|
* preserve their relative RRF order among themselves.
|
|
136
153
|
*
|
|
137
154
|
* @param candidates - RRF-ordered candidates; `score` is the RRF fusion score (positive).
|
|
138
|
-
* @param reranked - reranker output `{file, score in [0,1]}`; may be empty/partial/all-zero.
|
|
139
|
-
* @param
|
|
155
|
+
* @param reranked - reranker output `{file, score in [0,1]}`; may be empty/partial/all-zero/degenerate.
|
|
156
|
+
* @param options - bare number (rerankWeight, back-compat) OR { rerankWeight, degenerateFloor, onFallback }.
|
|
140
157
|
* @returns candidates re-scored and sorted by blended score descending.
|
|
141
158
|
*/
|
|
142
159
|
export function blendRerank(
|
|
143
160
|
candidates: { file: string; score: number }[],
|
|
144
161
|
reranked: { file: string; score: number }[],
|
|
145
|
-
|
|
162
|
+
options: number | BlendRerankOptions = {}
|
|
146
163
|
): { file: string; score: number }[] {
|
|
164
|
+
const opts: BlendRerankOptions = typeof options === "number" ? { rerankWeight: options } : options;
|
|
165
|
+
const rerankWeight = opts.rerankWeight ?? 0.9;
|
|
166
|
+
const degenerateFloor = opts.degenerateFloor ?? RERANK_DEGENERATE_FLOOR;
|
|
167
|
+
|
|
147
168
|
const rerankScoreMap = new Map(reranked.map(r => [r.file, r.score]));
|
|
148
|
-
|
|
169
|
+
// Usable iff at least one score clears the degenerate floor. The old check (`> 0`) let the broken
|
|
170
|
+
// reranker's ~1e-11 scores through as "usable", contributing ~nothing at weight 0.9 — a silent
|
|
171
|
+
// collapse to RRF order. The floor closes that hole; onFallback makes the degrade visible.
|
|
172
|
+
const rerankUsable = reranked.length > 0 && reranked.some(r => Number.isFinite(r.score) && r.score > degenerateFloor);
|
|
173
|
+
if (!rerankUsable && opts.onFallback) {
|
|
174
|
+
opts.onFallback(
|
|
175
|
+
reranked.length === 0
|
|
176
|
+
? "reranker returned no scores"
|
|
177
|
+
: `all ${reranked.length} rerank scores <= degenerate floor ${degenerateFloor}`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
149
180
|
const maxRrf = candidates.reduce((m, c) => Math.max(m, c.score), 0) || 1;
|
|
150
181
|
return candidates
|
|
151
182
|
.map(c => {
|
package/src/store.ts
CHANGED
|
@@ -1143,7 +1143,7 @@ export type Store = {
|
|
|
1143
1143
|
|
|
1144
1144
|
// Query expansion & reranking
|
|
1145
1145
|
expandQuery: (query: string, model?: string, intent?: string) => Promise<ExpandedQuery[]>;
|
|
1146
|
-
rerank: (query: string, documents: { file: string; text: string }[], model?: string, intent?: string) => Promise<{ file: string; score: number }[]>;
|
|
1146
|
+
rerank: (query: string, documents: { file: string; text: string }[], model?: string, intent?: string, options?: RerankProbeOptions) => Promise<{ file: string; score: number }[]>;
|
|
1147
1147
|
|
|
1148
1148
|
// Document retrieval
|
|
1149
1149
|
findDocument: (filename: string, options?: { includeBody?: boolean }) => DocumentResult | DocumentNotFound;
|
|
@@ -1337,7 +1337,7 @@ export function createStore(dbPath?: string, opts?: { readonly?: boolean; busyTi
|
|
|
1337
1337
|
|
|
1338
1338
|
// Query expansion & reranking
|
|
1339
1339
|
expandQuery: (query: string, model?: string, intent?: string) => expandQuery(query, model, db, intent),
|
|
1340
|
-
rerank: (query: string, documents: { file: string; text: string }[], model?: string, intent?: string) => rerank(query, documents, model, db, intent),
|
|
1340
|
+
rerank: (query: string, documents: { file: string; text: string }[], model?: string, intent?: string, options?: RerankProbeOptions) => rerank(query, documents, model, db, intent, options),
|
|
1341
1341
|
|
|
1342
1342
|
// Document retrieval
|
|
1343
1343
|
findDocument: (filename: string, options?: { includeBody?: boolean }) => findDocument(db, filename, options),
|
|
@@ -3630,9 +3630,46 @@ export async function expandQuery(query: string, model: string = DEFAULT_QUERY_M
|
|
|
3630
3630
|
// Reranking
|
|
3631
3631
|
// =============================================================================
|
|
3632
3632
|
|
|
3633
|
-
|
|
3633
|
+
/** Options for the reranker health probe. Production query/hook callers omit all of these. */
|
|
3634
|
+
export type RerankProbeOptions = {
|
|
3635
|
+
/** Skip the rerank cache entirely — forces a live endpoint call (health probes). */
|
|
3636
|
+
noCache?: boolean;
|
|
3637
|
+
/** Throw RerankCoverageError if any input doc was not scored by the reranker (checked before zero-fill). */
|
|
3638
|
+
requireLiveCoverage?: boolean;
|
|
3639
|
+
/** Abort signal for the remote fetch. */
|
|
3640
|
+
signal?: AbortSignal;
|
|
3641
|
+
/** Convenience: derive AbortSignal.timeout(timeoutMs) for the remote fetch when no signal is given. */
|
|
3642
|
+
timeoutMs?: number;
|
|
3643
|
+
};
|
|
3644
|
+
|
|
3645
|
+
/** Thrown by rerank() when requireLiveCoverage is set and the reranker did not score every input doc. */
|
|
3646
|
+
export class RerankCoverageError extends Error {
|
|
3647
|
+
constructor(public readonly missing: string[]) {
|
|
3648
|
+
super(`rerank coverage incomplete: ${missing.length} document(s) not scored by the reranker`);
|
|
3649
|
+
this.name = "RerankCoverageError";
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
|
|
3653
|
+
/**
|
|
3654
|
+
* Thrown by rerank() when requireLiveCoverage is set and the reranker's raw response violates the
|
|
3655
|
+
* coverage contract: wrong result count, duplicate/out-of-range index, or a non-finite score. A
|
|
3656
|
+
* malformed-but-responding reranker is exactly the failure a health probe must catch — it must not
|
|
3657
|
+
* be silently accepted (and a duplicate index can otherwise leave a doc unscored, or an out-of-range
|
|
3658
|
+
* index can crash the score apply).
|
|
3659
|
+
*/
|
|
3660
|
+
export class RerankMalformedResponseError extends Error {
|
|
3661
|
+
constructor(public readonly problems: string[]) {
|
|
3662
|
+
super(`rerank response malformed: ${problems.join("; ")}`);
|
|
3663
|
+
this.name = "RerankMalformedResponseError";
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3667
|
+
export async function rerank(query: string, documents: { file: string; text: string }[], model: string = DEFAULT_RERANK_MODEL, db: Database, intent?: string, options?: RerankProbeOptions): Promise<{ file: string; score: number }[]> {
|
|
3634
3668
|
// Prepend intent to rerank query so the reranker scores with domain context
|
|
3635
3669
|
const rerankQuery = intent ? `${intent}\n\n${query}` : query;
|
|
3670
|
+
const noCache = options?.noCache === true;
|
|
3671
|
+
// Health probes thread a timeout to the remote fetch (the production path is otherwise untimed).
|
|
3672
|
+
const fetchSignal = options?.signal ?? (options?.timeoutMs ? AbortSignal.timeout(options.timeoutMs) : undefined);
|
|
3636
3673
|
|
|
3637
3674
|
// Deduplicate identical chunk texts — same content from different files shares a single score
|
|
3638
3675
|
const textToFiles = new Map<string, string[]>();
|
|
@@ -3650,16 +3687,22 @@ export async function rerank(query: string, documents: { file: string; text: str
|
|
|
3650
3687
|
const cachedResults: Map<string, number> = new Map();
|
|
3651
3688
|
const uncachedDocs: RerankDocument[] = [];
|
|
3652
3689
|
|
|
3653
|
-
// Check cache for each unique document
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
|
|
3690
|
+
// Check cache for each unique document. noCache (health probes) skips the cache entirely so the
|
|
3691
|
+
// call always exercises the live endpoint — a cached probe would mask an endpoint silently
|
|
3692
|
+
// reverted to a broken reranker.
|
|
3693
|
+
if (noCache) {
|
|
3694
|
+
for (const doc of uniqueDocs) uncachedDocs.push({ file: doc.file, text: doc.text });
|
|
3695
|
+
} else {
|
|
3696
|
+
for (const doc of uniqueDocs) {
|
|
3697
|
+
const cacheKey = getCacheKey("rerank", { query: rerankQuery, file: doc.file, model });
|
|
3698
|
+
const cached = getCachedResult(db, cacheKey);
|
|
3699
|
+
if (cached !== null) {
|
|
3700
|
+
const score = parseFloat(cached);
|
|
3701
|
+
// Apply score to all files sharing this text
|
|
3702
|
+
for (const file of textToFiles.get(doc.text)!) cachedResults.set(file, score);
|
|
3703
|
+
} else {
|
|
3704
|
+
uncachedDocs.push({ file: doc.file, text: doc.text });
|
|
3705
|
+
}
|
|
3663
3706
|
}
|
|
3664
3707
|
}
|
|
3665
3708
|
|
|
@@ -3684,13 +3727,50 @@ export async function rerank(query: string, documents: { file: string; text: str
|
|
|
3684
3727
|
query: rerankQuery,
|
|
3685
3728
|
documents: batch.map(d => d.text.slice(0, 400)),
|
|
3686
3729
|
}),
|
|
3730
|
+
signal: fetchSignal,
|
|
3687
3731
|
});
|
|
3688
3732
|
if (resp.ok) {
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3733
|
+
let data: { results: { index: number; relevance_score: number }[] };
|
|
3734
|
+
try {
|
|
3735
|
+
data = await resp.json() as { results: { index: number; relevance_score: number }[] };
|
|
3736
|
+
} catch {
|
|
3737
|
+
// Invalid JSON from a 200 response. For a probe this is a malformed remote → surface it;
|
|
3738
|
+
// for production treat it like a transport failure and fall through to local.
|
|
3739
|
+
if (options?.requireLiveCoverage) throw new RerankMalformedResponseError(["response body is not valid JSON"]);
|
|
3740
|
+
break;
|
|
3741
|
+
}
|
|
3742
|
+
// Strict contract for health probes: a results array of exactly batch.length, each with a
|
|
3743
|
+
// unique in-range integer index and a finite numeric score. A malformed-but-responding
|
|
3744
|
+
// reranker (including a null/primitive body) must surface, not be silently accepted.
|
|
3745
|
+
if (options?.requireLiveCoverage) {
|
|
3746
|
+
const problems: string[] = [];
|
|
3747
|
+
if (data === null || typeof data !== "object" || !Array.isArray(data.results)) {
|
|
3748
|
+
problems.push("response is not an object with a results array");
|
|
3749
|
+
} else {
|
|
3750
|
+
if (data.results.length !== batch.length) {
|
|
3751
|
+
problems.push(`batch expected ${batch.length} results, got ${data.results.length}`);
|
|
3752
|
+
}
|
|
3753
|
+
const seen = new Set<number>();
|
|
3754
|
+
for (const r of data.results) {
|
|
3755
|
+
if (!Number.isInteger(r?.index) || r.index < 0 || r.index >= batch.length) problems.push(`index ${r?.index} out of range`);
|
|
3756
|
+
else if (seen.has(r.index)) problems.push(`duplicate index ${r.index}`);
|
|
3757
|
+
else seen.add(r.index);
|
|
3758
|
+
if (typeof r?.relevance_score !== "number" || !Number.isFinite(r.relevance_score)) problems.push(`non-finite score at index ${r?.index}`);
|
|
3759
|
+
}
|
|
3760
|
+
}
|
|
3761
|
+
if (problems.length > 0) throw new RerankMalformedResponseError(problems);
|
|
3762
|
+
}
|
|
3763
|
+
// Defensive (all callers): guard a non-array body, and skip out-of-range/non-finite entries
|
|
3764
|
+
// so a malformed response can never crash the score apply or store garbage. Under
|
|
3765
|
+
// requireLiveCoverage the strict check above has already thrown; here a skipped entry just
|
|
3766
|
+
// leaves the doc unscored (→ coverage error for probes, → zero-fill for production).
|
|
3767
|
+
for (const r of (Array.isArray(data?.results) ? data.results : [])) {
|
|
3768
|
+
const doc = batch[r.index];
|
|
3769
|
+
if (!doc || typeof r.relevance_score !== "number" || !Number.isFinite(r.relevance_score)) continue;
|
|
3770
|
+
if (!noCache) {
|
|
3771
|
+
const cacheKey = getCacheKey("rerank", { query: rerankQuery, file: doc.file, model });
|
|
3772
|
+
setCachedResult(db, cacheKey, r.relevance_score.toString());
|
|
3773
|
+
}
|
|
3694
3774
|
// Apply score to all files sharing this text
|
|
3695
3775
|
for (const file of textToFiles.get(doc.text)!) cachedResults.set(file, r.relevance_score);
|
|
3696
3776
|
}
|
|
@@ -3699,8 +3779,11 @@ export async function rerank(query: string, documents: { file: string; text: str
|
|
|
3699
3779
|
}
|
|
3700
3780
|
}
|
|
3701
3781
|
scored = cachedResults.size > 0;
|
|
3702
|
-
} catch {
|
|
3703
|
-
//
|
|
3782
|
+
} catch (e) {
|
|
3783
|
+
// Network/transport failure → fall through to local. But a malformed-response error (only
|
|
3784
|
+
// raised under requireLiveCoverage) is a probe FINDING about the remote endpoint — propagate
|
|
3785
|
+
// it instead of masking it with the local fallback.
|
|
3786
|
+
if (e instanceof RerankMalformedResponseError) throw e;
|
|
3704
3787
|
}
|
|
3705
3788
|
}
|
|
3706
3789
|
|
|
@@ -3712,8 +3795,10 @@ export async function rerank(query: string, documents: { file: string; text: str
|
|
|
3712
3795
|
const rerankResult = await llm.rerank(rerankQuery, remaining, { model });
|
|
3713
3796
|
for (const result of rerankResult.results) {
|
|
3714
3797
|
const doc = remaining.find(d => d.file === result.file);
|
|
3715
|
-
|
|
3716
|
-
|
|
3798
|
+
if (!noCache) {
|
|
3799
|
+
const cacheKey = getCacheKey("rerank", { query: rerankQuery, file: result.file, model });
|
|
3800
|
+
setCachedResult(db, cacheKey, result.score.toString());
|
|
3801
|
+
}
|
|
3717
3802
|
// Apply score to all files sharing this text
|
|
3718
3803
|
if (doc) {
|
|
3719
3804
|
for (const file of textToFiles.get(doc.text)!) cachedResults.set(file, result.score);
|
|
@@ -3725,6 +3810,14 @@ export async function rerank(query: string, documents: { file: string; text: str
|
|
|
3725
3810
|
}
|
|
3726
3811
|
}
|
|
3727
3812
|
|
|
3813
|
+
// Coverage check BEFORE the zero-fill below (health probes only, via requireLiveCoverage).
|
|
3814
|
+
// After the map, an omitted score and a true 0 are indistinguishable, so a partial endpoint
|
|
3815
|
+
// would otherwise look fully covered. See RERANKER-HEALTH-GUARD-DESIGN.md §5 (H1/M4).
|
|
3816
|
+
if (options?.requireLiveCoverage) {
|
|
3817
|
+
const missing = documents.filter(doc => !cachedResults.has(doc.file)).map(doc => doc.file);
|
|
3818
|
+
if (missing.length > 0) throw new RerankCoverageError(missing);
|
|
3819
|
+
}
|
|
3820
|
+
|
|
3728
3821
|
// Return all results sorted by score
|
|
3729
3822
|
return documents
|
|
3730
3823
|
.map(doc => ({ file: doc.file, score: cachedResults.get(doc.file) || 0 }))
|