@dzhechkov/harness-core 0.8.36 → 0.8.37

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 (106) hide show
  1. package/.dz-manifest.json +195 -75
  2. package/README.md +235 -8
  3. package/dist/agentdb-index.d.ts +87 -7
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +416 -57
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/apply-leg.d.ts +19 -1
  8. package/dist/apply-leg.d.ts.map +1 -1
  9. package/dist/apply-leg.js +187 -36
  10. package/dist/apply-leg.js.map +1 -1
  11. package/dist/codex-rollouts.d.ts +118 -0
  12. package/dist/codex-rollouts.d.ts.map +1 -0
  13. package/dist/codex-rollouts.js +297 -0
  14. package/dist/codex-rollouts.js.map +1 -0
  15. package/dist/cost-ledger.d.ts +56 -4
  16. package/dist/cost-ledger.d.ts.map +1 -1
  17. package/dist/cost-ledger.js +176 -20
  18. package/dist/cost-ledger.js.map +1 -1
  19. package/dist/cross-family-control.d.ts +345 -0
  20. package/dist/cross-family-control.d.ts.map +1 -0
  21. package/dist/cross-family-control.js +802 -0
  22. package/dist/cross-family-control.js.map +1 -0
  23. package/dist/debt-ratchet.d.ts +53 -0
  24. package/dist/debt-ratchet.d.ts.map +1 -0
  25. package/dist/debt-ratchet.js +107 -0
  26. package/dist/debt-ratchet.js.map +1 -0
  27. package/dist/embedding-config.d.ts +42 -0
  28. package/dist/embedding-config.d.ts.map +1 -1
  29. package/dist/embedding-config.js +106 -10
  30. package/dist/embedding-config.js.map +1 -1
  31. package/dist/feature-adr-checkpoints.d.ts +6 -0
  32. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  33. package/dist/feature-adr-checkpoints.js +29 -0
  34. package/dist/feature-adr-checkpoints.js.map +1 -1
  35. package/dist/feature-adr-decision-recall.d.ts +2 -2
  36. package/dist/feature-adr-decision-recall.d.ts.map +1 -1
  37. package/dist/feature-adr-decision-recall.js +5 -3
  38. package/dist/feature-adr-decision-recall.js.map +1 -1
  39. package/dist/feature-adr-envelope.d.ts +96 -0
  40. package/dist/feature-adr-envelope.d.ts.map +1 -0
  41. package/dist/feature-adr-envelope.js +183 -0
  42. package/dist/feature-adr-envelope.js.map +1 -0
  43. package/dist/feature-adr-routing.d.ts +64 -0
  44. package/dist/feature-adr-routing.d.ts.map +1 -1
  45. package/dist/feature-adr-routing.js +122 -2
  46. package/dist/feature-adr-routing.js.map +1 -1
  47. package/dist/feature-adr-stage-canon.d.ts +79 -0
  48. package/dist/feature-adr-stage-canon.d.ts.map +1 -0
  49. package/dist/feature-adr-stage-canon.js +117 -0
  50. package/dist/feature-adr-stage-canon.js.map +1 -0
  51. package/dist/index.d.ts +19 -9
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +13 -5
  54. package/dist/index.js.map +1 -1
  55. package/dist/loop-blobs.generated.js +4 -4
  56. package/dist/loop-blobs.generated.js.map +1 -1
  57. package/dist/mutation-gate.d.ts +51 -0
  58. package/dist/mutation-gate.d.ts.map +1 -1
  59. package/dist/mutation-gate.js +295 -0
  60. package/dist/mutation-gate.js.map +1 -1
  61. package/dist/qe-bridge.d.ts.map +1 -1
  62. package/dist/qe-bridge.js +4 -2
  63. package/dist/qe-bridge.js.map +1 -1
  64. package/dist/qe-findings.d.ts +107 -0
  65. package/dist/qe-findings.d.ts.map +1 -0
  66. package/dist/qe-findings.js +417 -0
  67. package/dist/qe-findings.js.map +1 -0
  68. package/dist/recap.d.ts +1 -1
  69. package/dist/recap.d.ts.map +1 -1
  70. package/dist/recap.js +4 -2
  71. package/dist/recap.js.map +1 -1
  72. package/dist/round.d.ts +74 -1
  73. package/dist/round.d.ts.map +1 -1
  74. package/dist/round.js +112 -4
  75. package/dist/round.js.map +1 -1
  76. package/dist/run-records.d.ts +60 -0
  77. package/dist/run-records.d.ts.map +1 -1
  78. package/dist/run-records.js +244 -2
  79. package/dist/run-records.js.map +1 -1
  80. package/dist/score.d.ts +44 -1
  81. package/dist/score.d.ts.map +1 -1
  82. package/dist/score.js +78 -5
  83. package/dist/score.js.map +1 -1
  84. package/package.json +1 -1
  85. package/sbom.json +374 -74
  86. package/src/agentdb-index.ts +423 -60
  87. package/src/apply-leg.ts +187 -36
  88. package/src/codex-rollouts.ts +374 -0
  89. package/src/cost-ledger.ts +232 -24
  90. package/src/cross-family-control.ts +960 -0
  91. package/src/debt-ratchet.ts +143 -0
  92. package/src/embedding-config.ts +131 -10
  93. package/src/feature-adr-checkpoints.ts +29 -0
  94. package/src/feature-adr-decision-recall.ts +6 -4
  95. package/src/feature-adr-envelope.ts +242 -0
  96. package/src/feature-adr-routing.ts +139 -2
  97. package/src/feature-adr-stage-canon.ts +141 -0
  98. package/src/index.ts +60 -6
  99. package/src/loop-blobs.generated.ts +4 -4
  100. package/src/mutation-gate.ts +316 -0
  101. package/src/qe-bridge.ts +4 -2
  102. package/src/qe-findings.ts +463 -0
  103. package/src/recap.ts +10 -3
  104. package/src/round.ts +165 -6
  105. package/src/run-records.ts +282 -2
  106. package/src/score.ts +115 -6
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Shared debt-ratchet verdict — one place, called by every "N exceeds ceiling C" gate.
3
+ *
4
+ * WHY (backlog `19e671ebfea26fc9`, feature debt-ceiling-diff): a count-only ceiling knows how MANY
5
+ * debt items exist but not WHICH ones. Once the set crosses the ceiling, printing "every current
6
+ * finding" drowns the one new offender in a legacy list of 100+ — the check technically fires, but
7
+ * a human (or an agent) reading the failure cannot see what actually changed. Pinning the set
8
+ * alongside the count lets the verdict print a DIFFERENCE: `added` (new, not in the pinned set) and
9
+ * `resolved` (pinned, no longer present) — the legacy tail stops being printed at all.
10
+ *
11
+ * The three-way verdict:
12
+ * - current.length > ceiling → ok:false, names only the newly added ids/findings.
13
+ * If `added` is empty despite being over ceiling, the pinned set and the ceiling number have
14
+ * drifted apart (caller error, not a real debt increase) — the message says so explicitly
15
+ * rather than silently printing an empty new: list.
16
+ * - current.length < ceiling → ok:true, suggests lowering the ceiling, names what resolved.
17
+ * - current.length === ceiling → ok:true UNLESS the membership itself changed (one debt swapped
18
+ * for another at the same count) — that specific case is ok:false with a `swapped:` message,
19
+ * because a same-count substitution is exactly the failure mode a raw-number ratchet cannot see:
20
+ * a new debt item can hide behind a legacy one that happened to be fixed in the same window.
21
+ */
22
+
23
+ export interface DebtRatchetArgs {
24
+ readonly label: string;
25
+ readonly current: readonly string[];
26
+ readonly pinned: readonly string[];
27
+ readonly ceiling: number;
28
+ }
29
+
30
+ export interface DebtRatchetVerdict {
31
+ readonly ok: boolean;
32
+ readonly message: string;
33
+ readonly added: string[];
34
+ readonly resolved: string[];
35
+ }
36
+
37
+ export function debtRatchetVerdict(args: DebtRatchetArgs): DebtRatchetVerdict {
38
+ const { label, current, pinned, ceiling } = args;
39
+ const pinnedSet = new Set(pinned);
40
+ const currentSet = new Set(current);
41
+ const added = current.filter((id) => !pinnedSet.has(id));
42
+ const resolved = pinned.filter((id) => !currentSet.has(id));
43
+ const n = current.length;
44
+
45
+ // Lead delta after Codex r1 (MEDIUM): a "set" with duplicates is not a set — the count, the +K and
46
+ // the suggested ceiling would all be wrong. Loud, never normalised silently.
47
+ if (currentSet.size !== n) {
48
+ const dupes = current.filter((id, i) => current.indexOf(id) !== i);
49
+ return { ok: false, message: `${label}: duplicate ids in the current set (${[...new Set(dupes)].join(', ')}) — the count ${n} is not a set size`, added, resolved };
50
+ }
51
+
52
+ // Lead delta after Codex r1 (HIGH): ANY new debt is a failure, whatever the count does — two legacy
53
+ // items resolved plus one new one is 2 < 3 by count and would otherwise pass silently, hiding the
54
+ // very thing this ratchet exists to name. The count-only ceiling never hid this any better; the
55
+ // pinned set finally makes it visible.
56
+ if (n <= ceiling && added.length > 0) {
57
+ return {
58
+ ok: false,
59
+ message: `${label} ${n}; ceiling ${ceiling}, but NEW debt appeared: new: ${added.join(', ')}; resolved: ${resolved.length > 0 ? resolved.join(', ') : '(none)'}`,
60
+ added,
61
+ resolved,
62
+ };
63
+ }
64
+
65
+ if (n > ceiling) {
66
+ const newPart = added.length > 0 ? added.join(', ') : '(none — set/ceiling inconsistent)';
67
+ const resolvedPart = resolved.length > 0 ? resolved.join(', ') : '(none)';
68
+ return {
69
+ ok: false,
70
+ message: `${label} ${n} exceeds ceiling ${ceiling} (+${n - ceiling}): new: ${newPart}; resolved: ${resolvedPart}`,
71
+ added,
72
+ resolved,
73
+ };
74
+ }
75
+
76
+ if (n < ceiling) {
77
+ const resolvedPart = resolved.length > 0 ? resolved.join(', ') : '(none)';
78
+ return {
79
+ ok: true,
80
+ message: `${label} ${n}; ceiling can be lowered to ${n}; resolved: ${resolvedPart}`,
81
+ added,
82
+ resolved,
83
+ };
84
+ }
85
+
86
+ // n === ceiling with no new ids: a swap (one resolved, one added) is already caught above by the
87
+ // any-new-debt rule, so the only way to land here is an unchanged set — a clean pass.
88
+
89
+ return {
90
+ ok: true,
91
+ message: `${label} ${n}; ceiling ${ceiling}`,
92
+ added,
93
+ resolved,
94
+ };
95
+ }
96
+
97
+ export interface PinnedCeiling {
98
+ readonly count: number;
99
+ readonly pinned: string[];
100
+ readonly measuredAt: string;
101
+ readonly reproducer: string;
102
+ }
103
+
104
+ /**
105
+ * Reads a debt-ceiling json file and requires the pinned set to agree with the declared count —
106
+ * a ceiling whose set and number disagree is not trustworthy data, so this fails loud rather than
107
+ * trusting the number alone (the exact defect this feature exists to close).
108
+ */
109
+ export function ceilingUnreadableMessage(file: string): string {
110
+ return `Cannot read ${file}; measure the debt and create this ceiling file by hand.`;
111
+ }
112
+
113
+ /**
114
+ * PURE: the caller reads the file (tests own their I/O — the core-boundary ratchet counts src files
115
+ * that touch node:fs, and a debt-ceiling parser has no business being one); `file` is only used to
116
+ * name the offending file in error messages.
117
+ */
118
+ export function parsePinnedCeiling(
119
+ source: string,
120
+ file: string,
121
+ countField: 'unobserved' | 'uncommented',
122
+ setField: 'ids' | 'findings',
123
+ ): PinnedCeiling {
124
+ const parsed = JSON.parse(source) as Record<string, unknown>;
125
+ const count = parsed[countField];
126
+ const set = parsed[setField];
127
+ const measuredAt = parsed.measuredAt;
128
+ const reproducer = parsed.reproducer;
129
+ if (
130
+ !Number.isSafeInteger(count) || (count as number) < 0
131
+ || typeof measuredAt !== 'string' || typeof reproducer !== 'string'
132
+ || !Array.isArray(set) || !set.every((item) => typeof item === 'string')
133
+ ) {
134
+ throw new Error(`Invalid debt ceiling: ${file}`);
135
+ }
136
+ if (set.length !== count) {
137
+ throw new Error(`Invalid debt ceiling: ${file}: ${setField}.length ${set.length} ≠ ${countField} ${count}`);
138
+ }
139
+ if (new Set(set as string[]).size !== set.length) {
140
+ throw new Error(`Invalid debt ceiling: ${file}: ${setField} contains duplicates — a pinned set must be a set`);
141
+ }
142
+ return { count: count as number, pinned: set as string[], measuredAt, reproducer };
143
+ }
@@ -1,12 +1,27 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, copyFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
 
