@dzhechkov/harness-core 0.8.6 → 0.8.10

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 (105) hide show
  1. package/.dz-manifest.json +257 -53
  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/compounding.d.ts +54 -0
  7. package/dist/compounding.d.ts.map +1 -1
  8. package/dist/compounding.js +221 -1
  9. package/dist/compounding.js.map +1 -1
  10. package/dist/eta.d.ts +92 -0
  11. package/dist/eta.d.ts.map +1 -0
  12. package/dist/eta.js +488 -0
  13. package/dist/eta.js.map +1 -0
  14. package/dist/feature-adr-checkpoints.js +1 -1
  15. package/dist/feature-adr-decision-recall.d.ts +167 -0
  16. package/dist/feature-adr-decision-recall.d.ts.map +1 -0
  17. package/dist/feature-adr-decision-recall.js +519 -0
  18. package/dist/feature-adr-decision-recall.js.map +1 -0
  19. package/dist/feature-adr-landing.d.ts +37 -0
  20. package/dist/feature-adr-landing.d.ts.map +1 -0
  21. package/dist/feature-adr-landing.js +59 -0
  22. package/dist/feature-adr-landing.js.map +1 -0
  23. package/dist/feature-adr-routing.d.ts +2 -2
  24. package/dist/feature-adr-routing.d.ts.map +1 -1
  25. package/dist/feature-adr-routing.js +7 -11
  26. package/dist/feature-adr-routing.js.map +1 -1
  27. package/dist/guard-promotion.d.ts +41 -0
  28. package/dist/guard-promotion.d.ts.map +1 -1
  29. package/dist/guard-promotion.js +218 -4
  30. package/dist/guard-promotion.js.map +1 -1
  31. package/dist/guard-volume.d.ts +108 -0
  32. package/dist/guard-volume.d.ts.map +1 -0
  33. package/dist/guard-volume.js +536 -0
  34. package/dist/guard-volume.js.map +1 -0
  35. package/dist/guard.d.ts +9 -0
  36. package/dist/guard.d.ts.map +1 -1
  37. package/dist/guard.js +55 -4
  38. package/dist/guard.js.map +1 -1
  39. package/dist/index.d.ts +16 -2
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +11 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/integration-apply.d.ts +25 -0
  44. package/dist/integration-apply.d.ts.map +1 -0
  45. package/dist/integration-apply.js +299 -0
  46. package/dist/integration-apply.js.map +1 -0
  47. package/dist/integration-evidence.d.ts +46 -0
  48. package/dist/integration-evidence.d.ts.map +1 -0
  49. package/dist/integration-evidence.js +44 -0
  50. package/dist/integration-evidence.js.map +1 -0
  51. package/dist/integration-probe-worker.d.ts +22 -0
  52. package/dist/integration-probe-worker.d.ts.map +1 -0
  53. package/dist/integration-probe-worker.js +334 -0
  54. package/dist/integration-probe-worker.js.map +1 -0
  55. package/dist/integrations-verify.d.ts +60 -0
  56. package/dist/integrations-verify.d.ts.map +1 -0
  57. package/dist/integrations-verify.js +194 -0
  58. package/dist/integrations-verify.js.map +1 -0
  59. package/dist/lesson-generalization.d.ts +29 -0
  60. package/dist/lesson-generalization.d.ts.map +1 -0
  61. package/dist/lesson-generalization.js +84 -0
  62. package/dist/lesson-generalization.js.map +1 -0
  63. package/dist/operations.d.ts +27 -0
  64. package/dist/operations.d.ts.map +1 -1
  65. package/dist/operations.js +178 -2
  66. package/dist/operations.js.map +1 -1
  67. package/dist/patterns.d.ts +27 -1
  68. package/dist/patterns.d.ts.map +1 -1
  69. package/dist/patterns.js +211 -45
  70. package/dist/patterns.js.map +1 -1
  71. package/dist/setup.d.ts.map +1 -1
  72. package/dist/setup.js +20 -17
  73. package/dist/setup.js.map +1 -1
  74. package/dist/target-integrations.d.ts +65 -0
  75. package/dist/target-integrations.d.ts.map +1 -0
  76. package/dist/target-integrations.js +152 -0
  77. package/dist/target-integrations.js.map +1 -0
  78. package/dist/vector-tier.d.ts +6 -1
  79. package/dist/vector-tier.d.ts.map +1 -1
  80. package/dist/vector-tier.js +32 -7
  81. package/dist/vector-tier.js.map +1 -1
  82. package/package.json +9 -8
  83. package/sbom.json +562 -52
  84. package/src/amendment-trace.ts +12 -1
  85. package/src/compounding.ts +300 -1
  86. package/src/eta.ts +590 -0
  87. package/src/feature-adr-checkpoints.ts +1 -1
  88. package/src/feature-adr-decision-recall.ts +652 -0
  89. package/src/feature-adr-landing.ts +109 -0
  90. package/src/feature-adr-routing.ts +7 -11
  91. package/src/guard-promotion.ts +245 -4
  92. package/src/guard-volume.ts +752 -0
  93. package/src/guard.ts +70 -4
  94. package/src/index.ts +63 -2
  95. package/src/integration-apply.ts +332 -0
  96. package/src/integration-evidence.ts +89 -0
  97. package/src/integration-probe-worker.ts +310 -0
  98. package/src/integration-receipts/claude-code/mcp/2.1.235.json +35 -0
  99. package/src/integrations-verify.ts +258 -0
  100. package/src/lesson-generalization.ts +115 -0
  101. package/src/operations.ts +199 -2
  102. package/src/patterns.ts +252 -43
  103. package/src/setup.ts +20 -17
  104. package/src/target-integrations.ts +225 -0
  105. 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
@@ -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
  }