@remnic/core 9.3.738 → 9.3.739

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.
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Recall rerank coordinator — extracted from the orchestrator (issue #1526).
3
+ *
4
+ * Owns the post-retrieval result-ranking subsystem: Memory-Worth reranking,
5
+ * TrustScore scoring + quarantine, reasoning-trace boost, and MMR
6
+ * diversification/limiting. Behavior-preserving move from orchestrator.ts —
7
+ * no logic changes; the orchestrator constructs one instance and keeps thin
8
+ * delegating methods so existing call sites (recallInternal,
9
+ * applyColdFallbackPipeline) continue to work.
10
+ *
11
+ * Config, storage, and the QMD-result memory reader are accessed through
12
+ * getter callbacks (not captured at construction) so that post-construction
13
+ * reassignment of the orchestrator's live fields is honored. This mirrors
14
+ * the ConversationIndexCoordinator / TierMigrationCoordinator accessor
15
+ * pattern.
16
+ */
17
+
18
+ import { resolveCapabilities, type CapabilitySet } from "../capabilities.js";
19
+ import type { PluginConfig, QmdSearchResult, MemoryFile } from "../types.js";
20
+ import type { StorageManager } from "../index.js";
21
+ import { log } from "../logger.js";
22
+ import {
23
+ applyMemoryWorthFilter,
24
+ buildMemoryWorthCounterMap,
25
+ type MemoryWorthCounters,
26
+ } from "../memory-worth-filter.js";
27
+ import {
28
+ applyTrustScoreStage,
29
+ buildTrustSignalsForRerank,
30
+ type TrustStageResultItem,
31
+ } from "../trust-score-stage.js";
32
+ import type { TrustSignals } from "../trust-score.js";
33
+ import { reorderRecallResultsWithMmr } from "../recall-mmr.js";
34
+ import { applyReasoningTraceBoost } from "../reasoning-trace-recall.js";
35
+
36
+ /**
37
+ * Coordinator for the recall-result reranking subsystem.
38
+ *
39
+ * Holds the per-namespace caches for memory-worth counters and trust signals
40
+ * (previously orchestrator fields) so the reranking stages don't trigger a
41
+ * full `readAllMemories` scan per query.
42
+ */
43
+ export class RecallRerankCoordinator {
44
+ private readonly getConfig: () => PluginConfig;
45
+ private readonly getStorage: (namespace: string) => Promise<StorageManager>;
46
+ private readonly readQmdResultMemory: (
47
+ resultPath: string,
48
+ fallbackStorage: StorageManager,
49
+ recallNamespaces: readonly string[],
50
+ ) => Promise<MemoryFile | null>;
51
+
52
+ private readonly memoryWorthCounterCache = new Map<
53
+ string,
54
+ { at: number; counters: ReadonlyMap<string, MemoryWorthCounters> }
55
+ >();
56
+ private static readonly MEMORY_WORTH_CACHE_TTL_MS = 30_000;
57
+
58
+ private readonly trustSignalCache = new Map<
59
+ string,
60
+ { at: number; signals: ReadonlyMap<string, TrustSignals> }
61
+ >();
62
+ private static readonly TRUST_SIGNAL_CACHE_TTL_MS = 30_000;
63
+
64
+ constructor(options: {
65
+ getConfig: () => PluginConfig;
66
+ getStorage: (namespace: string) => Promise<StorageManager>;
67
+ readQmdResultMemory: (
68
+ resultPath: string,
69
+ fallbackStorage: StorageManager,
70
+ recallNamespaces: readonly string[],
71
+ ) => Promise<MemoryFile | null>;
72
+ }) {
73
+ this.getConfig = options.getConfig;
74
+ this.getStorage = options.getStorage;
75
+ this.readQmdResultMemory = options.readQmdResultMemory;
76
+ }
77
+
78
+ async applyMemoryWorthRerank(
79
+ results: QmdSearchResult[],
80
+ namespaces: string[],
81
+ ): Promise<QmdSearchResult[]> {
82
+ // Build the counter lookup. We union frontmatter counters across every
83
+ // namespace the recall spans — the recall path itself already
84
+ // aggregates candidates from multiple namespaces, so we must do the
85
+ // same when looking up counters. Per-namespace results are cached with
86
+ // a short TTL so interactive recall doesn't trigger a full
87
+ // `readAllMemories` scan per query (addresses codex P2 on PR 4).
88
+ const counters = new Map<string, MemoryWorthCounters>();
89
+ const seenNamespaces = new Set<string>();
90
+ const nowMs = Date.now();
91
+
92
+ // Evict all expired entries on every call so long-running processes
93
+ // touching a high-cardinality namespace set (coding/project overlays,
94
+ // per-branch) don't grow the cache unboundedly. Without this, an entry
95
+ // for a namespace that's never looked up again would pin its full
96
+ // counter map forever.
97
+ for (const [key, entry] of this.memoryWorthCounterCache) {
98
+ if (nowMs - entry.at >= RecallRerankCoordinator.MEMORY_WORTH_CACHE_TTL_MS) {
99
+ this.memoryWorthCounterCache.delete(key);
100
+ }
101
+ }
102
+
103
+ for (const ns of namespaces) {
104
+ if (seenNamespaces.has(ns)) continue;
105
+ seenNamespaces.add(ns);
106
+ try {
107
+ const cached = this.memoryWorthCounterCache.get(ns);
108
+ let nsMap: ReadonlyMap<string, MemoryWorthCounters> | undefined;
109
+ if (
110
+ cached &&
111
+ nowMs - cached.at < RecallRerankCoordinator.MEMORY_WORTH_CACHE_TTL_MS
112
+ ) {
113
+ nsMap = cached.counters;
114
+ } else {
115
+ const storage = await this.getStorage(ns);
116
+ const memories = await storage.readAllMemories();
117
+ nsMap = buildMemoryWorthCounterMap(memories);
118
+ this.memoryWorthCounterCache.set(ns, { at: nowMs, counters: nsMap });
119
+ }
120
+ for (const [path, c] of nsMap) counters.set(path, c);
121
+ } catch (err) {
122
+ log.debug("memory-worth: failed to read namespace, skipping", {
123
+ namespace: ns,
124
+ error: (err as Error).message,
125
+ });
126
+ }
127
+ }
128
+
129
+ // For candidates whose path didn't show up in any hot-tier namespace
130
+ // scan (typical of cold-tier / archive fallback), try a direct
131
+ // per-path read. Without this, cold-tier candidates always stay at
132
+ // multiplier 1.0 even when they have outcome history. Errors are
133
+ // swallowed so a single unreadable archive entry can't break the
134
+ // whole recall.
135
+ const missing = results.filter((r) => !counters.has(r.path));
136
+ if (missing.length > 0) {
137
+ // Use the first-seen namespace's storage as the reader — all
138
+ // StorageManagers share the same on-disk format, and
139
+ // `readMemoryByPath` takes an absolute path so the baseDir doesn't
140
+ // have to match.
141
+ let reader: StorageManager | null = null;
142
+ for (const ns of namespaces) {
143
+ try {
144
+ reader = await this.getStorage(ns);
145
+ break;
146
+ } catch {
147
+ // try next namespace
148
+ }
149
+ }
150
+ if (reader) {
151
+ for (const r of missing) {
152
+ try {
153
+ const memory = await this.readQmdResultMemory(r.path, reader, namespaces);
154
+ if (!memory) continue;
155
+ const fm = memory.frontmatter;
156
+ if (fm.mw_success === undefined && fm.mw_fail === undefined) continue;
157
+ counters.set(r.path, {
158
+ mw_success: fm.mw_success,
159
+ mw_fail: fm.mw_fail,
160
+ lastAccessed: fm.lastAccessed,
161
+ });
162
+ } catch (err) {
163
+ log.debug("memory-worth: direct path lookup failed", {
164
+ path: r.path,
165
+ error: (err as Error).message,
166
+ });
167
+ }
168
+ }
169
+ }
170
+ }
171
+
172
+ // If no memory in the candidate set has any counter data, the filter
173
+ // would be a no-op — skip the reorder to avoid spurious log spam.
174
+ if (counters.size === 0) return results;
175
+
176
+ // Preserve upstream ordering (reranker, specialized tiers, etc.) for
177
+ // neutral candidates. The upstream stages set `memoryResults` in their
178
+ // intended order but often leave `r.score` as the raw QMD score. If we
179
+ // sorted by `r.score * multiplier` directly, neutral candidates
180
+ // (multiplier 1.0) would snap back to raw-QMD order and silently undo
181
+ // the reranker. Feed the filter a synthetic monotone-decreasing rank
182
+ // score so it uses input position as the baseline, then applies the
183
+ // multiplier on top. Ties fall back to the stable secondary key in
184
+ // `applyMemoryWorthFilter`.
185
+ const rankedInputs = results.map((r, i) => ({
186
+ path: r.path,
187
+ // Large positive rank score so multiplier math stays well-scaled and
188
+ // we never hit zero; descending so earlier items rank higher.
189
+ score: results.length - i,
190
+ }));
191
+ const config = this.getConfig();
192
+ const filtered = applyMemoryWorthFilter(rankedInputs, {
193
+ counters,
194
+ now: new Date(),
195
+ halfLifeMs:
196
+ config.recallMemoryWorthHalfLifeMs > 0
197
+ ? config.recallMemoryWorthHalfLifeMs
198
+ : undefined,
199
+ });
200
+
201
+ // Reconstruct the QmdSearchResult list in the new order. `.score` is
202
+ // preserved from the upstream pipeline (rerank, tier scoring, etc.) —
203
+ // we only reorder. Writing the synthetic rank-weighted score back
204
+ // would contaminate downstream logic (telemetry, confidence gates)
205
+ // that expects the original QMD/rerank score semantics.
206
+ const byPath = new Map(results.map((r) => [r.path, r]));
207
+ const reordered: QmdSearchResult[] = [];
208
+ for (const item of filtered) {
209
+ const original = byPath.get(item.path);
210
+ if (original) reordered.push(original);
211
+ }
212
+ return reordered;
213
+ }
214
+
215
+ /**
216
+ * Issue #1577 — unified TrustScore recall stage. Thin wiring over the pure
217
+ * {@link applyTrustScoreStage} scorer + the {@link buildTrustSignalsForRerank}
218
+ * signal builder. The stage subsumes the Memory Worth multiplier — the
219
+ * orchestrator runs exactly one of the two (mutual exclusion, rule 39; the
220
+ * double-multiplier test in trust-score-stage.test.ts pins it structurally).
221
+ *
222
+ * Returns the admitted results AND the per-path trust map (including
223
+ * quarantined items) so the caller can: (a) render epistemic hedges, (b)
224
+ * surface quarantined items in X-ray with a reason (rule 34), and (c) filter
225
+ * quarantined paths from fallback recall branches. The trust map is a
226
+ * per-recall local — never instance state — so concurrent recalls cannot
227
+ * race on it (review: shared-trust-map concurrency).
228
+ */
229
+ async applyTrustScoreRerank(
230
+ results: QmdSearchResult[],
231
+ namespaces: string[],
232
+ ): Promise<{
233
+ results: QmdSearchResult[];
234
+ trustByPath: Map<string, TrustStageResultItem> | null;
235
+ }> {
236
+ if (results.length === 0) return { results, trustByPath: null };
237
+ const config = this.getConfig();
238
+ const now = new Date();
239
+ const halfLifeDays =
240
+ config.recallMemoryWorthHalfLifeMs > 0
241
+ ? config.recallMemoryWorthHalfLifeMs / (24 * 60 * 60 * 1000)
242
+ : undefined;
243
+ // Cold-tier direct-fallback reader: resolve once, reuse for every missing
244
+ // candidate (mirrors the memory-worth filter's reader selection).
245
+ let fallbackReader: StorageManager | null = null;
246
+ const signals = await buildTrustSignalsForRerank(
247
+ results.map((r) => r.path),
248
+ namespaces,
249
+ {
250
+ readNamespaceMemories: async (ns) => (await this.getStorage(ns)).readAllMemories(),
251
+ readMemoryFrontmatter: async (path) => {
252
+ if (!fallbackReader) {
253
+ for (const ns of namespaces) {
254
+ try {
255
+ fallbackReader = await this.getStorage(ns);
256
+ break;
257
+ } catch {
258
+ // try next namespace
259
+ }
260
+ }
261
+ }
262
+ if (!fallbackReader) return null;
263
+ const memory = await this.readQmdResultMemory(path, fallbackReader, namespaces);
264
+ return memory ? memory.frontmatter : null;
265
+ },
266
+ },
267
+ { cache: this.trustSignalCache, ttlMs: RecallRerankCoordinator.TRUST_SIGNAL_CACHE_TTL_MS },
268
+ now,
269
+ {
270
+ recencyHalfLifeDays: halfLifeDays,
271
+ logDebug: (message, context) => log.debug(message, context),
272
+ },
273
+ );
274
+ if (signals.size === 0) {
275
+ return { results, trustByPath: null };
276
+ }
277
+ // Synthetic monotone-decreasing rank so the multiplier rebias is applied
278
+ // on top of upstream ordering, not raw QMD scores (see applyMemoryWorthRerank).
279
+ const rankedInputs = results.map((r, i) => ({ path: r.path, score: results.length - i }));
280
+ const stage = applyTrustScoreStage(rankedInputs, {
281
+ signals,
282
+ weights: config.trustScoreWeights,
283
+ minMultiplier: config.trustScoreMinMultiplier,
284
+ maxMultiplier: config.trustScoreMaxMultiplier,
285
+ quarantine: config.trustScoreQuarantine,
286
+ });
287
+ const trustByPath = new Map(stage.all.map((item) => [item.path, item]));
288
+ const byPath = new Map(results.map((r) => [r.path, r]));
289
+ const admitted = stage.admitted
290
+ .map((item) => byPath.get(item.path))
291
+ .filter((r): r is QmdSearchResult => r !== undefined);
292
+ return { results: admitted, trustByPath };
293
+ }
294
+
295
+ /**
296
+ * Issue #1577 — apply the TrustScore stage (or, when trust is off, the Memory
297
+ * Worth multiplier fallback) to ONE recall branch's results, returning the
298
+ * scored results + the per-path trust map. Thin wiring over
299
+ * {@link applyTrustScoreRerank} so every recall path — hot QMD, embedding
300
+ * fallback, recent scan — applies the SAME multiplier gate (rule 41: a
301
+ * feature gate must apply across ALL parallel recall paths). TrustScore
302
+ * subsumes Memory Worth; exactly one runs (rule 39). Fail-open on lookup
303
+ * errors so a storage hiccup never breaks a fallback path.
304
+ */
305
+ async applyTrustScoreToBranch(
306
+ results: QmdSearchResult[],
307
+ namespaces: string[],
308
+ caps: CapabilitySet,
309
+ label: string,
310
+ ): Promise<{
311
+ results: QmdSearchResult[];
312
+ trustByPath: Map<string, TrustStageResultItem> | null;
313
+ }> {
314
+ if (caps.recallTrustScore && results.length > 0) {
315
+ try {
316
+ return await this.applyTrustScoreRerank(results, namespaces);
317
+ } catch (err) {
318
+ log.debug(`trust-score stage (${label}) failed open`, {
319
+ error: (err as Error).message,
320
+ });
321
+ }
322
+ } else if (caps.recallMemoryWorthFilter && results.length > 0) {
323
+ try {
324
+ const filtered = await this.applyMemoryWorthRerank(results, namespaces);
325
+ return { results: filtered, trustByPath: null };
326
+ } catch (err) {
327
+ log.debug(`memory-worth filter (${label}) failed open`, {
328
+ error: (err as Error).message,
329
+ });
330
+ }
331
+ }
332
+ return { results, trustByPath: null };
333
+ }
334
+
335
+ diversifyAndLimitRecallResults(
336
+ sectionId: string,
337
+ results: QmdSearchResult[],
338
+ limit: number,
339
+ retrievalQuery?: string,
340
+ // `caps` is additive AND last (issue #1523) so the positional call shape
341
+ // stays backward-compatible: the recall pipeline threads a resolved set,
342
+ // but callers that omit it (e.g. direct unit-test invocations) get an
343
+ // equivalent set derived from the same config — behavior-preserving.
344
+ caps: CapabilitySet = resolveCapabilities(this.getConfig()),
345
+ ): QmdSearchResult[] {
346
+ const safeLimit =
347
+ typeof limit === "number" && Number.isFinite(limit)
348
+ ? Math.max(0, Math.floor(limit))
349
+ : 0;
350
+ if (!Array.isArray(results) || results.length === 0) return [];
351
+ // `recallResultLimit === 0` is a true zero limit (e.g. when
352
+ // `memoriesSectionEnabled` is false) and must return an empty array so
353
+ // the memories section is genuinely skipped. This mirrors the
354
+ // `slice(0, 0)` semantics of every call site this helper replaced.
355
+ if (safeLimit === 0) return [];
356
+ // Issue #564 PR 3: when the feature flag is on, boost reasoning_trace
357
+ // memories for problem-solving asks so they bubble up ahead of ordinary
358
+ // facts/decisions before MMR picks the final section. No-op when the
359
+ // flag is off or the query is not a problem-solving ask.
360
+ const boosted =
361
+ caps.recallReasoningTraceBoost && typeof retrievalQuery === "string"
362
+ ? applyReasoningTraceBoost(results, {
363
+ enabled: true,
364
+ query: retrievalQuery,
365
+ })
366
+ : results;
367
+ const diversified = this.applyMmrToQmdResults(sectionId, boosted, caps);
368
+ return diversified.slice(0, safeLimit);
369
+ }
370
+
371
+ /**
372
+ * Apply Maximal Marginal Relevance to a section's ordered candidate list.
373
+ *
374
+ * Operates per-section so one redundant cluster cannot dominate a section,
375
+ * and so one section's MMR pass cannot starve other sections. Returns the
376
+ * input unchanged when disabled, when there are fewer than 2 candidates, or
377
+ * when no budget information is available.
378
+ */
379
+ applyMmrToQmdResults(
380
+ sectionId: string,
381
+ results: QmdSearchResult[],
382
+ // Additive `caps` (issue #1523); defaults to a config-derived set so direct
383
+ // callers that omit it behave identically to the threaded recall path.
384
+ caps: CapabilitySet = resolveCapabilities(this.getConfig()),
385
+ ): QmdSearchResult[] {
386
+ if (!caps.recallMmr) return results;
387
+ if (!Array.isArray(results) || results.length < 2) return results;
388
+
389
+ // Config is runtime API (see AGENTS.md §4): preserve `0` as a true zero
390
+ // limit rather than coercing it to a non-zero value. A configured topN of
391
+ // 0 means "apply MMR over an empty window" — i.e. skip the reorder and
392
+ // return the upstream candidates unchanged. This keeps read-time
393
+ // behavior symmetric with the write-time semantics parseConfig exposes.
394
+ const config = this.getConfig();
395
+ const configuredTopN = config.recallMmrTopN;
396
+ const topN =
397
+ typeof configuredTopN === "number" && Number.isFinite(configuredTopN)
398
+ ? Math.max(0, Math.floor(configuredTopN))
399
+ : 40;
400
+ if (topN === 0) return results;
401
+ const lambda = config.recallMmrLambda ?? 0.7;
402
+
403
+ // Delegate to the pure helper so candidate keying (path-first, index
404
+ // suffixed for uniqueness) and the head-of-list diversity metric are
405
+ // exercised by the same code path that the unit tests cover.
406
+ const { reordered, diversity } = reorderRecallResultsWithMmr(results, {
407
+ lambda,
408
+ topN,
409
+ });
410
+
411
+ try {
412
+ log.info(
413
+ `recall_mmr: section=${sectionId} kept=${diversity.kept}/${diversity.considered} ` +
414
+ `headReorderCount=${diversity.headReorderCount} ` +
415
+ `avgSimBefore=${diversity.avgPairwiseSimBefore.toFixed(3)} ` +
416
+ `avgSimAfter=${diversity.avgPairwiseSimAfter.toFixed(3)} ` +
417
+ `lambda=${lambda.toFixed(2)}`,
418
+ );
419
+ } catch {
420
+ // Metrics must never break recall.
421
+ }
422
+
423
+ return reordered;
424
+ }
425
+ }