4
4
  export type EmbedModelSource = 'env' | 'config' | 'default';
5
5
 
6
+ /**
7
+ * `embed-daemon-memory` (ADR-001 D2): dtype is a property of the STORE, not a global runtime
8
+ * setting. `fp32` is the full-precision default (existing behaviour, unchanged); `q8` is the
9
+ * quantized variant (`{ dtype: 'q8' }` at `pipeline()` construction, `model_quantized.onnx`) —
10
+ * roughly half the RSS of fp32 at a measured cosine parity >= 0.990 (see the harness-core README's
11
+ * `memory.embed.dtype` section for the numbers). `memory.embed.dtype` in config selects the dtype
12
+ * for a NEW index/reindex; the STORE's manifest is what a query is actually embedded with
13
+ * ({@link guardEmbedSpace}) — the two are deliberately allowed to disagree only long enough for the
14
+ * guard to demand a reindex, never silently.
15
+ */
16
+ export type EmbedDtype = 'fp32' | 'q8';
17
+ export const KNOWN_EMBED_DTYPES: readonly EmbedDtype[] = ['fp32', 'q8'];
18
+ const DEFAULT_EMBED_DTYPE: EmbedDtype = 'fp32';
19
+
6
20
  export interface EmbedModelConfig {
7
21
  readonly model: string;
8
22
  readonly dim: 384;
9
23
  readonly source: EmbedModelSource;
24
+ readonly dtype: EmbedDtype;
10
25
  }
