@dzhechkov/harness-core 0.8.6 → 0.8.11

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 (164) hide show
  1. package/.dz-manifest.json +361 -97
  2. package/README.md +48 -1
  3. package/dist/amendment-trace.d.ts.map +1 -1
  4. package/dist/amendment-trace.js +12 -1
  5. package/dist/amendment-trace.js.map +1 -1
  6. package/dist/codex-invoke.d.ts +73 -0
  7. package/dist/codex-invoke.d.ts.map +1 -0
  8. package/dist/codex-invoke.js +80 -0
  9. package/dist/codex-invoke.js.map +1 -0
  10. package/dist/compounding.d.ts +54 -0
  11. package/dist/compounding.d.ts.map +1 -1
  12. package/dist/compounding.js +221 -1
  13. package/dist/compounding.js.map +1 -1
  14. package/dist/discrimination-gate.d.ts +63 -3
  15. package/dist/discrimination-gate.d.ts.map +1 -1
  16. package/dist/discrimination-gate.js +113 -16
  17. package/dist/discrimination-gate.js.map +1 -1
  18. package/dist/eta.d.ts +92 -0
  19. package/dist/eta.d.ts.map +1 -0
  20. package/dist/eta.js +488 -0
  21. package/dist/eta.js.map +1 -0
  22. package/dist/event-chain.d.ts +30 -0
  23. package/dist/event-chain.d.ts.map +1 -1
  24. package/dist/event-chain.js +24 -0
  25. package/dist/event-chain.js.map +1 -1
  26. package/dist/feature-adr-checkpoints.js +1 -1
  27. package/dist/feature-adr-decision-recall.d.ts +167 -0
  28. package/dist/feature-adr-decision-recall.d.ts.map +1 -0
  29. package/dist/feature-adr-decision-recall.js +519 -0
  30. package/dist/feature-adr-decision-recall.js.map +1 -0
  31. package/dist/feature-adr-landing.d.ts +37 -0
  32. package/dist/feature-adr-landing.d.ts.map +1 -0
  33. package/dist/feature-adr-landing.js +59 -0
  34. package/dist/feature-adr-landing.js.map +1 -0
  35. package/dist/feature-adr-routing.d.ts +2 -2
  36. package/dist/feature-adr-routing.d.ts.map +1 -1
  37. package/dist/feature-adr-routing.js +7 -11
  38. package/dist/feature-adr-routing.js.map +1 -1
  39. package/dist/guard-promotion.d.ts +41 -0
  40. package/dist/guard-promotion.d.ts.map +1 -1
  41. package/dist/guard-promotion.js +218 -4
  42. package/dist/guard-promotion.js.map +1 -1
  43. package/dist/guard-volume.d.ts +108 -0
  44. package/dist/guard-volume.d.ts.map +1 -0
  45. package/dist/guard-volume.js +536 -0
  46. package/dist/guard-volume.js.map +1 -0
  47. package/dist/guard.d.ts +17 -0
  48. package/dist/guard.d.ts.map +1 -1
  49. package/dist/guard.js +92 -4
  50. package/dist/guard.js.map +1 -1
  51. package/dist/index.d.ts +24 -7
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +19 -5
  54. package/dist/index.js.map +1 -1
  55. package/dist/integration-apply.d.ts +25 -0
  56. package/dist/integration-apply.d.ts.map +1 -0
  57. package/dist/integration-apply.js +299 -0
  58. package/dist/integration-apply.js.map +1 -0
  59. package/dist/integration-evidence.d.ts +46 -0
  60. package/dist/integration-evidence.d.ts.map +1 -0
  61. package/dist/integration-evidence.js +44 -0
  62. package/dist/integration-evidence.js.map +1 -0
  63. package/dist/integration-probe-worker.d.ts +22 -0
  64. package/dist/integration-probe-worker.d.ts.map +1 -0
  65. package/dist/integration-probe-worker.js +334 -0
  66. package/dist/integration-probe-worker.js.map +1 -0
  67. package/dist/integrations-verify.d.ts +60 -0
  68. package/dist/integrations-verify.d.ts.map +1 -0
  69. package/dist/integrations-verify.js +194 -0
  70. package/dist/integrations-verify.js.map +1 -0
  71. package/dist/lesson-generalization.d.ts +29 -0
  72. package/dist/lesson-generalization.d.ts.map +1 -0
  73. package/dist/lesson-generalization.js +84 -0
  74. package/dist/lesson-generalization.js.map +1 -0
  75. package/dist/mutation-gate.d.ts +39 -36
  76. package/dist/mutation-gate.d.ts.map +1 -1
  77. package/dist/mutation-gate.js +111 -5
  78. package/dist/mutation-gate.js.map +1 -1
  79. package/dist/operations.d.ts +27 -0
  80. package/dist/operations.d.ts.map +1 -1
  81. package/dist/operations.js +186 -7
  82. package/dist/operations.js.map +1 -1
  83. package/dist/patterns.d.ts +27 -1
  84. package/dist/patterns.d.ts.map +1 -1
  85. package/dist/patterns.js +211 -45
  86. package/dist/patterns.js.map +1 -1
  87. package/dist/plugin.d.ts.map +1 -1
  88. package/dist/plugin.js +27 -5
  89. package/dist/plugin.js.map +1 -1
  90. package/dist/recommend.d.ts +4 -5
  91. package/dist/recommend.d.ts.map +1 -1
  92. package/dist/recommend.js +110 -45
  93. package/dist/recommend.js.map +1 -1
  94. package/dist/registry.d.ts +32 -1
  95. package/dist/registry.d.ts.map +1 -1
  96. package/dist/registry.js +165 -9
  97. package/dist/registry.js.map +1 -1
  98. package/dist/run-records.d.ts +3 -0
  99. package/dist/run-records.d.ts.map +1 -1
  100. package/dist/run-records.js +18 -0
  101. package/dist/run-records.js.map +1 -1
  102. package/dist/score.d.ts +95 -0
  103. package/dist/score.d.ts.map +1 -1
  104. package/dist/score.js +274 -2
  105. package/dist/score.js.map +1 -1
  106. package/dist/setup.d.ts.map +1 -1
  107. package/dist/setup.js +20 -17
  108. package/dist/setup.js.map +1 -1
  109. package/dist/skill-selection.d.ts +72 -0
  110. package/dist/skill-selection.d.ts.map +1 -0
  111. package/dist/skill-selection.js +76 -0
  112. package/dist/skill-selection.js.map +1 -0
  113. package/dist/stem.d.ts +12 -0
  114. package/dist/stem.d.ts.map +1 -0
  115. package/dist/stem.js +89 -0
  116. package/dist/stem.js.map +1 -0
  117. package/dist/target-integrations.d.ts +65 -0
  118. package/dist/target-integrations.d.ts.map +1 -0
  119. package/dist/target-integrations.js +152 -0
  120. package/dist/target-integrations.js.map +1 -0
  121. package/dist/telemetry-vocabulary.d.ts +7 -0
  122. package/dist/telemetry-vocabulary.d.ts.map +1 -1
  123. package/dist/telemetry-vocabulary.js +29 -0
  124. package/dist/telemetry-vocabulary.js.map +1 -1
  125. package/dist/vector-tier.d.ts +6 -1
  126. package/dist/vector-tier.d.ts.map +1 -1
  127. package/dist/vector-tier.js +32 -7
  128. package/dist/vector-tier.js.map +1 -1
  129. package/package.json +7 -6
  130. package/sbom.json +772 -112
  131. package/src/amendment-trace.ts +12 -1
  132. package/src/codex-invoke.ts +138 -0
  133. package/src/compounding.ts +300 -1
  134. package/src/discrimination-gate.ts +183 -19
  135. package/src/eta.ts +590 -0
  136. package/src/event-chain.ts +41 -0
  137. package/src/feature-adr-checkpoints.ts +1 -1
  138. package/src/feature-adr-decision-recall.ts +652 -0
  139. package/src/feature-adr-landing.ts +109 -0
  140. package/src/feature-adr-routing.ts +7 -11
  141. package/src/guard-promotion.ts +245 -4
  142. package/src/guard-volume.ts +752 -0
  143. package/src/guard.ts +110 -4
  144. package/src/index.ts +73 -6
  145. package/src/integration-apply.ts +332 -0
  146. package/src/integration-evidence.ts +89 -0
  147. package/src/integration-probe-worker.ts +310 -0
  148. package/src/integration-receipts/claude-code/mcp/2.1.235.json +35 -0
  149. package/src/integrations-verify.ts +258 -0
  150. package/src/lesson-generalization.ts +115 -0
  151. package/src/mutation-gate.ts +165 -5
  152. package/src/operations.ts +207 -7
  153. package/src/patterns.ts +252 -43
  154. package/src/plugin.ts +27 -5
  155. package/src/recommend.ts +116 -46
  156. package/src/registry.ts +144 -11
  157. package/src/run-records.ts +23 -0
  158. package/src/score.ts +361 -3
  159. package/src/setup.ts +20 -17
  160. package/src/skill-selection.ts +111 -0
  161. package/src/stem.ts +87 -0
  162. package/src/target-integrations.ts +225 -0
  163. package/src/telemetry-vocabulary.ts +36 -0
  164. package/src/vector-tier.ts +44 -14
