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.
Files changed (40) hide show
  1. package/AGENTS.md +165 -677
  2. package/CLAUDE.md +4 -747
  3. package/README.md +20 -191
  4. package/SKILL.md +157 -711
  5. package/docs/clawmem-architecture.excalidraw +2415 -0
  6. package/docs/clawmem-architecture.png +0 -0
  7. package/docs/clawmem_hero.jpg +0 -0
  8. package/docs/concepts/architecture.md +413 -0
  9. package/docs/concepts/composite-scoring.md +133 -0
  10. package/docs/concepts/hooks-vs-mcp.md +156 -0
  11. package/docs/concepts/multi-vault.md +71 -0
  12. package/docs/contributing.md +101 -0
  13. package/docs/guides/cloud-embedding.md +134 -0
  14. package/docs/guides/hermes-plugin.md +187 -0
  15. package/docs/guides/inference-services.md +144 -0
  16. package/docs/guides/multi-vault-config.md +84 -0
  17. package/docs/guides/openclaw-plugin.md +306 -0
  18. package/docs/guides/setup-hooks.md +146 -0
  19. package/docs/guides/setup-mcp.md +76 -0
  20. package/docs/guides/systemd-services.md +332 -0
  21. package/docs/guides/upgrading.md +566 -0
  22. package/docs/internals/entity-resolution.md +135 -0
  23. package/docs/internals/graph-traversal.md +85 -0
  24. package/docs/internals/intent-search-pipeline.md +103 -0
  25. package/docs/internals/query-pipeline.md +100 -0
  26. package/docs/introduction.md +104 -0
  27. package/docs/quickstart.md +158 -0
  28. package/docs/reference/cli.md +195 -0
  29. package/docs/reference/configuration.md +101 -0
  30. package/docs/reference/mcp-tools.md +336 -0
  31. package/docs/reference/rest-api.md +204 -0
  32. package/docs/troubleshooting.md +330 -0
  33. package/package.json +2 -1
  34. package/src/clawmem.ts +60 -0
  35. package/src/health/rerank-golden.json +54 -0
  36. package/src/health/rerank-health.ts +150 -0
  37. package/src/mcp.ts +20 -1
  38. package/src/memory.ts +2 -0
  39. package/src/search-utils.ts +35 -4
  40. 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,
@@ -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 rerankWeight - weight on the reranker term (default 0.9; `1-rerankWeight` on RRF).
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
- rerankWeight: number = 0.9
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
- const rerankUsable = reranked.length > 0 && reranked.some(r => Number.isFinite(r.score) && r.score > 0);
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
- export async function rerank(query: string, documents: { file: string; text: string }[], model: string = DEFAULT_RERANK_MODEL, db: Database, intent?: string): Promise<{ file: string; score: number }[]> {
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
- for (const doc of uniqueDocs) {
3655
- const cacheKey = getCacheKey("rerank", { query: rerankQuery, file: doc.file, model });
3656
- const cached = getCachedResult(db, cacheKey);
3657
- if (cached !== null) {
3658
- const score = parseFloat(cached);
3659
- // Apply score to all files sharing this text
3660
- for (const file of textToFiles.get(doc.text)!) cachedResults.set(file, score);
3661
- } else {
3662
- uncachedDocs.push({ file: doc.file, text: doc.text });
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
- const data = await resp.json() as { results: { index: number; relevance_score: number }[] };
3690
- for (const r of data.results) {
3691
- const doc = batch[r.index]!;
3692
- const cacheKey = getCacheKey("rerank", { query: rerankQuery, file: doc.file, model });
3693
- setCachedResult(db, cacheKey, r.relevance_score.toString());
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
- // Remote failed, fall through to local
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
- const cacheKey = getCacheKey("rerank", { query: rerankQuery, file: result.file, model });
3716
- setCachedResult(db, cacheKey, result.score.toString());
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 }))