@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.
Files changed (53) hide show
  1. package/.dz-manifest.json +97 -37
  2. package/README.md +27 -1
  3. package/dist/amendment-trace.d.ts +12 -1
  4. package/dist/amendment-trace.d.ts.map +1 -1
  5. package/dist/amendment-trace.js +22 -4
  6. package/dist/amendment-trace.js.map +1 -1
  7. package/dist/feature-adr-routing.d.ts +69 -12
  8. package/dist/feature-adr-routing.d.ts.map +1 -1
  9. package/dist/feature-adr-routing.js +117 -7
  10. package/dist/feature-adr-routing.js.map +1 -1
  11. package/dist/index.d.ts +7 -4
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +10 -3
  14. package/dist/index.js.map +1 -1
  15. package/dist/learning-backend.d.ts +39 -0
  16. package/dist/learning-backend.d.ts.map +1 -1
  17. package/dist/learning-backend.js +31 -11
  18. package/dist/learning-backend.js.map +1 -1
  19. package/dist/lesson-bandit.d.ts +116 -0
  20. package/dist/lesson-bandit.d.ts.map +1 -0
  21. package/dist/lesson-bandit.js +235 -0
  22. package/dist/lesson-bandit.js.map +1 -0
  23. package/dist/lesson-payoff.d.ts +260 -0
  24. package/dist/lesson-payoff.d.ts.map +1 -0
  25. package/dist/lesson-payoff.js +597 -0
  26. package/dist/lesson-payoff.js.map +1 -0
  27. package/dist/patterns.d.ts +21 -0
  28. package/dist/patterns.d.ts.map +1 -1
  29. package/dist/patterns.js +42 -3
  30. package/dist/patterns.js.map +1 -1
  31. package/dist/project-skills-root.d.ts +44 -0
  32. package/dist/project-skills-root.d.ts.map +1 -0
  33. package/dist/project-skills-root.js +62 -0
  34. package/dist/project-skills-root.js.map +1 -0
  35. package/dist/publish.d.ts.map +1 -1
  36. package/dist/publish.js +6 -0
  37. package/dist/publish.js.map +1 -1
  38. package/dist/vector-tier.d.ts +30 -0
  39. package/dist/vector-tier.d.ts.map +1 -1
  40. package/dist/vector-tier.js +130 -15
  41. package/dist/vector-tier.js.map +1 -1
  42. package/package.json +6 -6
  43. package/sbom.json +186 -36
  44. package/src/amendment-trace.ts +34 -4
  45. package/src/feature-adr-routing.ts +146 -7
  46. package/src/index.ts +16 -2
  47. package/src/learning-backend.ts +62 -11
  48. package/src/lesson-bandit.ts +279 -0
  49. package/src/lesson-payoff.ts +728 -0
  50. package/src/patterns.ts +66 -5
  51. package/src/project-skills-root.ts +63 -0
  52. package/src/publish.ts +6 -0
  53. 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
- return await withStoreLock(projectRoot, async () => reinforcePatternLocked(projectRoot, dzIdOrText, opts));
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);
@@ -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
- function markRecallHits(projectRoot: string, backend: LearningSignalBackend, hits: readonly HybridHit[], idOf: (p: PatternRecord) => string): void {
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 & { readonly limit?: number | undefined; readonly mode?: HybridRecallMode | undefined } = {},
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
- const lexicalOnly = (extra: Partial<Pick<HybridRecall, 'vectorEngine' | 'vectorReason' | 'vectorError'>>): HybridRecall => ({
1051
- hits: enhance(lexical.map((h, rank) => ({ pattern: h.pattern, backend: h.backend, score: 1 / (RRF_K + rank + 1) }))),
1052
- lexicalBackend,
1053
- vectorEngine: 'none',
1054
- semanticCandidates: 0,
1055
- semanticRanked: 0,
1056
- ...extra,
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