@@ -339,7 +339,18 @@ export const AMENDMENT_VACUITY_NOTE =
339
339
  * are named later than ideation guesses); the SUBJECT may not.
340
340
  */
341
341
  export function amendmentSubject(raw: string): string {
342
- const withoutPointer = raw.split(/→\s*tests?\s/)[0] ?? '';
342
+ // BOTH arrows, for the same reason extractTestIds accepts both: the corpus carries `->` and `→`
343
+ // from two authors, and splitting on only one leaves the whole Confirmation sentence inside the
344
+ // "subject". Two documents that word their Confirmation differently — which they are entitled to,
345
+ // since only the SUBJECT must survive verbatim — then read as a subject change. MEASURED
346
+ // 2026-08-30 on os-matrix-pack-smoke: all 9 amendments resolved to real tests, yet all 9 reported
347
+ // "the plan describes a DIFFERENT change"; the ideation used `->` throughout.
348
+ // The pointer region starts at `Confirmation:` — everything from there on is HOW the amendment is
349
+ // proven (which fixture, which arrow form, which test name), and the rule says only the pointer may
350
+ // differ between the two documents. Splitting at the arrow alone left the Confirmation PROSE inside
351
+ // the subject, so two documents describing the same fixture in different words read as a subject
352
+ // change. Both arrows are still handled, for corpora that omit the `Confirmation:` lead-in.
353
+ const withoutPointer = raw.split(/Confirmation\s*:/i)[0]?.split(/(?:\u2192|->)\s*tests?\s/)[0] ?? '';
343
354
  // Strip ONLY the row's furniture: bullet/table marks, the bold id, an optional `(source)` tag and
344
355
  // a colon. An earlier version consumed up to 80 characters after the id, which ate the SUBJECT
345
356
  // itself whenever a row carried no `(source):` tag — the checker then compared two truncations
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The one supported way to invoke Codex from a Claude Code session.
3
+ *
4
+ * Three failure modes were measured on 2026-08-31, each violating knowledge we had already
5
+ * written down and could not enforce: the fire-and-forget wrapper returning a dispatch stub for a
6
+ * stage whose deliverable is its return value; a prompt passed as a shell ARGUMENT whose backticks
7
+ * the shell read as command substitution, leaving codex to read an empty stdin and hang for 26
8
+ * minutes; and an unscoped run spending its whole budget exploring the tree and returning no
9
+ * verdict at all.
10
+ *
11
+ * The cure is structural, not advisory: this module has no parameter that accepts a prompt STRING
12
+ * (so a shell can never mangle it), a timeout is always present, and the outcome set is CLOSED —
13
+ * either the model's text, or one of five named refusals. There is no third state, which is the
14
+ * property the tests pin.
15
+ */
16
+
17
+ /** Every way this can fail, enumerated. A refusal outside this set is a bug, not a new case. */
18
+ export type CodexRefusal =
19
+ | 'timeout'
20
+ | 'no-output'
21
+ | 'model-unavailable'
22
+ | 'tool-error'
23
+ | 'bad-usage';
24
+
25
+ export interface CodexOk {
26
+ readonly ok: true;
27
+ readonly text: string;
28
+ readonly model: string;
29
+ readonly elapsedMs: number;
30
+ }
31
+
32
+ export interface CodexRefused {
33
+ readonly ok: false;
34
+ readonly refusal: CodexRefusal;
35
+ /** Human-readable, names WHAT was observed — never a guess at the cause. */
36
+ readonly detail: string;
37
+ readonly model?: string;
38
+ readonly elapsedMs?: number;
39
+ }
40
+
41
+ export type CodexOutcome = CodexOk | CodexRefused;
42
+
43
+ export const CODEX_DEFAULT_TIMEOUT_MS = 600_000;
44
+ export const CODEX_PROBE_TIMEOUT_MS = 90_000;
45
+
46
+ /** Model ids this account has been seen to answer on. A name here is spellable, never available. */
47
+ export const CODEX_KNOWN_MODELS = ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5'] as const;
48
+
49
+ export interface CodexRunInput {
50
+ /** Absolute path to a file holding the prompt. There is deliberately no string variant. */
51
+ readonly promptFile: string;
52
+ readonly model: string;
53
+ readonly timeoutMs?: number;
54
+ /**
55
+ * Files the model may read. REQUIRED for review-shaped work: an unscoped run was measured at
56
+ * 280s / exit 124 / 416KB of exploration and no verdict, while the same question scoped to two
57
+ * named files answered in 41s. Empty means "no scope declared" and is allowed only for a
58
+ * self-contained question that needs no repository access.
59
+ */
60
+ readonly scope?: readonly string[];
61
+ readonly effort?: 'low' | 'medium' | 'high' | 'xhigh';
62
+ }
63
+
64
+ /** What a runner must provide. Kept tiny so tests can substitute it without spawning anything. */
65
+ export interface CodexRunner {
66
+ (argv: readonly string[], timeoutMs: number): {
67
+ readonly status: number | null;
68
+ readonly stdout: string;
69
+ readonly stderr: string;
70
+ readonly timedOut: boolean;
71
+ };
72
+ }
73
+
74
+ /** Build the argv. Exported so a test can assert the prompt never rides on the command line. */
75
+ export function codexArgv(input: CodexRunInput): string[] {
76
+ const argv = ['exec', '-m', input.model, '--skip-git-repo-check'];
77
+ if (input.effort !== undefined) argv.push('-c', `model_reasoning_effort=${input.effort}`);
78
+ return argv;
79
+ }
80
+
81
+ /** Turn a raw runner result into the closed outcome set. */
82
+ export function classifyCodexResult(
83
+ raw: { status: number | null; stdout: string; stderr: string; timedOut: boolean },
84
+ model: string,
85
+ elapsedMs: number,
86
+ ): CodexOutcome {
87
+ if (raw.timedOut) {
88
+ return {
89
+ ok: false,
90
+ refusal: 'timeout',
91
+ detail: `no answer within the deadline (${elapsedMs}ms); narrow the scope rather than raising the ceiling — an unscoped run spends the budget exploring`,
92
+ model,
93
+ elapsedMs,
94
+ };
95
+ }
96
+ if (raw.status !== 0) {
97
+ return {
98
+ ok: false,
99
+ refusal: 'tool-error',
100
+ detail: `codex exited ${raw.status === null ? 'by signal' : String(raw.status)}: ${raw.stderr.trim().slice(0, 300) || '(no stderr)'}`,
101
+ model,
102
+ elapsedMs,
103
+ };
104
+ }
105
+ const text = raw.stdout.trim();
106
+ if (text === '') {
107
+ // Silence is the failure this module exists to make impossible. A clean exit with nothing
108
+ // written is what a mangled prompt looks like from the outside, and it must never read as ok.
109
+ return {
110
+ ok: false,
111
+ refusal: 'no-output',
112
+ detail: 'codex exited 0 but wrote nothing — an empty answer is a refusal, never a clean result',
113
+ model,
114
+ elapsedMs,
115
+ };
116
+ }
117
+ return { ok: true, text, model, elapsedMs };
118
+ }
119
+
120
+ /** Compose the prompt file's content: the task, plus the scope fence when one is declared. */
121
+ export function codexPromptBody(task: string, scope?: readonly string[]): string {
122
+ const trimmed = String(task ?? '').trim();
123
+ if (scope === undefined || scope.length === 0) return trimmed;
124
+ const list = scope.map((p) => `- ${p}`).join('\n');
125
+ return [
126
+ trimmed,
127
+ '',
128
+ 'SCOPE — read ONLY these files and do not open others. This bound is what makes an answer',
129
+ 'possible at all: an unscoped run was measured spending its entire budget exploring the tree',
130
+ 'and returning no verdict.',
131
+ list,
132
+ ].join('\n');
133
+ }
134
+
135
+ /** True when the outcome may be consumed as an answer. Exists so callers cannot forget the check. */
136
+ export function codexAnswered(outcome: CodexOutcome): outcome is CodexOk {
137
+ return outcome.ok === true;
138
+ }
@@ -16,6 +16,11 @@
16
16
  */
17
17
 
18
18
  import { EVENT_CHAIN_SCOPE, verifyEventChainText } from './event-chain.js';
19
+ import {
20
+ isOffsetIsoTimestamp,
21
+ type PromotionAcceptanceEvidence,
22
+ type PromotionRunEvidence,
23
+ } from './guard-promotion.js';
19
24
 
20
25
  // ── Seeded statistics (verbatim-shape port from darwin-mode bench/stats.ts) ──
21
26
 
@@ -151,8 +156,52 @@ export function replayableInstances(
151
156
 
152
157
  export interface GuardEvent {
153
158
  readonly ts: string;
159
+ readonly op?: 'publish' | 'teach' | 'consolidate' | 'reindex';
154
160
  readonly verdict: string;
155
161
  readonly rules: readonly string[]; // violated rule ids
162
+ readonly violations?: readonly { readonly rule: string; readonly contentAnchor?: string }[];
163
+ }
164
+
165
+ export type FunnelEvidenceSource<T> =
166
+ | { readonly status: 'measured'; readonly rows: readonly T[] }
167
+ | { readonly status: 'not-measured'; readonly reason: string };
168
+
169
+ export interface LessonToRuleFunnelFacts {
170
+ readonly promotionRuns: FunnelEvidenceSource<PromotionRunEvidence>;
171
+ readonly guardAudits: FunnelEvidenceSource<GuardEvent>;
172
+ readonly promotionAcceptances?: readonly PromotionAcceptanceEvidence[];
173
+ readonly truncatedPromotionPeriods?: readonly string[];
174
+ readonly acceptanceHistoryComplete?: boolean;
175
+ }
176
+
177
+ export type LessonToRuleStage = 'eligible' | 'attempted' | 'accepted' | 'executions';
178
+ export type FunnelStageMeasurement =
179
+ | { readonly status: 'measured'; readonly value: number }
180
+ | { readonly status: 'not-measured'; readonly reason: string };
181
+
182
+ export interface LessonToRuleFunnelPeriod {
183
+ readonly period: string;
184
+ readonly eligible: FunnelStageMeasurement;
185
+ readonly attempted: FunnelStageMeasurement;
186
+ readonly accepted: FunnelStageMeasurement;
187
+ readonly executions: FunnelStageMeasurement;
188
+ }
189
+
190
+ export interface LessonToRuleFunnelFinding {
191
+ readonly predecessor: LessonToRuleStage;
192
+ readonly stage: LessonToRuleStage;
193
+ readonly fromPeriod: string;
194
+ readonly toPeriod: string;
195
+ readonly counts: readonly {
196
+ readonly period: string;
197
+ readonly predecessor: number;
198
+ readonly successor: number;
199
+ }[];
200
+ }
201
+
202
+ export interface LessonToRuleFunnelReport {
203
+ readonly periods: readonly LessonToRuleFunnelPeriod[];
204
+ readonly findings: readonly LessonToRuleFunnelFinding[];
156
205
  }
157
206
 
158
207
  /**
@@ -175,6 +224,8 @@ export interface CompoundingFacts {
175
224
  readonly evidenceLogs?: readonly EvidenceLogFact[];
176
225
  /** Depth of the command-invocation corpus. `null` means no readable log, never zero-by-default. */
177
226
  readonly cmdUsageDepthDays?: number | null;
227
+ /** Prospective route observations; absence is explicit NOT MEASURED, never an empty funnel. */
228
+ readonly lessonToRule?: LessonToRuleFunnelFacts;
178
229
  }
179
230
 
180
231
  // ── The report ──────────────────────────────────────────────────────
@@ -244,11 +295,202 @@ export interface CompoundingReport {
244
295
  readonly guardTrajectory: readonly GuardRuleTrajectory[];
245
296
  readonly replay: ReplayReadiness;
246
297
  readonly instrumentation: InstrumentationHealth;
298
+ readonly lessonToRuleFunnel: LessonToRuleFunnelReport;
247
299
  /** The one-line honest answer. */
248
300
  readonly verdict: string;
249
301
  }
250
302
 
251
303
  const APPLY_LEG_STALE_DAYS = 7;
304
+ const FUNNEL_MONTH_LIMIT = 12;
305
+
306
+ const LESSON_TO_RULE_FUNNEL_POLICY = {
307
+ unavailable: (reason: string): FunnelStageMeasurement => ({ status: 'not-measured', reason }),
308
+ successor: (stage: LessonToRuleStage): LessonToRuleStage => stage,
309
+ };
310
+
311
+ function utcMonth(ts: string): string | null {
312
+ return isOffsetIsoTimestamp(ts) ? new Date(Date.parse(ts)).toISOString().slice(0, 7) : null;
313
+ }
314
+
315
+ function monthOffset(period: string, delta: number): string {
316
+ const [year, month] = period.split('-').map(Number);
317
+ return new Date(Date.UTC(year!, month! - 1 + delta, 1)).toISOString().slice(0, 7);
318
+ }
319
+
320
+ function funnelPeriods(facts: LessonToRuleFunnelFacts, nowTs: string): string[] {
321
+ const evidenceMonths: string[] = [];
322
+ if (facts.promotionRuns.status === 'measured') {
323
+ for (const row of facts.promotionRuns.rows) {
324
+ const period = utcMonth(row.ts);
325
+ if (period !== null) evidenceMonths.push(period);
326
+ }
327
+ }
328
+ if (facts.guardAudits.status === 'measured') {
329
+ for (const row of facts.guardAudits.rows) {
330
+ const period = utcMonth(row.ts);
331
+ if (period !== null) evidenceMonths.push(period);
332
+ }
333
+ }
334
+ for (const period of facts.truncatedPromotionPeriods ?? []) evidenceMonths.push(period);
335
+ const current = utcMonth(nowTs) ?? evidenceMonths.sort().at(-1) ?? '1970-01';
336
+ const floor = monthOffset(current, -(FUNNEL_MONTH_LIMIT - 1));
337
+ const earliest = evidenceMonths.filter((period) => period >= floor && period <= current).sort()[0] ?? current;
338
+ const periods: string[] = [];
339
+ for (let period = earliest; period <= current; period = monthOffset(period, 1)) periods.push(period);
340
+ return periods;
341
+ }
342
+
343
+ function promotionMeasurements(
344
+ facts: LessonToRuleFunnelFacts,
345
+ period: string,
346
+ ): Pick<LessonToRuleFunnelPeriod, 'eligible' | 'attempted' | 'accepted'> {
347
+ if (facts.promotionRuns.status === 'not-measured') {
348
+ const unavailable = LESSON_TO_RULE_FUNNEL_POLICY.unavailable(facts.promotionRuns.reason);
349
+ return { eligible: unavailable, attempted: unavailable, accepted: unavailable };
350
+ }
351
+ if (facts.truncatedPromotionPeriods?.includes(period) === true) {
352
+ const unavailable = LESSON_TO_RULE_FUNNEL_POLICY.unavailable(`promotion-history-pruned:${period}`);
353
+ return { eligible: unavailable, attempted: unavailable, accepted: unavailable };
354
+ }
355
+ const runs = facts.promotionRuns.rows.filter((row) => utcMonth(row.ts) === period);
356
+ if (runs.length === 0) {
357
+ const unavailable = LESSON_TO_RULE_FUNNEL_POLICY.unavailable(`promotion-run-not-recorded:${period}`);
358
+ return { eligible: unavailable, attempted: unavailable, accepted: unavailable };
359
+ }
360
+ if (runs.some((row) => row.complete !== true)) {
361
+ const unavailable = LESSON_TO_RULE_FUNNEL_POLICY.unavailable(`promotion-run-incomplete:${period}`);
362
+ return { eligible: unavailable, attempted: unavailable, accepted: unavailable };
363
+ }
364
+ const eligible = new Set<string>();
365
+ const attempted = new Set<string>();
366
+ const accepted = new Set<string>();
367
+ for (const run of runs) {
368
+ for (const candidate of run.candidates) {
369
+ if (candidate.eligible !== true) continue;
370
+ eligible.add(candidate.candidateAnchor);
371
+ if (typeof candidate.ruleContentAnchor !== 'string' || candidate.ruleContentAnchor === '') continue;
372
+ attempted.add(candidate.candidateAnchor);
373
+ if (candidate.verdict === 'promote') accepted.add(candidate.candidateAnchor);
374
+ }
375
+ }
376
+ return {
377
+ eligible: { status: 'measured', value: eligible.size },
378
+ attempted: { status: 'measured', value: attempted.size },
379
+ accepted: { status: 'measured', value: accepted.size },
380
+ };
381
+ }
382
+
383
+ function executionMeasurement(
384
+ facts: LessonToRuleFunnelFacts,
385
+ period: string,
386
+ ): FunnelStageMeasurement {
387
+ if (facts.promotionRuns.status === 'not-measured') {
388
+ return LESSON_TO_RULE_FUNNEL_POLICY.unavailable(facts.promotionRuns.reason);
389
+ }
390
+ if (facts.guardAudits.status === 'not-measured') {
391
+ return LESSON_TO_RULE_FUNNEL_POLICY.unavailable(facts.guardAudits.reason);
392
+ }
393
+ const periodAudits = facts.guardAudits.rows.filter((row) => utcMonth(row.ts) === period);
394
+ if (periodAudits.length === 0) {
395
+ return LESSON_TO_RULE_FUNNEL_POLICY.unavailable(`guard-audit-not-recorded:${period}`);
396
+ }
397
+ const audits = periodAudits.filter((row) => row.op === 'publish');
398
+ if (audits.length === 0) {
399
+ return LESSON_TO_RULE_FUNNEL_POLICY.unavailable(`guard-publish-not-recorded:${period}`);
400
+ }
401
+ const violations = audits.flatMap((audit) => {
402
+ const observed: readonly { readonly rule: string; readonly contentAnchor?: string }[] =
403
+ audit.violations ?? audit.rules.map((rule) => ({ rule }));
404
+ return observed.map((violation) => ({ ...violation, auditTs: Date.parse(audit.ts) }));
405
+ });
406
+ if (violations.some((item) => item.rule.startsWith('promoted-') && !item.contentAnchor)) {
407
+ return LESSON_TO_RULE_FUNNEL_POLICY.unavailable(`guard-audit-anchor-missing:${period}`);
408
+ }
409
+ const acceptances = facts.promotionAcceptances ?? facts.promotionRuns.rows.flatMap((run) =>
410
+ run.complete !== true
411
+ ? []
412
+ : run.candidates
413
+ .filter((candidate) => candidate.eligible === true && candidate.verdict === 'promote' && candidate.ruleContentAnchor)
414
+ .map((candidate) => ({ ruleContentAnchor: candidate.ruleContentAnchor!, acceptedTs: run.ts })),
415
+ );
416
+ const acceptedAt = new Map<string, number>();
417
+ for (const acceptance of acceptances) {
418
+ const acceptedTs = Date.parse(acceptance.acceptedTs);
419
+ const prior = acceptedAt.get(acceptance.ruleContentAnchor);
420
+ if (Number.isFinite(acceptedTs) && (prior === undefined || acceptedTs < prior)) {
421
+ acceptedAt.set(acceptance.ruleContentAnchor, acceptedTs);
422
+ }
423
+ }
424
+ const unattributedPromoted = violations.some((item) =>
425
+ item.rule.startsWith('promoted-') &&
426
+ typeof item.contentAnchor === 'string' &&
427
+ (acceptedAt.get(item.contentAnchor) === undefined || acceptedAt.get(item.contentAnchor)! > item.auditTs),
428
+ );
429
+ const acceptanceHistoryComplete = facts.acceptanceHistoryComplete ??
430
+ facts.promotionRuns.rows.every((run) => run.complete === true);
431
+ if (unattributedPromoted && !acceptanceHistoryComplete) {
432
+ return LESSON_TO_RULE_FUNNEL_POLICY.unavailable(`promotion-acceptance-history-incomplete:${period}`);
433
+ }
434
+ return {
435
+ status: 'measured',
436
+ value: violations.filter((item) =>
437
+ typeof item.contentAnchor === 'string' &&
438
+ acceptedAt.has(item.contentAnchor) &&
439
+ acceptedAt.get(item.contentAnchor)! <= item.auditTs,
440
+ ).length,
441
+ };
442
+ }
443
+
444
+ function funnelFindings(periods: readonly LessonToRuleFunnelPeriod[]): LessonToRuleFunnelFinding[] {
445
+ const edges: readonly [LessonToRuleStage, LessonToRuleStage][] = [
446
+ ['eligible', 'attempted'],
447
+ ['attempted', 'accepted'],
448
+ ['accepted', 'executions'],
449
+ ];
450
+ const findings: LessonToRuleFunnelFinding[] = [];
451
+ for (const [predecessor, successor] of edges) {
452
+ let streak: LessonToRuleFunnelPeriod[] = [];
453
+ const emit = (): void => {
454
+ if (streak.length < 3) return;
455
+ const observed = streak.slice(-3);
456
+ findings.push({
457
+ predecessor,
458
+ stage: LESSON_TO_RULE_FUNNEL_POLICY.successor(successor),
459
+ fromPeriod: observed[0]!.period,
460
+ toPeriod: observed[2]!.period,
461
+ counts: observed.map((row) => ({
462
+ period: row.period,
463
+ predecessor: (row[predecessor] as { status: 'measured'; value: number }).value,
464
+ successor: (row[successor] as { status: 'measured'; value: number }).value,
465
+ })),
466
+ });
467
+ };
468
+ for (const row of periods) {
469
+ const before = row[predecessor];
470
+ const after = row[successor];
471
+ if (before.status === 'measured' && after.status === 'measured' && before.value > 0 && after.value === 0) {
472
+ streak.push(row);
473
+ } else {
474
+ emit();
475
+ streak = [];
476
+ }
477
+ }
478
+ emit();
479
+ }
480
+ return findings;
481
+ }
482
+
483
+ export function assembleLessonToRuleFunnel(
484
+ facts: LessonToRuleFunnelFacts,
485
+ nowTs: string,
486
+ ): LessonToRuleFunnelReport {
487
+ const periods = funnelPeriods(facts, nowTs).map((period): LessonToRuleFunnelPeriod => ({
488
+ period,
489
+ ...promotionMeasurements(facts, period),
490
+ executions: executionMeasurement(facts, period),
491
+ }));
492
+ return { periods, findings: funnelFindings(periods) };
493
+ }
252
494
 
253
495
  export function assembleCompoundingReport(facts: CompoundingFacts): CompoundingReport {
254
496
  const { lessons, usage, guard } = facts;
@@ -367,6 +609,14 @@ export function assembleCompoundingReport(facts: CompoundingFacts): CompoundingR
367
609
  : null,
368
610
  };
369
611
 
612
+ const lessonToRuleFunnel = assembleLessonToRuleFunnel(
613
+ facts.lessonToRule ?? {
614
+ promotionRuns: { status: 'not-measured', reason: 'promotion-journal-not-provided' },
615
+ guardAudits: { status: 'not-measured', reason: 'guard-audit-not-provided' },
616
+ },
617
+ facts.nowTs,
618
+ );
619
+
370
620
  const improvedRules = trajectory.filter((t) => t.improved).length;
371
621
  const verdict = [
372
622
  `pool: ${injectedEver}/${total} lessons ever injected (${Math.round(pool.writeOnlyRatio * 100)}% write-only under the strict bar)`,
@@ -382,7 +632,43 @@ export function assembleCompoundingReport(facts: CompoundingFacts): CompoundingR
382
632
  ]),
