clawmem 0.12.0 → 0.14.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 CHANGED
@@ -200,6 +200,45 @@ systemctl --user status clawmem-watcher.service clawmem-embed.timer
200
200
 
201
201
  **Note:** The service files use `%h` (home directory specifier). If clawmem is installed elsewhere, update `ExecStart` paths. For remote GPU setups, add `Environment=CLAWMEM_EMBED_URL=http://host:8088` etc. to both service files (the `bin/clawmem` wrapper sets defaults).
202
202
 
203
+ ### Reranker health check (optional, recommended for remote-sidecar setups)
204
+
205
+ `clawmem rerank-health` probes the reranker for **discrimination** (not just liveness) and exits non-zero when it is degenerate — catching the failure mode where a mis-converted reranker (e.g. the deprecated zerank-2 GGUF, whose llama.cpp conversion drops the score head) returns HTTP 200 + valid JSON but near-zero, non-discriminating scores, silently collapsing the final ranking to RRF. The same probe runs inside `clawmem doctor` (section 9); this scheduled unit alerts proactively without query traffic. Schedule it when the reranker is a remote sidecar that could be redeployed/reverted out from under you.
206
+
207
+ ```bash
208
+ # clawmem-rerank-health.service — oneshot probe; exits 1 if the reranker is degenerate
209
+ cat > ~/.config/systemd/user/clawmem-rerank-health.service << 'EOF'
210
+ [Unit]
211
+ Description=ClawMem reranker discrimination health check
212
+
213
+ [Service]
214
+ Type=oneshot
215
+ # Hard outer bound — a hung reranker must not hang the check (the probe also times out per-request).
216
+ TimeoutStartSec=120
217
+ ExecStart=%h/clawmem/bin/clawmem rerank-health
218
+ # Alert on failure: point this at your notifier unit (ntfy, mail, etc.).
219
+ OnFailure=clawmem-rerank-health-alert@%n.service
220
+ EOF
221
+
222
+ # clawmem-rerank-health.timer — every 6h (the failure it guards is rare + infra-driven)
223
+ cat > ~/.config/systemd/user/clawmem-rerank-health.timer << 'EOF'
224
+ [Unit]
225
+ Description=ClawMem reranker health check (every 6h)
226
+
227
+ [Timer]
228
+ OnCalendar=*-*-* 00/6:00:00
229
+ Persistent=true
230
+ RandomizedDelaySec=300
231
+
232
+ [Install]
233
+ WantedBy=timers.target
234
+ EOF
235
+
236
+ systemctl --user daemon-reload
237
+ systemctl --user enable --now clawmem-rerank-health.timer
238
+ ```
239
+
240
+ For remote GPU setups, add `Environment=CLAWMEM_RERANK_URL=http://host:8090` (+ embed/LLM) to the `.service`. `OnFailure=` is the primary alert path; the `curator-nudge` SessionStart hook is a secondary "you missed the page" surface. Run `clawmem rerank-health` (or `--json`) manually any time to check on demand.
241
+
203
242
  ---
204
243
 
205
244
  ## OpenClaw Integration: Memory System Configuration
@@ -461,6 +500,8 @@ Recency intent detected ("latest", "recent", "last session"):
461
500
  compositeScore = (0.10 × searchScore + 0.70 × recencyScore + 0.20 × confidenceScore) × qualityMultiplier × coActivationBoost
462
501
  ```
463
502
 
503
+ **`query` tool (v0.13.0+):** for non-recency queries the `query` tool uses retrieval-tuned weights `0.70 × searchScore + 0.15 × recencyScore + 0.15 × confidenceScore` (from a held-out judged eval). Recency intent still switches to the `0.10 / 0.70 / 0.20` weights above. `search`, `vsearch`, `memory_retrieve`, and the context-surfacing hook keep the `0.50 / 0.25 / 0.25` default.
504
+
464
505
  | Content Type | Half-Life | Effect |
465
506
  |--------------|-----------|--------|
466
507
  | decision, deductive, preference, hub | ∞ | Never decay |
package/CLAUDE.md CHANGED
@@ -200,6 +200,45 @@ systemctl --user status clawmem-watcher.service clawmem-embed.timer
200
200
 
201
201
  **Note:** The service files use `%h` (home directory specifier). If clawmem is installed elsewhere, update `ExecStart` paths. For remote GPU setups, add `Environment=CLAWMEM_EMBED_URL=http://host:8088` etc. to both service files (the `bin/clawmem` wrapper sets defaults).
202
202
 
