@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.
- package/dist/access-boundary.d.ts +6 -6
- package/dist/access-cli.js +1 -1
- package/dist/access-http.d.ts +6 -6
- package/dist/access-mcp.d.ts +6 -6
- package/dist/access-operations.d.ts +6 -6
- package/dist/{access-service-DwQ6BIQw.d.ts → access-service-Cbk-Kv_A.d.ts} +1 -1
- package/dist/access-service.d.ts +6 -6
- package/dist/access-surface-catalog.d.ts +6 -6
- package/dist/bootstrap.d.ts +5 -5
- package/dist/{chunk-EKQ3LWBM.js → chunk-IEWUPZ75.js} +583 -511
- package/dist/chunk-IEWUPZ75.js.map +1 -0
- package/dist/{cli-BlEIWDQw.d.ts → cli-BQW2xKdb.d.ts} +2 -2
- package/dist/cli.d.ts +7 -7
- package/dist/explicit-capture.d.ts +5 -5
- package/dist/index.d.ts +8 -8
- package/dist/index.js +1 -1
- package/dist/mcp-memory-inspector-app.d.ts +6 -6
- package/dist/{orchestrator-CJZ21ZiB.d.ts → orchestrator-BOAFN_Yr.d.ts} +186 -56
- package/dist/orchestrator.d.ts +4 -4
- package/dist/orchestrator.js +1 -1
- package/dist/schemas.d.ts +22 -22
- package/dist/transfer/types.d.ts +12 -12
- package/package.json +2 -2
- package/src/orchestration/conversation-index-coordinator.ts +344 -0
- package/src/orchestration/recall-rerank-coordinator.ts +425 -0
- package/src/orchestrator.ts +39 -552
- package/dist/chunk-EKQ3LWBM.js.map +0 -1
|
@@ -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
|
+
}
|