383
633
  ].join(' · ');
384
634
 
385
- return { pool, guardTrajectory: trajectory, replay, instrumentation, verdict };
635
+ return { pool, guardTrajectory: trajectory, replay, instrumentation, lessonToRuleFunnel, verdict };
636
+ }
637
+
638
+ function renderFunnelMeasurement(stage: LessonToRuleStage, value: FunnelStageMeasurement): string {
639
+ return value.status === 'measured'
640
+ ? `${stage} ${value.value}`
641
+ : `${stage} NOT MEASURED (${value.reason})`;
642
+ }
643
+
644
+ function renderPromotionMeasurements(row: LessonToRuleFunnelPeriod): string {
645
+ const { eligible, attempted, accepted } = row;
646
+ if (
647
+ eligible.status === 'not-measured' &&
648
+ attempted.status === 'not-measured' &&
649
+ accepted.status === 'not-measured' &&
650
+ eligible.reason === attempted.reason &&
651
+ eligible.reason === accepted.reason
652
+ ) {
653
+ return `eligible/attempted/accepted NOT MEASURED (${eligible.reason})`;
654
+ }
655
+ return [
656
+ renderFunnelMeasurement('eligible', row.eligible),
657
+ renderFunnelMeasurement('attempted', row.attempted),
658
+ renderFunnelMeasurement('accepted', row.accepted),
659
+ ].join(' · ');
660
+ }
661
+
662
+ function renderFunnelPeriodMeasurements(row: LessonToRuleFunnelPeriod): string {
663
+ const values = [row.eligible, row.attempted, row.accepted, row.executions] as const;
664
+ const unavailable = values.filter((value) => value.status === 'not-measured');
665
+ if (
666
+ unavailable.length === values.length &&
667
+ unavailable.every((value) => value.reason === unavailable[0]!.reason)
668
+ ) {
669
+ return `eligible/attempted/accepted/executions NOT MEASURED (${unavailable[0]!.reason})`;
670
+ }
671
+ return `${renderPromotionMeasurements(row)} · ${renderFunnelMeasurement('executions', row.executions)}`;
386
672
  }
387
673
 
388
674
  export function renderCompoundingReport(r: CompoundingReport): string {
@@ -420,6 +706,19 @@ export function renderCompoundingReport(r: CompoundingReport): string {
420
706
  );
421
707
  if (r.instrumentation.chains.length > 0) out.push(` scope: ${EVENT_CHAIN_SCOPE}`);
422
708
  out.push('');
709
+ out.push(' LESSON → RULE FUNNEL (calendar month, observed traffic only):');
710
+ for (const row of r.lessonToRuleFunnel.periods) {
711
+ out.push(
712
+ ` ${row.period} · ${renderFunnelPeriodMeasurements(row)}`,
713
+ );
714
+ }
715
+ for (const finding of r.lessonToRuleFunnel.findings) {
716
+ const counts = finding.counts
717
+ .map((row) => `${row.period} ${finding.predecessor} ${row.predecessor} → ${finding.stage} ${row.successor}`)
718
+ .join('; ');
719
+ out.push(` FLOW STOP ${finding.stage} (${finding.fromPeriod}..${finding.toPeriod}): ${counts}`);
720
+ }
721
+ out.push('');
423
722
  out.push(` VERDICT: ${r.verdict}`);
424
723
  return out.join('\n');
425
724
  }