@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.
- package/.dz-manifest.json +361 -97
- package/README.md +48 -1
- package/dist/amendment-trace.d.ts.map +1 -1
- package/dist/amendment-trace.js +12 -1
- package/dist/amendment-trace.js.map +1 -1
- package/dist/codex-invoke.d.ts +73 -0
- package/dist/codex-invoke.d.ts.map +1 -0
- package/dist/codex-invoke.js +80 -0
- package/dist/codex-invoke.js.map +1 -0
- package/dist/compounding.d.ts +54 -0
- package/dist/compounding.d.ts.map +1 -1
- package/dist/compounding.js +221 -1
- package/dist/compounding.js.map +1 -1
- package/dist/discrimination-gate.d.ts +63 -3
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +113 -16
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/eta.d.ts +92 -0
- package/dist/eta.d.ts.map +1 -0
- package/dist/eta.js +488 -0
- package/dist/eta.js.map +1 -0
- package/dist/event-chain.d.ts +30 -0
- package/dist/event-chain.d.ts.map +1 -1
- package/dist/event-chain.js +24 -0
- package/dist/event-chain.js.map +1 -1
- package/dist/feature-adr-checkpoints.js +1 -1
- package/dist/feature-adr-decision-recall.d.ts +167 -0
- package/dist/feature-adr-decision-recall.d.ts.map +1 -0
- package/dist/feature-adr-decision-recall.js +519 -0
- package/dist/feature-adr-decision-recall.js.map +1 -0
- package/dist/feature-adr-landing.d.ts +37 -0
- package/dist/feature-adr-landing.d.ts.map +1 -0
- package/dist/feature-adr-landing.js +59 -0
- package/dist/feature-adr-landing.js.map +1 -0
- package/dist/feature-adr-routing.d.ts +2 -2
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +7 -11
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard-promotion.d.ts +41 -0
- package/dist/guard-promotion.d.ts.map +1 -1
- package/dist/guard-promotion.js +218 -4
- package/dist/guard-promotion.js.map +1 -1
- package/dist/guard-volume.d.ts +108 -0
- package/dist/guard-volume.d.ts.map +1 -0
- package/dist/guard-volume.js +536 -0
- package/dist/guard-volume.js.map +1 -0
- package/dist/guard.d.ts +17 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +92 -4
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +24 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -5
- package/dist/index.js.map +1 -1
- package/dist/integration-apply.d.ts +25 -0
- package/dist/integration-apply.d.ts.map +1 -0
- package/dist/integration-apply.js +299 -0
- package/dist/integration-apply.js.map +1 -0
- package/dist/integration-evidence.d.ts +46 -0
- package/dist/integration-evidence.d.ts.map +1 -0
- package/dist/integration-evidence.js +44 -0
- package/dist/integration-evidence.js.map +1 -0
- package/dist/integration-probe-worker.d.ts +22 -0
- package/dist/integration-probe-worker.d.ts.map +1 -0
- package/dist/integration-probe-worker.js +334 -0
- package/dist/integration-probe-worker.js.map +1 -0
- package/dist/integrations-verify.d.ts +60 -0
- package/dist/integrations-verify.d.ts.map +1 -0
- package/dist/integrations-verify.js +194 -0
- package/dist/integrations-verify.js.map +1 -0
- package/dist/lesson-generalization.d.ts +29 -0
- package/dist/lesson-generalization.d.ts.map +1 -0
- package/dist/lesson-generalization.js +84 -0
- package/dist/lesson-generalization.js.map +1 -0
- package/dist/mutation-gate.d.ts +39 -36
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +111 -5
- package/dist/mutation-gate.js.map +1 -1
- package/dist/operations.d.ts +27 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +186 -7
- package/dist/operations.js.map +1 -1
- package/dist/patterns.d.ts +27 -1
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +211 -45
- package/dist/patterns.js.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +27 -5
- package/dist/plugin.js.map +1 -1
- package/dist/recommend.d.ts +4 -5
- package/dist/recommend.d.ts.map +1 -1
- package/dist/recommend.js +110 -45
- package/dist/recommend.js.map +1 -1
- package/dist/registry.d.ts +32 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +165 -9
- package/dist/registry.js.map +1 -1
- package/dist/run-records.d.ts +3 -0
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +18 -0
- package/dist/run-records.js.map +1 -1
- package/dist/score.d.ts +95 -0
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +274 -2
- package/dist/score.js.map +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +20 -17
- package/dist/setup.js.map +1 -1
- package/dist/skill-selection.d.ts +72 -0
- package/dist/skill-selection.d.ts.map +1 -0
- package/dist/skill-selection.js +76 -0
- package/dist/skill-selection.js.map +1 -0
- package/dist/stem.d.ts +12 -0
- package/dist/stem.d.ts.map +1 -0
- package/dist/stem.js +89 -0
- package/dist/stem.js.map +1 -0
- package/dist/target-integrations.d.ts +65 -0
- package/dist/target-integrations.d.ts.map +1 -0
- package/dist/target-integrations.js +152 -0
- package/dist/target-integrations.js.map +1 -0
- package/dist/telemetry-vocabulary.d.ts +7 -0
- package/dist/telemetry-vocabulary.d.ts.map +1 -1
- package/dist/telemetry-vocabulary.js +29 -0
- package/dist/telemetry-vocabulary.js.map +1 -1
- package/dist/vector-tier.d.ts +6 -1
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +32 -7
- package/dist/vector-tier.js.map +1 -1
- package/package.json +7 -6
- package/sbom.json +772 -112
- package/src/amendment-trace.ts +12 -1
- package/src/codex-invoke.ts +138 -0
- package/src/compounding.ts +300 -1
- package/src/discrimination-gate.ts +183 -19
- package/src/eta.ts +590 -0
- package/src/event-chain.ts +41 -0
- package/src/feature-adr-checkpoints.ts +1 -1
- package/src/feature-adr-decision-recall.ts +652 -0
- package/src/feature-adr-landing.ts +109 -0
- package/src/feature-adr-routing.ts +7 -11
- package/src/guard-promotion.ts +245 -4
- package/src/guard-volume.ts +752 -0
- package/src/guard.ts +110 -4
- package/src/index.ts +73 -6
- package/src/integration-apply.ts +332 -0
- package/src/integration-evidence.ts +89 -0
- package/src/integration-probe-worker.ts +310 -0
- package/src/integration-receipts/claude-code/mcp/2.1.235.json +35 -0
- package/src/integrations-verify.ts +258 -0
- package/src/lesson-generalization.ts +115 -0
- package/src/mutation-gate.ts +165 -5
- package/src/operations.ts +207 -7
- package/src/patterns.ts +252 -43
- package/src/plugin.ts +27 -5
- package/src/recommend.ts +116 -46
- package/src/registry.ts +144 -11
- package/src/run-records.ts +23 -0
- package/src/score.ts +361 -3
- package/src/setup.ts +20 -17
- package/src/skill-selection.ts +111 -0
- package/src/stem.ts +87 -0
- package/src/target-integrations.ts +225 -0
- package/src/telemetry-vocabulary.ts +36 -0
- package/src/vector-tier.ts +44 -14
package/src/amendment-trace.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/compounding.ts
CHANGED
|
@@ -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
|
}
|