pi-mega-compact 0.11.5 → 0.11.7
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/dist/extensions/dashboard-server/api-contracts/cache-stripes.js +8 -0
- package/dist/extensions/dashboard-server/api-contracts/endpoints.js +13 -0
- package/dist/extensions/dashboard-server/api-contracts/memory-map.js +1 -0
- package/dist/extensions/dashboard-server/routes-cache.js +28 -0
- package/dist/extensions/dashboard-server/routes-game.js +3 -0
- package/dist/extensions/dashboard-server/routes-memory-map.js +50 -0
- package/dist/extensions/dashboard-server/routes-setup.js +105 -0
- package/dist/extensions/dashboard-server/routes.js +3 -1
- package/dist/extensions/dashboard-server/server.js +7 -1
- package/dist/extensions/mega-compact.js +5 -0
- package/dist/extensions/mega-config.js +5 -0
- package/dist/extensions/mega-events/context-handler.js +10 -2
- package/dist/extensions/mega-events/perf-handler.js +83 -1
- package/dist/extensions/mega-events/separated-prompt.js +248 -0
- package/dist/extensions/mega-runtime/env-loader.js +56 -0
- package/dist/extensions/mega-runtime/reset-runtime.js +1 -0
- package/dist/extensions/mega-runtime/runtime-snapshot.js +1 -0
- package/dist/extensions/mega-runtime/runtime.js +1 -0
- package/dist/extensions/mega-runtime/snapshot.js +1 -0
- package/dist/extensions/mega-runtime/widget.js +5 -0
- package/dist/src/cache-stripe-impl.js +288 -0
- package/dist/src/cache-stripe.js +11 -0
- package/dist/src/config/stopwords.js +24 -0
- package/dist/src/config.js +17 -0
- package/dist/src/memoryGraph.js +299 -0
- package/dist/src/monitoring.js +13 -0
- package/dist/src/queryExpansion.js +163 -0
- package/dist/src/queryReformulation/cache.js +123 -0
- package/dist/src/queryReformulation/rrf.js +29 -0
- package/dist/src/queryReformulation/tfidf.js +61 -0
- package/dist/src/queryReformulation/vagueness.js +33 -0
- package/dist/src/queryReformulation.js +225 -0
- package/dist/src/recall.js +136 -1
- package/dist/src/recallMetrics.js +258 -0
- package/dist/src/store/sqlite/cache-stripes.js +133 -0
- package/dist/src/store/sqlite/fts5-search.js +68 -0
- package/dist/src/store/sqlite/perf-samples.js +1 -0
- package/dist/src/store/sqlite/schema.js +60 -1
- package/dist/src/store/sqlite.js +1 -0
- package/dist/src/store/vectorIndex.js +29 -5
- package/dist/src/tiered-router/types.js +18 -0
- package/dist/src/tieredRouter.js +421 -0
- package/dist/src/vectorStore.js +8 -6
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-BoL7qBOJ.js → AchievementTiles-DR9cLGDL.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-BoL7qBOJ.js.map → AchievementTiles-DR9cLGDL.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-BFDE3HYc.js → AchievementsTab-svNUgJ1e.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-BFDE3HYc.js.map → AchievementsTab-svNUgJ1e.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/CacheTab-ASwhOMjR.js +2 -0
- package/extensions/dashboard-client/dist/assets/CacheTab-ASwhOMjR.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{ConfigTab-DDFE-ckA.js → ConfigTab-CmmWheuW.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ConfigTab-DDFE-ckA.js.map → ConfigTab-CmmWheuW.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{EventsTab-B6UxZ7PM.js → EventsTab-CFE7jLsa.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{EventsTab-B6UxZ7PM.js.map → EventsTab-CFE7jLsa.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/GameTab-CJAX_th_.js +2 -0
- package/extensions/dashboard-client/dist/assets/{GameTab-DehUX-KE.js.map → GameTab-CJAX_th_.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MaintenanceTab-0GWRt4wn.js → MaintenanceTab-P4SUsvMf.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MaintenanceTab-0GWRt4wn.js.map → MaintenanceTab-P4SUsvMf.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/MemoryMapTab-BBiH76eF.js +2 -0
- package/extensions/dashboard-client/dist/assets/MemoryMapTab-BBiH76eF.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{MetricsTab-C3rwMVsN.js → MetricsTab-CHQscixy.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MetricsTab-C3rwMVsN.js.map → MetricsTab-CHQscixy.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{OverviewTab-39O9_x5j.js → OverviewTab-DBs4DJdd.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{OverviewTab-39O9_x5j.js.map → OverviewTab-DBs4DJdd.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ReposTab-Cfj6Q5sH.js → ReposTab-DeFz76L5.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ReposTab-Cfj6Q5sH.js.map → ReposTab-DeFz76L5.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{SessionsTab-DXDTCi3r.js → SessionsTab-BWNsj1OB.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{SessionsTab-DXDTCi3r.js.map → SessionsTab-BWNsj1OB.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/SetupTab-uS6A0xmw.js +2 -0
- package/extensions/dashboard-client/dist/assets/SetupTab-uS6A0xmw.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Cw1dJ4e9.js → TimeSavedCard-CiqCOQuL.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Cw1dJ4e9.js.map → TimeSavedCard-CiqCOQuL.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TopicsTab-BAw972BV.js → TopicsTab-BWWQxltv.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TopicsTab-BAw972BV.js.map → TopicsTab-BWWQxltv.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TurnsTab-Cf5mGIdl.js → TurnsTab-CVz4f4-o.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TurnsTab-Cf5mGIdl.js.map → TurnsTab-CVz4f4-o.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/index-CUNn22iV.css +1 -0
- package/extensions/dashboard-client/dist/assets/{index-4gQ7auad.js → index-DrktNOoT.js} +11 -11
- package/extensions/dashboard-client/dist/assets/index-DrktNOoT.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{useSSE-DNgMuyP6.js → useSSE-Bf5LOLSi.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{useSSE-DNgMuyP6.js.map → useSSE-Bf5LOLSi.js.map} +1 -1
- package/extensions/dashboard-client/dist/index.html +2 -2
- package/extensions/dashboard-client/src/App.tsx +24 -8
- package/extensions/dashboard-client/src/api/client.ts +15 -0
- package/extensions/dashboard-client/src/components/CacheHitRateTrendCard.tsx +100 -0
- package/extensions/dashboard-client/src/components/StripeDistributionCard.tsx +110 -0
- package/extensions/dashboard-client/src/components/TabBar.tsx +49 -4
- package/extensions/dashboard-client/src/main.tsx +1 -0
- package/extensions/dashboard-client/src/styles/base.css +30 -0
- package/extensions/dashboard-client/src/styles/cache.css +167 -0
- package/extensions/dashboard-client/src/tabs/CacheTab.tsx +75 -13
- package/extensions/dashboard-client/src/tabs/MemoryMapTab.tsx +440 -0
- package/extensions/dashboard-client/src/tabs/SetupTab.tsx +65 -2
- package/extensions/dashboard-server/api-contracts/cache-stripes.ts +49 -0
- package/extensions/dashboard-server/api-contracts/endpoints.ts +31 -1
- package/extensions/dashboard-server/api-contracts/index.ts +16 -1
- package/extensions/dashboard-server/api-contracts/memory-map.ts +50 -0
- package/extensions/dashboard-server/api-contracts/setup.ts +22 -0
- package/extensions/dashboard-server/routes-cache.ts +36 -1
- package/extensions/dashboard-server/routes-game.ts +3 -0
- package/extensions/dashboard-server/routes-memory-map.ts +105 -0
- package/extensions/dashboard-server/routes-setup.ts +112 -0
- package/extensions/dashboard-server/routes.ts +3 -1
- package/extensions/dashboard-server/server.ts +6 -0
- package/extensions/mega-compact.ts +5 -0
- package/extensions/mega-config.ts +17 -0
- package/extensions/mega-events/context-handler.ts +10 -2
- package/extensions/mega-events/perf-handler.ts +111 -1
- package/extensions/mega-events/separated-prompt.ts +329 -0
- package/extensions/mega-runtime/env-loader.ts +55 -0
- package/extensions/mega-runtime/helpers.ts +2 -0
- package/extensions/mega-runtime/reset-runtime.ts +1 -0
- package/extensions/mega-runtime/runtime-snapshot.ts +1 -0
- package/extensions/mega-runtime/runtime.ts +1 -0
- package/extensions/mega-runtime/snapshot.ts +6 -0
- package/extensions/mega-runtime/widget-types.ts +3 -0
- package/extensions/mega-runtime/widget.ts +5 -0
- package/package.json +1 -1
- package/src/cache-stripe-impl.ts +385 -0
- package/src/cache-stripe.ts +18 -0
- package/src/config/stopwords.ts +25 -0
- package/src/config.ts +26 -0
- package/src/memoryGraph.ts +438 -0
- package/src/monitoring.ts +35 -0
- package/src/queryExpansion.ts +206 -0
- package/src/queryReformulation/cache.ts +169 -0
- package/src/queryReformulation/rrf.ts +51 -0
- package/src/queryReformulation/tfidf.ts +79 -0
- package/src/queryReformulation/vagueness.ts +38 -0
- package/src/queryReformulation.ts +355 -0
- package/src/recall.ts +168 -4
- package/src/recallMetrics.ts +388 -0
- package/src/store/sqlite/cache-stripes.ts +196 -0
- package/src/store/sqlite/fts5-search.ts +90 -0
- package/src/store/sqlite/perf-samples.ts +3 -1
- package/src/store/sqlite/schema.ts +60 -1
- package/src/store/sqlite.ts +1 -0
- package/src/store/vectorIndex.ts +38 -7
- package/src/tiered-router/types.ts +64 -0
- package/src/tieredRouter.ts +489 -0
- package/src/vectorStore.ts +9 -5
- package/extensions/dashboard-client/dist/assets/CacheTab-eKVc-NV1.js +0 -2
- package/extensions/dashboard-client/dist/assets/CacheTab-eKVc-NV1.js.map +0 -1
- package/extensions/dashboard-client/dist/assets/GameTab-DehUX-KE.js +0 -2
- package/extensions/dashboard-client/dist/assets/SetupTab-mMlfunJa.js +0 -2
- package/extensions/dashboard-client/dist/assets/SetupTab-mMlfunJa.js.map +0 -1
- package/extensions/dashboard-client/dist/assets/index-4gQ7auad.js.map +0 -1
- package/extensions/dashboard-client/dist/assets/index-BEdKlpAB.css +0 -1
package/src/recall.ts
CHANGED
|
@@ -28,6 +28,21 @@ import { rehydrateRaptorTree, isShadowMode } from "./dedup/raptor/index.js";
|
|
|
28
28
|
import { maxCheckpointTimestamp } from "./store/sqlite.js";
|
|
29
29
|
import { normalizeSessionId } from "./store.js";
|
|
30
30
|
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// S57 RAG suite feature flags + module imports. Each flag defaults OFF — the
|
|
33
|
+
// flag-OFF path below is byte-identical to the pre-S57 recall path (recallQuery
|
|
34
|
+
// === opts.query, the single searchRecall call), so the helpers are only reached
|
|
35
|
+
// when their flag is ON. Wiring is additive; the proven v0.11.5 behavior is the
|
|
36
|
+
// default.
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
import { RAG_QUERY_REFORMULATION, RAG_TIERED_ROUTER, RAG_RECALL_METRICS } from "./config.js";
|
|
39
|
+
import { reformulateQuery, isVagueQuery } from "./queryReformulation.js";
|
|
40
|
+
import type { CorpusStats, EmbedderLike, NeighborScanner } from "./queryReformulation.js";
|
|
41
|
+
import { VAGUE_MIN_WORDS, VAGUE_VERY_SHORT_WORDS } from "./queryReformulation/cache.js";
|
|
42
|
+
import { getTieredRouter } from "./tieredRouter.js";
|
|
43
|
+
import { computeRecallMetrics } from "./recallMetrics.js";
|
|
44
|
+
import { Logger } from "./log.js";
|
|
45
|
+
|
|
31
46
|
export type RecallSource = "resume" | "command" | "sentinel";
|
|
32
47
|
|
|
33
48
|
export interface RecallInjectOptions {
|
|
@@ -133,6 +148,135 @@ export function formatRaptorBlock(
|
|
|
133
148
|
);
|
|
134
149
|
}
|
|
135
150
|
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// S57 RAG suite helpers (B1/B2/B3). Each is only called when its feature flag
|
|
153
|
+
// is ON; flag-OFF never reaches them. All degrade non-fatally to the standard
|
|
154
|
+
// path on any error.
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* B1: If the query is vague, reformulate it via embedding-neighbor TF-IDF
|
|
159
|
+
* expansion. Returns the original query when reformulation is not supported
|
|
160
|
+
* or the query is already specific. Non-fatal: any error → original query.
|
|
161
|
+
*/
|
|
162
|
+
function reformulateRecallQuery(
|
|
163
|
+
query: string,
|
|
164
|
+
store: VectorStore,
|
|
165
|
+
sessionId: string,
|
|
166
|
+
): string {
|
|
167
|
+
try {
|
|
168
|
+
if (
|
|
169
|
+
!isVagueQuery(query, {
|
|
170
|
+
vagueMinWords: VAGUE_MIN_WORDS,
|
|
171
|
+
vagueVeryShortWords: VAGUE_VERY_SHORT_WORDS,
|
|
172
|
+
})
|
|
173
|
+
)
|
|
174
|
+
return query;
|
|
175
|
+
// Pre-compute neighbor candidates once. reformulateQuery embeds the query
|
|
176
|
+
// and calls `scan` internally; we return these pre-computed neighbors (same
|
|
177
|
+
// query) so the store needs no by-embedding lookup.
|
|
178
|
+
const { hits } = searchRecall(
|
|
179
|
+
{ sessionId, query, limit: 10, skipInjected: false },
|
|
180
|
+
store,
|
|
181
|
+
);
|
|
182
|
+
if (hits.length < 2) return query; // too few neighbors for TF-IDF
|
|
183
|
+
const neighbors = hits.map((h) => ({
|
|
184
|
+
id: h.checkpoint.checkpointId,
|
|
185
|
+
score: h.score,
|
|
186
|
+
}));
|
|
187
|
+
const textById = new Map<string, string>();
|
|
188
|
+
for (const h of hits) textById.set(h.checkpoint.checkpointId, h.checkpoint.summary);
|
|
189
|
+
const scan: NeighborScanner = () => neighbors;
|
|
190
|
+
const corpus: CorpusStats = {
|
|
191
|
+
totalDocs: neighbors.length,
|
|
192
|
+
docFreq: (term: string) => {
|
|
193
|
+
let df = 0;
|
|
194
|
+
for (const text of textById.values()) {
|
|
195
|
+
if (text.toLowerCase().includes(term.toLowerCase())) df++;
|
|
196
|
+
}
|
|
197
|
+
return Math.max(df, 1);
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
const neighborTexts = (ids: string[]) =>
|
|
201
|
+
ids.map((id) => ({ id, text: textById.get(id) ?? "" }));
|
|
202
|
+
const { result } = reformulateQuery(
|
|
203
|
+
query,
|
|
204
|
+
defaultEmbedder() as unknown as EmbedderLike,
|
|
205
|
+
scan,
|
|
206
|
+
corpus,
|
|
207
|
+
neighborTexts,
|
|
208
|
+
);
|
|
209
|
+
return result.expanded.length > query.length ? result.expanded : query;
|
|
210
|
+
} catch {
|
|
211
|
+
return query; // non-fatal: fall back to original query
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* B2: Synchronous tiered recall. The TieredRouter.route() is async, so the
|
|
217
|
+
* sync path can only peek the L0 in-memory cache; on miss it falls through to
|
|
218
|
+
* the standard sync vector scan. The router's L1/L2 value is realized on the
|
|
219
|
+
* async path (recallAndInlineAsync). Non-fatal.
|
|
220
|
+
*/
|
|
221
|
+
function runTieredRecall(
|
|
222
|
+
query: string,
|
|
223
|
+
sessionId: string,
|
|
224
|
+
limit: number,
|
|
225
|
+
skip: boolean,
|
|
226
|
+
store: VectorStore,
|
|
227
|
+
): { newHits: SearchHit[]; tier: string } {
|
|
228
|
+
try {
|
|
229
|
+
const router = getTieredRouter();
|
|
230
|
+
if (!router) {
|
|
231
|
+
const result = searchRecall(
|
|
232
|
+
{ sessionId, query, limit, skipInjected: skip },
|
|
233
|
+
store,
|
|
234
|
+
);
|
|
235
|
+
return { newHits: result.newHits, tier: "off" };
|
|
236
|
+
}
|
|
237
|
+
// Peek the L0 in-memory cache synchronously; fall through on miss.
|
|
238
|
+
const cached = router.peekCache(sessionId, query, limit);
|
|
239
|
+
if (cached && cached.length > 0) return { newHits: cached, tier: "L0-cache" };
|
|
240
|
+
const result = searchRecall(
|
|
241
|
+
{ sessionId, query, limit, skipInjected: skip },
|
|
242
|
+
store,
|
|
243
|
+
);
|
|
244
|
+
return { newHits: result.newHits, tier: "sync-fallback" };
|
|
245
|
+
} catch {
|
|
246
|
+
const result = searchRecall(
|
|
247
|
+
{ sessionId, query, limit, skipInjected: skip },
|
|
248
|
+
store,
|
|
249
|
+
);
|
|
250
|
+
return { newHits: result.newHits, tier: "fallback-error" };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* B3: Compute recall-quality metrics on the injected hits and log them.
|
|
256
|
+
* Only called when RAG_RECALL_METRICS() is ON. Non-fatal.
|
|
257
|
+
*/
|
|
258
|
+
function scoreAndLogRecallMetrics(query: string, toInject: SearchHit[]): void {
|
|
259
|
+
try {
|
|
260
|
+
const logger = new Logger();
|
|
261
|
+
const metrics = computeRecallMetrics(query, toInject);
|
|
262
|
+
logger.info("recall_metrics", {
|
|
263
|
+
hitCount: toInject.length,
|
|
264
|
+
score: metrics.score,
|
|
265
|
+
pass: metrics.pass,
|
|
266
|
+
});
|
|
267
|
+
if (!metrics.pass && toInject.length > 0) {
|
|
268
|
+
logger.info("recall_metrics_low_quality", {
|
|
269
|
+
score: metrics.score,
|
|
270
|
+
relevance: metrics.breakdown.relevance,
|
|
271
|
+
coverage: metrics.breakdown.coverage,
|
|
272
|
+
diversity: metrics.breakdown.diversity,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
} catch {
|
|
276
|
+
/* non-fatal: metrics never break recall */
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
136
280
|
/**
|
|
137
281
|
* Run the unified recall+dudupe+prepare-inject pipeline. Does NOT touch pi;
|
|
138
282
|
* it records injections via `markInjected` so the next call dedupes. The
|
|
@@ -189,10 +333,24 @@ export function recallAndInline(
|
|
|
189
333
|
// F4: thread skipInjected through to searchRecall instead of hardcoding false
|
|
190
334
|
// and re-implementing the filter here. newHits is already deduped when skip
|
|
191
335
|
// is true (default); equals hits when skip is false (openclaw command path).
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
336
|
+
//
|
|
337
|
+
// S57 B1: optionally expand a vague query via TF-IDF neighbor terms.
|
|
338
|
+
// S57 B2: optionally route via the TieredRouter's sync L0 cache peek.
|
|
339
|
+
// Both flags default OFF — recallQuery === opts.query and the single
|
|
340
|
+
// searchRecall call reproduce the pre-S57 byte-identical path.
|
|
341
|
+
const recallQuery = RAG_QUERY_REFORMULATION()
|
|
342
|
+
? reformulateRecallQuery(opts.query, store, opts.sessionId)
|
|
343
|
+
: opts.query;
|
|
344
|
+
let newHits: SearchHit[];
|
|
345
|
+
if (RAG_TIERED_ROUTER()) {
|
|
346
|
+
newHits = runTieredRecall(recallQuery, opts.sessionId, limit, skip, store).newHits;
|
|
347
|
+
} else {
|
|
348
|
+
const result = searchRecall(
|
|
349
|
+
{ sessionId: opts.sessionId, query: recallQuery, limit, skipInjected: skip },
|
|
350
|
+
store,
|
|
351
|
+
);
|
|
352
|
+
newHits = result.newHits;
|
|
353
|
+
}
|
|
196
354
|
|
|
197
355
|
// F1: hoist one embedder instance for inline dedupe (matches the async path).
|
|
198
356
|
// defaultEmbedder() is deterministic but creating it per hit wastes allocations.
|
|
@@ -228,6 +386,12 @@ export function recallAndInline(
|
|
|
228
386
|
vectorMarkInjected(store, opts.sessionId, h.checkpoint.checkpointId);
|
|
229
387
|
}
|
|
230
388
|
|
|
389
|
+
// S57 B3: optionally score + log recall quality metrics (flag-OFF: skipped,
|
|
390
|
+
// byte-identical). Scores the injected hits against the ORIGINAL query so the
|
|
391
|
+
// metric measures whether the (possibly expanded) search results stay
|
|
392
|
+
// relevant to what the user actually asked.
|
|
393
|
+
if (RAG_RECALL_METRICS()) scoreAndLogRecallMetrics(opts.query, toInject);
|
|
394
|
+
|
|
231
395
|
// F3: format once — one preamble, correct [1..n] numbering.
|
|
232
396
|
const recallBlock = toInject.length > 0 ? formatRecallBlock(toInject) : "";
|
|
233
397
|
const report = toInject.map(
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recallMetrics.ts — S45 CRAG quality metrics for recall evaluation.
|
|
3
|
+
*
|
|
4
|
+
* Four metrics evaluate the quality of a set of recall hits:
|
|
5
|
+
* - relevance: average cosine similarity (SearchHit.score) across hits
|
|
6
|
+
* - coverage: fraction of query sub-terms found in at least one hit summary
|
|
7
|
+
* - diversity: average pairwise cosine distance among top-K embeddings (MMR-style)
|
|
8
|
+
* - specificity: inverse document-frequency proxy via average chunk length
|
|
9
|
+
*
|
|
10
|
+
* Pi-agnostic: no pi runtime types, no network calls (PREVENT-PI-004).
|
|
11
|
+
* All config is passed explicitly; defaults are documented but NOT hardcoded
|
|
12
|
+
* inside the metric functions (the four constants from S45A.5 are also received
|
|
13
|
+
* via config, not baked in).
|
|
14
|
+
*
|
|
15
|
+
* Best-effort: all functions handle degenerate inputs (empty hits, missing
|
|
16
|
+
* embeddings, zero query terms) and return sensible defaults instead of NaN.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { SearchHit } from "./vectorStore.js";
|
|
20
|
+
import { cosineSimilarity } from "./embedder.js";
|
|
21
|
+
import { STOP_WORDS } from "./config/stopwords.js";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Exported types
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export interface RecallQualityWeights {
|
|
28
|
+
relevance: number;
|
|
29
|
+
coverage: number;
|
|
30
|
+
diversity: number;
|
|
31
|
+
specificity: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RecallQualityBreakdown {
|
|
35
|
+
relevance: number;
|
|
36
|
+
coverage: number;
|
|
37
|
+
diversity: number;
|
|
38
|
+
specificity: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RecallQualityResult {
|
|
42
|
+
/** Whether the overall quality passes the configured thresholds. */
|
|
43
|
+
pass: boolean;
|
|
44
|
+
/** Weighted composite score in [0, 1]. */
|
|
45
|
+
score: number;
|
|
46
|
+
/** Per-metric breakdown. */
|
|
47
|
+
breakdown: RecallQualityBreakdown;
|
|
48
|
+
/** Human-readable recommendation string, or null when everything passes. */
|
|
49
|
+
recommendation: string | null;
|
|
50
|
+
/** True when default (uncalibrated) weights/thresholds are in use. */
|
|
51
|
+
uncalibrated: boolean;
|
|
52
|
+
/** The weights used for this evaluation. */
|
|
53
|
+
weights: RecallQualityWeights;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RecallQualityConfig {
|
|
57
|
+
/** Minimum diversity to pass. Default 0.3. Labeled uncalibrated. */
|
|
58
|
+
minDiversity: number;
|
|
59
|
+
/** Minimum coverage to pass. Default 0.4. Labeled uncalibrated. */
|
|
60
|
+
minCoverage: number;
|
|
61
|
+
/** Minimum average relevance to pass. Default 0.5. Labeled uncalibrated. */
|
|
62
|
+
minRelevance: number;
|
|
63
|
+
/** Minimum overall composite score to pass. Default 0.4. Labeled uncalibrated. */
|
|
64
|
+
minOverallScore: number;
|
|
65
|
+
/** Weight vector for the composite score. Defaults sum to 1.0. Labeled uncalibrated. */
|
|
66
|
+
weights: RecallQualityWeights;
|
|
67
|
+
/** Token-estimate divisor for specificity. Default 300. Labeled uncalibrated. */
|
|
68
|
+
specificityDivisor: number;
|
|
69
|
+
/**
|
|
70
|
+
* Below specificityOptimalMin/2 the chunk is too short → specificity = 0.
|
|
71
|
+
* Default 100. Labeled uncalibrated.
|
|
72
|
+
*/
|
|
73
|
+
specificityOptimalMin: number;
|
|
74
|
+
/**
|
|
75
|
+
* Diminishing returns above this value; linear ramp-down to 0 by 2x this value.
|
|
76
|
+
* Default 500. Labeled uncalibrated.
|
|
77
|
+
*/
|
|
78
|
+
specificityOptimalMax: number;
|
|
79
|
+
/**
|
|
80
|
+
* Fraction of checkpoints a term may appear in before it is considered "common"
|
|
81
|
+
* for the broaden-query strategy. Default 0.5. Labeled uncalibrated.
|
|
82
|
+
*/
|
|
83
|
+
idfBroadenRatio: number;
|
|
84
|
+
/** Set of stopwords for tokenization. Defaults to English STOP_WORDS. */
|
|
85
|
+
stopwords: ReadonlySet<string>;
|
|
86
|
+
/** When true, weights/thresholds are calibrated (not defaults). Default false. */
|
|
87
|
+
calibrated: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Default config — all values marked "uncalibrated" unless CRAG_CALIBRATED=true. */
|
|
91
|
+
export const DEFAULT_RECALL_QUALITY_CONFIG: RecallQualityConfig = {
|
|
92
|
+
minDiversity: 0.3,
|
|
93
|
+
minCoverage: 0.4,
|
|
94
|
+
minRelevance: 0.5,
|
|
95
|
+
minOverallScore: 0.4,
|
|
96
|
+
weights: { relevance: 0.35, coverage: 0.25, diversity: 0.25, specificity: 0.15 },
|
|
97
|
+
specificityDivisor: 300,
|
|
98
|
+
specificityOptimalMin: 100,
|
|
99
|
+
specificityOptimalMax: 500,
|
|
100
|
+
idfBroadenRatio: 0.5,
|
|
101
|
+
stopwords: STOP_WORDS,
|
|
102
|
+
calibrated: false,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Metric helpers (exported individually for unit testing)
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* S45A.2: Relevance — average of SearchHit.score across all hits.
|
|
111
|
+
* Scores are already cosine similarities in [0, 1].
|
|
112
|
+
* Empty hits → 0.
|
|
113
|
+
*/
|
|
114
|
+
export function computeRelevance(hits: SearchHit[]): number {
|
|
115
|
+
if (hits.length === 0) return 0;
|
|
116
|
+
let sum = 0;
|
|
117
|
+
for (const h of hits) sum += h.score;
|
|
118
|
+
return sum / hits.length;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* S45A.3: Coverage — fraction of query subterms present in at least one
|
|
123
|
+
* hit's `checkpoint.summary`. Tokenization removes stopwords and short terms.
|
|
124
|
+
* Zero query terms after filtering → coverage = 1.0 (trivially satisfied).
|
|
125
|
+
*/
|
|
126
|
+
export function computeCoverage(
|
|
127
|
+
query: string,
|
|
128
|
+
hits: SearchHit[],
|
|
129
|
+
stopwords: ReadonlySet<string> = STOP_WORDS,
|
|
130
|
+
): number {
|
|
131
|
+
const queryTerms = tokenizeTerms(query, stopwords);
|
|
132
|
+
if (queryTerms.length === 0) return 1.0;
|
|
133
|
+
if (hits.length === 0) return 0;
|
|
134
|
+
|
|
135
|
+
let foundCount = 0;
|
|
136
|
+
for (const qt of queryTerms) {
|
|
137
|
+
const found = hits.some((h) => {
|
|
138
|
+
const summary = h.checkpoint.summary ?? "";
|
|
139
|
+
return summary.toLowerCase().includes(qt);
|
|
140
|
+
});
|
|
141
|
+
if (found) foundCount++;
|
|
142
|
+
}
|
|
143
|
+
return foundCount / queryTerms.length;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* S45A.4: Diversity — average pairwise cosine distance (1 - similarity)
|
|
148
|
+
* among hit embeddings. Filtered to hits with non-empty embedding arrays.
|
|
149
|
+
*
|
|
150
|
+
* Edge cases:
|
|
151
|
+
* - < 2 valid hits → 1.0 (nothing to compare)
|
|
152
|
+
* - empty-embedding checkpoints are skipped (no NaN path)
|
|
153
|
+
*/
|
|
154
|
+
export function computeDiversity(hits: SearchHit[]): number {
|
|
155
|
+
const valid = hits.filter(
|
|
156
|
+
(h) => Array.isArray(h.checkpoint.embedding) && h.checkpoint.embedding.length > 0,
|
|
157
|
+
);
|
|
158
|
+
if (valid.length < 2) return 1.0;
|
|
159
|
+
|
|
160
|
+
let totalDist = 0;
|
|
161
|
+
let pairCount = 0;
|
|
162
|
+
for (let i = 0; i < valid.length; i++) {
|
|
163
|
+
for (let j = i + 1; j < valid.length; j++) {
|
|
164
|
+
const sim = cosineSimilarity(valid[i].checkpoint.embedding, valid[j].checkpoint.embedding);
|
|
165
|
+
// cosineSimilarity returns 0 for empty vectors, but we've filtered those out.
|
|
166
|
+
// NaN/Infinity could theoretically arise from degenerate floats; guard defensively.
|
|
167
|
+
const dist = Number.isFinite(sim) ? 1 - sim : 1.0;
|
|
168
|
+
totalDist += dist;
|
|
169
|
+
pairCount++;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return pairCount > 0 ? totalDist / pairCount : 1.0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* S45A.5: Specificity — average chunk length (tokenEstimate) as a proxy
|
|
177
|
+
* for information density.
|
|
178
|
+
*
|
|
179
|
+
* Formula (all constants from config, NOT hardcoded):
|
|
180
|
+
* avg = mean(tokenEstimate across hits; hits missing tokenEstimate → 0)
|
|
181
|
+
* If avg < optimalMin / 2 → 0 (too short to be specific)
|
|
182
|
+
* base = avg / divisor
|
|
183
|
+
* If avg > optimalMax:
|
|
184
|
+
* ramp = 1 - (avg - optimalMax) / optimalMax // linear to 0 at 2*optimalMax
|
|
185
|
+
* capped = max(0, ramp)
|
|
186
|
+
* specificity = min(base, capped)
|
|
187
|
+
* else:
|
|
188
|
+
* specificity = min(base, 1)
|
|
189
|
+
*
|
|
190
|
+
* Edge cases: empty hits → 1.0 (nothing to penalize).
|
|
191
|
+
*/
|
|
192
|
+
export function computeSpecificity(
|
|
193
|
+
hits: SearchHit[],
|
|
194
|
+
config: Pick<RecallQualityConfig, "specificityDivisor" | "specificityOptimalMin" | "specificityOptimalMax">,
|
|
195
|
+
): number {
|
|
196
|
+
if (hits.length === 0) return 1.0;
|
|
197
|
+
|
|
198
|
+
const { specificityDivisor: divisor, specificityOptimalMin: optMin, specificityOptimalMax: optMax } = config;
|
|
199
|
+
|
|
200
|
+
let sum = 0;
|
|
201
|
+
for (const h of hits) {
|
|
202
|
+
sum += h.checkpoint.tokenEstimate ?? 0;
|
|
203
|
+
}
|
|
204
|
+
const avg = sum / hits.length;
|
|
205
|
+
|
|
206
|
+
// Below optMin/2 → too short to be specific
|
|
207
|
+
if (avg < optMin / 2) return 0;
|
|
208
|
+
|
|
209
|
+
const base = divisor > 0 ? avg / divisor : 0;
|
|
210
|
+
const clamped = base > 1 ? 1 : base;
|
|
211
|
+
|
|
212
|
+
if (avg > optMax) {
|
|
213
|
+
// Linear ramp-down from optMax to 0 at 2*optMax
|
|
214
|
+
const ramp = optMax > 0 ? 1 - (avg - optMax) / optMax : 0;
|
|
215
|
+
const capped = ramp > 0 ? ramp : 0;
|
|
216
|
+
return clamped < capped ? clamped : capped;
|
|
217
|
+
}
|
|
218
|
+
return clamped;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Composite evaluation
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Normalize a four-weight vector so its components sum to 1.0.
|
|
227
|
+
* If all weights are 0, return equal weights (0.25 each).
|
|
228
|
+
* Used defensively since users may set non-sum-1 weights via env.
|
|
229
|
+
*/
|
|
230
|
+
export function normalizeWeights(w: RecallQualityWeights): RecallQualityWeights {
|
|
231
|
+
const sum = w.relevance + w.coverage + w.diversity + w.specificity;
|
|
232
|
+
if (sum === 0) return { relevance: 0.25, coverage: 0.25, diversity: 0.25, specificity: 0.25 };
|
|
233
|
+
return {
|
|
234
|
+
relevance: w.relevance / sum,
|
|
235
|
+
coverage: w.coverage / sum,
|
|
236
|
+
diversity: w.diversity / sum,
|
|
237
|
+
specificity: w.specificity / sum,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* S45A.6: Evaluate recall quality — compute all four metrics and return a
|
|
243
|
+
* composite result with pass/fail + recommendation.
|
|
244
|
+
*/
|
|
245
|
+
export function evaluateRecall(
|
|
246
|
+
query: string,
|
|
247
|
+
hits: SearchHit[],
|
|
248
|
+
config: RecallQualityConfig,
|
|
249
|
+
): RecallQualityResult {
|
|
250
|
+
const weights = normalizeWeights(config.weights);
|
|
251
|
+
const breakdown: RecallQualityBreakdown = {
|
|
252
|
+
relevance: computeRelevance(hits),
|
|
253
|
+
coverage: computeCoverage(query, hits, config.stopwords),
|
|
254
|
+
diversity: computeDiversity(hits),
|
|
255
|
+
specificity: computeSpecificity(hits, config),
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const score =
|
|
259
|
+
weights.relevance * breakdown.relevance +
|
|
260
|
+
weights.coverage * breakdown.coverage +
|
|
261
|
+
weights.diversity * breakdown.diversity +
|
|
262
|
+
weights.specificity * breakdown.specificity;
|
|
263
|
+
|
|
264
|
+
const pass =
|
|
265
|
+
score >= config.minOverallScore &&
|
|
266
|
+
breakdown.relevance >= config.minRelevance &&
|
|
267
|
+
breakdown.coverage >= config.minCoverage &&
|
|
268
|
+
breakdown.diversity >= config.minDiversity;
|
|
269
|
+
|
|
270
|
+
const recommendation = buildRecommendation(breakdown, score, config);
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
pass,
|
|
274
|
+
score,
|
|
275
|
+
breakdown,
|
|
276
|
+
recommendation,
|
|
277
|
+
uncalibrated: !config.calibrated,
|
|
278
|
+
weights,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// Recommendation builder
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Build a human-readable recommendation based on which metric is lowest.
|
|
288
|
+
* Uses env-overridable thresholds from config (not hardcoded).
|
|
289
|
+
*/
|
|
290
|
+
function buildRecommendation(
|
|
291
|
+
breakdown: RecallQualityBreakdown,
|
|
292
|
+
score: number,
|
|
293
|
+
config: RecallQualityConfig,
|
|
294
|
+
): string | null {
|
|
295
|
+
// Check each metric against its threshold (lowest first for specificity).
|
|
296
|
+
const issues: string[] = [];
|
|
297
|
+
|
|
298
|
+
if (breakdown.diversity < config.minDiversity) {
|
|
299
|
+
issues.push("chunks are too similar — consider expanding K or using MMR");
|
|
300
|
+
}
|
|
301
|
+
if (breakdown.coverage < config.minCoverage) {
|
|
302
|
+
issues.push("query terms not well covered — consider query expansion");
|
|
303
|
+
}
|
|
304
|
+
if (breakdown.relevance < config.minRelevance) {
|
|
305
|
+
issues.push("chunks may be irrelevant — consider stricter threshold");
|
|
306
|
+
}
|
|
307
|
+
if (score < config.minOverallScore) {
|
|
308
|
+
issues.push("quality is low — recommend re-retrieval with expanded query");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return issues.length > 0 ? issues.join("; ") : null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// Unified entry point (pi-agnostic, matches task spec)
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
export interface RecallMetrics {
|
|
319
|
+
/** Per-metric breakdown. */
|
|
320
|
+
breakdown: RecallQualityBreakdown;
|
|
321
|
+
/** Weighted composite score. */
|
|
322
|
+
score: number;
|
|
323
|
+
/** Whether quality passes all thresholds. */
|
|
324
|
+
pass: boolean;
|
|
325
|
+
/** Human-readable recommendation. */
|
|
326
|
+
recommendation: string | null;
|
|
327
|
+
/** Whether default config was used (uncalibrated). */
|
|
328
|
+
uncalibrated: boolean;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Compute recall quality metrics for a set of search hits.
|
|
333
|
+
*
|
|
334
|
+
* Pi-agnostic: takes query text and SearchHit results directly, no pi types.
|
|
335
|
+
* The `config` parameter is optional — when omitted, DEFAULT_RECALL_QUALITY_CONFIG
|
|
336
|
+
* is used (labelled uncalibrated).
|
|
337
|
+
*
|
|
338
|
+
* This is the primary export for external callers (e.g., recall.ts integration).
|
|
339
|
+
*/
|
|
340
|
+
export function computeRecallMetrics(
|
|
341
|
+
query: string,
|
|
342
|
+
results: SearchHit[],
|
|
343
|
+
config?: Partial<RecallQualityConfig>,
|
|
344
|
+
): RecallMetrics {
|
|
345
|
+
const effectiveConfig: RecallQualityConfig = {
|
|
346
|
+
...DEFAULT_RECALL_QUALITY_CONFIG,
|
|
347
|
+
...config,
|
|
348
|
+
// Merge weights shallowly if both sides exist
|
|
349
|
+
weights: {
|
|
350
|
+
...DEFAULT_RECALL_QUALITY_CONFIG.weights,
|
|
351
|
+
...(config?.weights ?? {}),
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const result = evaluateRecall(query, results, effectiveConfig);
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
breakdown: result.breakdown,
|
|
359
|
+
score: result.score,
|
|
360
|
+
pass: result.pass,
|
|
361
|
+
recommendation: result.recommendation,
|
|
362
|
+
uncalibrated: result.uncalibrated,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
// Internal helpers
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Tokenize text into lowercased alphanumeric terms, filtering stop words
|
|
372
|
+
* and terms shorter than 3 characters.
|
|
373
|
+
*/
|
|
374
|
+
function tokenizeTerms(text: string, stopwords: ReadonlySet<string>): string[] {
|
|
375
|
+
return text
|
|
376
|
+
.toLowerCase()
|
|
377
|
+
.split(/[^a-z0-9]+/)
|
|
378
|
+
.filter((t) => t.length >= 3 && !stopwords.has(t));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Estimate block tokens for a piece of text (rough approximation).
|
|
383
|
+
* Used by the specificity metric fallback when tokenEstimate is missing.
|
|
384
|
+
*/
|
|
385
|
+
export function estimateBlockTokens(text: string): number {
|
|
386
|
+
// ~4 chars per token for English text
|
|
387
|
+
return Math.ceil(text.length / 4);
|
|
388
|
+
}
|