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 +41 -0
- package/CLAUDE.md +41 -0
- package/README.md +1 -1
- package/package.json +1 -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 +25 -2
- package/src/memory.ts +27 -3
- package/src/search-utils.ts +35 -4
- package/src/store.ts +115 -22
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
|
-
→
|
|
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
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
|
-
|
|
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
|
|
283
|
-
|
|
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);
|
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 }))
|