@dzhechkov/harness-core 0.7.11 → 0.8.1
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 +287 -87
- package/README.md +60 -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/book-kb.d.ts.map +1 -1
- package/dist/book-kb.js +17 -0
- package/dist/book-kb.js.map +1 -1
- package/dist/feature-adr-checkpoints.d.ts +61 -0
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +116 -2
- package/dist/feature-adr-checkpoints.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 +23 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -4
- 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/loop-blobs.generated.js +2 -2
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/mutation-gate.d.ts +31 -8
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +57 -16
- package/dist/mutation-gate.js.map +1 -1
- package/dist/named-lock.d.ts.map +1 -1
- package/dist/named-lock.js +11 -9
- package/dist/named-lock.js.map +1 -1
- package/dist/native-dep-probe.d.ts +11 -0
- package/dist/native-dep-probe.d.ts.map +1 -0
- package/dist/native-dep-probe.js +100 -0
- package/dist/native-dep-probe.js.map +1 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +82 -1
- package/dist/operations.js.map +1 -1
- package/dist/parser-safe-region.d.ts +47 -0
- package/dist/parser-safe-region.d.ts.map +1 -0
- package/dist/parser-safe-region.js +47 -0
- package/dist/parser-safe-region.js.map +1 -0
- package/dist/patterns.d.ts +21 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +52 -4
- package/dist/patterns.js.map +1 -1
- package/dist/profile.d.ts +202 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +536 -0
- package/dist/profile.js.map +1 -0
- 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 +5 -3
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +46 -8
- package/dist/publish.js.map +1 -1
- package/dist/qe-rounds.d.ts +75 -0
- package/dist/qe-rounds.d.ts.map +1 -0
- package/dist/qe-rounds.js +174 -0
- package/dist/qe-rounds.js.map +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +5 -1
- package/dist/registry.js.map +1 -1
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +8 -1
- package/dist/run-records.js.map +1 -1
- package/dist/setup.d.ts +35 -4
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +86 -23
- package/dist/setup.js.map +1 -1
- package/dist/sign.d.ts +29 -24
- package/dist/sign.d.ts.map +1 -1
- package/dist/sign.js +183 -61
- package/dist/sign.js.map +1 -1
- package/dist/store-location.d.ts +56 -0
- package/dist/store-location.d.ts.map +1 -0
- package/dist/store-location.js +56 -0
- package/dist/store-location.js.map +1 -0
- package/dist/store-lock.d.ts +1 -0
- package/dist/store-lock.d.ts.map +1 -1
- package/dist/store-lock.js +7 -1
- package/dist/store-lock.js.map +1 -1
- package/dist/store-merge.d.ts +55 -0
- package/dist/store-merge.d.ts.map +1 -0
- package/dist/store-merge.js +77 -0
- package/dist/store-merge.js.map +1 -0
- package/dist/teach-target.d.ts +61 -0
- package/dist/teach-target.d.ts.map +1 -0
- package/dist/teach-target.js +105 -0
- package/dist/teach-target.js.map +1 -0
- package/dist/vector-tier.d.ts +30 -0
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +178 -20
- package/dist/vector-tier.js.map +1 -1
- package/package.json +13 -13
- package/sbom.json +591 -87
- package/src/amendment-trace.ts +34 -4
- package/src/book-kb.ts +17 -0
- package/src/feature-adr-checkpoints.ts +111 -2
- package/src/feature-adr-routing.ts +146 -7
- package/src/index.ts +55 -3
- package/src/learning-backend.ts +62 -11
- package/src/lesson-bandit.ts +279 -0
- package/src/lesson-payoff.ts +728 -0
- package/src/loop-blobs.generated.ts +2 -2
- package/src/mutation-gate.ts +74 -17
- package/src/named-lock.ts +10 -8
- package/src/native-dep-probe.ts +118 -0
- package/src/operations.ts +77 -1
- package/src/parser-safe-region.ts +65 -0
- package/src/patterns.ts +76 -6
- package/src/profile.ts +593 -0
- package/src/project-skills-root.ts +63 -0
- package/src/publish.ts +43 -7
- package/src/qe-rounds.ts +226 -0
- package/src/registry.ts +5 -1
- package/src/run-records.ts +9 -1
- package/src/setup.ts +87 -23
- package/src/sign.ts +180 -60
- package/src/store-location.ts +76 -0
- package/src/store-lock.ts +8 -1
- package/src/store-merge.ts +91 -0
- package/src/teach-target.ts +126 -0
- package/src/vector-tier.ts +236 -21
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* teach-target — which store does THIS lesson belong to, and who decided.
|
|
3
|
+
*
|
|
4
|
+
* The owner asked for a per-session choice: "in this session accumulate per project; in another,
|
|
5
|
+
* across all projects." For a CLI every invocation is a fresh process, so there is no session
|
|
6
|
+
* object to hold a mode. An environment variable IS a shell session — set once, governs every
|
|
7
|
+
* command in that terminal, dies with it. A persisted file would outlive what the user meant by
|
|
8
|
+
* "this session" and become a mode nobody remembers setting.
|
|
9
|
+
*
|
|
10
|
+
* This increment is third on purpose. A mode set once and forgotten lies silently, and it is safe
|
|
11
|
+
* here only because `learning-store-says-where` already makes every write announce its destination.
|
|
12
|
+
* This adds the other half of that sentence: not only WHERE the lesson landed, but WHY that store
|
|
13
|
+
* was chosen. A fact that is not stated cannot be checked.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
/** Where a lesson is written. */
|
|
20
|
+
export type TeachStore = 'project' | 'global';
|
|
21
|
+
|
|
22
|
+
/** Who decided — the reason is printed, so a forgotten mode cannot stay invisible. */
|
|
23
|
+
export type TeachReason = 'flag' | 'environment' | 'config' | 'default';
|
|
24
|
+
|
|
25
|
+
export interface TeachTarget {
|
|
26
|
+
readonly store: TeachStore;
|
|
27
|
+
readonly reason: TeachReason;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Refusal, never a silent fallback. */
|
|
31
|
+
export class TeachTargetError extends Error {}
|
|
32
|
+
|
|
33
|
+
export const TEACH_STORES: readonly TeachStore[] = ['project', 'global'];
|
|
34
|
+
|
|
35
|
+
const VALID = new Set<string>(TEACH_STORES);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One level of input, validated.
|
|
39
|
+
*
|
|
40
|
+
* An unknown value is REFUSED and the source is named, because the cure differs: a bad flag is a
|
|
41
|
+
* typo on this command line, a bad environment variable was exported hours ago in another window,
|
|
42
|
+
* and a bad config is committed and affects everyone on the project.
|
|
43
|
+
*
|
|
44
|
+
* `DZ_LEARN=globl` quietly writing to the project store would be the invisible mislabel this whole
|
|
45
|
+
* mode exists to prevent, arriving through the mode itself.
|
|
46
|
+
*/
|
|
47
|
+
function validate(value: string | undefined, source: string): TeachStore | undefined {
|
|
48
|
+
if (value === undefined) return undefined;
|
|
49
|
+
const v = value.trim();
|
|
50
|
+
if (v === '') return undefined; // an empty export is not a choice
|
|
51
|
+
if (!VALID.has(v)) {
|
|
52
|
+
throw new TeachTargetError(
|
|
53
|
+
`${source}: unknown store ${JSON.stringify(v)} — expected one of: ${TEACH_STORES.join(', ')}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return v as TeachStore;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the destination.
|
|
61
|
+
*
|
|
62
|
+
* Precedence, most specific first: `--to` → `DZ_LEARN` → project config → `project`.
|
|
63
|
+
* That ordering is the only one a user can predict without reading the source.
|
|
64
|
+
*
|
|
65
|
+
* **The default does not move.** MEASURED: 361 records in this repo's own store were written under
|
|
66
|
+
* today's behaviour, and every other user's store is the same. Redirecting an un-flagged `dz teach`
|
|
67
|
+
* would silently change every existing workflow, and the user would find out when a lesson was not
|
|
68
|
+
* where they expected it.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveTeachTarget(input: {
|
|
71
|
+
flag?: string | undefined;
|
|
72
|
+
env?: string | undefined;
|
|
73
|
+
config?: string | undefined;
|
|
74
|
+
}): TeachTarget {
|
|
75
|
+
const flag = validate(input.flag, '--to');
|
|
76
|
+
if (flag !== undefined) return { store: flag, reason: 'flag' };
|
|
77
|
+
|
|
78
|
+
const env = validate(input.env, 'DZ_LEARN');
|
|
79
|
+
if (env !== undefined) return { store: env, reason: 'environment' };
|
|
80
|
+
|
|
81
|
+
const cfg = validate(input.config, '.dz/config.json learning.teachTo');
|
|
82
|
+
if (cfg !== undefined) return { store: cfg, reason: 'config' };
|
|
83
|
+
|
|
84
|
+
return { store: 'project', reason: 'default' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* How the reason reads in the store line.
|
|
89
|
+
*
|
|
90
|
+
* Each phrase names WHERE the decision came from, so a user who set `DZ_LEARN=global` three hours
|
|
91
|
+
* ago and forgot has something to recognise. `default` deliberately says nothing extra: adding a
|
|
92
|
+
* phrase there would change the line for every user who set nothing, breaking the byte-identity
|
|
93
|
+
* this feature promises them.
|
|
94
|
+
*/
|
|
95
|
+
export function teachReasonPhrase(reason: TeachReason): string {
|
|
96
|
+
switch (reason) {
|
|
97
|
+
case 'flag': return 'chosen by --to';
|
|
98
|
+
case 'environment': return 'chosen by DZ_LEARN in this shell';
|
|
99
|
+
case 'config': return 'chosen by .dz/config.json';
|
|
100
|
+
case 'default': return '';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Read the project default from `.dz/config.json` → `learning.teachTo`.
|
|
106
|
+
*
|
|
107
|
+
* Returns the RAW string, not a validated store: an unreadable file is "no opinion", but a file
|
|
108
|
+
* that says `teachTo: "globl"` HAS an opinion and must be refused by `resolveTeachTarget` rather
|
|
109
|
+
* than swallowed here. Those two cases are different and the caller can only tell them apart if
|
|
110
|
+
* this function keeps them apart.
|
|
111
|
+
*/
|
|
112
|
+
export function readTeachToConfig(projectRoot: string): string | undefined {
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(
|
|
115
|
+
readFileSync(join(projectRoot, '.dz', 'config.json'), 'utf-8'),
|
|
116
|
+
) as { learning?: { teachTo?: unknown } };
|
|
117
|
+
const raw = parsed.learning?.teachTo;
|
|
118
|
+
if (raw === undefined || raw === null) return undefined; // no key is no opinion
|
|
119
|
+
// A present key of the WRONG TYPE (`teachTo: true`) is still an opinion, and a wrong one.
|
|
120
|
+
// Returning undefined here would fall through to the project default silently — the exact
|
|
121
|
+
// fail-open the resolver refuses everywhere else (cross-family QE, 2026-08-27).
|
|
122
|
+
return typeof raw === 'string' ? raw : JSON.stringify(raw);
|
|
123
|
+
} catch {
|
|
124
|
+
return undefined; // missing or unparseable config is no opinion at all
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/vector-tier.ts
CHANGED
|
@@ -74,7 +74,23 @@ 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
|
+
import {
|
|
79
|
+
describeNativeDep,
|
|
80
|
+
exerciseSqliteOpen,
|
|
81
|
+
probeNativeDep,
|
|
82
|
+
type NativeDepVerdict,
|
|
83
|
+
} from './native-dep-probe.js';
|
|
84
|
+
// lesson-bandit-rerank: the payoff axis. ONE-DIRECTIONAL — lesson-payoff imports patterns.js and
|
|
85
|
+
// nothing from here, so there is no cycle.
|
|
86
|
+
import {
|
|
87
|
+
contextKeyFor,
|
|
88
|
+
narrowBanditReport,
|
|
89
|
+
payoffTermsFor,
|
|
90
|
+
recordExposures,
|
|
91
|
+
resolveBanditConfig,
|
|
92
|
+
type BanditRecallReport,
|
|
93
|
+
} from './lesson-payoff.js';
|
|
78
94
|
|
|
79
95
|
/* ------------------------------------------------------------------ */
|
|
80
96
|
/* Types (04_domain_model §3.4 / §4.1) */
|
|
@@ -219,6 +235,17 @@ export interface HybridRecall {
|
|
|
219
235
|
* returns only ids the lexical store no longer has has participated in nothing.
|
|
220
236
|
*/
|
|
221
237
|
readonly semanticRanked: number;
|
|
238
|
+
/**
|
|
239
|
+
* The bandit payoff explanation (feature lesson-bandit-rerank, FR-8/AC-11) — PRESENT only when
|
|
240
|
+
* `memory.learning.banditRerank` is armed, ABSENT otherwise (not `null`, not `{}`): its mere
|
|
241
|
+
* presence tells a reader the feature ran. `armsConsidered` describes the POST-cut list, and
|
|
242
|
+
* `moved` — the honest headline — counts the candidates whose position the term actually changed.
|
|
243
|
+
* An armed, silent re-ranker is indistinguishable from a broken one, which is why this exists.
|
|
244
|
+
*/
|
|
245
|
+
readonly bandit?: BanditRecallReport | undefined;
|
|
246
|
+
/** Present ONLY when `deferExposures` was requested and the bandit ran. Call it with the ids the
|
|
247
|
+
* caller actually printed; until it is called, no exposure has been recorded for this recall. */
|
|
248
|
+
readonly commitExposures?: ((shownDzIds: readonly string[]) => void) | undefined;
|
|
222
249
|
}
|
|
223
250
|
|
|
224
251
|
|
|
@@ -297,6 +324,14 @@ export const DEFAULT_VECTOR_TIMEOUT_MS = 10_000;
|
|
|
297
324
|
/** Default cosine cutoff for near-duplicate clustering (`--threshold` / config overrides). */
|
|
298
325
|
export const DEFAULT_HARMONIZE_THRESHOLD = 0.92;
|
|
299
326
|
export const REINFORCE_RRF_CAP = (1 / (60 + 1)) - (1 / (60 + 4));
|
|
327
|
+
/**
|
|
328
|
+
* The bandit payoff term's bound (ADR-001 D-2). Deliberately the SAME constant the reinforcement and
|
|
329
|
+
* SAFLA-delta terms use, not a new one: it keeps the "a learning signal is worth less than one RRF
|
|
330
|
+
* rank step" invariant those terms already established, and makes the joint excursion of two payoff
|
|
331
|
+
* terms auditable as exactly `2 × CAP`. Payoff reorders near-ties; it cannot overturn a real
|
|
332
|
+
* relevance gap.
|
|
333
|
+
*/
|
|
334
|
+
export const BANDIT_RRF_CAP = REINFORCE_RRF_CAP;
|
|
300
335
|
|
|
301
336
|
/* ------------------------------------------------------------------ */
|
|
302
337
|
/* Harmonize + import types (dz-vector-harmonize-import 05 §2.1/§2.2) */
|
|
@@ -605,8 +640,11 @@ export function mirrorWriterExplanation(state: MirrorWriterState): string {
|
|
|
605
640
|
}
|
|
606
641
|
|
|
607
642
|
/**
|
|
608
|
-
* Engine selection cascade: config mode →
|
|
609
|
-
*
|
|
643
|
+
* Engine selection cascade: config mode → project-local package gate → native usability probe
|
|
644
|
+
* → adapter or an honest reason. A lexical-only project still pays nothing because
|
|
645
|
+
* {@link isPackageInstalled} gates the probe; a project with the dependency installed pays one
|
|
646
|
+
* require + one in-memory open per process (measured: agentdb 53.6 ms, better-sqlite3 8.6 ms,
|
|
647
|
+
* 62.2 ms total, versus about 0.008 ms for the old existsSync-only check).
|
|
610
648
|
* Never throws. `auto` prefers agentdb (it reads the vectors consolidate already wrote),
|
|
611
649
|
* falling through to rvf.
|
|
612
650
|
*/
|
|
@@ -630,19 +668,43 @@ function isPackageInstalled(projectRoot: string, pkgName: string): boolean {
|
|
|
630
668
|
export function resolveVectorEngine(projectRoot: string): ResolvedVectorEngine {
|
|
631
669
|
const mode = readVectorEngineMode(projectRoot);
|
|
632
670
|
if (mode === 'off') return { reason: 'vector tier disabled (memory.vector.engine = "off")' };
|
|
633
|
-
|
|
671
|
+
let autoAgentdbFailure: string | undefined;
|
|
672
|
+
const probeIfInstalled = (id: string): NativeDepVerdict =>
|
|
673
|
+
isPackageInstalled(projectRoot, id)
|
|
674
|
+
? id === 'better-sqlite3'
|
|
675
|
+
? probeNativeDep(projectRoot, id, exerciseSqliteOpen)
|
|
676
|
+
: probeNativeDep(projectRoot, id)
|
|
677
|
+
: { state: 'absent', pkg: id };
|
|
634
678
|
if (mode === 'agentdb' || mode === 'auto') {
|
|
635
|
-
|
|
679
|
+
const agentdb = probeIfInstalled('agentdb');
|
|
680
|
+
if (agentdb.state === 'unusable') {
|
|
681
|
+
const reason = describeNativeDep(agentdb);
|
|
682
|
+
if (mode === 'agentdb') return { reason };
|
|
683
|
+
autoAgentdbFailure = reason;
|
|
684
|
+
}
|
|
685
|
+
const sqlite = agentdb.state === 'usable' ? probeIfInstalled('better-sqlite3') : undefined;
|
|
686
|
+
if (sqlite?.state === 'unusable') return { reason: describeNativeDep(sqlite) };
|
|
687
|
+
if (agentdb.state === 'usable' && sqlite?.state === 'usable') {
|
|
688
|
+
return { engine: agentdbVectorEngine(projectRoot) };
|
|
689
|
+
}
|
|
636
690
|
if (mode === 'agentdb') {
|
|
637
691
|
return { reason: 'agentdb/better-sqlite3 not installed in project (run: dz setup --memory agentdb)' };
|
|
638
692
|
}
|
|
639
693
|
}
|
|
640
694
|
if (mode === 'rvf' || mode === 'auto') {
|
|
641
|
-
|
|
695
|
+
const rvf = probeIfInstalled('@ruvector/rvf');
|
|
696
|
+
if (rvf.state === 'unusable') {
|
|
697
|
+
const reason = describeNativeDep(rvf);
|
|
698
|
+
return { reason: autoAgentdbFailure === undefined ? reason : `${autoAgentdbFailure}; ${reason}` };
|
|
699
|
+
}
|
|
700
|
+
if (rvf.state === 'usable') return { engine: rvfVectorEngine(projectRoot) };
|
|
642
701
|
if (mode === 'rvf') {
|
|
643
702
|
return { reason: '@ruvector/rvf not installed in project (npm i @ruvector/rvf) — vector tier inactive' };
|
|
644
703
|
}
|
|
645
704
|
}
|
|
705
|
+
if (autoAgentdbFailure !== undefined) {
|
|
706
|
+
return { reason: `${autoAgentdbFailure}; @ruvector/rvf not installed in project (npm i @ruvector/rvf) — vector tier inactive` };
|
|
707
|
+
}
|
|
646
708
|
return { reason: 'no vector engine available: agentdb/better-sqlite3 not installed in project (run: dz setup --memory agentdb)' };
|
|
647
709
|
}
|
|
648
710
|
|
|
@@ -979,11 +1041,44 @@ export function mergeHybridHits(
|
|
|
979
1041
|
return cut.map(toHit);
|
|
980
1042
|
}
|
|
981
1043
|
|
|
982
|
-
|
|
1044
|
+
/**
|
|
1045
|
+
* Emit the recall-hit telemetry for the hits a caller is about to see.
|
|
1046
|
+
*
|
|
1047
|
+
* `bandit` is passed ONLY when `memory.learning.banditRerank` is armed; when it is absent this
|
|
1048
|
+
* function is byte-identical to its pre-feature self — no state file, no lock, no allocation.
|
|
1049
|
+
*/
|
|
1050
|
+
function markRecallHits(
|
|
1051
|
+
projectRoot: string,
|
|
1052
|
+
backend: LearningSignalBackend,
|
|
1053
|
+
hits: readonly HybridHit[],
|
|
1054
|
+
idOf: (p: PatternRecord) => string,
|
|
1055
|
+
bandit?: {
|
|
1056
|
+
readonly contextKey: string; readonly explored: readonly string[]; readonly moved: number; readonly arms: number;
|
|
1057
|
+
/** TRUE ⇒ record the learning samples but NOT the bandit exposures; the caller commits them once
|
|
1058
|
+
* it knows which hits it actually printed. `dz recall --domain` over-fetches and truncates AGAIN
|
|
1059
|
+
* downstream, so committing here counted hits nobody ever saw as "seen" — inflating the health
|
|
1060
|
+
* metrics and mislabeling hidden candidates (cross-family QE, gpt-5.6-sol). */
|
|
1061
|
+
readonly deferred?: boolean;
|
|
1062
|
+
} | undefined,
|
|
1063
|
+
): void {
|
|
983
1064
|
const cfg = readMemoryLearningConfig(projectRoot);
|
|
984
1065
|
if (cfg.backend === 'off' || cfg.onRecallHits === false) return;
|
|
985
1066
|
const ts = new Date().toISOString();
|
|
986
1067
|
for (const h of hits) backend.addSample({ dzId: idOf(h.pattern), kind: 'recall-hit', reward: h.pattern.reward, ts });
|
|
1068
|
+
if (bandit !== undefined && bandit.deferred === true) return; // the caller will commit post-cut
|
|
1069
|
+
if (bandit !== undefined) {
|
|
1070
|
+
// EXPOSURE, not reward (INV-2): one BATCHED locked transaction per recall, and it touches only
|
|
1071
|
+
// our own counters — `alpha`/`beta`/`totalReward` are not passed to the engine at all. Taking
|
|
1072
|
+
// the lock once per HIT would be `limit` transactions per recall for bookkeeping.
|
|
1073
|
+
try {
|
|
1074
|
+
recordExposures(
|
|
1075
|
+
projectRoot,
|
|
1076
|
+
hits.map((h) => ({ dzId: idOf(h.pattern), contextKey: bandit.contextKey, ts })),
|
|
1077
|
+
bandit.explored,
|
|
1078
|
+
{ moved: bandit.moved, arms: bandit.arms },
|
|
1079
|
+
);
|
|
1080
|
+
} catch { /* a derived index never blocks the recall return (NFR-5) */ }
|
|
1081
|
+
}
|
|
987
1082
|
void backend.train().catch(() => undefined);
|
|
988
1083
|
}
|
|
989
1084
|
|
|
@@ -997,7 +1092,20 @@ function markRecallHits(projectRoot: string, backend: LearningSignalBackend, hit
|
|
|
997
1092
|
export async function recallHybrid(
|
|
998
1093
|
projectRoot: string,
|
|
999
1094
|
query: string,
|
|
1000
|
-
opts: VectorServiceOptions & {
|
|
1095
|
+
opts: VectorServiceOptions & {
|
|
1096
|
+
readonly limit?: number | undefined;
|
|
1097
|
+
readonly mode?: HybridRecallMode | undefined;
|
|
1098
|
+
/** Defer bandit EXPOSURE recording to the caller (default false ⇒ byte-identical to today).
|
|
1099
|
+
* A caller that over-fetches and truncates again — `dz recall --domain` does — must set this and
|
|
1100
|
+
* then call `commitExposures(shownDzIds)`, or hits nobody ever saw are counted as seen. */
|
|
1101
|
+
readonly deferExposures?: boolean | undefined;
|
|
1102
|
+
/**
|
|
1103
|
+
* The resolved recall domain (the axis `dz recall --domain` already boosts on). It becomes the
|
|
1104
|
+
* bandit's ContextKey (FR-5) — coarse on purpose, so posteriors accumulate instead of every arm
|
|
1105
|
+
* sitting at `pulls === 0` forever. Absent ⇒ `general`; it changes nothing while disarmed.
|
|
1106
|
+
*/
|
|
1107
|
+
readonly domain?: string | undefined;
|
|
1108
|
+
} = {},
|
|
1001
1109
|
): Promise<HybridRecall> {
|
|
1002
1110
|
// Config-surface note (QE P3, benign by design): recall resolves the engine directly, while teach
|
|
1003
1111
|
// only mirrors when the memory backend is agentdb (or an engine is explicit). In the window where
|
|
@@ -1035,30 +1143,135 @@ export async function recallHybrid(
|
|
|
1035
1143
|
})
|
|
1036
1144
|
.sort((a, b) => b.score - a.score);
|
|
1037
1145
|
};
|
|
1146
|
+
// lesson-bandit-rerank (ADR-001): the payoff axis. Resolved ONCE per recall; `enabled:false` ⇒
|
|
1147
|
+
// the Lesson Payoff context is NEVER CONSTRUCTED — the branch is taken BEFORE any work, so the
|
|
1148
|
+
// disarmed path reads no file, takes no lock and allocates nothing (INV-1).
|
|
1149
|
+
const banditCfg = resolveBanditConfig(projectRoot, memCfg);
|
|
1150
|
+
const banditCtxKey = contextKeyFor(opts.domain);
|
|
1151
|
+
let banditReport: BanditRecallReport | undefined;
|
|
1152
|
+
let banditExplored: readonly string[] = [];
|
|
1038
1153
|
const enhance = (hits: readonly HybridHit[]): HybridHit[] => {
|
|
1039
1154
|
const candidates = hits.map((h) => {
|
|
1040
1155
|
const dzId = idOf(h.pattern);
|
|
1041
1156
|
const rec = idToRecord.get(dzId);
|
|
1042
1157
|
return { dzId, score: h.score, reinforcement: rec !== undefined ? readReinforcementState(rec) : undefined };
|
|
1043
1158
|
});
|
|
1159
|
+
if (banditCfg.enabled) {
|
|
1160
|
+
// Quarantine read from the AUTHORITATIVE store records (idToRecord), never from mirror
|
|
1161
|
+
// metadata — the mirror may lag a promotion; the store cannot (same rule as dampQuarantined).
|
|
1162
|
+
const quarantined = new Set<string>();
|
|
1163
|
+
for (const c of candidates) {
|
|
1164
|
+
const rec = idToRecord.get(c.dzId);
|
|
1165
|
+
if (rec !== undefined && readQuarantineState(rec).quarantined) quarantined.add(c.dzId);
|
|
1166
|
+
}
|
|
1167
|
+
// INV-3 / AC-3: with exploration disarmed a quarantined lesson is filtered out of the arm list
|
|
1168
|
+
// BEFORE the engine is called, so it literally never learns that arm exists. (The ACL applies
|
|
1169
|
+
// its own set-subtraction for the exploration lift as well — two independent gates, because
|
|
1170
|
+
// this is the property ADR-003 says may only be weakened by an explicit request.)
|
|
1171
|
+
const armKeys = candidates.map((c) => c.dzId).filter((id) => banditCfg.exploration || !quarantined.has(id));
|
|
1172
|
+
const payoff = payoffTermsFor(projectRoot, banditCtxKey, armKeys, {
|
|
1173
|
+
exploration: banditCfg.exploration,
|
|
1174
|
+
quarantined,
|
|
1175
|
+
});
|
|
1176
|
+
const baseTerms: RerankTerm[] = deltaMap === undefined
|
|
1177
|
+
? []
|
|
1178
|
+
: [{ id: 'delta', byIndex: candidates.map((c) => deltaMap.get(c.dzId) ?? 0), cap: REINFORCE_RRF_CAP }];
|
|
1179
|
+
// The SAME ranking without the payoff term — the only honest way to say what the term moved.
|
|
1180
|
+
const before = dampQuarantined(applyLearningSignalsWithTerms(hits, learning, candidates, REINFORCE_RRF_CAP, baseTerms));
|
|
1181
|
+
const after = dampQuarantined(applyLearningSignalsWithTerms(hits, learning, candidates, REINFORCE_RRF_CAP, [
|
|
1182
|
+
...baseTerms,
|
|
1183
|
+
// ADDED, never assigned, and pre-bounded to [-1,+1] by the ACL — so `squash` is identity and
|
|
1184
|
+
// `cap` is an EXACT bound on this term's contribution (INV-4).
|
|
1185
|
+
{ id: 'bandit', byIndex: candidates.map((c) => payoff.terms.get(c.dzId)?.term ?? 0), cap: BANDIT_RRF_CAP, squash: (v) => v },
|
|
1186
|
+
]));
|
|
1187
|
+
const beforeIds = before.map((h) => idOf(h.pattern));
|
|
1188
|
+
const afterIds = after.map((h) => idOf(h.pattern));
|
|
1189
|
+
const movedDzIds = afterIds.filter((id, i) => beforeIds[i] !== id);
|
|
1190
|
+
banditExplored = payoff.explored;
|
|
1191
|
+
banditReport = {
|
|
1192
|
+
contextKey: banditCtxKey,
|
|
1193
|
+
armsConsidered: armKeys.length,
|
|
1194
|
+
quarantinedExcluded: candidates.length - armKeys.length,
|
|
1195
|
+
unknownArms: payoff.unknownArms,
|
|
1196
|
+
moved: movedDzIds.length,
|
|
1197
|
+
exploration: banditCfg.exploration,
|
|
1198
|
+
explored: payoff.explored.length,
|
|
1199
|
+
reason: payoff.reason,
|
|
1200
|
+
armDzIds: armKeys,
|
|
1201
|
+
movedDzIds,
|
|
1202
|
+
unknownDzIds: payoff.unknownDzIds,
|
|
1203
|
+
exploredDzIds: payoff.explored,
|
|
1204
|
+
beforeOrder: beforeIds,
|
|
1205
|
+
afterOrder: afterIds,
|
|
1206
|
+
};
|
|
1207
|
+
return after;
|
|
1208
|
+
}
|
|
1044
1209
|
if (deltaMap !== undefined) {
|
|
1045
1210
|
const deltaByIndex = candidates.map((c) => deltaMap.get(c.dzId) ?? 0);
|
|
1046
1211
|
return dampQuarantined(applyLearningSignalsWithDelta(hits, learning, candidates, REINFORCE_RRF_CAP, deltaByIndex, REINFORCE_RRF_CAP));
|
|
1047
1212
|
}
|
|
1048
1213
|
return dampQuarantined(applyLearningSignals(hits, learning, candidates, REINFORCE_RRF_CAP));
|
|
1049
1214
|
};
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1215
|
+
/** The exposure/telemetry payload for `markRecallHits` — `undefined` while disarmed (INV-1). */
|
|
1216
|
+
const banditEmission = (): { readonly contextKey: string; readonly explored: readonly string[]; readonly moved: number; readonly arms: number; readonly deferred?: boolean } | undefined =>
|
|
1217
|
+
banditReport === undefined
|
|
1218
|
+
? undefined
|
|
1219
|
+
: {
|
|
1220
|
+
contextKey: banditReport.contextKey, explored: banditExplored,
|
|
1221
|
+
moved: banditReport.moved, arms: banditReport.armsConsidered,
|
|
1222
|
+
...(opts.deferExposures === true ? { deferred: true } : {}),
|
|
1223
|
+
};
|
|
1224
|
+
const lexicalOnly = (extra: Partial<Pick<HybridRecall, 'vectorEngine' | 'vectorReason' | 'vectorError'>>): HybridRecall => {
|
|
1225
|
+
// `enhance` FIRST: it is what populates `banditReport` (the key is absent while disarmed).
|
|
1226
|
+
const hits = enhance(lexical.map((h, rank) => ({ pattern: h.pattern, backend: h.backend, score: 1 / (RRF_K + rank + 1) })));
|
|
1227
|
+
return {
|
|
1228
|
+
hits,
|
|
1229
|
+
lexicalBackend,
|
|
1230
|
+
vectorEngine: 'none',
|
|
1231
|
+
semanticCandidates: 0,
|
|
1232
|
+
semanticRanked: 0,
|
|
1233
|
+
...extra,
|
|
1234
|
+
...(banditReport !== undefined ? { bandit: banditReport } : {}),
|
|
1235
|
+
...deferredCommit(),
|
|
1236
|
+
};
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
/**
|
|
1240
|
+
* The deferred-exposure commit, built ONCE and attached by EVERY return path.
|
|
1241
|
+
*
|
|
1242
|
+
* The first draft attached it only inside `lexicalOnly()` — the FALLBACK. On the main hybrid path
|
|
1243
|
+
* (a vector engine present, i.e. the normal case) the field was absent, so `markRecallHits` had
|
|
1244
|
+
* already returned early on `deferred: true` and NOTHING ever committed the exposures. MEASURED
|
|
1245
|
+
* the same day: an armed recall printed `bandit payoff: … state absent` while `.dz/lesson-bandit/`
|
|
1246
|
+
* was never created — the feature reported itself running while its reward feed was severed.
|
|
1247
|
+
* A ranking feature that quietly stops ranking looks exactly like one that works; that sentence is
|
|
1248
|
+
* in the vendored engine's own header, and the failure recurred one layer down anyway.
|
|
1249
|
+
*/
|
|
1250
|
+
function deferredCommit(): { commitExposures?: (shownDzIds: readonly string[]) => void } {
|
|
1251
|
+
return banditReport !== undefined && opts.deferExposures === true
|
|
1252
|
+
? {
|
|
1253
|
+
/** Record exposures for the hits the caller actually PRINTED. Everything it needs is in
|
|
1254
|
+
* the report plus the shown ids — no closure over the pre-cut hit list, so there is no
|
|
1255
|
+
* way for this to disagree with `narrowBanditReport` about which list is being described. */
|
|
1256
|
+
commitExposures: (shownDzIds: readonly string[]): void => {
|
|
1257
|
+
const narrowed = narrowBanditReport(banditReport!, shownDzIds);
|
|
1258
|
+
const ts2 = new Date().toISOString();
|
|
1259
|
+
try {
|
|
1260
|
+
recordExposures(
|
|
1261
|
+
projectRoot,
|
|
1262
|
+
shownDzIds.map((dzId) => ({ dzId, contextKey: narrowed.contextKey, ts: ts2 })),
|
|
1263
|
+
narrowed.exploredDzIds,
|
|
1264
|
+
{ moved: narrowed.moved, arms: narrowed.armsConsidered },
|
|
1265
|
+
);
|
|
1266
|
+
} catch { /* a derived index never blocks the recall return (NFR-5) */ }
|
|
1267
|
+
},
|
|
1268
|
+
}
|
|
1269
|
+
: {};
|
|
1270
|
+
}
|
|
1058
1271
|
|
|
1059
1272
|
if (mode === 'lexical') {
|
|
1060
1273
|
const out = lexicalOnly({});
|
|
1061
|
-
markRecallHits(projectRoot, learning, out.hits, idOf);
|
|
1274
|
+
markRecallHits(projectRoot, learning, out.hits, idOf, banditEmission());
|
|
1062
1275
|
return out;
|
|
1063
1276
|
}
|
|
1064
1277
|
|
|
@@ -1067,12 +1280,12 @@ export async function recallHybrid(
|
|
|
1067
1280
|
resolved = pickEngine(projectRoot, opts);
|
|
1068
1281
|
} catch (err) {
|
|
1069
1282
|
const out = lexicalOnly({ vectorReason: err instanceof Error ? err.message : String(err) });
|
|
1070
|
-
markRecallHits(projectRoot, learning, out.hits, idOf);
|
|
1283
|
+
markRecallHits(projectRoot, learning, out.hits, idOf, banditEmission());
|
|
1071
1284
|
return out;
|
|
1072
1285
|
}
|
|
1073
1286
|
if (resolved.engine === undefined) {
|
|
1074
1287
|
const out = lexicalOnly(resolved.reason !== undefined ? { vectorReason: resolved.reason } : {});
|
|
1075
|
-
markRecallHits(projectRoot, learning, out.hits, idOf);
|
|
1288
|
+
markRecallHits(projectRoot, learning, out.hits, idOf, banditEmission());
|
|
1076
1289
|
return out;
|
|
1077
1290
|
}
|
|
1078
1291
|
const engine = resolved.engine;
|
|
@@ -1089,7 +1302,7 @@ export async function recallHybrid(
|
|
|
1089
1302
|
if (sr.error !== undefined) {
|
|
1090
1303
|
// The engine answered with an error, so nothing was returned and nothing ranked — both zero.
|
|
1091
1304
|
const out = { ...lexicalOnly({}), vectorEngine: engine.kind, vectorError: sr.error };
|
|
1092
|
-
markRecallHits(projectRoot, learning, out.hits, idOf);
|
|
1305
|
+
markRecallHits(projectRoot, learning, out.hits, idOf, banditEmission());
|
|
1093
1306
|
return out;
|
|
1094
1307
|
}
|
|
1095
1308
|
|
|
@@ -1122,13 +1335,15 @@ export async function recallHybrid(
|
|
|
1122
1335
|
backend: h.backend,
|
|
1123
1336
|
}));
|
|
1124
1337
|
const hits = enhance(mergeHybridHits(lex, semantic, { limit, semanticWeight: mode === 'semantic' ? 2 : 1 }));
|
|
1125
|
-
markRecallHits(projectRoot, learning, hits, idOf);
|
|
1338
|
+
markRecallHits(projectRoot, learning, hits, idOf, banditEmission());
|
|
1126
1339
|
return {
|
|
1127
1340
|
hits,
|
|
1128
1341
|
lexicalBackend,
|
|
1129
1342
|
vectorEngine: engine.kind,
|
|
1130
1343
|
semanticCandidates: sr.hits.length,
|
|
1131
1344
|
semanticRanked: semantic.length,
|
|
1345
|
+
...(banditReport !== undefined ? { bandit: banditReport } : {}),
|
|
1346
|
+
...deferredCommit(),
|
|
1132
1347
|
};
|
|
1133
1348
|
}
|
|
1134
1349
|
|