@gamaze/hicortex 0.15.1 → 0.15.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -2
- package/dist/consolidate.d.ts +0 -3
- package/dist/consolidate.js +7 -6
- package/dist/eval/recall-sweep.d.ts +59 -0
- package/dist/eval/recall-sweep.js +715 -0
- package/dist/index.js +1 -1
- package/dist/init.js +27 -0
- package/dist/mcp-server.js +32 -8
- package/dist/memory-instructions.js +1 -1
- package/dist/nightly.js +3 -1
- package/dist/recall-index.d.ts +4 -1
- package/dist/recall-index.js +2 -2
- package/dist/recall-registry.d.ts +39 -1
- package/dist/recall-registry.js +52 -1
- package/dist/retrieval.d.ts +68 -3
- package/dist/retrieval.js +158 -9
- package/dist/schema-prototypes.d.ts +15 -0
- package/dist/schema-prototypes.js +24 -0
- package/dist/storage.js +10 -0
- package/dist/telemetry.d.ts +17 -0
- package/dist/telemetry.js +31 -0
- package/dist/uninstall.js +10 -0
- package/package.json +4 -3
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* Recall@k sweep eval for session-intent keying (#192, commit 0867d6c).
|
|
5
|
+
*
|
|
6
|
+
* SHIP GATE: does blending the prompt with the per-session EMA centroid
|
|
7
|
+
* (retrieval.blendQueryVector) improve recall across rephrased turns, AND
|
|
8
|
+
* what does it COST when a session shifts topics mid-stream? Picks the
|
|
9
|
+
* default `sessionIntentWeight`.
|
|
10
|
+
*
|
|
11
|
+
* Two scenario families, same corpus:
|
|
12
|
+
*
|
|
13
|
+
* FOCUSED — 6 sessions (one per topic), 4 turns each of GENUINE REPHRASES
|
|
14
|
+
* of one intent. Tests the UPSIDE: does the centroid pull drifting
|
|
15
|
+
* rephrases back on-topic? (recall@5 / p@5 should rise with w.)
|
|
16
|
+
*
|
|
17
|
+
* SHIFT — 5 sessions, each 2 turns on topic A then 2 turns on topic B (a
|
|
18
|
+
* clean mid-session topic change). Tests the DOWNSIDE that focused-only
|
|
19
|
+
* can't see: a higher weight leaves the centroid partly A right after the
|
|
20
|
+
* shift, so the first turn(s) on B may LAG (lower recall@5 for B). The
|
|
21
|
+
* load-bearing read-out is post-shift recovery at turn-3 (first B turn)
|
|
22
|
+
* and turn-4, plus turns-to-recover.
|
|
23
|
+
*
|
|
24
|
+
* Method (both families):
|
|
25
|
+
* - Synthetic corpus of 30 memories across 6 well-separated topics (5 each),
|
|
26
|
+
* embedded with the REAL bge-small-en-v1.5 model (the production embedder).
|
|
27
|
+
* - For each weight w in {0.0, 0.2, 0.4, 0.5, 0.6, 0.8}: fresh
|
|
28
|
+
* SessionRecallRegistry per session; per turn, embed the prompt ONCE,
|
|
29
|
+
* blend with the live centroid, pass the blended vector to retrieve() via
|
|
30
|
+
* queryEmbedding, record recall@5 + p@5 for the turn's CURRENT topic,
|
|
31
|
+
* then fold the prompt into the centroid (EMA α=0.4).
|
|
32
|
+
*
|
|
33
|
+
* Fairness controls (load-bearing — the eval is useless if these slip):
|
|
34
|
+
* - `noStrengthen: true` on every retrieve(): the DB stays STATIC across all
|
|
35
|
+
* retrieve calls. Strengthening would mutate effective_strength/access_count
|
|
36
|
+
* between runs and contaminate cross-weight comparisons.
|
|
37
|
+
* - Uniform memory metadata: every memory has base_strength=0.5, created_at
|
|
38
|
+
* ≈ now, access_count=0, and NO links. So effective_strength, recency,
|
|
39
|
+
* connections, and the freshness boost are all uniform → the ONLY
|
|
40
|
+
* discriminator is vector cosine + RRF rank. That isolates the blend.
|
|
41
|
+
* - Cold-exposure slots are a no-op here (all candidates equally cold),
|
|
42
|
+
* so the top-k is the plain score order.
|
|
43
|
+
* - The centroid update is weight-INDEPENDENT (EMA of prompts at α=0.4), so
|
|
44
|
+
* the per-turn centroid is identical across all weights for a given
|
|
45
|
+
* session; only the blend differs. Embeddings are precomputed once.
|
|
46
|
+
*
|
|
47
|
+
* Metrics (per turn, for the turn's CURRENT topic):
|
|
48
|
+
* - recall@5 — did >=1 same-topic memory surface in the top-5? (coarse; the
|
|
49
|
+
* requested ship-gate metric — saturates on a small corpus.)
|
|
50
|
+
* - p@5 — on-topic count in the top-5 (0..5, finer).
|
|
51
|
+
* - recall@1 — was the SINGLE top result on-topic? (finest; focused only.)
|
|
52
|
+
*
|
|
53
|
+
* Honest by construction: it prints whatever the numbers are, including
|
|
54
|
+
* w=0 winning, the blend hurting a focused scenario, or high weights lagging
|
|
55
|
+
* badly post-shift. Report written to data/eval-recall-sweep/report.md and
|
|
56
|
+
* printed to stdout.
|
|
57
|
+
*
|
|
58
|
+
* Run: npm run eval:recall-sweep (== node dist/eval/recall-sweep.js)
|
|
59
|
+
*/
|
|
60
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
61
|
+
if (k2 === undefined) k2 = k;
|
|
62
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
63
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
64
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
65
|
+
}
|
|
66
|
+
Object.defineProperty(o, k2, desc);
|
|
67
|
+
}) : (function(o, m, k, k2) {
|
|
68
|
+
if (k2 === undefined) k2 = k;
|
|
69
|
+
o[k2] = m[k];
|
|
70
|
+
}));
|
|
71
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
72
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
73
|
+
}) : function(o, v) {
|
|
74
|
+
o["default"] = v;
|
|
75
|
+
});
|
|
76
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
77
|
+
var ownKeys = function(o) {
|
|
78
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
79
|
+
var ar = [];
|
|
80
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
81
|
+
return ar;
|
|
82
|
+
};
|
|
83
|
+
return ownKeys(o);
|
|
84
|
+
};
|
|
85
|
+
return function (mod) {
|
|
86
|
+
if (mod && mod.__esModule) return mod;
|
|
87
|
+
var result = {};
|
|
88
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
89
|
+
__setModuleDefault(result, mod);
|
|
90
|
+
return result;
|
|
91
|
+
};
|
|
92
|
+
})();
|
|
93
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
94
|
+
const node_os_1 = require("node:os");
|
|
95
|
+
const node_path_1 = require("node:path");
|
|
96
|
+
const node_fs_1 = require("node:fs");
|
|
97
|
+
const node_crypto_1 = require("node:crypto");
|
|
98
|
+
const db_js_1 = require("../db.js");
|
|
99
|
+
const storage = __importStar(require("../storage.js"));
|
|
100
|
+
const embedder_js_1 = require("../embedder.js");
|
|
101
|
+
const retrieval_js_1 = require("../retrieval.js");
|
|
102
|
+
const recall_registry_js_1 = require("../recall-registry.js");
|
|
103
|
+
const TOPICS = ["auth", "database", "ui", "deploy", "testing", "networking"];
|
|
104
|
+
/**
|
|
105
|
+
* 30 memories, 5 per topic. Each 2-4 sentences of concrete on-topic prose.
|
|
106
|
+
* Generic verbs/adjectives (fix, broken, failed, wrong, timeout, test) recur
|
|
107
|
+
* ACROSS topics to create realistic cross-topic competition for the top-5 —
|
|
108
|
+
* without that, recall@5 trivially saturates at 1.0 and the eval can't
|
|
109
|
+
* discriminate.
|
|
110
|
+
*/
|
|
111
|
+
const CORPUS = [
|
|
112
|
+
// --- auth (login / authentication / OAuth / sessions) ---
|
|
113
|
+
{
|
|
114
|
+
topic: "auth",
|
|
115
|
+
text: "Debugging the login form: the password field validation rejected valid credentials because the regex was too strict. Users with special characters in their passwords couldn't authenticate. Relaxed the character class and re-ran the auth test suite.",
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
topic: "auth",
|
|
119
|
+
text: "OAuth 2.0 token refresh flow: the refresh token wasn't persisted to the session store, so users got logged out after an hour. Moved token persistence into the auth middleware so it survives across requests.",
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
topic: "auth",
|
|
123
|
+
text: "Session management bug: the JWT expiry claim was set in seconds but the library expected milliseconds, causing immediate token invalidation. Every authenticated request returned 401 right after login.",
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
topic: "auth",
|
|
127
|
+
text: "Password reset endpoint: the email token was signed with the wrong secret, so reset links 404'd on confirmation. Regenerated the secret and invalidated all outstanding reset tokens.",
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
topic: "auth",
|
|
131
|
+
text: "Rate limiting on the login route: brute-force attempts locked out real users because the limiter counted by IP and the office NAT shares one address. Switched to per-account limiting with a CAPTCHA fallback.",
|
|
132
|
+
},
|
|
133
|
+
// --- database (SQL / Postgres / ORM / queries) ---
|
|
134
|
+
{
|
|
135
|
+
topic: "database",
|
|
136
|
+
text: "Slow query on the orders table: a missing index on created_at made the dashboard date-range scan do a full table scan. Added a composite index on (user_id, created_at) and the query dropped from 4s to 12ms.",
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
topic: "database",
|
|
140
|
+
text: "Postgres connection pool exhaustion: the ORM wasn't releasing connections after read transactions, so the pool hit its ceiling under load and requests queued. Added explicit transaction boundaries and a leak detector.",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
topic: "database",
|
|
144
|
+
text: "N+1 query in the catalog serializer: each product fetched its categories separately, generating hundreds of round trips. Rewrote it as a single JOIN and used the ORM's eager-load hint.",
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
topic: "database",
|
|
148
|
+
text: "SQL migration deadlock: two deployments ran ALTER TABLE concurrently and Postgres serialized them, timing out the second. Switched to advisory locks in the migration runner.",
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
topic: "database",
|
|
152
|
+
text: "Wrong aggregate results: the revenue report double-counted refunded orders because the JOIN to the refunds table didn't exclude cancelled refunds. Added a status filter and a regression test.",
|
|
153
|
+
},
|
|
154
|
+
// --- ui (frontend / CSS / layout / rendering) ---
|
|
155
|
+
{
|
|
156
|
+
topic: "ui",
|
|
157
|
+
text: "Broken grid layout on Safari: CSS grid's auto-fit collapsed to a single column because the minmax lower bound was zero. Set a sensible minimum and added a flexbox fallback for older browsers.",
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
topic: "ui",
|
|
161
|
+
text: "Button not rendering: the component's conditional render checked a prop that was undefined rather than null, so the truthy branch never fired. Standardized on nullish coalescing across the component tree.",
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
topic: "ui",
|
|
165
|
+
text: "Responsive nav overflow: the hamburger menu pushed content off-screen on phones in landscape because the viewport meta tag was missing. Added the meta tag and a max-width on the header.",
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
topic: "ui",
|
|
169
|
+
text: "Theme flash on load: the CSS variables weren't set before the first paint, causing a white-then-dark flicker. Inlined the theme bootstrap script in the document head.",
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
topic: "ui",
|
|
173
|
+
text: "Form validation UX: error messages appeared on every keystroke, which felt jarring. Debounced the validation to fire on blur, or after the user paused typing.",
|
|
174
|
+
},
|
|
175
|
+
// --- deploy (CI / release / rollback) ---
|
|
176
|
+
{
|
|
177
|
+
topic: "deploy",
|
|
178
|
+
text: "Deploy failed at the asset compilation step: webpack ran out of memory on the CI runner. Raised the Node heap to 4GB and split the vendor bundle so the build stays under the limit.",
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
topic: "deploy",
|
|
182
|
+
text: "GitHub Actions workflow broke after the runner image updated: the preinstalled Python changed minor versions and our pinned dependency wouldn't install. Pinned the toolchain explicitly in the workflow.",
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
topic: "deploy",
|
|
186
|
+
text: "Blue-green deploy rollback: the new version had a migration that wasn't backward-compatible, so rolling back left half the schema on the new version. Added a deploy-time migration compatibility check.",
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
topic: "deploy",
|
|
190
|
+
text: "Container registry rate limit: CI pulled the base image on every run and hit Docker Hub's anonymous pull cap. Set up a pull-through cache and authenticated the registry.",
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
topic: "deploy",
|
|
194
|
+
text: "Canary deploy caught a memory leak: the new build's steady-state RSS crept up under synthetic traffic. The canary's OOM-kills metric failed the promotion gate and the deploy auto-rolled back.",
|
|
195
|
+
},
|
|
196
|
+
// --- testing (unit / integration / TDD) ---
|
|
197
|
+
{
|
|
198
|
+
topic: "testing",
|
|
199
|
+
text: "Flaky integration test: the test assumed a clean database but ran in parallel with the orders suite, so it saw phantom rows. Gave each test its own schema and a transactional rollback fixture.",
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
topic: "testing",
|
|
203
|
+
text: "Unit test for the pricing module: the pure-function test didn't cover the bulk-discount branch. Added cases for the boundary quantities and a property-based test for rounding.",
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
topic: "testing",
|
|
207
|
+
text: "Mocking the database in tests: the suite hit a real Postgres and timed out in CI. Introduced a repository seam and swapped in an in-memory fake for the fast path.",
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
topic: "testing",
|
|
211
|
+
text: "Test suite runtime: the full suite took nine minutes, mostly from synchronous waits. Parallelized with a sharded runner and dropped the wall clock to under two minutes.",
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
topic: "testing",
|
|
215
|
+
text: "Snapshot test churn: every cosmetic change updated fifty snapshots and reviewers stopped reading them. Replaced the brittle snapshots with explicit assertions on the relevant fields.",
|
|
216
|
+
},
|
|
217
|
+
// --- networking (HTTP / API / CORS / webhooks) ---
|
|
218
|
+
{
|
|
219
|
+
topic: "networking",
|
|
220
|
+
text: "API returning 500s: the upstream service started sending a new error envelope and our deserializer threw on the unknown field. Switched to lenient parsing and added a regression test for the new shape.",
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
topic: "networking",
|
|
224
|
+
text: "CORS preflight failing: the OPTIONS handler didn't echo back the request's Origin header, so browsers blocked the response. Fixed the header reflection and tightened the allowed-methods list.",
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
topic: "networking",
|
|
228
|
+
text: "HTTP request timeout: the client's default timeout was 30s but the upstream's P99 was 45s under load. Tuned the timeout per endpoint and added a circuit breaker for sustained failures.",
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
topic: "networking",
|
|
232
|
+
text: "Webhook delivery failures: retries used a fixed delay and the receiving endpoint rate-limited us after the first burst. Moved to exponential backoff with jitter and a dead-letter queue.",
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
topic: "networking",
|
|
236
|
+
text: "TLS handshake errors: the server's certificate chain was missing the intermediate, so mobile clients failed the trust check. Bundled the full chain in the PEM and renewed via ACME.",
|
|
237
|
+
},
|
|
238
|
+
];
|
|
239
|
+
/**
|
|
240
|
+
* 4-turn rephrase set per topic — GENUINE REPHRASES of one intent: same
|
|
241
|
+
* meaning, different surface wording, deliberately spanning terse/oblique to
|
|
242
|
+
* descriptive. The vocabulary shift is the focused-scenario fairness lever
|
|
243
|
+
* (pure-prompt ranking should plausibly move turn-to-turn). Turn order is a
|
|
244
|
+
* realistic session flow; the EMA centroid (α=0.4) weights the most recent
|
|
245
|
+
* turn most and retains geometrically-decaying signal from earlier ones, so
|
|
246
|
+
* ordering a strong matcher early keeps it in the centroid as turns accumulate.
|
|
247
|
+
* (The shipped default 0.33 sits in the eval-supported 0.3–0.4 band but is
|
|
248
|
+
* not swept directly — the sweep validates the band, not the exact default.)
|
|
249
|
+
*
|
|
250
|
+
* Shift sessions reuse turns[0..1] of A then turns[0..1] of B — so the same
|
|
251
|
+
* rephrases drive both families (no separate, easier shift corpus).
|
|
252
|
+
*/
|
|
253
|
+
const FOCUSED_TURNS = {
|
|
254
|
+
auth: [
|
|
255
|
+
"fix the login bug", // terse; "login" lexical, "bug" generic
|
|
256
|
+
"why is authentication failing", // "authenticate" vs "login"; "failing" generic
|
|
257
|
+
"the signin page is broken", // "signin"/"page" loose; "broken" generic
|
|
258
|
+
"users can't log in", // terse; "log in" vs "login"/"authenticate"
|
|
259
|
+
],
|
|
260
|
+
database: [
|
|
261
|
+
"the database query is slow", // direct: "query"/"slow"
|
|
262
|
+
"optimize this SQL statement", // "SQL"/"optimize" vs "slow"
|
|
263
|
+
"postgres is returning wrong results", // "postgres"/"wrong" generic
|
|
264
|
+
"the ORM is generating bad queries", // "ORM"/"queries"
|
|
265
|
+
],
|
|
266
|
+
ui: [
|
|
267
|
+
"the layout is broken on mobile", // "layout"/"mobile"; "broken" generic
|
|
268
|
+
"fix the CSS grid", // "CSS"/"grid"
|
|
269
|
+
"the button doesn't render", // "button"/"render"
|
|
270
|
+
"responsive design is off", // "responsive"; "off" terse/generic
|
|
271
|
+
],
|
|
272
|
+
deploy: [
|
|
273
|
+
"the deploy failed", // terse; "deploy"/"failed" generic
|
|
274
|
+
"CI pipeline is red", // "CI"; "red" = failing
|
|
275
|
+
"the GitHub Actions workflow broke", // "GitHub Actions"/"workflow"/"broke"
|
|
276
|
+
"rollback the production release", // "rollback"/"release"
|
|
277
|
+
],
|
|
278
|
+
testing: [
|
|
279
|
+
"write a unit test for this", // "unit test"
|
|
280
|
+
"the test suite is flaky", // "test suite"/"flaky"
|
|
281
|
+
"fix the failing assertion", // "assertion"; "failing" generic
|
|
282
|
+
"mock the database in tests", // "mock"/"tests"
|
|
283
|
+
],
|
|
284
|
+
networking: [
|
|
285
|
+
"the API returns 500 errors", // "API"/"500"
|
|
286
|
+
"fix the CORS issue", // "CORS"; "issue" generic
|
|
287
|
+
"the HTTP request times out", // "HTTP"/"times out"
|
|
288
|
+
"webhook delivery is failing", // "webhook"; "failing" generic
|
|
289
|
+
],
|
|
290
|
+
};
|
|
291
|
+
/**
|
|
292
|
+
* Shift pairs: 2 turns on A → 2 turns on B. A and B are deliberately different
|
|
293
|
+
* so the A-seeded centroid actively opposes the first B turn(s) at high w.
|
|
294
|
+
* Five pairs cover each topic once or twice on each side of a shift.
|
|
295
|
+
*/
|
|
296
|
+
const SHIFT_PAIRS = [
|
|
297
|
+
["auth", "database"],
|
|
298
|
+
["ui", "deploy"],
|
|
299
|
+
["testing", "networking"],
|
|
300
|
+
["database", "ui"],
|
|
301
|
+
["deploy", "testing"],
|
|
302
|
+
];
|
|
303
|
+
const WEIGHTS = [0.0, 0.2, 0.4, 0.5, 0.6, 0.8];
|
|
304
|
+
function buildSessions() {
|
|
305
|
+
const focused = TOPICS.map((t) => ({
|
|
306
|
+
kind: "focused",
|
|
307
|
+
id: `focused-${t}`,
|
|
308
|
+
label: t,
|
|
309
|
+
turns: FOCUSED_TURNS[t].map((p) => ({ prompt: p, topic: t })),
|
|
310
|
+
}));
|
|
311
|
+
const shifts = SHIFT_PAIRS.map(([a, b], i) => ({
|
|
312
|
+
kind: "shift",
|
|
313
|
+
id: `shift-${i}-${a}-${b}`,
|
|
314
|
+
label: `${a}->${b}`,
|
|
315
|
+
topicA: a,
|
|
316
|
+
topicB: b,
|
|
317
|
+
turns: [
|
|
318
|
+
{ prompt: FOCUSED_TURNS[a][0], topic: a },
|
|
319
|
+
{ prompt: FOCUSED_TURNS[a][1], topic: a },
|
|
320
|
+
{ prompt: FOCUSED_TURNS[b][0], topic: b },
|
|
321
|
+
{ prompt: FOCUSED_TURNS[b][1], topic: b },
|
|
322
|
+
],
|
|
323
|
+
}));
|
|
324
|
+
return [...focused, ...shifts];
|
|
325
|
+
}
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// DB construction
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
async function buildCorpusDb(dbPath) {
|
|
330
|
+
const db = (0, db_js_1.initDb)(dbPath);
|
|
331
|
+
const idToTopic = new Map();
|
|
332
|
+
for (const mem of CORPUS) {
|
|
333
|
+
const vec = await (0, embedder_js_1.embed)(mem.text);
|
|
334
|
+
const id = storage.insertMemory(db, mem.text, vec, {
|
|
335
|
+
sourceAgent: "eval-corpus",
|
|
336
|
+
memoryType: "episode",
|
|
337
|
+
baseStrength: 0.5, // uniform — strength is NOT a discriminator here
|
|
338
|
+
});
|
|
339
|
+
idToTopic.set(id, mem.topic);
|
|
340
|
+
}
|
|
341
|
+
return { db, idToTopic };
|
|
342
|
+
}
|
|
343
|
+
/** Embed every distinct prompt once; return a cache keyed by prompt text. */
|
|
344
|
+
async function embedAllPrompts(sessions) {
|
|
345
|
+
const cache = new Map();
|
|
346
|
+
for (const s of sessions) {
|
|
347
|
+
for (const t of s.turns) {
|
|
348
|
+
if (!cache.has(t.prompt))
|
|
349
|
+
cache.set(t.prompt, await (0, embedder_js_1.embed)(t.prompt));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return cache;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Run the full sweep. Prompts are pre-embedded once (the centroid update is
|
|
356
|
+
* weight-independent, so the per-turn centroid is identical across weights;
|
|
357
|
+
* only the blend differs). A fresh SessionRecallRegistry per (weight,session)
|
|
358
|
+
* keeps each centroid seeded only by that session's own turns.
|
|
359
|
+
*/
|
|
360
|
+
async function runSweep(db, idToTopic, sessions) {
|
|
361
|
+
const promptEmb = await embedAllPrompts(sessions);
|
|
362
|
+
// Fail-explicit embedFn: we ALWAYS supply queryEmbedding, so retrieve()
|
|
363
|
+
// must never call this. If it does, the sweep is measuring the wrong thing.
|
|
364
|
+
const neverCalledEmbed = async () => {
|
|
365
|
+
throw new Error("recall-sweep: retrieve() called the embedFn — queryEmbedding was not honored. Eval aborted (results would be invalid).");
|
|
366
|
+
};
|
|
367
|
+
const records = [];
|
|
368
|
+
for (const w of WEIGHTS) {
|
|
369
|
+
for (const s of sessions) {
|
|
370
|
+
const registry = new recall_registry_js_1.SessionRecallRegistry();
|
|
371
|
+
const sessionId = `w${w}-${s.id}`;
|
|
372
|
+
for (let i = 0; i < s.turns.length; i++) {
|
|
373
|
+
const turnDef = s.turns[i];
|
|
374
|
+
const promptText = turnDef.prompt;
|
|
375
|
+
const promptVec = promptEmb.get(promptText);
|
|
376
|
+
const centroid = registry.getCentroid(sessionId);
|
|
377
|
+
const queryVec = (0, retrieval_js_1.blendQueryVector)(promptVec, centroid, w);
|
|
378
|
+
const results = await (0, retrieval_js_1.retrieve)(db, neverCalledEmbed, promptText, {
|
|
379
|
+
limit: 5,
|
|
380
|
+
queryEmbedding: queryVec,
|
|
381
|
+
noStrengthen: true,
|
|
382
|
+
});
|
|
383
|
+
const topIds = results.map((r) => r.id);
|
|
384
|
+
const onTopic = topIds.filter((id) => idToTopic.get(id) === turnDef.topic);
|
|
385
|
+
const topTopic = topIds.length > 0 ? idToTopic.get(topIds[0]) : undefined;
|
|
386
|
+
records.push({
|
|
387
|
+
weight: w,
|
|
388
|
+
sessionId: s.id,
|
|
389
|
+
kind: s.kind,
|
|
390
|
+
label: s.label,
|
|
391
|
+
topicA: s.topicA,
|
|
392
|
+
topicB: s.topicB,
|
|
393
|
+
turn: i + 1,
|
|
394
|
+
currentTopic: turnDef.topic,
|
|
395
|
+
recallAt5: onTopic.length > 0 ? 1 : 0,
|
|
396
|
+
recallAt1: topTopic === turnDef.topic ? 1 : 0,
|
|
397
|
+
precisionAt5: onTopic.length,
|
|
398
|
+
topIds,
|
|
399
|
+
});
|
|
400
|
+
// Fold this prompt into the centroid AFTER the search (seeds on turn 1,
|
|
401
|
+
// blends after). α is the shipped constant.
|
|
402
|
+
registry.updateCentroid(sessionId, promptVec, retrieval_js_1.SESSION_INTENT_ALPHA);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return records;
|
|
407
|
+
}
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// Aggregation helpers
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
function avg(rs, field) {
|
|
412
|
+
if (rs.length === 0)
|
|
413
|
+
return 0;
|
|
414
|
+
return rs.reduce((s, r) => s + r[field], 0) / rs.length;
|
|
415
|
+
}
|
|
416
|
+
function pct(x) {
|
|
417
|
+
return `${(x * 100).toFixed(1)}%`;
|
|
418
|
+
}
|
|
419
|
+
function fmtW(w) {
|
|
420
|
+
return w.toFixed(1);
|
|
421
|
+
}
|
|
422
|
+
function summarizeFocused(records, sessions) {
|
|
423
|
+
const focusedSessions = sessions.filter((s) => s.kind === "focused");
|
|
424
|
+
return WEIGHTS.map((w) => {
|
|
425
|
+
const wr = records.filter((r) => r.weight === w && r.kind === "focused");
|
|
426
|
+
const turnN = (n) => wr.filter((r) => r.turn === n);
|
|
427
|
+
const byScenario = focusedSessions.map((s) => avg(wr.filter((r) => r.sessionId === s.id), "recallAt5"));
|
|
428
|
+
return {
|
|
429
|
+
weight: w,
|
|
430
|
+
recallAt5All: avg(wr, "recallAt5"),
|
|
431
|
+
recallAt5Turn1: avg(turnN(1), "recallAt5"),
|
|
432
|
+
recallAt5Turn4: avg(turnN(4), "recallAt5"),
|
|
433
|
+
recallAt1All: avg(wr, "recallAt1"),
|
|
434
|
+
precisionAt5All: avg(wr, "precisionAt5"),
|
|
435
|
+
byScenario,
|
|
436
|
+
};
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
function summarizeShift(records) {
|
|
440
|
+
return WEIGHTS.map((w) => {
|
|
441
|
+
const wr = records.filter((r) => r.weight === w && r.kind === "shift");
|
|
442
|
+
const pre = wr.filter((r) => r.turn <= 2);
|
|
443
|
+
const t3 = wr.filter((r) => r.turn === 3);
|
|
444
|
+
const t4 = wr.filter((r) => r.turn === 4);
|
|
445
|
+
// Recovery per session: recall@5 for B at turn 3 (and turn 4).
|
|
446
|
+
const sessionLabels = Array.from(new Set(wr.map((r) => r.sessionId)));
|
|
447
|
+
let recByT3 = 0;
|
|
448
|
+
let recByT4 = 0;
|
|
449
|
+
for (const sid of sessionLabels) {
|
|
450
|
+
const t3r = wr.find((r) => r.sessionId === sid && r.turn === 3);
|
|
451
|
+
const t4r = wr.find((r) => r.sessionId === sid && r.turn === 4);
|
|
452
|
+
if (t3r && t3r.recallAt5 === 1)
|
|
453
|
+
recByT3++;
|
|
454
|
+
if ((t3r && t3r.recallAt5 === 1) || (t4r && t4r.recallAt5 === 1))
|
|
455
|
+
recByT4++;
|
|
456
|
+
}
|
|
457
|
+
const n = sessionLabels.length || 1;
|
|
458
|
+
return {
|
|
459
|
+
weight: w,
|
|
460
|
+
preRecallAt5: avg(pre, "recallAt5"),
|
|
461
|
+
prePrecisionAt5: avg(pre, "precisionAt5"),
|
|
462
|
+
postT3RecallAt5: avg(t3, "recallAt5"),
|
|
463
|
+
postT3PrecisionAt5: avg(t3, "precisionAt5"),
|
|
464
|
+
postT4RecallAt5: avg(t4, "recallAt5"),
|
|
465
|
+
postT4PrecisionAt5: avg(t4, "precisionAt5"),
|
|
466
|
+
recoveredByT3: recByT3 / n,
|
|
467
|
+
recoveredByT4: recByT4 / n,
|
|
468
|
+
};
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
function renderReport(focused, shifts, records, sessions, meta) {
|
|
472
|
+
const L = [];
|
|
473
|
+
const nFocused = sessions.filter((s) => s.kind === "focused").length;
|
|
474
|
+
const nShift = sessions.filter((s) => s.kind === "shift").length;
|
|
475
|
+
L.push("# Recall@k Sweep — session-intent keying (#192, 0867d6c)\n");
|
|
476
|
+
L.push(`Corpus: ${meta.memoryCount} memories across ${TOPICS.length} topics (5 each), embedded with the ` +
|
|
477
|
+
`real bge-small-en-v1.5 model. ${nFocused} focused scenarios x 4 turns + ${nShift} shift scenarios ` +
|
|
478
|
+
`x 4 turns (2 on A -> 2 on B).\n`);
|
|
479
|
+
L.push("_Static DB across all retrieve() calls (`noStrengthen: true`); uniform memory metadata " +
|
|
480
|
+
"(base_strength=0.5, created_at~now, no links) so vector cosine + RRF is the only discriminator; " +
|
|
481
|
+
"cold-exposure slots are a no-op (all candidates equally cold). Centroid EMA at α=0.4. " +
|
|
482
|
+
"Per-turn metrics are vs the turn's CURRENT topic (A then B for shifts)._\n");
|
|
483
|
+
// ---- 1. Focused ----
|
|
484
|
+
L.push("## 1. Focused recall@5 / p@5 by weight (upside)\n");
|
|
485
|
+
L.push("Does the centroid pull same-intent rephrases back on-topic? recall@5 = >=1 on-topic in top-5; " +
|
|
486
|
+
"recall@1 = TOP result on-topic; p@5 = mean on-topic count in top-5 (0..5).\n");
|
|
487
|
+
L.push("| weight | recall@5 (all) | recall@5 turn-1 | recall@5 turn-4 | recall@1 (all) | p@5 (all) |");
|
|
488
|
+
L.push("|---|---|---|---|---|---|");
|
|
489
|
+
for (const s of focused) {
|
|
490
|
+
L.push(`| ${fmtW(s.weight)} | ${pct(s.recallAt5All)} | ${pct(s.recallAt5Turn1)} | ${pct(s.recallAt5Turn4)} | ${pct(s.recallAt1All)} | ${s.precisionAt5All.toFixed(2)} |`);
|
|
491
|
+
}
|
|
492
|
+
L.push("");
|
|
493
|
+
const f0 = focused.find((s) => s.weight === 0);
|
|
494
|
+
const fOthers = focused.filter((s) => s.weight > 0);
|
|
495
|
+
const fBestOverall = focused.reduce((a, b) => (b.recallAt5All > a.recallAt5All + 1e-9 ? b : a));
|
|
496
|
+
const fBeatR5 = fOthers.some((s) => s.recallAt5All > f0.recallAt5All + 1e-9);
|
|
497
|
+
const fBeatR1 = fOthers.some((s) => s.recallAt1All > f0.recallAt1All + 1e-9);
|
|
498
|
+
const fBeatP5 = fOthers.some((s) => s.precisionAt5All > f0.precisionAt5All + 1e-9);
|
|
499
|
+
L.push("### Focused verdict\n");
|
|
500
|
+
L.push(`- Best overall recall@5: w=${fmtW(fBestOverall.weight)} (${pct(fBestOverall.recallAt5All)})`);
|
|
501
|
+
L.push(`- Any w>0 beat w=0 on recall@5: **${fBeatR5 ? "YES" : "NO"}**`);
|
|
502
|
+
L.push(`- Any w>0 beat w=0 on recall@1: **${fBeatR1 ? "YES" : "NO"}**`);
|
|
503
|
+
L.push(`- Any w>0 beat w=0 on p@5: **${fBeatP5 ? "YES" : "NO"}**`);
|
|
504
|
+
// Focused regressions (blend hurt a focused scenario vs w=0)
|
|
505
|
+
const fRegs = [];
|
|
506
|
+
for (let si = 0; si < focused[0].byScenario.length; si++) {
|
|
507
|
+
const v0 = focused.find((x) => x.weight === 0).byScenario[si];
|
|
508
|
+
for (const s of focused) {
|
|
509
|
+
if (s.weight === 0)
|
|
510
|
+
continue;
|
|
511
|
+
const v = s.byScenario[si];
|
|
512
|
+
if (v < v0 - 1e-9) {
|
|
513
|
+
const lbl = sessions.filter((x) => x.kind === "focused")[si].label;
|
|
514
|
+
fRegs.push(`- ${lbl} at w=${fmtW(s.weight)}: ${pct(v)} vs w=0 ${pct(v0)}`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
L.push(`- Focused regressions (blend HURT vs w=0): ${fRegs.length > 0 ? fRegs.join("; ") : "_(none)_"}\n`);
|
|
519
|
+
// ---- 2. Shift recovery ----
|
|
520
|
+
L.push("## 2. Shift recovery by weight (downside)\n");
|
|
521
|
+
L.push("2 turns on topic A, then 2 turns on topic B (clean mid-session shift). Metrics are vs the turn's " +
|
|
522
|
+
"CURRENT topic. **Turn-3 = first turn on B, right after the shift** — the load-bearing read-out: " +
|
|
523
|
+
"a higher weight leaves the centroid partly A, so B recall@5 should DROP at high w. Turn-4 = second " +
|
|
524
|
+
"B turn — has the EMA recovered toward B?\n");
|
|
525
|
+
L.push("| weight | pre recall@5 (A, t1-2) | pre p@5 | **post recall@5 (B, t3)** | post p@5 (t3) | post recall@5 (B, t4) | post p@5 (t4) | recovered by t3 | recovered by t4 |");
|
|
526
|
+
L.push("|---|---|---|---|---|---|---|---|---|");
|
|
527
|
+
for (const s of shifts) {
|
|
528
|
+
L.push(`| ${fmtW(s.weight)} | ${pct(s.preRecallAt5)} | ${s.prePrecisionAt5.toFixed(2)} | ` +
|
|
529
|
+
`**${pct(s.postT3RecallAt5)}** | ${s.postT3PrecisionAt5.toFixed(2)} | ` +
|
|
530
|
+
`${pct(s.postT4RecallAt5)} | ${s.postT4PrecisionAt5.toFixed(2)} | ` +
|
|
531
|
+
`${pct(s.recoveredByT3)} | ${pct(s.recoveredByT4)} |`);
|
|
532
|
+
}
|
|
533
|
+
L.push("");
|
|
534
|
+
// Per-shift-scenario post-shift recall@5 (turn-3) by weight — where does lag show up?
|
|
535
|
+
L.push("### Per-shift-scenario post-shift recall@5 (first turn on B = turn-3)\n");
|
|
536
|
+
L.push("Each cell = recall@5 for the NEW topic at the first post-shift turn. w=0 column = pure-prompt baseline (no centroid lag). Drops below the w=0 column = the centroid LAGGING the shift.\n");
|
|
537
|
+
const shiftSessions = sessions.filter((s) => s.kind === "shift");
|
|
538
|
+
const head = ["shift (A->B)", ...WEIGHTS.map((w) => `w=${fmtW(w)}`)];
|
|
539
|
+
L.push("| " + head.join(" | ") + " |");
|
|
540
|
+
L.push("|" + head.map(() => "---").join("|") + "|");
|
|
541
|
+
for (const s of shiftSessions) {
|
|
542
|
+
const cells = WEIGHTS.map((w) => {
|
|
543
|
+
const rec = records.find((r) => r.weight === w && r.kind === "shift" && r.sessionId === s.id && r.turn === 3);
|
|
544
|
+
return rec && rec.recallAt5 === 1 ? "1" : "0";
|
|
545
|
+
});
|
|
546
|
+
L.push(`| ${s.label} | ${cells.join(" | ")} |`);
|
|
547
|
+
}
|
|
548
|
+
L.push("");
|
|
549
|
+
// Shift verdict — does any weight lag vs w=0?
|
|
550
|
+
const sh0 = shifts.find((s) => s.weight === 0);
|
|
551
|
+
const laggersT3 = [];
|
|
552
|
+
const laggersT4 = [];
|
|
553
|
+
for (const s of shifts) {
|
|
554
|
+
if (s.weight === 0)
|
|
555
|
+
continue;
|
|
556
|
+
if (s.postT3RecallAt5 < sh0.postT3RecallAt5 - 1e-9) {
|
|
557
|
+
laggersT3.push(`w=${fmtW(s.weight)}: ${pct(s.postT3RecallAt5)} (vs w=0 ${pct(sh0.postT3RecallAt5)}, −${(sh0.postT3RecallAt5 - s.postT3RecallAt5).toFixed(2)})`);
|
|
558
|
+
}
|
|
559
|
+
if (s.postT4RecallAt5 < sh0.postT4RecallAt5 - 1e-9) {
|
|
560
|
+
laggersT4.push(`w=${fmtW(s.weight)}: ${pct(s.postT4RecallAt5)} (vs w=0 ${pct(sh0.postT4RecallAt5)}, −${(sh0.postT4RecallAt5 - s.postT4RecallAt5).toFixed(2)})`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
L.push("### Shift verdict\n");
|
|
564
|
+
L.push(`- Baseline (w=0) post-shift recall@5: turn-3 ${pct(sh0.postT3RecallAt5)}, turn-4 ${pct(sh0.postT4RecallAt5)}`);
|
|
565
|
+
L.push(`- Weights that LAG at turn-3 (B recall@5 < w=0): ${laggersT3.length > 0 ? laggersT3.join("; ") : "_(none — no shift lag at any weight)_"}`);
|
|
566
|
+
L.push(`- Weights still lagging at turn-4: ${laggersT4.length > 0 ? laggersT4.join("; ") : "_(none — all recovered by turn-4)_"}\n`);
|
|
567
|
+
// ---- 3. Combined verdict ----
|
|
568
|
+
L.push("## 3. Combined verdict (focused upside vs shift cost)\n");
|
|
569
|
+
L.push("Two recommendations, deliberately side by side. recall@5 is BINARY (>=1 on-topic) so it hides the " +
|
|
570
|
+
"steady p@5 erosion the shift table shows — read both. The p@5-aware pick is the finer signal.\n");
|
|
571
|
+
// (a) recall@5-based: highest weight with NO turn-3 recall@5 lag AND maximal
|
|
572
|
+
// focused recall@5. Coarse — a weight can pass this while halving the
|
|
573
|
+
// on-topic count in the top-5 (p@5), because one survivor still flips
|
|
574
|
+
// recall@5 to 1.
|
|
575
|
+
const noLagT3 = shifts.filter((s) => s.postT3RecallAt5 >= sh0.postT3RecallAt5 - 1e-9);
|
|
576
|
+
const noLagWeights = noLagT3.map((s) => s.weight).filter((w) => w > 0);
|
|
577
|
+
const focusedCeiling = Math.max(...focused.map((s) => s.recallAt5All));
|
|
578
|
+
const r5Cands = noLagWeights.filter((w) => {
|
|
579
|
+
const f = focused.find((x) => x.weight === w);
|
|
580
|
+
return f.recallAt5All >= focusedCeiling - 1e-9;
|
|
581
|
+
});
|
|
582
|
+
const recR5 = r5Cands.length > 0 ? Math.max(...r5Cands) : null;
|
|
583
|
+
// (b) p@5-aware: among weights that capture the focused p@5 upside (within
|
|
584
|
+
// 5% of the plateau), pick the one with the HIGHEST turn-3 shift p@5 (least
|
|
585
|
+
// shift cost). p@5 is non-saturating on this corpus, so this respects BOTH
|
|
586
|
+
// the focused upside and the shift cost — the recommendation to actually ship.
|
|
587
|
+
const p5Plateau = Math.max(...focused.map((s) => s.precisionAt5All));
|
|
588
|
+
const p5Cands = WEIGHTS.filter((w) => w > 0 &&
|
|
589
|
+
focused.find((x) => x.weight === w).precisionAt5All >= 0.95 * p5Plateau - 1e-9);
|
|
590
|
+
let recP5 = null;
|
|
591
|
+
let bestT3p5 = -Infinity;
|
|
592
|
+
for (const w of p5Cands) {
|
|
593
|
+
const t3p5 = shifts.find((x) => x.weight === w).postT3PrecisionAt5;
|
|
594
|
+
if (t3p5 > bestT3p5 + 1e-9) {
|
|
595
|
+
bestT3p5 = t3p5;
|
|
596
|
+
recP5 = w;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
L.push(`- **recall@5-based recommendation: w=${recR5 !== null ? fmtW(recR5) : "n/a"}** — highest weight with ` +
|
|
600
|
+
`no turn-3 recall@5 lag and maximal focused recall@5 (${pct(focusedCeiling)}). Coarse: it does NOT ` +
|
|
601
|
+
`see the p@5 erosion.`);
|
|
602
|
+
L.push(`- **p@5-aware recommendation: w=${recP5 !== null ? fmtW(recP5) : "n/a"}** — among weights capturing ` +
|
|
603
|
+
`>=95% of the focused p@5 plateau (${p5Plateau.toFixed(2)}), the one with the highest turn-3 shift ` +
|
|
604
|
+
`p@5 (${bestT3p5.toFixed(2)} on-topic in the top-5 at the first post-shift turn). This is the pick ` +
|
|
605
|
+
`that respects both upside and shift cost.\n`);
|
|
606
|
+
// Plain-English summary line — honest about the divergence.
|
|
607
|
+
const maxLagT3r5 = Math.max(0, ...shifts.filter((s) => s.weight > 0).map((s) => sh0.postT3RecallAt5 - s.postT3RecallAt5));
|
|
608
|
+
// Worst p@5 erosion at turn-3 vs the w=0 baseline (the finer, earlier signal).
|
|
609
|
+
const maxP5drop = Math.max(0, ...shifts
|
|
610
|
+
.filter((s) => s.weight > 0)
|
|
611
|
+
.map((s) => sh0.postT3PrecisionAt5 - s.postT3PrecisionAt5));
|
|
612
|
+
const diverge = recR5 !== null && recP5 !== null && Math.abs(recR5 - recP5) > 1e-9;
|
|
613
|
+
let plain;
|
|
614
|
+
if (!fBeatR5 && !fBeatR1 && !fBeatP5 && maxLagT3r5 <= 0 && maxP5drop <= 0) {
|
|
615
|
+
plain =
|
|
616
|
+
"The blend shows no focused benefit and no shift cost — on this corpus it is a no-op. w=0 is the honest default.";
|
|
617
|
+
}
|
|
618
|
+
else if (diverge) {
|
|
619
|
+
plain =
|
|
620
|
+
`The recall@5-based pick (w=${fmtW(recR5)}) and the p@5-aware pick (w=${fmtW(recP5)}) DIVERGE — ` +
|
|
621
|
+
`recall@5 stays at 100% through w=${fmtW(recR5)} but p@5 at the first post-shift turn erodes steadily ` +
|
|
622
|
+
`(down up to ${maxP5drop.toFixed(2)} on-topic memories in the top-5 vs w=0), and collapses at w=0.8 ` +
|
|
623
|
+
`(recall@5 ${pct(shifts.find((s) => s.weight === 0.8).postT3RecallAt5)} — the new topic misses the ` +
|
|
624
|
+
`top-5 entirely in 3/5 shift scenarios). The finer metric argues for the lower default w=${fmtW(recP5)}: ` +
|
|
625
|
+
`it captures the focused upside (p@5 within 5% of plateau) with the least shift cost. Ship w=${fmtW(recP5)}.`;
|
|
626
|
+
}
|
|
627
|
+
else if (maxLagT3r5 > 0) {
|
|
628
|
+
plain =
|
|
629
|
+
`The blend buys focused recall but costs recall@5 lag at turn-3 at the highest weight ` +
|
|
630
|
+
`(up to −${maxLagT3r5.toFixed(2)}). Recommended w=${recP5 !== null ? fmtW(recP5) : recR5 !== null ? fmtW(recR5) : "?"} keeps the upside without the lag.`;
|
|
631
|
+
}
|
|
632
|
+
else {
|
|
633
|
+
plain =
|
|
634
|
+
`The blend helps focused recall with NO recall@5 shift lag at any tested weight — ship a non-zero ` +
|
|
635
|
+
`default (recommended w=${recP5 !== null ? fmtW(recP5) : recR5 !== null ? fmtW(recR5) : "0.4"}).`;
|
|
636
|
+
}
|
|
637
|
+
L.push(`**${plain}**\n`);
|
|
638
|
+
// Sanity: w=0 per-turn recall@5 for focused (did pure-prompt actually diverge?)
|
|
639
|
+
L.push("## Appendix: w=0 per-turn recall@5 — did pure-prompt diverge across rephrases?\n");
|
|
640
|
+
L.push("| scenario | turn-1 | turn-2 | turn-3 | turn-4 |");
|
|
641
|
+
L.push("|---|---|---|---|---|");
|
|
642
|
+
for (const s of sessions.filter((x) => x.kind === "focused")) {
|
|
643
|
+
const cells = [1, 2, 3, 4].map((n) => {
|
|
644
|
+
const r = records.find((r) => r.weight === 0 && r.sessionId === s.id && r.turn === n);
|
|
645
|
+
return r.recallAt5 === 1 ? "1" : "0";
|
|
646
|
+
});
|
|
647
|
+
L.push(`| ${s.label} | ${cells.join(" | ")} |`);
|
|
648
|
+
}
|
|
649
|
+
L.push("");
|
|
650
|
+
return L.join("\n");
|
|
651
|
+
}
|
|
652
|
+
// ---------------------------------------------------------------------------
|
|
653
|
+
// main
|
|
654
|
+
// ---------------------------------------------------------------------------
|
|
655
|
+
async function main() {
|
|
656
|
+
// Pin all module-level knobs to shipped defaults so a host config can't
|
|
657
|
+
// leak into the sweep. The blend weight is varied per-run via blendQueryVector
|
|
658
|
+
// directly (NOT via configureSessionIntent), so the global sessionIntentWeight
|
|
659
|
+
// is irrelevant here.
|
|
660
|
+
(0, retrieval_js_1.configureScoring)();
|
|
661
|
+
(0, retrieval_js_1.configureDecay)();
|
|
662
|
+
(0, retrieval_js_1.configureRecall)();
|
|
663
|
+
(0, retrieval_js_1.configureSessionIntent)();
|
|
664
|
+
const sessions = buildSessions();
|
|
665
|
+
const tmpDir = (0, node_path_1.join)((0, node_os_1.tmpdir)(), `hicortex-recall-sweep-${(0, node_crypto_1.randomUUID)().slice(0, 8)}`);
|
|
666
|
+
(0, node_fs_1.mkdirSync)(tmpDir, { recursive: true });
|
|
667
|
+
const dbPath = (0, node_path_1.join)(tmpDir, "sweep.db");
|
|
668
|
+
const reportDir = (0, node_path_1.join)(process.cwd(), "data", "eval-recall-sweep");
|
|
669
|
+
console.log(`[recall-sweep] temp DB: ${dbPath}`);
|
|
670
|
+
console.log(`[recall-sweep] report dir: ${reportDir}`);
|
|
671
|
+
console.log(`[recall-sweep] ${sessions.filter((s) => s.kind === "focused").length} focused + ` +
|
|
672
|
+
`${sessions.filter((s) => s.kind === "shift").length} shift sessions; ` +
|
|
673
|
+
`${WEIGHTS.length} weights = ${sessions.length * WEIGHTS.length} sweeps x 4 turns = ` +
|
|
674
|
+
`${sessions.length * WEIGHTS.length * 4} retrieves`);
|
|
675
|
+
let db = null;
|
|
676
|
+
try {
|
|
677
|
+
console.log("[recall-sweep] building corpus (embedding 30 memories)...");
|
|
678
|
+
const t0 = Date.now();
|
|
679
|
+
const { db: opened, idToTopic } = await buildCorpusDb(dbPath);
|
|
680
|
+
db = opened;
|
|
681
|
+
console.log(`[recall-sweep] corpus ready in ${Date.now() - t0}ms (${idToTopic.size} memories)`);
|
|
682
|
+
console.log("[recall-sweep] running sweep...");
|
|
683
|
+
const t1 = Date.now();
|
|
684
|
+
const records = await runSweep(db, idToTopic, sessions);
|
|
685
|
+
console.log(`[recall-sweep] sweep done in ${Date.now() - t1}ms (${records.length} turn records)`);
|
|
686
|
+
const focused = summarizeFocused(records, sessions);
|
|
687
|
+
const shifts = summarizeShift(records);
|
|
688
|
+
const report = renderReport(focused, shifts, records, sessions, {
|
|
689
|
+
memoryCount: CORPUS.length,
|
|
690
|
+
});
|
|
691
|
+
(0, node_fs_1.mkdirSync)(reportDir, { recursive: true });
|
|
692
|
+
const reportPath = (0, node_path_1.join)(reportDir, "report.md");
|
|
693
|
+
(0, node_fs_1.writeFileSync)(reportPath, report, "utf-8");
|
|
694
|
+
console.log("\n" + report);
|
|
695
|
+
console.log(`[recall-sweep] report written to ${reportPath}`);
|
|
696
|
+
}
|
|
697
|
+
finally {
|
|
698
|
+
if (db) {
|
|
699
|
+
try {
|
|
700
|
+
db.close();
|
|
701
|
+
}
|
|
702
|
+
catch { /* already closed */ }
|
|
703
|
+
}
|
|
704
|
+
if ((0, node_fs_1.existsSync)(tmpDir)) {
|
|
705
|
+
try {
|
|
706
|
+
(0, node_fs_1.rmSync)(tmpDir, { recursive: true, force: true });
|
|
707
|
+
}
|
|
708
|
+
catch { /* non-fatal */ }
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
main().catch((err) => {
|
|
713
|
+
console.error("[recall-sweep] FAILED:", err instanceof Error ? err.stack : String(err));
|
|
714
|
+
process.exitCode = 1;
|
|
715
|
+
});
|