203
+ ### Reranker health check (optional, recommended for remote-sidecar setups)
204
+
205
+ `clawmem rerank-health` probes the reranker for **discrimination** (not just liveness) and exits non-zero when it is degenerate — catching the failure mode where a mis-converted reranker (e.g. the deprecated zerank-2 GGUF, whose llama.cpp conversion drops the score head) returns HTTP 200 + valid JSON but near-zero, non-discriminating scores, silently collapsing the final ranking to RRF. The same probe runs inside `clawmem doctor` (section 9); this scheduled unit alerts proactively without query traffic. Schedule it when the reranker is a remote sidecar that could be redeployed/reverted out from under you.
206
+
207
+ ```bash
208
+ # clawmem-rerank-health.service — oneshot probe; exits 1 if the reranker is degenerate
209
+ cat > ~/.config/systemd/user/clawmem-rerank-health.service << 'EOF'
210
+ [Unit]
211
+ Description=ClawMem reranker discrimination health check
212
+
213
+ [Service]
214
+ Type=oneshot
215
+ # Hard outer bound — a hung reranker must not hang the check (the probe also times out per-request).
216
+ TimeoutStartSec=120
217
+ ExecStart=%h/clawmem/bin/clawmem rerank-health
218
+ # Alert on failure: point this at your notifier unit (ntfy, mail, etc.).
219
+ OnFailure=clawmem-rerank-health-alert@%n.service
220
+ EOF
221
+
222
+ # clawmem-rerank-health.timer — every 6h (the failure it guards is rare + infra-driven)
223
+ cat > ~/.config/systemd/user/clawmem-rerank-health.timer << 'EOF'
224
+ [Unit]
225
+ Description=ClawMem reranker health check (every 6h)
226
+
227
+ [Timer]
228
+ OnCalendar=*-*-* 00/6:00:00
229
+ Persistent=true
230
+ RandomizedDelaySec=300
231
+
232
+ [Install]
233
+ WantedBy=timers.target
234
+ EOF
235
+
236
+ systemctl --user daemon-reload
237
+ systemctl --user enable --now clawmem-rerank-health.timer
238
+ ```
239
+
240
+ For remote GPU setups, add `Environment=CLAWMEM_RERANK_URL=http://host:8090` (+ embed/LLM) to the `.service`. `OnFailure=` is the primary alert path; the `curator-nudge` SessionStart hook is a secondary "you missed the page" surface. Run `clawmem rerank-health` (or `--json`) manually any time to check on demand.
241
+
203
242
  ---
204
243
 
205
244
  ## OpenClaw Integration: Memory System Configuration
@@ -461,6 +500,8 @@ Recency intent detected ("latest", "recent", "last session"):
461
500
  compositeScore = (0.10 × searchScore + 0.70 × recencyScore + 0.20 × confidenceScore) × qualityMultiplier × coActivationBoost
