@dzhechkov/harness-core 0.7.10 → 0.7.12
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/.dz-manifest.json +97 -37
- package/README.md +27 -1
- package/dist/amendment-trace.d.ts +12 -1
- package/dist/amendment-trace.d.ts.map +1 -1
- package/dist/amendment-trace.js +22 -4
- package/dist/amendment-trace.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +69 -12
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +117 -7
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/learning-backend.d.ts +39 -0
- package/dist/learning-backend.d.ts.map +1 -1
- package/dist/learning-backend.js +31 -11
- package/dist/learning-backend.js.map +1 -1
- package/dist/lesson-bandit.d.ts +116 -0
- package/dist/lesson-bandit.d.ts.map +1 -0
- package/dist/lesson-bandit.js +235 -0
- package/dist/lesson-bandit.js.map +1 -0
- package/dist/lesson-payoff.d.ts +260 -0
- package/dist/lesson-payoff.d.ts.map +1 -0
- package/dist/lesson-payoff.js +597 -0
- package/dist/lesson-payoff.js.map +1 -0
- package/dist/patterns.d.ts +21 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +42 -3
- package/dist/patterns.js.map +1 -1
- package/dist/project-skills-root.d.ts +44 -0
- package/dist/project-skills-root.d.ts.map +1 -0
- package/dist/project-skills-root.js +62 -0
- package/dist/project-skills-root.js.map +1 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +6 -0
- package/dist/publish.js.map +1 -1
- package/dist/vector-tier.d.ts +30 -0
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +130 -15
- package/dist/vector-tier.js.map +1 -1
- package/package.json +6 -6
- package/sbom.json +186 -36
- package/src/amendment-trace.ts +34 -4
- package/src/feature-adr-routing.ts +146 -7
- package/src/index.ts +16 -2
- package/src/learning-backend.ts +62 -11
- package/src/lesson-bandit.ts +279 -0
- package/src/lesson-payoff.ts +728 -0
- package/src/patterns.ts +66 -5
- package/src/project-skills-root.ts +63 -0
- package/src/publish.ts +6 -0
- package/src/vector-tier.ts +182 -16
package/src/patterns.ts
CHANGED
|
@@ -106,6 +106,24 @@ export interface MemoryLearningConfig {
|
|
|
106
106
|
readonly quarantineDamp: number;
|
|
107
107
|
/** Days after which an unreinforced quarantined lesson is an EXPIRY CANDIDATE (informational). */
|
|
108
108
|
readonly quarantineExpireDays: number;
|
|
109
|
+
/**
|
|
110
|
+
* Bandit payoff re-rank (feature lesson-bandit-rerank, ADR-001). When true, recall adds a BOUNDED
|
|
111
|
+
* term derived from each lesson's measured payoff posterior — "has this lesson ever actually
|
|
112
|
+
* helped", the axis similarity cannot answer. ADDITIVE alongside {@link deltaRerank}, never a
|
|
113
|
+
* replacement, and capped at the same `REINFORCE_RRF_CAP`: similarity still decides WHICH lessons
|
|
114
|
+
* are candidates; the bandit only reorders WITHIN them. Default FALSE: absent config is
|
|
115
|
+
* byte-identical to today — no file read, no file written, no term applied.
|
|
116
|
+
*/
|
|
117
|
+
readonly banditRerank: boolean;
|
|
118
|
+
/**
|
|
119
|
+
* Bandit EXPLORATION (feature lesson-bandit-rerank, ADR-003). When true, an arm with no evidence
|
|
120
|
+
* may receive a bounded trial lift so it can accumulate some. This deliberately weakens the
|
|
121
|
+
* view-does-not-promote posture, so it SHIPS DISARMED and is a separate flag: enabling payoff
|
|
122
|
+
* re-ranking must never silently mean "start surfacing unproven lessons". Quarantined lessons are
|
|
123
|
+
* NEVER explored in any configuration. Default FALSE; `true` with `banditRerank: false` is a
|
|
124
|
+
* warned no-op.
|
|
125
|
+
*/
|
|
126
|
+
readonly banditExploration: boolean;
|
|
109
127
|
}
|
|
110
128
|
|
|
111
129
|
/**
|
|
@@ -141,10 +159,10 @@ export function readLearningConfig(projectRoot: string): LearningConfig {
|
|
|
141
159
|
}
|
|
142
160
|
|
|
143
161
|
export function readMemoryLearningConfig(projectRoot: string): MemoryLearningConfig {
|
|
144
|
-
const fallback: MemoryLearningConfig = { backend: 'native', onRecallHits: true, usesSat: 64, halfLifeDays: 30, reinforceThreshold: 0.95, deltaRerank: false, quarantine: false, quarantineDamp: 0.5, quarantineExpireDays: 30 };
|
|
162
|
+
const fallback: MemoryLearningConfig = { backend: 'native', onRecallHits: true, usesSat: 64, halfLifeDays: 30, reinforceThreshold: 0.95, deltaRerank: false, quarantine: false, quarantineDamp: 0.5, quarantineExpireDays: 30, banditRerank: false, banditExploration: false };
|
|
145
163
|
try {
|
|
146
164
|
const parsed = JSON.parse(readFileSync(join(projectRoot, '.dz', 'config.json'), 'utf-8')) as {
|
|
147
|
-
memory?: { learning?: { backend?: string; onRecallHits?: boolean; usesSat?: number; halfLifeDays?: number; reinforceThreshold?: number; deltaRerank?: boolean; quarantine?: boolean; quarantineDamp?: number; quarantineExpireDays?: number } };
|
|
165
|
+
memory?: { learning?: { backend?: string; onRecallHits?: boolean; usesSat?: number; halfLifeDays?: number; reinforceThreshold?: number; deltaRerank?: boolean; quarantine?: boolean; quarantineDamp?: number; quarantineExpireDays?: number; banditRerank?: boolean; banditExploration?: boolean } };
|
|
148
166
|
};
|
|
149
167
|
const learning = parsed.memory?.learning ?? {};
|
|
150
168
|
const backend = learning.backend === 'off' || learning.backend === 'ruvector-gnn' || learning.backend === 'native' ? learning.backend : 'native';
|
|
@@ -168,6 +186,11 @@ export function readMemoryLearningConfig(projectRoot: string): MemoryLearningCon
|
|
|
168
186
|
typeof learning.quarantineExpireDays === 'number' && Number.isFinite(learning.quarantineExpireDays) && learning.quarantineExpireDays > 0
|
|
169
187
|
? Math.floor(learning.quarantineExpireDays)
|
|
170
188
|
: fallback.quarantineExpireDays,
|
|
189
|
+
// Same `=== true` discipline as deltaRerank/quarantine: absent, null, "true" (a string), 1, or
|
|
190
|
+
// any legacy value ⇒ false. That is what makes "flag absent ⇒ byte-identical" true BY
|
|
191
|
+
// CONSTRUCTION rather than by care (ADR-001 R1).
|
|
192
|
+
banditRerank: learning.banditRerank === true,
|
|
193
|
+
banditExploration: learning.banditExploration === true,
|
|
171
194
|
};
|
|
172
195
|
} catch {
|
|
173
196
|
return fallback;
|
|
@@ -574,16 +597,54 @@ export interface ReinforcePatternResult {
|
|
|
574
597
|
readonly dzId?: string;
|
|
575
598
|
readonly uses?: number;
|
|
576
599
|
readonly error?: string;
|
|
600
|
+
/** The reward value actually observed for this reinforcement, clamped to [0,1]. */
|
|
601
|
+
readonly reward?: number;
|
|
577
602
|
}
|
|
578
603
|
|
|
579
|
-
export async function reinforcePattern(projectRoot: string, dzIdOrText: string, opts: { reward?: number; ts?: string; mergedFrom?: readonly string[]; exposure?: boolean } = {}): Promise<ReinforcePatternResult> {
|
|
604
|
+
export async function reinforcePattern(projectRoot: string, dzIdOrText: string, opts: { reward?: number; ts?: string; mergedFrom?: readonly string[]; exposure?: boolean; domain?: string } = {}): Promise<ReinforcePatternResult> {
|
|
580
605
|
// The WHOLE read-modify-write holds the store lock (finding 5): two concurrent
|
|
581
606
|
// reinforces would otherwise both read uses=N and both write back N+1.
|
|
607
|
+
let result: ReinforcePatternResult;
|
|
582
608
|
try {
|
|
583
|
-
|
|
609
|
+
result = await withStoreLock(projectRoot, async () => reinforcePatternLocked(projectRoot, dzIdOrText, opts));
|
|
584
610
|
} catch (err) {
|
|
585
611
|
return { ok: false, error: lockErrorMessage(err) };
|
|
586
612
|
}
|
|
613
|
+
// ── Bandit reward emission (feature lesson-bandit-rerank, ADR-001 D-5 / architecture §4) ──
|
|
614
|
+
//
|
|
615
|
+
// WHERE: here, in `reinforcePattern`, and NOT inside `reinforcePatternLocked` — that body runs
|
|
616
|
+
// under `withStoreLock`, and the bandit takes a DIFFERENT named lock. Lock nesting is AVOIDED,
|
|
617
|
+
// not managed: the bandit call is made after the store lock is released, from the result the
|
|
618
|
+
// locked section already returns. A crash between the two leaves the bandit one reward behind —
|
|
619
|
+
// a tolerable, self-correcting inconsistency for a ranking hint, and explicitly NOT tolerable for
|
|
620
|
+
// the store itself, which is why the store write is the one inside the lock.
|
|
621
|
+
//
|
|
622
|
+
// WHEN: only on the non-exposure branch — the SAME predicate that decides quarantine promotion
|
|
623
|
+
// eleven lines below. Hanging both on one predicate means the two can never disagree; the
|
|
624
|
+
// exposure≠reward distinction has ONE implementation, not two that must be kept in sync (INV-2).
|
|
625
|
+
//
|
|
626
|
+
// The store write is authoritative; the bandit is a derived index. Its failure is counted inside
|
|
627
|
+
// `recordReward` and can never fail a reinforce.
|
|
628
|
+
if (result.ok && result.dzId !== undefined && opts.exposure !== true) {
|
|
629
|
+
try {
|
|
630
|
+
if (readMemoryLearningConfig(projectRoot).banditRerank) {
|
|
631
|
+
// Dynamic import so the module is not even LOADED on the disarmed path (INV-1), and so
|
|
632
|
+
// patterns.ts keeps no static edge to a module that imports it.
|
|
633
|
+
const payoff = await import('./lesson-payoff.js');
|
|
634
|
+
const ev = payoff.makeRewardEvent(
|
|
635
|
+
result.dzId,
|
|
636
|
+
// HONEST LIMIT: a confirmation carries no memory of WHICH recall surfaced the lesson, so
|
|
637
|
+
// without an explicit `domain` the reward lands in the `general` bucket — the same bucket
|
|
638
|
+
// an undomained recall reads. A caller that knows the recall context should name it.
|
|
639
|
+
payoff.contextKeyFor(opts.domain),
|
|
640
|
+
result.reward ?? 1,
|
|
641
|
+
opts.ts ?? new Date().toISOString(),
|
|
642
|
+
);
|
|
643
|
+
if (ev !== null) payoff.recordReward(projectRoot, ev);
|
|
644
|
+
}
|
|
645
|
+
} catch { /* counted inside the payoff module; a derived index never fails a reinforce */ }
|
|
646
|
+
}
|
|
647
|
+
return result;
|
|
587
648
|
}
|
|
588
649
|
|
|
589
650
|
async function reinforcePatternLocked(projectRoot: string, dzIdOrText: string, opts: { reward?: number; ts?: string; mergedFrom?: readonly string[]; exposure?: boolean }): Promise<ReinforcePatternResult> {
|
|
@@ -619,7 +680,7 @@ async function reinforcePatternLocked(projectRoot: string, dzIdOrText: string, o
|
|
|
619
680
|
try {
|
|
620
681
|
appendFileSync(join(projectRoot, '.dz', 'sessions.jsonl'), JSON.stringify({ event: 'reinforce', ts, dzId: rec.id, uses: nextState.uses }) + '\n');
|
|
621
682
|
} catch { /* best-effort */ }
|
|
622
|
-
return { ok: true, dzId: rec.id, uses: nextState.uses };
|
|
683
|
+
return { ok: true, dzId: rec.id, uses: nextState.uses, reward: observed };
|
|
623
684
|
}
|
|
624
685
|
|
|
625
686
|
export async function updateReinforcementState(projectRoot: string, dzId: string, state: ReinforcementState): Promise<ReinforcePatternResult> {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which root does `dz project-skills` read the manifest from?
|
|
3
|
+
*
|
|
4
|
+
* Field report doc-25b (2026-08-25). The Step-0 probe in `feature-adr.js` and the `PS_GUIDANCE`
|
|
5
|
+
* paragraph handed to every design/code/qe stage BOTH hardcoded `cd <REPO> && dz project-skills`.
|
|
6
|
+
* On a run whose REPO is an external checkout, the manifest installed in the WORKSPACE was therefore
|
|
7
|
+
* unreachable: the probe answered `hasManifest:false`, the run recorded an honest `polymorphism:null`,
|
|
8
|
+
* and not one project lens reached any stage. Nothing failed loudly — the run just silently became
|
|
9
|
+
* generic. (How many lenses were lost on the reporting machine is NOT-ESTABLISHED — their workspace
|
|
10
|
+
* is not on this host. This repo's own manifest wires 4 injections from 3 files, which says what the
|
|
11
|
+
* blast radius looks like HERE and nothing about theirs. An earlier draft of this comment called their
|
|
12
|
+
* number "overstated" on the strength of that local measurement; measuring one manifest and reporting
|
|
13
|
+
* it as a verdict on a different one is the same error the reports themselves keep getting caught on.)
|
|
14
|
+
*
|
|
15
|
+
* The rule, symmetric with the doc-21 fix for the K2 gate:
|
|
16
|
+
* probe the TARGET repo first — a repo's own conventions are authoritative for that repo —
|
|
17
|
+
* and fall back to the workspace ONLY when the target has no manifest AND the workspace is a
|
|
18
|
+
* genuinely different root.
|
|
19
|
+
*
|
|
20
|
+
* The choice is made in the SHELL by `grep -q`, never by the dispatched agent's judgment: a model
|
|
21
|
+
* asked to "pick the one that worked" is layer 4 on the cost-of-detection ladder, and this whole
|
|
22
|
+
* defect class is what layer 4 costs. One builder feeds both call sites so they cannot drift apart
|
|
23
|
+
* again — which is the actual bug the report describes, twice over.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Shell-quote one argument. The root is spliced into a command string that an agent executes, and
|
|
28
|
+
* neither `assertAbsoluteNoTraversal` nor `checkArtifactRoot` rejects a space or a `$`. Unquoted, a
|
|
29
|
+
* workspace at `/tmp/space ws` silently relapsed into exactly this bug through a new door, and one at
|
|
30
|
+
* `/tmp/ws$(touch /tmp/PWNED)` EXECUTED the substitution — both MEASURED 2026-08-25 by the adversarial
|
|
31
|
+
* review of the first version of this file.
|
|
32
|
+
*/
|
|
33
|
+
function sq(s: string): string {
|
|
34
|
+
return "'" + String(s).replace(/'/g, "'\\''") + "'";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The single-root form. Belt AND braces: it `cd`s to the root *and* names it with `--project`.
|
|
39
|
+
*
|
|
40
|
+
* `--project` alone would be enough for a current CLI — but `skills-feature-adr` ships this workflow
|
|
41
|
+
* to machines whose `dz` may predate the flag, and because the known-flag list is FLAT an old CLI
|
|
42
|
+
* accepts `--project` and ignores it. On such a CLI the bare `--project` form silently reads whatever
|
|
43
|
+
* the dispatched agent's cwd happens to be, which is strictly worse than the `cd` it replaced. With
|
|
44
|
+
* both, the command is correct on an old CLI (via the cd) and cwd-independent on a new one (via the
|
|
45
|
+
* flag), and the two can never disagree because they are built from the same `root`.
|
|
46
|
+
*/
|
|
47
|
+
export function projectSkillsOneRoot(dzBin: string, root: string): string {
|
|
48
|
+
return 'cd ' + sq(root) + ' && ' + dzBin + ' project-skills --project ' + sq(root) + ' --stages-json';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The command the Step-0 probe runs and the command `PS_GUIDANCE` tells each stage agent to run —
|
|
53
|
+
* necessarily the same string, or the stages fetch guidance from a root the probe never checked.
|
|
54
|
+
*
|
|
55
|
+
* `workspace` null / equal to `repo` ⇒ the plain single-root form (byte-identical to a run that has
|
|
56
|
+
* nowhere else to look), so the common workspace-CWD case pays nothing for this.
|
|
57
|
+
*/
|
|
58
|
+
export function projectSkillsProbeCommand(dzBin: string, repo: string, workspace?: string | null): string {
|
|
59
|
+
const primary = projectSkillsOneRoot(dzBin, repo);
|
|
60
|
+
if (workspace === null || workspace === undefined || workspace === repo) return primary;
|
|
61
|
+
return 'o=$(' + primary + ' 2>/dev/null); echo "$o" | grep -q \'"hasManifest":true\' || o=$('
|
|
62
|
+
+ projectSkillsOneRoot(dzBin, workspace) + ' 2>/dev/null); echo "$o"';
|
|
63
|
+
}
|
package/src/publish.ts
CHANGED
|
@@ -440,6 +440,12 @@ export function changelogRegion(lines: readonly string[]): Set<number> {
|
|
|
440
440
|
const masked = maskFences(lines);
|
|
441
441
|
const start = masked.findIndex((l) => ANY_ENTRY.test(l));
|
|
442
442
|
if (start < 0) return out;
|
|
443
|
+
// REJECTED design, recorded so it is not retried: "sync the FIRST entry, protect the rest". It
|
|
444
|
+
// looks like it restores the lock-step for the current release, and it is unsafe in exactly the
|
|
445
|
+
// case that produced the bug — an author who bumps WITHOUT adding a new entry has the previous
|
|
446
|
+
// release's entry sitting first, and syncing it relabels that release's contents to the new
|
|
447
|
+
// version. The whole region stays protected; writing the newest heading is the author's job, and
|
|
448
|
+
// the prompt for it is that the version they type is the version they are about to publish.
|
|
443
449
|
for (let i = start; i < masked.length; i++) {
|
|
444
450
|
if (i > start && REGION_END.test(masked[i] as string)) break;
|
|
445
451
|
out.add(i);
|
package/src/vector-tier.ts
CHANGED
|
@@ -74,7 +74,17 @@ import {
|
|
|
74
74
|
// agentdb-index/compounding only, never vector-tier, so there is no cycle.
|
|
75
75
|
import { BACKLOG_TASK_TYPE } from './backlog.js';
|
|
76
76
|
import { currentEmbedManifest, guardEmbedSpace, DEFAULT_EMBED_DIM, resolveEmbedModel, type EmbedModelConfig } from './embedding-config.js';
|
|
77
|
-
import { applyLearningSignals, applyLearningSignalsWithDelta, resolveLearningBackend, type LearningSignalBackend } from './learning-backend.js';
|
|
77
|
+
import { applyLearningSignals, applyLearningSignalsWithDelta, applyLearningSignalsWithTerms, resolveLearningBackend, type LearningSignalBackend, type RerankTerm } from './learning-backend.js';
|
|
78
|
+
// lesson-bandit-rerank: the payoff axis. ONE-DIRECTIONAL — lesson-payoff imports patterns.js and
|
|
79
|
+
// nothing from here, so there is no cycle.
|
|
80
|
+
import {
|
|
81
|
+
contextKeyFor,
|
|
82
|
+
narrowBanditReport,
|
|
83
|
+
payoffTermsFor,
|
|
84
|
+
recordExposures,
|
|
85
|
+
resolveBanditConfig,
|
|
86
|
+
type BanditRecallReport,
|
|
87
|
+
} from './lesson-payoff.js';
|
|
78
88
|
|
|
79
89
|
/* ------------------------------------------------------------------ */
|
|
80
90
|
/* Types (04_domain_model §3.4 / §4.1) */
|
|
@@ -219,6 +229,17 @@ export interface HybridRecall {
|
|
|
219
229
|
* returns only ids the lexical store no longer has has participated in nothing.
|
|
220
230
|
*/
|
|
221
231
|
readonly semanticRanked: number;
|
|
232
|
+
/**
|
|
233
|
+
* The bandit payoff explanation (feature lesson-bandit-rerank, FR-8/AC-11) — PRESENT only when
|
|
234
|
+
* `memory.learning.banditRerank` is armed, ABSENT otherwise (not `null`, not `{}`): its mere
|
|
235
|
+
* presence tells a reader the feature ran. `armsConsidered` describes the POST-cut list, and
|
|
236
|
+
* `moved` — the honest headline — counts the candidates whose position the term actually changed.
|
|
237
|
+
* An armed, silent re-ranker is indistinguishable from a broken one, which is why this exists.
|
|
238
|
+
*/
|
|
239
|
+
readonly bandit?: BanditRecallReport | undefined;
|
|
240
|
+
/** Present ONLY when `deferExposures` was requested and the bandit ran. Call it with the ids the
|
|
241
|
+
* caller actually printed; until it is called, no exposure has been recorded for this recall. */
|
|
242
|
+
readonly commitExposures?: ((shownDzIds: readonly string[]) => void) | undefined;
|
|
222
243
|
}
|
|
223
244
|
|
|
224
245
|
|
|
@@ -297,6 +318,14 @@ export const DEFAULT_VECTOR_TIMEOUT_MS = 10_000;
|
|
|
297
318
|
/** Default cosine cutoff for near-duplicate clustering (`--threshold` / config overrides). */
|
|
298
319
|
export const DEFAULT_HARMONIZE_THRESHOLD = 0.92;
|
|
299
320
|
export const REINFORCE_RRF_CAP = (1 / (60 + 1)) - (1 / (60 + 4));
|
|
321
|
+
/**
|
|
322
|
+
* The bandit payoff term's bound (ADR-001 D-2). Deliberately the SAME constant the reinforcement and
|
|
323
|
+
* SAFLA-delta terms use, not a new one: it keeps the "a learning signal is worth less than one RRF
|
|
324
|
+
* rank step" invariant those terms already established, and makes the joint excursion of two payoff
|
|
325
|
+
* terms auditable as exactly `2 × CAP`. Payoff reorders near-ties; it cannot overturn a real
|
|
326
|
+
* relevance gap.
|
|
327
|
+
*/
|
|
328
|
+
export const BANDIT_RRF_CAP = REINFORCE_RRF_CAP;
|
|
300
329
|
|
|
301
330
|
/* ------------------------------------------------------------------ */
|
|
302
331
|
/* Harmonize + import types (dz-vector-harmonize-import 05 §2.1/§2.2) */
|
|
@@ -979,11 +1008,44 @@ export function mergeHybridHits(
|
|
|
979
1008
|
return cut.map(toHit);
|
|
980
1009
|
}
|
|
981
1010
|
|
|
982
|
-
|
|
1011
|
+
/**
|
|
1012
|
+
* Emit the recall-hit telemetry for the hits a caller is about to see.
|
|
1013
|
+
*
|
|
1014
|
+
* `bandit` is passed ONLY when `memory.learning.banditRerank` is armed; when it is absent this
|
|
1015
|
+
* function is byte-identical to its pre-feature self — no state file, no lock, no allocation.
|
|
1016
|
+
*/
|
|
1017
|
+
function markRecallHits(
|
|
1018
|
+
projectRoot: string,
|
|
1019
|
+
backend: LearningSignalBackend,
|
|
1020
|
+
hits: readonly HybridHit[],
|
|
1021
|
+
idOf: (p: PatternRecord) => string,
|
|
1022
|
+
bandit?: {
|
|
1023
|
+
readonly contextKey: string; readonly explored: readonly string[]; readonly moved: number; readonly arms: number;
|
|
1024
|
+
/** TRUE ⇒ record the learning samples but NOT the bandit exposures; the caller commits them once
|
|
1025
|
+
* it knows which hits it actually printed. `dz recall --domain` over-fetches and truncates AGAIN
|
|
1026
|
+
* downstream, so committing here counted hits nobody ever saw as "seen" — inflating the health
|
|
1027
|
+
* metrics and mislabeling hidden candidates (cross-family QE, gpt-5.6-sol). */
|
|
1028
|
+
readonly deferred?: boolean;
|
|
1029
|
+
} | undefined,
|
|
1030
|
+
): void {
|
|
983
1031
|
const cfg = readMemoryLearningConfig(projectRoot);
|
|
984
1032
|
if (cfg.backend === 'off' || cfg.onRecallHits === false) return;
|
|
985
1033
|
const ts = new Date().toISOString();
|
|
986
1034
|
for (const h of hits) backend.addSample({ dzId: idOf(h.pattern), kind: 'recall-hit', reward: h.pattern.reward, ts });
|
|
1035
|
+
if (bandit !== undefined && bandit.deferred === true) return; // the caller will commit post-cut
|
|
1036
|
+
if (bandit !== undefined) {
|
|
1037
|
+
// EXPOSURE, not reward (INV-2): one BATCHED locked transaction per recall, and it touches only
|
|
1038
|
+
// our own counters — `alpha`/`beta`/`totalReward` are not passed to the engine at all. Taking
|
|
1039
|
+
// the lock once per HIT would be `limit` transactions per recall for bookkeeping.
|
|
1040
|
+
try {
|
|
1041
|
+
recordExposures(
|
|
1042
|
+
projectRoot,
|
|
1043
|
+
hits.map((h) => ({ dzId: idOf(h.pattern), contextKey: bandit.contextKey, ts })),
|
|
1044
|
+
bandit.explored,
|
|
1045
|
+
{ moved: bandit.moved, arms: bandit.arms },
|
|
1046
|
+
);
|
|
1047
|
+
} catch { /* a derived index never blocks the recall return (NFR-5) */ }
|
|
1048
|
+
}
|
|
987
1049
|
void backend.train().catch(() => undefined);
|
|
988
1050
|
}
|
|
989
1051
|
|
|
@@ -997,7 +1059,20 @@ function markRecallHits(projectRoot: string, backend: LearningSignalBackend, hit
|
|
|
997
1059
|
export async function recallHybrid(
|
|
998
1060
|
projectRoot: string,
|
|
999
1061
|
query: string,
|
|
1000
|
-
opts: VectorServiceOptions & {
|
|
1062
|
+
opts: VectorServiceOptions & {
|
|
1063
|
+
readonly limit?: number | undefined;
|
|
1064
|
+
readonly mode?: HybridRecallMode | undefined;
|
|
1065
|
+
/** Defer bandit EXPOSURE recording to the caller (default false ⇒ byte-identical to today).
|
|
1066
|
+
* A caller that over-fetches and truncates again — `dz recall --domain` does — must set this and
|
|
1067
|
+
* then call `commitExposures(shownDzIds)`, or hits nobody ever saw are counted as seen. */
|
|
1068
|
+
readonly deferExposures?: boolean | undefined;
|
|
1069
|
+
/**
|
|
1070
|
+
* The resolved recall domain (the axis `dz recall --domain` already boosts on). It becomes the
|
|
1071
|
+
* bandit's ContextKey (FR-5) — coarse on purpose, so posteriors accumulate instead of every arm
|
|
1072
|
+
* sitting at `pulls === 0` forever. Absent ⇒ `general`; it changes nothing while disarmed.
|
|
1073
|
+
*/
|
|
1074
|
+
readonly domain?: string | undefined;
|
|
1075
|
+
} = {},
|
|
1001
1076
|
): Promise<HybridRecall> {
|
|
1002
1077
|
// Config-surface note (QE P3, benign by design): recall resolves the engine directly, while teach
|
|
1003
1078
|
// only mirrors when the memory backend is agentdb (or an engine is explicit). In the window where
|
|
@@ -1035,30 +1110,120 @@ export async function recallHybrid(
|
|
|
1035
1110
|
})
|
|
1036
1111
|
.sort((a, b) => b.score - a.score);
|
|
1037
1112
|
};
|
|
1113
|
+
// lesson-bandit-rerank (ADR-001): the payoff axis. Resolved ONCE per recall; `enabled:false` ⇒
|
|
1114
|
+
// the Lesson Payoff context is NEVER CONSTRUCTED — the branch is taken BEFORE any work, so the
|
|
1115
|
+
// disarmed path reads no file, takes no lock and allocates nothing (INV-1).
|
|
1116
|
+
const banditCfg = resolveBanditConfig(projectRoot, memCfg);
|
|
1117
|
+
const banditCtxKey = contextKeyFor(opts.domain);
|
|
1118
|
+
let banditReport: BanditRecallReport | undefined;
|
|
1119
|
+
let banditExplored: readonly string[] = [];
|
|
1038
1120
|
const enhance = (hits: readonly HybridHit[]): HybridHit[] => {
|
|
1039
1121
|
const candidates = hits.map((h) => {
|
|
1040
1122
|
const dzId = idOf(h.pattern);
|
|
1041
1123
|
const rec = idToRecord.get(dzId);
|
|
1042
1124
|
return { dzId, score: h.score, reinforcement: rec !== undefined ? readReinforcementState(rec) : undefined };
|
|
1043
1125
|
});
|
|
1126
|
+
if (banditCfg.enabled) {
|
|
1127
|
+
// Quarantine read from the AUTHORITATIVE store records (idToRecord), never from mirror
|
|
1128
|
+
// metadata — the mirror may lag a promotion; the store cannot (same rule as dampQuarantined).
|
|
1129
|
+
const quarantined = new Set<string>();
|
|
1130
|
+
for (const c of candidates) {
|
|
1131
|
+
const rec = idToRecord.get(c.dzId);
|
|
1132
|
+
if (rec !== undefined && readQuarantineState(rec).quarantined) quarantined.add(c.dzId);
|
|
1133
|
+
}
|
|
1134
|
+
// INV-3 / AC-3: with exploration disarmed a quarantined lesson is filtered out of the arm list
|
|
1135
|
+
// BEFORE the engine is called, so it literally never learns that arm exists. (The ACL applies
|
|
1136
|
+
// its own set-subtraction for the exploration lift as well — two independent gates, because
|
|
1137
|
+
// this is the property ADR-003 says may only be weakened by an explicit request.)
|
|
1138
|
+
const armKeys = candidates.map((c) => c.dzId).filter((id) => banditCfg.exploration || !quarantined.has(id));
|
|
1139
|
+
const payoff = payoffTermsFor(projectRoot, banditCtxKey, armKeys, {
|
|
1140
|
+
exploration: banditCfg.exploration,
|
|
1141
|
+
quarantined,
|
|
1142
|
+
});
|
|
1143
|
+
const baseTerms: RerankTerm[] = deltaMap === undefined
|
|
1144
|
+
? []
|
|
1145
|
+
: [{ id: 'delta', byIndex: candidates.map((c) => deltaMap.get(c.dzId) ?? 0), cap: REINFORCE_RRF_CAP }];
|
|
1146
|
+
// The SAME ranking without the payoff term — the only honest way to say what the term moved.
|
|
1147
|
+
const before = dampQuarantined(applyLearningSignalsWithTerms(hits, learning, candidates, REINFORCE_RRF_CAP, baseTerms));
|
|
1148
|
+
const after = dampQuarantined(applyLearningSignalsWithTerms(hits, learning, candidates, REINFORCE_RRF_CAP, [
|
|
1149
|
+
...baseTerms,
|
|
1150
|
+
// ADDED, never assigned, and pre-bounded to [-1,+1] by the ACL — so `squash` is identity and
|
|
1151
|
+
// `cap` is an EXACT bound on this term's contribution (INV-4).
|
|
1152
|
+
{ id: 'bandit', byIndex: candidates.map((c) => payoff.terms.get(c.dzId)?.term ?? 0), cap: BANDIT_RRF_CAP, squash: (v) => v },
|
|
1153
|
+
]));
|
|
1154
|
+
const beforeIds = before.map((h) => idOf(h.pattern));
|
|
1155
|
+
const afterIds = after.map((h) => idOf(h.pattern));
|
|
1156
|
+
const movedDzIds = afterIds.filter((id, i) => beforeIds[i] !== id);
|
|
1157
|
+
banditExplored = payoff.explored;
|
|
1158
|
+
banditReport = {
|
|
1159
|
+
contextKey: banditCtxKey,
|
|
1160
|
+
armsConsidered: armKeys.length,
|
|
1161
|
+
quarantinedExcluded: candidates.length - armKeys.length,
|
|
1162
|
+
unknownArms: payoff.unknownArms,
|
|
1163
|
+
moved: movedDzIds.length,
|
|
1164
|
+
exploration: banditCfg.exploration,
|
|
1165
|
+
explored: payoff.explored.length,
|
|
1166
|
+
reason: payoff.reason,
|
|
1167
|
+
armDzIds: armKeys,
|
|
1168
|
+
movedDzIds,
|
|
1169
|
+
unknownDzIds: payoff.unknownDzIds,
|
|
1170
|
+
exploredDzIds: payoff.explored,
|
|
1171
|
+
beforeOrder: beforeIds,
|
|
1172
|
+
afterOrder: afterIds,
|
|
1173
|
+
};
|
|
1174
|
+
return after;
|
|
1175
|
+
}
|
|
1044
1176
|
if (deltaMap !== undefined) {
|
|
1045
1177
|
const deltaByIndex = candidates.map((c) => deltaMap.get(c.dzId) ?? 0);
|
|
1046
1178
|
return dampQuarantined(applyLearningSignalsWithDelta(hits, learning, candidates, REINFORCE_RRF_CAP, deltaByIndex, REINFORCE_RRF_CAP));
|
|
1047
1179
|
}
|
|
1048
1180
|
return dampQuarantined(applyLearningSignals(hits, learning, candidates, REINFORCE_RRF_CAP));
|
|
1049
1181
|
};
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1182
|
+
/** The exposure/telemetry payload for `markRecallHits` — `undefined` while disarmed (INV-1). */
|
|
1183
|
+
const banditEmission = (): { readonly contextKey: string; readonly explored: readonly string[]; readonly moved: number; readonly arms: number; readonly deferred?: boolean } | undefined =>
|
|
1184
|
+
banditReport === undefined
|
|
1185
|
+
? undefined
|
|
1186
|
+
: {
|
|
1187
|
+
contextKey: banditReport.contextKey, explored: banditExplored,
|
|
1188
|
+
moved: banditReport.moved, arms: banditReport.armsConsidered,
|
|
1189
|
+
...(opts.deferExposures === true ? { deferred: true } : {}),
|
|
1190
|
+
};
|
|
1191
|
+
const lexicalOnly = (extra: Partial<Pick<HybridRecall, 'vectorEngine' | 'vectorReason' | 'vectorError'>>): HybridRecall => {
|
|
1192
|
+
// `enhance` FIRST: it is what populates `banditReport` (the key is absent while disarmed).
|
|
1193
|
+
const hits = enhance(lexical.map((h, rank) => ({ pattern: h.pattern, backend: h.backend, score: 1 / (RRF_K + rank + 1) })));
|
|
1194
|
+
return {
|
|
1195
|
+
hits,
|
|
1196
|
+
lexicalBackend,
|
|
1197
|
+
vectorEngine: 'none',
|
|
1198
|
+
semanticCandidates: 0,
|
|
1199
|
+
semanticRanked: 0,
|
|
1200
|
+
...extra,
|
|
1201
|
+
...(banditReport !== undefined ? { bandit: banditReport } : {}),
|
|
1202
|
+
...(banditReport !== undefined && opts.deferExposures === true
|
|
1203
|
+
? {
|
|
1204
|
+
/** Record exposures for the hits the caller actually PRINTED. Everything it needs is in
|
|
1205
|
+
* the report plus the shown ids — no closure over the pre-cut hit list, so there is no
|
|
1206
|
+
* way for this to disagree with `narrowBanditReport` about which list is being described. */
|
|
1207
|
+
commitExposures: (shownDzIds: readonly string[]): void => {
|
|
1208
|
+
const narrowed = narrowBanditReport(banditReport!, shownDzIds);
|
|
1209
|
+
const ts2 = new Date().toISOString();
|
|
1210
|
+
try {
|
|
1211
|
+
recordExposures(
|
|
1212
|
+
projectRoot,
|
|
1213
|
+
shownDzIds.map((dzId) => ({ dzId, contextKey: narrowed.contextKey, ts: ts2 })),
|
|
1214
|
+
narrowed.exploredDzIds,
|
|
1215
|
+
{ moved: narrowed.moved, arms: narrowed.armsConsidered },
|
|
1216
|
+
);
|
|
1217
|
+
} catch { /* a derived index never blocks the recall return (NFR-5) */ }
|
|
1218
|
+
},
|
|
1219
|
+
}
|
|
1220
|
+
: {}),
|
|
1221
|
+
};
|
|
1222
|
+
};
|
|
1058
1223
|
|
|
1059
1224
|
if (mode === 'lexical') {
|
|
1060
1225
|
const out = lexicalOnly({});
|
|
1061
|
-
markRecallHits(projectRoot, learning, out.hits, idOf);
|
|
1226
|
+
markRecallHits(projectRoot, learning, out.hits, idOf, banditEmission());
|
|
1062
1227
|
return out;
|
|
1063
1228
|
}
|
|
1064
1229
|
|
|
@@ -1067,12 +1232,12 @@ export async function recallHybrid(
|
|
|
1067
1232
|
resolved = pickEngine(projectRoot, opts);
|
|
1068
1233
|
} catch (err) {
|
|
1069
1234
|
const out = lexicalOnly({ vectorReason: err instanceof Error ? err.message : String(err) });
|
|
1070
|
-
markRecallHits(projectRoot, learning, out.hits, idOf);
|
|
1235
|
+
markRecallHits(projectRoot, learning, out.hits, idOf, banditEmission());
|
|
1071
1236
|
return out;
|
|
1072
1237
|
}
|
|
1073
1238
|
if (resolved.engine === undefined) {
|
|
1074
1239
|
const out = lexicalOnly(resolved.reason !== undefined ? { vectorReason: resolved.reason } : {});
|
|
1075
|
-
markRecallHits(projectRoot, learning, out.hits, idOf);
|
|
1240
|
+
markRecallHits(projectRoot, learning, out.hits, idOf, banditEmission());
|
|
1076
1241
|
return out;
|
|
1077
1242
|
}
|
|
1078
1243
|
const engine = resolved.engine;
|
|
@@ -1089,7 +1254,7 @@ export async function recallHybrid(
|
|
|
1089
1254
|
if (sr.error !== undefined) {
|
|
1090
1255
|
// The engine answered with an error, so nothing was returned and nothing ranked — both zero.
|
|
1091
1256
|
const out = { ...lexicalOnly({}), vectorEngine: engine.kind, vectorError: sr.error };
|
|
1092
|
-
markRecallHits(projectRoot, learning, out.hits, idOf);
|
|
1257
|
+
markRecallHits(projectRoot, learning, out.hits, idOf, banditEmission());
|
|
1093
1258
|
return out;
|
|
1094
1259
|
}
|
|
1095
1260
|
|
|
@@ -1122,13 +1287,14 @@ export async function recallHybrid(
|
|
|
1122
1287
|
backend: h.backend,
|
|
1123
1288
|
}));
|
|
1124
1289
|
const hits = enhance(mergeHybridHits(lex, semantic, { limit, semanticWeight: mode === 'semantic' ? 2 : 1 }));
|
|
1125
|
-
markRecallHits(projectRoot, learning, hits, idOf);
|
|
1290
|
+
markRecallHits(projectRoot, learning, hits, idOf, banditEmission());
|
|
1126
1291
|
return {
|
|
1127
1292
|
hits,
|
|
1128
1293
|
lexicalBackend,
|
|
1129
1294
|
vectorEngine: engine.kind,
|
|
1130
1295
|
semanticCandidates: sr.hits.length,
|
|
1131
1296
|
semanticRanked: semantic.length,
|
|
1297
|
+
...(banditReport !== undefined ? { bandit: banditReport } : {}),
|
|
1132
1298
|
};
|
|
1133
1299
|
}
|
|
1134
1300
|
|