Skip to content

Commit f6e3056

Browse files
refactor(config): CapabilitySet for recall-operation feature gates (#1523) (#1567)
* refactor(config): CapabilitySet for recall-operation feature gates (#1523) * fix(config): keep CapabilitySet helpers backward-compatible (#1523) * fix(config): move CapabilitySet param last to preserve positional call shape (#1523)
1 parent 604e691 commit f6e3056

8 files changed

Lines changed: 344 additions & 40 deletions
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import { parseConfig } from "./config.js";
5+
import { resolveCapabilities, type CapabilitySet } from "./capabilities.js";
6+
7+
/**
8+
* Characterization tests for the recall-operation CapabilitySet (issue #1523).
9+
*
10+
* These guard against composition drift: every capability field must project
11+
* from its `<field>Enabled` config flag, so a future edit to
12+
* `resolveCapabilities` that accidentally maps a field to the wrong flag (the
13+
* rule-39 gate-divergence class, moved up one layer) fails loudly here.
14+
*/
15+
16+
/**
17+
* Map of CapabilitySet field → the PluginConfig flag it projects from.
18+
* Kept explicit (rather than derived by string concat) so the two graph flags
19+
* with non-`<field>Enabled` names are covered too.
20+
*/
21+
const FIELD_TO_FLAG: Record<keyof CapabilitySet, string> = {
22+
rerankCache: "rerankCacheEnabled",
23+
recallDirectAnswer: "recallDirectAnswerEnabled",
24+
recallMemoryWorthFilter: "recallMemoryWorthFilterEnabled",
25+
recallMmr: "recallMmrEnabled",
26+
recallReasoningTraceBoost: "recallReasoningTraceBoostEnabled",
27+
recallPlannerLlm: "recallPlannerLlmEnabled",
28+
recallPlanner: "recallPlannerEnabled",
29+
recallConfidenceGate: "recallConfidenceGateEnabled",
30+
graphRecall: "graphRecallEnabled",
31+
graphAssistInFullMode: "graphAssistInFullModeEnabled",
32+
graphExpandedIntent: "graphExpandedIntentEnabled",
33+
};
34+
35+
const FIELDS = Object.keys(FIELD_TO_FLAG) as Array<keyof CapabilitySet>;
36+
37+
test("resolveCapabilities projects every field from its <field>Enabled flag (true variant)", () => {
38+
// Build a config where every migrated flag is explicitly true.
39+
const overrides: Record<string, boolean> = {};
40+
for (const flag of Object.values(FIELD_TO_FLAG)) overrides[flag] = true;
41+
const config = parseConfig(overrides);
42+
const caps = resolveCapabilities(config);
43+
44+
for (const field of FIELDS) {
45+
const flag = FIELD_TO_FLAG[field];
46+
assert.equal(
47+
caps[field],
48+
(config as unknown as Record<string, boolean>)[flag],
49+
`caps.${field} must equal config.${flag} (true variant)`,
50+
);
51+
assert.equal(caps[field], true, `caps.${field} should be true here`);
52+
}
53+
});
54+
55+
test("resolveCapabilities projects every field from its <field>Enabled flag (false variant)", () => {
56+
const overrides: Record<string, boolean> = {};
57+
for (const flag of Object.values(FIELD_TO_FLAG)) overrides[flag] = false;
58+
const config = parseConfig(overrides);
59+
const caps = resolveCapabilities(config);
60+
61+
for (const field of FIELDS) {
62+
const flag = FIELD_TO_FLAG[field];
63+
// The two optional graph flags carry default-when-undefined semantics, but
64+
// when explicitly set to a concrete boolean the projection must match it.
65+
assert.equal(
66+
caps[field],
67+
(config as unknown as Record<string, boolean>)[flag],
68+
`caps.${field} must equal config.${flag} (false variant)`,
69+
);
70+
assert.equal(caps[field], false, `caps.${field} should be false here`);
71+
}
72+
});
73+
74+
test("resolveCapabilities preserves optional-flag defaults when the flag is undefined", () => {
75+
// parseConfig with no overrides exercises the documented defaults. The two
76+
// optional graph flags encode asymmetric defaults on purpose:
77+
// graphAssistInFullModeEnabled → default-ON (`!== false`)
78+
// graphExpandedIntentEnabled → default-OFF (`=== true`)
79+
const config = parseConfig({});
80+
const caps = resolveCapabilities(config);
81+
82+
assert.equal(
83+
caps.graphAssistInFullMode,
84+
config.graphAssistInFullModeEnabled !== false,
85+
"graphAssistInFullMode must be default-on unless explicitly false",
86+
);
87+
assert.equal(
88+
caps.graphExpandedIntent,
89+
config.graphExpandedIntentEnabled === true,
90+
"graphExpandedIntent must be default-off unless explicitly true",
91+
);
92+
});
93+
94+
test("resolveCapabilities returns a frozen object", () => {
95+
const caps = resolveCapabilities(parseConfig({}));
96+
assert.equal(Object.isFrozen(caps), true, "CapabilitySet must be frozen");
97+
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* CapabilitySet — recall-operation feature gates resolved once, then threaded.
3+
*
4+
* Issue #1523 (Phase 1 of epic #1520). Root cause this addresses: 161+
5+
* scattered `config.<flag>Enabled` reads mean each gate is re-derived at every
6+
* call site, and reviews keep finding parallel code paths where one branch
7+
* checks a gate the other forgot (CLAUDE.md rule 39 — the "gate divergence"
8+
* defect class). The fix is to resolve a frozen capability projection ONCE at
9+
* the top of the recall operation and pass it down explicitly.
10+
*
11+
* Scope of THIS module (first migration PR): only the recall-operation-scoped
12+
* flags below. Flags that are also read in graph construction, writes, CLI, or
13+
* the summarizer are deliberately deferred to a follow-up so we never leave a
14+
* single flag half-migrated (some sites on `caps.`, some on `config.`).
15+
*
16+
* Field naming: each field is the config flag name with the trailing `Enabled`
17+
* removed (`recallMmrEnabled` → `recallMmr`).
18+
*
19+
* This is plumbing, not a feature — there is deliberately NO `enabled` gate for
20+
* the CapabilitySet itself (rule 30 governs behavior changes; resolving and
21+
* threading a capability projection must stay behavior-preserving).
22+
*/
23+
24+
import type { PluginConfig } from "./types.js";
25+
26+
/**
27+
* Frozen projection of recall-operation feature gates.
28+
*
29+
* Every field is `readonly boolean`. The composition that maps a config flag to
30+
* a capability (including default-when-undefined semantics for optional flags)
31+
* lives ONLY in {@link resolveCapabilities} — call sites must read the
32+
* capability, never re-derive it from raw config.
33+
*/
34+
export interface CapabilitySet {
35+
/** `rerankCacheEnabled` — cache reranker scores across recall passes. */
36+
readonly rerankCache: boolean;
37+
/** `recallDirectAnswerEnabled` — observation-mode direct-answer tier. */
38+
readonly recallDirectAnswer: boolean;
39+
/** `recallMemoryWorthFilterEnabled` — Memory-Worth score reweighting. */
40+
readonly recallMemoryWorthFilter: boolean;
41+
/** `recallMmrEnabled` — maximal-marginal-relevance diversification. */
42+
readonly recallMmr: boolean;
43+
/** `recallReasoningTraceBoostEnabled` — boost reasoning-trace memories. */
44+
readonly recallReasoningTraceBoost: boolean;
45+
/** `recallPlannerLlmEnabled` — LLM-backed recall-mode planner. */
46+
readonly recallPlannerLlm: boolean;
47+
/** `recallPlannerEnabled` — recall-mode planner (heuristic + optional LLM). */
48+
readonly recallPlanner: boolean;
49+
/** `recallConfidenceGateEnabled` — Synapse-style confidence gate. */
50+
readonly recallConfidenceGate: boolean;
51+
/** `graphRecallEnabled` — graph-mode recall tier (gates planner graph mode). */
52+
readonly graphRecall: boolean;
53+
/** `graphAssistInFullModeEnabled` — graph-assist overlay in full mode. */
54+
readonly graphAssistInFullMode: boolean;
55+
/** `graphExpandedIntentEnabled` — promote broad-intent asks to graph mode. */
56+
readonly graphExpandedIntent: boolean;
57+
}
58+
59+
/**
60+
* Resolve the recall-operation {@link CapabilitySet} from parsed config.
61+
*
62+
* Call this ONCE per recall operation (at the `recall()` / `recallInternal`
63+
* entry) and thread the result down. Composition lives here and only here.
64+
*
65+
* Session toggles are intentionally not a parameter yet: `session-toggles.ts`
66+
* is agent-scoped (per session/agent enable-disable of the whole plugin), not
67+
* flag-scoped — none of the flags projected here have a per-session override,
68+
* so there is nothing for a toggle argument to compose at this layer.
69+
*/
70+
export function resolveCapabilities(config: PluginConfig): CapabilitySet {
71+
return Object.freeze({
72+
rerankCache: config.rerankCacheEnabled,
73+
recallDirectAnswer: config.recallDirectAnswerEnabled,
74+
recallMemoryWorthFilter: config.recallMemoryWorthFilterEnabled,
75+
recallMmr: config.recallMmrEnabled,
76+
recallReasoningTraceBoost: config.recallReasoningTraceBoostEnabled,
77+
recallPlannerLlm: config.recallPlannerLlmEnabled,
78+
recallPlanner: config.recallPlannerEnabled,
79+
recallConfidenceGate: config.recallConfidenceGateEnabled,
80+
graphRecall: config.graphRecallEnabled,
81+
// Optional flags: preserve the exact default-when-undefined semantics the
82+
// migrated call sites used (`!== false` = default-on, `=== true` = default-off).
83+
graphAssistInFullMode: config.graphAssistInFullModeEnabled !== false,
84+
graphExpandedIntent: config.graphExpandedIntentEnabled === true,
85+
});
86+
}

packages/remnic-core/src/direct-answer-wiring.test.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
type DirectAnswerWiringInput,
88
} from "./direct-answer-wiring.js";
99
import { DEFAULT_TAXONOMY } from "./taxonomy/default-taxonomy.js";
10-
import type { MemoryFile, PluginConfig } from "./types.js";
10+
import type { MemoryFile } from "./types.js";
1111
import type { TrustZoneName } from "./trust-zones.js";
1212

1313
type WiringConfig = DirectAnswerWiringInput["config"];
@@ -94,7 +94,8 @@ test("tryDirectAnswer disabled-path does not call any source accessor", async ()
9494
const result = await tryDirectAnswer({
9595
query: "does not matter",
9696
namespace: "default",
97-
config: { ...BASE_CONFIG, recallDirectAnswerEnabled: false },
97+
config: BASE_CONFIG,
98+
enabled: false,
9899
sources,
99100
});
100101
assert.equal(result.eligible, false);
@@ -104,6 +105,42 @@ test("tryDirectAnswer disabled-path does not call any source accessor", async ()
104105
assert.deepEqual(sources.calls.importance, []);
105106
});
106107

108+
// ── Backward-compat (#1523): omit top-level `enabled`, fall back to config ──
109+
110+
test("tryDirectAnswer falls back to config.recallDirectAnswerEnabled when `enabled` is omitted (disabled)", async () => {
111+
// Old input shape: config carries recallDirectAnswerEnabled: false and no
112+
// top-level `enabled`. Must short-circuit as "disabled" (identical to the
113+
// pre-#1523 behavior) rather than treating undefined as enabled.
114+
const sources = makeMockSources({ memories: [makeMemory()] });
115+
const result = await tryDirectAnswer({
116+
query: "does not matter",
117+
namespace: "default",
118+
config: { ...BASE_CONFIG, recallDirectAnswerEnabled: false },
119+
sources,
120+
});
121+
assert.equal(result.eligible, false);
122+
assert.equal(result.reason, "disabled");
123+
assert.equal(sources.calls.listCandidates, 0);
124+
});
125+
126+
test("tryDirectAnswer falls back to config.recallDirectAnswerEnabled when `enabled` is omitted (enabled)", async () => {
127+
// Old input shape with recallDirectAnswerEnabled: true and no `enabled` must
128+
// NOT short-circuit — it proceeds to materialize candidates.
129+
const sources = makeMockSources({
130+
memories: [makeMemory({ tags: ["pnpm"], content: "remnic uses pnpm" })],
131+
trustZones: { m1: "trusted" },
132+
importance: { m1: 0.9 },
133+
});
134+
const result = await tryDirectAnswer({
135+
query: "package manager remnic",
136+
namespace: "default",
137+
config: BASE_CONFIG, // recallDirectAnswerEnabled: true
138+
sources,
139+
});
140+
assert.notEqual(result.reason, "disabled");
141+
assert.equal(sources.calls.listCandidates, 1);
142+
});
143+
107144
// ── Empty-query short-circuit: no I/O ───────────────────────────────────────
108145

109146
test("tryDirectAnswer skips all I/O when query normalizes to zero searchable tokens", async () => {
@@ -119,6 +156,7 @@ test("tryDirectAnswer skips all I/O when query normalizes to zero searchable tok
119156
query: "? !!! ",
120157
namespace: "default",
121158
config: BASE_CONFIG,
159+
enabled: true,
122160
sources,
123161
});
124162
assert.equal(result.reason, "empty-query");
@@ -135,6 +173,7 @@ test("tryDirectAnswer with empty memory list returns no-candidates", async () =>
135173
query: "package manager remnic",
136174
namespace: "default",
137175
config: BASE_CONFIG,
176+
enabled: true,
138177
sources,
139178
});
140179
assert.equal(result.reason, "no-candidates");
@@ -158,6 +197,7 @@ test("tryDirectAnswer skips importance resolution for non-trusted memories", asy
158197
query: "package manager remnic",
159198
namespace: "default",
160199
config: BASE_CONFIG,
200+
enabled: true,
161201
sources,
162202
});
163203
assert.equal(result.eligible, false);
@@ -182,6 +222,7 @@ test("tryDirectAnswer skips importance for quarantine-zone memories", async () =
182222
query: "package manager remnic",
183223
namespace: "default",
184224
config: BASE_CONFIG,
225+
enabled: true,
185226
sources,
186227
});
187228
assert.equal(result.eligible, false);
@@ -203,6 +244,7 @@ test("tryDirectAnswer skips importance when trust zone is missing (null)", async
203244
query: "package manager remnic",
204245
namespace: "default",
205246
config: BASE_CONFIG,
247+
enabled: true,
206248
sources,
207249
});
208250
assert.equal(result.eligible, false);
@@ -229,6 +271,7 @@ test("tryDirectAnswer skips importance when taxonomy bucket is not eligible", as
229271
query: "package manager remnic",
230272
namespace: "default",
231273
config: BASE_CONFIG,
274+
enabled: true,
232275
sources,
233276
});
234277
assert.equal(result.eligible, false);
@@ -254,6 +297,7 @@ test("tryDirectAnswer returns eligible for a single trusted user-confirmed decis
254297
query: "package manager remnic",
255298
namespace: "default",
256299
config: BASE_CONFIG,
300+
enabled: true,
257301
sources,
258302
});
259303
assert.equal(result.eligible, true);
@@ -284,6 +328,7 @@ test("tryDirectAnswer defers to hybrid when two trusted candidates are within am
284328
query: "package manager remnic",
285329
namespace: "default",
286330
config: BASE_CONFIG,
331+
enabled: true,
287332
sources,
288333
});
289334
assert.equal(result.eligible, false);
@@ -327,6 +372,7 @@ test("tryDirectAnswer throws AbortError when signal aborts mid-loop", async () =
327372
query: "package manager remnic",
328373
namespace: "default",
329374
config: BASE_CONFIG,
375+
enabled: true,
330376
sources,
331377
abortSignal: controller.signal,
332378
}),
@@ -363,6 +409,7 @@ test("tryDirectAnswer throws when abort lands during trustZoneFor on the only me
363409
query: "package manager remnic",
364410
namespace: "default",
365411
config: BASE_CONFIG,
412+
enabled: true,
366413
sources,
367414
abortSignal: controller.signal,
368415
}),
@@ -391,6 +438,7 @@ test("tryDirectAnswer throws when abort lands during trustZoneFor on the last of
391438
query: "package manager remnic",
392439
namespace: "default",
393440
config: BASE_CONFIG,
441+
enabled: true,
394442
sources,
395443
abortSignal: controller.signal,
396444
}),
@@ -408,6 +456,7 @@ test("tryDirectAnswer throws when signal is already aborted before I/O", async (
408456
query: "anything",
409457
namespace: "default",
410458
config: BASE_CONFIG,
459+
enabled: true,
411460
sources,
412461
abortSignal: controller.signal,
413462
}),
@@ -434,6 +483,7 @@ test("tryDirectAnswer passes the requested namespace to listCandidateMemories",
434483
query: "anything",
435484
namespace: "project-x",
436485
config: BASE_CONFIG,
486+
enabled: true,
437487
sources,
438488
});
439489
assert.equal(observedNamespace, "project-x");
@@ -465,6 +515,7 @@ test("tryDirectAnswer forwards queryEntityRefs to the eligibility gate", async (
465515
query: "package manager remnic",
466516
namespace: "default",
467517
config: BASE_CONFIG,
518+
enabled: true,
468519
sources,
469520
queryEntityRefs: ["remnic"],
470521
});

0 commit comments

Comments
 (0)