462
501
  ```
463
502
 
503
+ **`query` tool (v0.13.0+):** for non-recency queries the `query` tool uses retrieval-tuned weights `0.70 × searchScore + 0.15 × recencyScore + 0.15 × confidenceScore` (from a held-out judged eval). Recency intent still switches to the `0.10 / 0.70 / 0.20` weights above. `search`, `vsearch`, `memory_retrieve`, and the context-surfacing hook keep the `0.50 / 0.25 / 0.25` default.
504
+
464
505
  | Content Type | Half-Life | Effect |
465
506
  |--------------|-----------|--------|
466
507
  | decision, deductive, preference, hub | ∞ | Never decay |
package/README.md CHANGED
@@ -814,7 +814,7 @@ User Query + optional intent hint
814
814
  → Intent-Aware Chunk Selection (intent terms at 0.5× weight alongside query terms at 1.0×)
815
815
  → Cross-Encoder Reranking (4000 char context; intent prepended; chunk dedup; batch cap=4)
816
816
  → Rerank/RRF Blend (blendRerank: 0.9·reranker + 0.1·normalized-RRF tiebreaker; reranker can promote over RRF #1; falls back to RRF order if reranker unavailable)
817
- SAME Composite Scoring ((search × 0.5 + recency × 0.25 + confidence × 0.25) × qualityMultiplier × lengthNorm × coActivationBoost + pinBoost)
817
+ → Composite Scoring (query-tuned: (search × 0.70 + recency × 0.15 + confidence × 0.15) × qualityMultiplier × lengthNorm × coActivationBoost + pinBoost; recency-intent queries → 0.10/0.70/0.20; other tools keep the 0.50/0.25/0.25 default)
818
818
  → MMR Diversity Filter (Jaccard bigram similarity > 0.6 → demoted)
819
819
  → Ranked Results
820
820
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmem",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "On-device memory layer for AI agents. Claude Code, OpenClaw, and Hermes. Hooks + MCP server + hybrid RAG search.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/clawmem.ts CHANGED
@@ -2157,6 +2157,27 @@ async function cmdDoctor() {
2157
2157
  // openclaw CLI unavailable — skip silently
2158
2158
  }
2159
2159
 
2160
+ // 9. Reranker discrimination (active probe — asserts the reranker DISCRIMINATES, not just
2161
+ // responds). Liveness is worthless here: the broken zerank-2 GGUF returned HTTP 200 + valid
2162
+ // JSON + finite positive ~1e-11 scores and passed every other check while silently collapsing
2163
+ // the final ranking to RRF. This routes a golden hard-pair set through the live reranker
2164
+ // (cache-bypassed, coverage-enforced) and checks calibration + per-pair discrimination.
2165
+ try {
2166
+ const s = getStore();
2167
+ const { probeRerankHealth } = await import("./health/rerank-health.ts");
2168
+ const health = await probeRerankHealth(s, { timeoutMs: 8000 });
2169
+ if (health.ok) {
2170
+ console.log(`${c.green}✓${c.reset} Reranker: discriminates (coverage ${health.pairsScored}/${health.pairsTotal}, max score ${health.maxScore.toFixed(2)} ≥ ${health.thresholds.calibFloor}, min margin ${health.minMargin.toFixed(2)} ≥ ${health.thresholds.discrimMargin})`);
2171
+ } else {
2172
+ console.log(`${c.red}✗${c.reset} Reranker: degenerate / not discriminating (coverage ${health.pairsScored}/${health.pairsTotal}, max score ${health.maxScore.toExponential(1)}, min margin ${health.minMargin.toFixed(2)})`);
2173
+ for (const f of health.failures.slice(0, 4)) console.log(` ${c.dim}${f}${c.reset}`);
2174
+ console.log(` ${c.dim}Likely the deprecated zerank-2 GGUF (no score head) — re-deploy the seq-cls sidecar. See CLAUDE.md "SOTA upgrade".${c.reset}`);
2175
+ issues++;
2176
+ }
2177
+ } catch (err) {
2178
+ console.log(`${c.yellow}!${c.reset} Reranker: could not probe (${(err as Error).message})`);
2179
+ }
2180
+
2160
2181
  console.log();
2161
2182
  if (issues > 0) {
2162
2183
  console.log(`${c.yellow}${issues} issue(s) found.${c.reset}`);
@@ -2165,6 +2186,41 @@ async function cmdDoctor() {
2165
2186
  }
2166
2187
  }
2167
2188
 
2189
+ // =============================================================================
2190
+ // Reranker health (scheduled-check CLI)
2191
+ // =============================================================================
2192
+
2193
+ // Oneshot reranker discrimination probe. Exits non-zero on degeneracy so a systemd OnFailure= (or
2194
+ // any scheduled check) can alert — the standalone counterpart to doctor section 9. Routes through
2195
+ // the live, cache-bypassed, coverage-enforced probe (src/health/rerank-health.ts).
2196
+ async function cmdRerankHealth(args: string[]) {
2197
+ const { values } = parseArgs({
2198
+ args,
2199
+ options: {
2200
+ json: { type: "boolean", default: false },
2201
+ "timeout-ms": { type: "string" },
2202
+ },
2203
+ allowPositionals: false,
2204
+ });
2205
+ const timeoutMs = values["timeout-ms"] ? parseInt(values["timeout-ms"] as string, 10) : undefined;
2206
+ const store = getStore();
2207
+ const { probeRerankHealth } = await import("./health/rerank-health.ts");
2208
+ const health = await probeRerankHealth(store, timeoutMs ? { timeoutMs } : {});
2209
+
2210
+ if (values.json) {
2211
+ console.log(JSON.stringify(health));
2212
+ } else if (health.ok) {
2213
+ console.log(`${c.green}✓ Reranker healthy${c.reset} — coverage ${health.pairsScored}/${health.pairsTotal}, max score ${health.maxScore.toFixed(2)} ≥ ${health.thresholds.calibFloor}, min margin ${health.minMargin.toFixed(2)} ≥ ${health.thresholds.discrimMargin}`);
2214
+ } else {
2215
+ console.log(`${c.red}✗ Reranker degenerate / not discriminating${c.reset} — coverage ${health.pairsScored}/${health.pairsTotal}, max score ${health.maxScore.toExponential(1)}, min margin ${health.minMargin.toFixed(2)}`);
2216
+ for (const f of health.failures) console.log(` - ${f}`);
2217
+ console.log(`Likely the deprecated zerank-2 GGUF (no score head) — re-deploy the seq-cls sidecar. See CLAUDE.md "SOTA upgrade".`);
2218
+ }
2219
+ // Non-zero exit on degeneracy so systemd OnFailure= / a scheduled check can alert. Use exitCode
2220
+ // (not process.exit) so main()'s finally { closeStore() } still runs.
2221
+ process.exitCode = health.ok ? 0 : 1;
2222
+ }
2223
+
2168
2224
  // =============================================================================
2169
2225
  // Bootstrap
2170
2226
  // =============================================================================
@@ -2495,6 +2551,9 @@ async function main() {
2495
2551
  case "doctor":
2496
2552
  await cmdDoctor();
2497
2553
  break;
2554
+ case "rerank-health":
2555
+ await cmdRerankHealth(subArgs);
2556
+ break;
2498
2557
  case "path":
2499
2558
  cmdPath();
2500
2559
  break;
@@ -3189,6 +3248,7 @@ ${c.bold}Integration:${c.reset}
3189
3248
  clawmem serve [--port 7438] [--host 127.0.0.1] Start HTTP REST API server
3190
3249
  clawmem update-context Regenerate all directory CLAUDE.md files
3191
3250
  clawmem doctor Full health check
3251
+ clawmem rerank-health [--json] Probe reranker discrimination (exit 1 if degenerate)
3192
3252
 
3193
3253
  ${c.bold}Options:${c.reset}
3194
3254
  -n, --num <N> Number of results
@@ -0,0 +1,54 @@
1
+ {
2
+ "version": 1,
3
+ "_comment": "Reranker-health golden set. Each triple is (query, relevant, hardNegative) where the hard-negative is the SAME topic as the relevant doc but answers a different sub-question — a near-random reranker (e.g. the deprecated zerank-2 GGUF that scored ~1e-11) cannot consistently rank relevant>hardNegative on these, while a working cross-encoder does. Constraints: same-topic fine-grained negatives (off-topic negatives would pass even on a broken reranker); discriminating signal in the first ~400 chars (store.rerank truncates docs to 400); vault-independent + deterministic. See RERANKER-HEALTH-GUARD-DESIGN.md §4.",
4
+ "triples": [
5
+ {
6
+ "query": "how to fix a memory leak from an unclosed file handle in python",
7
+ "relevant": "Unclosed file handles leak file descriptors and their buffers. Always open files with a `with open(path) as f:` context manager so the file is closed automatically when the block exits, even on an exception. Forgetting to close files inside a long-running loop is a classic descriptor leak.",
8
+ "hardNegative": "A common Python memory leak comes from circular references that keep objects alive past their scope. The cyclic garbage collector usually reclaims them, but objects defining `__del__` inside a reference cycle can be left uncollected. Use weakref to break the cycle.",
9
+ "note": "same topic = python memory leaks; query targets the file-handle cause, negative is the circular-reference cause"
10
+ },
11
+ {
12
+ "query": "what is the difference between git rebase and git merge",
13
+ "relevant": "git rebase rewrites history by replaying your commits onto a new base, producing a linear history with no merge commit, whereas git merge creates a merge commit that ties two histories together and preserves both. Rebase changes commit hashes, so never rebase shared branches; merge keeps hashes intact.",
14
+ "hardNegative": "git cherry-pick copies a single commit from one branch and re-applies it onto your current branch as a new commit. It is the tool for porting one specific fix without merging or rebasing the entire source branch.",
15
+ "note": "same topic = git history operations; query is rebase-vs-merge, negative is cherry-pick"
16
+ },
17
+ {
18
+ "query": "what does the http 429 status code mean",
19
+ "relevant": "HTTP 429 Too Many Requests means the client has sent too many requests in a given window and is being rate limited. The response commonly carries a Retry-After header telling the client how long to wait before retrying.",
20
+ "hardNegative": "HTTP 503 Service Unavailable means the server is temporarily unable to handle the request, usually due to overload or maintenance. It is a server-side 5xx error and may also include a Retry-After header.",
21
+ "note": "same topic = HTTP status codes that carry Retry-After; query is 429 (client rate limit), negative is 503 (server unavailable)"
22
+ },
23
+ {
24
+ "query": "what are the steps of the tcp three-way handshake",
25
+ "relevant": "The TCP three-way handshake establishes a connection in three steps: the client sends SYN, the server replies SYN-ACK, and the client sends ACK. After that final ACK the connection is established and data can flow.",
26
+ "hardNegative": "TCP connection teardown uses a four-way exchange: one side sends FIN, the peer ACKs it, then the peer sends its own FIN which is ACKed in return. This closes each direction of the connection independently.",
27
+ "note": "same topic = TCP connection lifecycle; query is the handshake (setup), negative is teardown"
28
+ },
29
+ {
30
+ "query": "difference between inner join and left join in sql",
31
+ "relevant": "An INNER JOIN returns only rows that have a match in both tables. A LEFT JOIN returns every row from the left table plus matched rows from the right, filling NULLs where there is no match — so a LEFT JOIN keeps the unmatched left rows that an INNER JOIN drops.",
32
+ "hardNegative": "A SQL CROSS JOIN returns the Cartesian product of two tables — every row of the first paired with every row of the second — with no join condition. It is rarely intended unless you genuinely want all combinations.",
33
+ "note": "same topic = SQL join types; query is inner-vs-left, negative is cross join"
34
+ },
35
+ {
36
+ "query": "why would you use a tuple instead of a list in python",
37
+ "relevant": "Tuples are immutable, so they can be used as dictionary keys or set members and are safe to share without defensive copying. Their immutability also signals that the collection is not meant to change, and they are slightly more memory-efficient than lists.",
38
+ "hardNegative": "A list comprehension builds a new list from an iterable in a single expression, like [x*x for x in range(10)]. It is usually more readable and faster than an equivalent for-loop that repeatedly appends to a list.",
39
+ "note": "same topic = python sequences; query is tuple-vs-list (immutability), negative is list comprehension"
40
+ },
41
+ {
42
+ "query": "is the http put method idempotent",
43
+ "relevant": "Yes, HTTP PUT is idempotent: sending the same PUT request multiple times has the same effect as sending it once, because it replaces the target resource with the supplied representation. A client can safely retry a PUT after a network failure.",
44
+ "hardNegative": "HTTP POST is not idempotent: each POST typically creates a new resource or triggers a new action, so retrying a POST after a timeout can create duplicates. Use an idempotency key when you need safe retries.",
45
+ "note": "same topic = HTTP method idempotency; query is PUT (idempotent), negative is POST (not idempotent)"
46
+ },
47
+ {
48
+ "query": "what is the difference between a docker image and a docker container",
49
+ "relevant": "A Docker image is an immutable, layered template built from a Dockerfile. A container is a running (or stopped) instance of that image with a thin writable layer on top. Many containers can be started from a single image.",
50
+ "hardNegative": "A Docker volume is a persistent storage mechanism managed by Docker and mounted into a container so data survives container restarts and removals. Volumes are the preferred way to persist database files.",
51
+ "note": "same topic = docker concepts; query is image-vs-container, negative is volume"
52
+ }
53
+ ]
54
+ }
@@ -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
@@ -28,6 +28,7 @@ import {
28
28
  import {
29
29
  applyCompositeScoring,
30
30
  hasRecencyIntent,
31
+ QUERY_WEIGHTS,
31
32
  type EnrichedResult,
32
33
  type CoActivationFn,
33
34
  } from "./memory.ts";
@@ -46,6 +47,25 @@ import {
46
47
  import { listVaults, loadVaultConfig } from "./config.ts";
47
48
  import { getEntityGraphNeighbors, searchEntities } from "./entity.ts";
48
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
+
49
69
  // =============================================================================
50
70
  // Types
51
71
  // =============================================================================
@@ -767,7 +787,7 @@ This is the recommended entry point for ALL memory queries.`,
767
787
  // prior w·(1/rrfRank) blend made RRF rank-1 immovable by the reranker. Harness-validated