11
26
 
12
27
  export interface EmbedManifest {
@@ -14,6 +29,24 @@ export interface EmbedManifest {
14
29
  readonly dim: 384;
15
30
  readonly version: number;
16
31
  readonly engine?: string;
32
+ /** Absent on a manifest written before this feature — reads as `'fp32'` everywhere it is compared
33
+ * ({@link guardEmbedSpace}), matching the pre-existing fp32-only behaviour exactly. */
34
+ readonly dtype?: EmbedDtype;
35
+ /**
36
+ * Fix round 1 (Codex #4): the RAW `dtype` string off disk when it is PRESENT but not one of
37
+ * {@link KNOWN_EMBED_DTYPES} — a corrupted manifest (`"Q8"`) or one written by a future version
38
+ * (`"int8"`). Distinct from an ABSENT field (legacy pre-feature manifest, safe to read as `'fp32'`):
39
+ * a present-but-unknown value must never be silently folded into the same "absent" bucket, because
40
+ * the underlying vectors may genuinely not be fp32 — {@link guardEmbedSpace} and
41
+ * {@link resolveStoreEmbedDtype} both refuse instead of guessing when this is set.
42
+ */
43
+ readonly dtypeError?: string;
44
+ /** Lead delta after Codex r2 (HIGH): the manifest file EXISTS but could not be read/parsed
45
+ * (truncated by a concurrent writer, hand-edited into invalid JSON). It used to read as "absent"
46
+ * and fall through to legacy fp32 — the same silent cross-space risk as an unknown dtype. Only
47
+ * ENOENT means absent; a read/parse failure is carried here and guardEmbedSpace refuses. The
48
+ * remedy stays reachable: reindex stamps a NEW manifest before it re-indexes. */
49
+ readonly readError?: string;
17
50
  }
18
51
 
19
52
  export const DEFAULT_EMBED_MODEL = 'Xenova/paraphrase-multilingual-MiniLM-L12-v2';
@@ -50,8 +83,10 @@ export const KNOWN_EMBED_DIMS: Readonly<Record<string, 384>> = {
50
83
  */
51
84
 
52
85
  export function resolveEmbedModel(projectRoot: string): EmbedModelConfig | { error: string } {
86
+ const dtypeResult = resolveEmbedDtype(projectRoot);
87
+ if ('error' in dtypeResult) return dtypeResult;
53
88
  const env = process.env['DZ_EMBED_MODEL'];
54
- if (env !== undefined && env.trim() !== '') return modelConfig(env.trim(), 'env');
89
+ if (env !== undefined && env.trim() !== '') return modelConfig(env.trim(), 'env', dtypeResult.dtype);
55
90
  const cfgPath = join(projectRoot, '.dz', 'config.json');
56
91
  if (existsSync(cfgPath)) {
57
92
  try {
@@ -60,20 +95,45 @@ export function resolveEmbedModel(projectRoot: string): EmbedModelConfig | { err
60
95
  const agentdb = memory?.['agentdb'] as Record<string, unknown> | undefined;
61
96
  const embed = memory?.['embed'] as Record<string, unknown> | undefined;
62
97
  const configured = agentdb?.['embeddingModel'] ?? embed?.['model'];
63
- if (typeof configured === 'string' && configured.trim() !== '') return modelConfig(configured.trim(), 'config');
98
+ if (typeof configured === 'string' && configured.trim() !== '') return modelConfig(configured.trim(), 'config', dtypeResult.dtype);
64
99
  } catch {
65
100
  /* corrupt config falls back to the default, matching the existing config-read discipline */
66
101
  }
67
102
  }
68
- return modelConfig(DEFAULT_EMBED_MODEL, 'default');
103
+ return modelConfig(DEFAULT_EMBED_MODEL, 'default', dtypeResult.dtype);
104
+ }
105
+
106
+ /**
107
+ * FR-3/AC-2 (`embed-daemon-memory`): `memory.embed.dtype` read INDEPENDENTLY of which model source
108
+ * won above — a dtype override must apply the same way whether the model itself came from env,
109
+ * config, or the default. An unset value (or a config file that predates this feature) is `'fp32'`,
110
+ * matching every store written before this feature existed. An unrecognized string is a hard
111
+ * `{error}` naming the allowed values, never a silent fp32 fallback — a typo in the dtype must not
112
+ * quietly build the wrong-shaped index.
113
+ */
114
+ function resolveEmbedDtype(projectRoot: string): { dtype: EmbedDtype } | { error: string } {
115
+ const cfgPath = join(projectRoot, '.dz', 'config.json');
116
+ if (!existsSync(cfgPath)) return { dtype: DEFAULT_EMBED_DTYPE };
117
+ try {
118
+ const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8')) as Record<string, unknown>;
119
+ const memory = cfg['memory'] as Record<string, unknown> | undefined;
120
+ const embed = memory?.['embed'] as Record<string, unknown> | undefined;
121
+ const raw = embed?.['dtype'];
122
+ if (raw === undefined) return { dtype: DEFAULT_EMBED_DTYPE };
123
+ if (typeof raw === 'string' && (KNOWN_EMBED_DTYPES as readonly string[]).includes(raw)) return { dtype: raw as EmbedDtype };
124
+ return { error: `unsupported embedding dtype '${String(raw)}' (known: ${KNOWN_EMBED_DTYPES.join(', ')})` };
125
+ } catch {
126
+ // corrupt config falls back to the default, matching resolveEmbedModel's own discipline
127
+ return { dtype: DEFAULT_EMBED_DTYPE };
128
+ }
69
129
  }
70
130
 
71
- function modelConfig(model: string, source: EmbedModelSource): EmbedModelConfig | { error: string } {
131
+ function modelConfig(model: string, source: EmbedModelSource, dtype: EmbedDtype): EmbedModelConfig | { error: string } {
72
132
  const dim = KNOWN_EMBED_DIMS[model];
73
133
  if (dim === undefined) {
74
134
  return { error: `unsupported embedding model '${model}' (known 384-dim models: ${Object.keys(KNOWN_EMBED_DIMS).join(', ')})` };
75
135
  }
76
- return { model, dim, source };
136
+ return { model, dim, source, dtype };
77
137
  }
78
138
 
79
139
  export function embedManifestPath(storePath: string): string {
@@ -86,13 +146,41 @@ export function readEmbedManifest(storePath: string): EmbedManifest | undefined
86
146
 
87
147
  function readManifestFile(p: string): EmbedManifest | undefined {
88
148
  if (!existsSync(p)) return undefined;
149
+ // `readError` (below) is scoped to a REGULAR FILE whose bytes cannot be parsed — the concurrent-
150
+ // writer/hand-edit case Codex r2 named. A non-file at the sidecar path (a directory) is a different
151
+ // pathology and stays "absent": the store-generation AM-3 fixture plants exactly that directory so
152
+ // the manifest WRITE fails loudly after a real commit — refusing here would hide that contract.
153
+ let isFile = false;
154
+ try {
155
+ isFile = statSync(p).isFile();
156
+ } catch {
157
+ isFile = false;
158
+ }
159
+ if (!isFile) return undefined;
89
160
  try {
90
161
  const m = JSON.parse(readFileSync(p, 'utf-8')) as Partial<EmbedManifest>;
91
162
  if (typeof m.model !== 'string' || m.model === '') return undefined;
92
163
  if (m.dim !== DEFAULT_EMBED_DIM) return undefined;
93
- return { model: m.model, dim: DEFAULT_EMBED_DIM, version: typeof m.version === 'number' ? m.version : 1, ...(typeof m.engine === 'string' ? { engine: m.engine } : {}) };
94
- } catch {
95
- return undefined;
164
+ // T1 + fix round 1 (Codex #4): only the two known dtype strings are trusted off disk as a real
165
+ // dtype. An ABSENT field reads as `undefined` (legacy pre-feature manifest — guardEmbedSpace
166
+ // treats it as fp32, the safe pre-existing default). A field that IS PRESENT but names neither
167
+ // known value is NEVER folded into that same "absent" bucket — it used to be (T1's original cut),
168
+ // which let a corrupted (`"Q8"`) or future-version (`"int8"`) dtype masquerade as legacy-fp32 and
169
+ // search would silently compare vectors from two different spaces. It is carried instead as
170
+ // `dtypeError` (the raw string), which guardEmbedSpace/resolveStoreEmbedDtype turn into a hard
171
+ // refusal rather than a guess.
172
+ const dtype = m.dtype === 'fp32' || m.dtype === 'q8' ? m.dtype : undefined;
173
+ const dtypeError = m.dtype !== undefined && dtype === undefined ? String(m.dtype) : undefined;
174
+ return {
175
+ model: m.model,
176
+ dim: DEFAULT_EMBED_DIM,
177
+ version: typeof m.version === 'number' ? m.version : 1,
178
+ ...(typeof m.engine === 'string' ? { engine: m.engine } : {}),
179
+ ...(dtype !== undefined ? { dtype } : {}),
180
+ ...(dtypeError !== undefined ? { dtypeError } : {}),
181
+ };
182
+ } catch (err) {
183
+ return { model: '', dim: DEFAULT_EMBED_DIM, version: 1, readError: err instanceof Error ? err.message : String(err) };
96
184
  }
97
185
  }
98
186
 
@@ -111,9 +199,20 @@ export function legacyEmbedManifest(): EmbedManifest {
111
199
  }
112
200
 
113
201
  export function currentEmbedManifest(configured: EmbedModelConfig, version = 1, engine?: string): EmbedManifest {
114
- return { model: configured.model, dim: configured.dim, version, ...(engine !== undefined ? { engine } : {}) };
202
+ return { model: configured.model, dim: configured.dim, version, dtype: configured.dtype, ...(engine !== undefined ? { engine } : {}) };
115
203
  }
116
204
 
205
+ /**
206
+ * D2 (`embed-daemon-memory`): the model/dim check is unchanged; a SEPARATE dtype check is added
207
+ * after it. Absent manifest dtype reads as `'fp32'` (matching every store written before this
208
+ * feature) before the comparison — so an existing fp32 store configured for fp32 never trips this,
209
+ * and only a genuine fp32<->q8 disagreement (or a q8 store re-configured to fp32) is refused.
210
+ *
211
+ * Fix round 1 (Codex #4): a manifest `dtype` that is PRESENT but unrecognized ({@link
212
+ * EmbedManifest.dtypeError}) is checked BEFORE the fp32-fallback comparison above — it must never
213
+ * be silently treated as the safe legacy-absent case, because the store's real vectors may not be
214
+ * fp32 at all.
215
+ */
117
216
  export function guardEmbedSpace(args: {
118
217
  storePath: string;
119
218
  configured: EmbedModelConfig;
@@ -122,6 +221,13 @@ export function guardEmbedSpace(args: {
122
221
  }): { ok: true; manifest: EmbedManifest } | { ok: false; error: string; manifest: EmbedManifest } {
123
222
  const manifest = readStoreManifest(args.storePath)
124
223
  ?? (args.hasRows ? legacyEmbedManifest() : currentEmbedManifest(args.configured));
224
+ if (manifest.readError !== undefined) {
225
+ return {
226
+ ok: false,
227
+ manifest,
228
+ error: `embedding manifest unreadable (${manifest.readError}); run ${args.reindexHint}`,
229
+ };
230
+ }
125
231
  if (manifest.model !== args.configured.model || manifest.dim !== args.configured.dim) {
126
232
  return {
127
233
  ok: false,
@@ -129,6 +235,21 @@ export function guardEmbedSpace(args: {
129
235
  error: `embedding model mismatch: index built with ${manifest.model}/${manifest.dim}, configured ${args.configured.model}/${args.configured.dim}; run ${args.reindexHint}`,
130
236
  };
131
237
  }
238
+ if (manifest.dtypeError !== undefined) {
239
+ return {
240
+ ok: false,
241
+ manifest,
242
+ error: `unknown embedding dtype "${manifest.dtypeError}" in manifest; run ${args.reindexHint}`,
243
+ };
244
+ }
245
+ const manifestDtype = manifest.dtype ?? DEFAULT_EMBED_DTYPE;
246
+ if (manifestDtype !== args.configured.dtype) {
247
+ return {
248
+ ok: false,
249
+ manifest,
250
+ error: `embedding dtype mismatch: index built with ${manifestDtype}, configured ${args.configured.dtype}; run ${args.reindexHint}`,
251
+ };
252
+ }
132
253
  return { ok: true, manifest };
133
254
  }
134
255
 
@@ -491,6 +491,11 @@ export interface TrainingPair {
491
491
  truncated: TrainingPairTruncation | null;
492
492
  captureMode: 'capture' | 'backfill';
493
493
  resumed: boolean;
494
+ /** experiment-envelope FR-3(б): the envelope built once after the Step-0 router, carried
495
+ * alongside `budgetMode` (not instead of it — `budgetMode` is a narrower legacy summary).
496
+ * Normalized via `validateExperimentEnvelope`: an invalid or absent value becomes `null`, never
497
+ * a malformed value smuggled into the dataset. */
498
+ envelope: unknown | null;
494
499
  }
495
500
 
496
501
  /** Per-stage JSONL path, relative to the repo root. ONE file per stage. */
@@ -649,6 +654,28 @@ function normalizeTrainingPairBudget(raw: unknown): TrainingPairBudget | null {
649
654
  }
650
655
  }
651
656
 
657
+ /**
658
+ * experiment-envelope FR-3(б): a LIGHTWEIGHT structural check, not the full field-by-field
659
+ * validator (`validateExperimentEnvelope` in `feature-adr-envelope.ts`) — this module is
660
+ * DELIBERATELY import-free (the workflow sandbox mirrors it inline, same discipline as
661
+ * `TP_PROFILE_MARKER_START`/`tpRedact` above), so importing the full validator here would break
662
+ * that mirroring. This check catches gross malformation (not an object, wrong schema, missing
663
+ * the four nested sections) — the AUTHORITATIVE per-field validation gate lives at the
664
+ * run-records/round layer, where an invalid envelope actually refuses a write. Absent or
665
+ * malformed here degrades HONESTLY to `null`, never a fabricated or partially-checked value.
666
+ */
667
+ function normalizeTrainingPairEnvelope(raw: unknown): unknown | null {
668
+ if (raw === null || raw === undefined || typeof raw !== 'object' || Array.isArray(raw)) return null;
669
+ const v = raw as Record<string, unknown>;
670
+ if (v.schema !== 1) return null;
671
+ if (typeof v.runId !== 'string' || v.runId.trim() === '') return null;
672
+ if (v.arms === null || typeof v.arms !== 'object') return null;
673
+ if (v.chosen === null || typeof v.chosen !== 'object') return null;
674
+ if (v.policy === null || typeof v.policy !== 'object') return null;
675
+ if (v.evaluator === null || typeof v.evaluator !== 'object') return null;
676
+ return raw;
677
+ }
678
+
652
679
  /** Assemble one SFT-ready training pair. Deterministic (ts passed in). Applies the oversize
653
680
  * guard: when input+output exceed TRAINPAIR_MAX_IO_CHARS combined, each over-budget side is
654
681
  * truncated with a marker naming the cut char count + the fnv1a64 of its FULL text (the
@@ -667,6 +694,7 @@ export function buildTrainingPair(opts: {
667
694
  budgetMode?: unknown;
668
695
  captureMode?: unknown;
669
696
  resumed?: unknown;
697
+ envelope?: unknown;
670
698
  }): TrainingPair {
671
699
  // Operator-profile redaction FIRST — before the oversize guard, so the truncation hashes are
672
700
  // hashes of the redacted text and the full-text fnv1a64 never fingerprints personal data.
@@ -709,6 +737,7 @@ export function buildTrainingPair(opts: {
709
737
  truncated,
710
738
  captureMode: opts.captureMode === 'backfill' ? 'backfill' : 'capture',
711
739
  resumed: opts.resumed === true,
740
+ envelope: normalizeTrainingPairEnvelope(opts.envelope),
712
741
  };
713
742
  }
714
743
 
@@ -8,8 +8,8 @@ const MAX_QUERY_CHARS = 512;
8
8
  const MAX_PATTERN_CHARS = 800;
9
9
  const MAX_EVIDENCE_CHARS = 800;
10
10
 
11
- export type DecisionRecallKind = 'adr-alternative-selection' | 'plan-route-selection';
12
- export type DecisionRecallStage = 'step-3' | 'step-6';
11
+ export type DecisionRecallKind = 'adr-alternative-selection' | 'plan-route-selection' | 'code-implementation';
12
+ export type DecisionRecallStage = 'step-3' | 'step-6' | 'step-7';
13
13
  export type DecisionRecallOutcomeName =
14
14
  | 'success'
15
15
  | 'empty'
@@ -63,6 +63,8 @@ function oneLine(value: unknown, cap: number): string {
63
63
  function decisionShape(kind: DecisionRecallKind): { stage: DecisionRecallStage; banditContext: string } {
64
64
  return kind === 'adr-alternative-selection'
65
65
  ? { stage: 'step-3', banditContext: 'feature-adr-decision-adr-alternative' }
66
+ : kind === 'code-implementation'
67
+ ? { stage: 'step-7', banditContext: 'feature-adr-decision-code-implementation' }
66
68
  : { stage: 'step-6', banditContext: 'feature-adr-decision-plan-route' };
67
69
  }
68
70
 
@@ -391,8 +393,8 @@ function validBase(value: Record<string, unknown>): boolean {
391
393
  return typeof value.slug === 'string' && value.slug !== ''
392
394
  && typeof value.logicalDecisionId === 'string' && /^decision:[0-9a-f]{16}$/.test(value.logicalDecisionId)
393
395
  && typeof value.attemptId === 'string' && value.attemptId.startsWith(`${value.logicalDecisionId}:`)
394
- && (value.stage === 'step-3' || value.stage === 'step-6')
395
- && (value.decisionKind === 'adr-alternative-selection' || value.decisionKind === 'plan-route-selection')
396
+ && (value.stage === 'step-3' || value.stage === 'step-6' || value.stage === 'step-7')
397
+ && (value.decisionKind === 'adr-alternative-selection' || value.decisionKind === 'plan-route-selection' || value.decisionKind === 'code-implementation')
396
398
  && typeof value.ts === 'string' && value.ts !== '';
397
399
  }
398
400