768
788
  // 2026-06-25 (NL+KW known-item recall): lifts recall@1-5 + MRR@10 with no material pooled
769
789
  // recall@10 regression.
770
- const blended = blendRerank(candidates, reranked);
790
+ const blended = blendRerank(candidates, reranked, { onFallback: onRerankFallback });
771
791
 
772
792
  // Map to SearchResults for composite scoring — hydrate from DB when needed
773
793
  const allSearchResults = [...store.searchFTS(query, 30)];
@@ -807,7 +827,10 @@ This is the recommended entry point for ALL memory queries.`,
807
827
 
808
828
  const coFn = (path: string) => store.getCoActivated(path);
809
829
  const enriched = enrichResults(store, searchResults, query);
810
- let scored = applyCompositeScoring(enriched, query, coFn)
830
+ // Phase B (§11.12): the `query` tool's hybrid+rerank pipeline uses QUERY_WEIGHTS (search 0.70)
831
+ // the eval-validated re-weight. Recency intent still wins RECENCY_WEIGHTS by construction
832
+ // (applyCompositeScoring ignores options.weights when hasRecencyIntent(query) and !forceWeights).
833
+ let scored = applyCompositeScoring(enriched, query, coFn, { weights: QUERY_WEIGHTS })
811
834
  .filter(r => r.compositeScore >= (minScore || 0));
812
835
  if (diverse !== false) scored = applyMMRDiversity(scored);
813
836
  scored = scored.slice(0, limit || 10);
package/src/memory.ts CHANGED
@@ -185,6 +185,15 @@ export type CompositeWeights = {
185
185
 
186
186
  export const DEFAULT_WEIGHTS: CompositeWeights = { search: 0.5, recency: 0.25, confidence: 0.25 };
187
187
  export const RECENCY_WEIGHTS: CompositeWeights = { search: 0.1, recency: 0.7, confidence: 0.2 };
188
+ // Query-tool retrieval weights (Phase B, 2026-06-25). A held-out judged eval (n=199, GLM-5.2 judge,
189
+ // quadratic-weighted κ=0.681 vs an independent annotator) showed the default 0.50 search weight
190
+ // under-weights topical relevance for a work-memory vault: w_search 0.70 lifts graded NDCG@10 by +0.064
191
+ // (paired permutation p<1e-4, robust across precision/exploratory/temporal families) with ZERO freshness regression —
192
+ // the newest-correct doc is never demoted in the supersession guard (demotionRate 0), whereas 0.80
193
+ // demotes it out of the top-10 in 2/19 cases. Applied ONLY by the `query` tool's full hybrid+rerank
194
+ // pipeline (the path the eval mirrored). NOT applied under recency intent — RECENCY_WEIGHTS wins by
195
+ // construction in applyCompositeScoring. See BLEND-COMPOSITE-REBALANCE-DESIGN.md §11.12.
196
+ export const QUERY_WEIGHTS: CompositeWeights = { search: 0.7, recency: 0.15, confidence: 0.15 };
188
197
 
189
198
  const RECENCY_PATTERNS = [
190
199
  /\brecent(ly)?\b/i,
@@ -274,13 +283,28 @@ function canonicalMemoryMultiplier(path: string, contentType: string, query: str
274
283
  return 1.0;
275
284
  }
276
285
 
286
+ export type CompositeScoringOptions = {
287
+ /** Query-scoped weights override. Replaces DEFAULT_WEIGHTS; NOT applied under recency intent unless forceWeights. */
288
+ weights?: CompositeWeights;
289
+ /** Injected clock for deterministic scoring (tests/eval). Defaults to new Date(). */
290
+ now?: Date;
291
+ /** Test/experiment only: apply `weights` even under recency intent (bypass the RECENCY_WEIGHTS switch). */
292
+ forceWeights?: boolean;
293
+ };
294
+
277
295
  export function applyCompositeScoring(
278
296
  results: EnrichedResult[],
279
297
  query: string,
280
- coActivationFn?: CoActivationFn
298
+ coActivationFn?: CoActivationFn,
299
+ options?: CompositeScoringOptions
281
300
  ): ScoredResult[] {
282
- const weights = hasRecencyIntent(query) ? RECENCY_WEIGHTS : DEFAULT_WEIGHTS;
283
- const now = new Date();
301
+ const recencyIntent = hasRecencyIntent(query);
302
+ // Recency intent keeps RECENCY_WEIGHTS (production contract) unless forceWeights overrides (experiments).
303
+ // A query-scoped `weights` override otherwise replaces DEFAULT_WEIGHTS; absent options => exact prior behavior.
304
+ const weights = (recencyIntent && !options?.forceWeights)
305
+ ? RECENCY_WEIGHTS
306
+ : (options?.weights ?? (recencyIntent ? RECENCY_WEIGHTS : DEFAULT_WEIGHTS));
307
+ const now = options?.now ?? new Date();
284
308
 
285
309
  const scored = results.map(r => {
286
310
  const recency = recencyScore(r.modifiedAt, r.contentType, now, r.accessCount, r.lastAccessedAt);
@@ -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 }))