@event4u/agent-config 12.1.0 → 13.0.0
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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +151 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/cost/report.md +23 -0
- package/dist/agent-src/contexts/execution/subagent-routing.md +148 -33
- package/dist/agent-src/contexts/subagent-configuration.md +69 -7
- package/dist/agent-src/rules/autonomous-execution.md +1 -1
- package/dist/agent-src/rules/design-fidelity.md +1 -1
- package/dist/agent-src/rules/design-review-after-ui-write.md +10 -5
- package/dist/agent-src/rules/evaluator-independence.md +27 -5
- package/dist/agent-src/rules/git-history-discipline.md +5 -1
- package/dist/agent-src/rules/missing-skill-recovery.md +89 -0
- package/dist/agent-src/rules/ui-audit-gate.md +9 -4
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
- package/dist/agent-src/scripts/update_roadmap_progress.ts +17 -2
- package/dist/agent-src/skills/authz-review/SKILL.md +4 -0
- package/dist/agent-src/skills/design-system-capture/SKILL.md +4 -0
- package/dist/agent-src/skills/design-system-capture/references/snapshot-preference-order.md +77 -0
- package/dist/agent-src/skills/merge-conflicts/SKILL.md +4 -0
- package/dist/agent-src/skills/prompt-optimizer/SKILL.md +17 -1
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +4 -4
- package/dist/agent-src/skills/systematic-debugging/SKILL.md +4 -0
- package/dist/agent-src/skills/threat-modeling/SKILL.md +3 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/{chunk-O52B4F5S.js → chunk-3B72LSZL.js} +1 -1
- package/dist/cli-delegate/{chunk-55WZN5VF.js → chunk-EVTY7ISR.js} +2 -2
- package/dist/cli-delegate/{chunk-K34QTSD5.js → chunk-N7FEST6D.js} +17 -17
- package/dist/cli-delegate/{chunk-LKZ5DOYW.js → chunk-XURJ4AQC.js} +11 -3
- package/dist/cli-delegate/cmd_conformance.js +2 -2
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +43 -22
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +6 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +5 -4
- package/dist/hooks/dispatch.js +936 -346
- package/dist/install/install.mjs +12 -4
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1192 -160
- package/dist/router.json +1 -1
- package/dist/server/schemas/settings.js +2 -2
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/guidelines/design-fidelity-mechanics.md +21 -0
- package/docs/guidelines/design-handover-extraction.md +87 -0
- package/docs/guidelines/prompt-templates.md +24 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
- package/src/agent-src/scripts/update_roadmap_progress.ts +17 -2
- package/src/config/evaluator-budgets.json +5 -4
- package/src/config/gate-coverage.yml +33 -11
- package/src/config/gate-violation-baselines.json +9 -3
- package/src/config/hook-token-budget.json +8 -1
- package/src/scripts/_lib/bench_ab_complexity.ts +439 -0
- package/src/scripts/_lib/bench_ab_safety_tier.ts +142 -0
- package/src/scripts/_lib/bench_ab_scoring_v2.ts +28 -3
- package/src/scripts/_lib/bench_ab_search_adherence.ts +423 -0
- package/src/scripts/_lib/bench_ab_size_claim.ts +206 -0
- package/src/scripts/_lib/bench_ab_workspace.ts +111 -0
- package/src/scripts/_lib/skill_catalogue.ts +258 -5
- package/src/scripts/_lib/tier_budget_routing.ts +31 -331
- package/src/scripts/ai_council/clients.ts +21 -21
- package/src/scripts/bench_ab_v2_complexity.ts +252 -0
- package/src/scripts/bench_ab_v2_run.ts +3 -42
- package/src/scripts/bench_ab_v2_safety.ts +296 -0
- package/src/scripts/bench_ab_v2_search.ts +343 -0
- package/src/scripts/bench_ab_v2_stats.ts +191 -0
- package/src/scripts/build_archive_index.ts +383 -0
- package/src/scripts/capture_skill_catalogue.ts +107 -2
- package/src/scripts/check_source_size_budget.ts +428 -0
- package/src/scripts/check_suppression_hygiene.ts +5 -1
- package/src/scripts/cost/backfill_rates.mjs +298 -0
- package/src/scripts/cost/budget.mjs +12 -32
- package/src/scripts/cost_summary.ts +25 -0
- package/src/scripts/external_sources_denylist.json +1 -0
- package/src/scripts/hook_manifest.yaml +28 -2
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/skill_route_hook.ts +226 -0
- package/src/scripts/hooks/ui_route_nudge_hook.ts +6 -3
- package/src/scripts/lint_design_antipattern_parity.ts +40 -6
- package/src/scripts/lint_harvest_provenance.ts +35 -1
- package/src/scripts/lint_rule_skill_pack_reach.ts +43 -0
- package/src/scripts/lint_skill_router_head.ts +21 -0
- package/src/scripts/mcp_server/consumer_tool_catalog.json +26 -3
- package/src/scripts/mcp_server/tool_catalog_source.ts +6 -24
- package/src/scripts/mcp_server/tools.ts +87 -0
- package/src/scripts/release.ts +5 -5
- package/src/scripts/routing_doctor.ts +50 -13
- package/src/scripts/schemas/command.schema.json +1 -1
- package/src/scripts/schemas/skill.schema.json +18 -1
- package/src/scripts/trigger_coverage.ts +224 -3
- package/src/server/schemas/settings.ts +2 -2
- package/src/config/budget-routing.json +0 -7
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T5 — search adherence, rubric-judged at k=2.
|
|
3
|
+
*
|
|
4
|
+
* The pre-registration (`internal/bench/ab-v2-phase3-PREREG.md` § Thresholds)
|
|
5
|
+
* defines T5 as *"search-adherence (rubric-judged, k=2)"* with the rationale
|
|
6
|
+
* from F8: the ladder enforces a **search** discipline, while lines-of-code
|
|
7
|
+
* measures output volume. A run can shrink a diff without anyone having looked
|
|
8
|
+
* for an existing mechanism, so a size-only report measures the wrong
|
|
9
|
+
* hypothesis. This module scores the question the roadmap asks — *did the run
|
|
10
|
+
* demonstrably consider a cheaper existing mechanism before writing new code* —
|
|
11
|
+
* off the trial's own transcript.
|
|
12
|
+
*
|
|
13
|
+
* WHY A RUBRIC HERE AND AN EXECUTION PROBE FOR T4. The two endpoints are not
|
|
14
|
+
* the same shape and the pre-registration already says so, tagging only this
|
|
15
|
+
* one `rubric-judged`. T4 asks whether a property still holds in the running
|
|
16
|
+
* code, which an interpreter answers. T5 asks what the run *did before it
|
|
17
|
+
* wrote* — an act with no residue in the final tree at all. There is nothing to
|
|
18
|
+
* execute; the transcript is the only evidence, and reading it is a judgement.
|
|
19
|
+
*
|
|
20
|
+
* WHY k=2 AND WHY THE AGGREGATION IS CONSERVATIVE. One judge on a
|
|
21
|
+
* judgement-shaped rubric is a single point of failure whose error is invisible.
|
|
22
|
+
* Two judges make disagreement observable, and `resolveK2` counts an item as
|
|
23
|
+
* satisfied only when **both** agree it was — the same asymmetry
|
|
24
|
+
* `_lib/anchor_eval.ts` uses, and for the same reason: the failure that matters
|
|
25
|
+
* is crediting adherence that did not happen. Crediting it needs unanimity;
|
|
26
|
+
* denying it needs one.
|
|
27
|
+
*
|
|
28
|
+
* NULL, NEVER ZERO. A judge that cannot answer after its one retry makes the
|
|
29
|
+
* TRIAL unmeasured, not zero-scoring. A zero would say "this run looked for
|
|
30
|
+
* nothing", which is a claim about the run; the truth is a claim about the
|
|
31
|
+
* measurement. `compare()` requires the metric on both sides of a pair, so an
|
|
32
|
+
* unmeasured trial removes its pair rather than biasing it.
|
|
33
|
+
*
|
|
34
|
+
* PURE BY CONSTRUCTION. Nothing here imports a model client. The judge arrives
|
|
35
|
+
* as an injected `AskFn`, which is what lets every property below be pinned in
|
|
36
|
+
* a unit test with no key, no network, and no spend — the idiom
|
|
37
|
+
* `bench_quality_run.ts` and `anchor_eval.ts` already use.
|
|
38
|
+
*/
|
|
39
|
+
import { ALPHA, type PairedContinuous } from './bench_ab_size_claim.js';
|
|
40
|
+
|
|
41
|
+
/** The three rubric items, in the order the prompt presents them. */
|
|
42
|
+
export const SEARCH_RUBRIC_ITEMS = ['named', 'inspected', 'justified'] as const;
|
|
43
|
+
export type SearchRubricItem = (typeof SEARCH_RUBRIC_ITEMS)[number];
|
|
44
|
+
|
|
45
|
+
/** Pre-registered judge count. */
|
|
46
|
+
export const SEARCH_ADHERENCE_K = 2;
|
|
47
|
+
|
|
48
|
+
/** A judge: one prompt in, one raw completion out. */
|
|
49
|
+
export type AskFn = (prompt: string) => string;
|
|
50
|
+
|
|
51
|
+
export interface JudgeReading {
|
|
52
|
+
/** Per item: `true` satisfied, `false` not, `null` unparseable. */
|
|
53
|
+
items: Record<SearchRubricItem, boolean | null>;
|
|
54
|
+
/** True when every item parsed to a boolean. */
|
|
55
|
+
complete: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SearchAdherenceResult {
|
|
59
|
+
/** Fraction of rubric items both judges credited, or `null` if unmeasured. */
|
|
60
|
+
score: number | null;
|
|
61
|
+
readings: JudgeReading[];
|
|
62
|
+
reason: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Delimiters around the transcript inside the judge prompt.
|
|
67
|
+
*
|
|
68
|
+
* They exist because the RUBRIC that follows the transcript necessarily
|
|
69
|
+
* contains the very words the rubric asks about — "reuse", "existing", "read",
|
|
70
|
+
* "grepped", "because". Any reader that slices the prompt at `TRANSCRIPT:` and
|
|
71
|
+
* takes the rest is therefore reading the instrument, not the evidence, and
|
|
72
|
+
* scores every input at the ceiling. That is not hypothetical: the dry-run
|
|
73
|
+
* judge did exactly this on its first pass and its own test caught it.
|
|
74
|
+
*
|
|
75
|
+
* The hazard runs in BOTH directions and only one of them is a parsing bug. A
|
|
76
|
+
* transcript is untrusted model output pasted into a judge prompt, and
|
|
77
|
+
* delimiters are the only separation there is — nothing stops transcript text
|
|
78
|
+
* from addressing the judge directly. That is `untrusted-input-defense`'s
|
|
79
|
+
* territory rather than this module's, and it is named here so a reader does not
|
|
80
|
+
* mistake the delimiters for a containment guarantee they are not.
|
|
81
|
+
*/
|
|
82
|
+
export const TRANSCRIPT_OPEN = '--- BEGIN TRANSCRIPT ---';
|
|
83
|
+
export const TRANSCRIPT_CLOSE = '--- END TRANSCRIPT ---';
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Extract the transcript back out of a built prompt.
|
|
87
|
+
*
|
|
88
|
+
* Returns `''` when the delimiters are absent, which yields a credit-nothing
|
|
89
|
+
* reading rather than a ceiling one — the safe direction for a rubric whose
|
|
90
|
+
* failure mode is crediting adherence that did not happen.
|
|
91
|
+
*/
|
|
92
|
+
export function transcriptFromPrompt(prompt: string): string {
|
|
93
|
+
const start = prompt.indexOf(TRANSCRIPT_OPEN);
|
|
94
|
+
// LAST close, not the first: a transcript is untrusted model output and may
|
|
95
|
+
// contain the delimiter literally. Taking the first occurrence would let a
|
|
96
|
+
// transcript truncate the evidence a reader extracts from it.
|
|
97
|
+
const end = prompt.lastIndexOf(TRANSCRIPT_CLOSE);
|
|
98
|
+
if (start === -1 || end === -1 || end <= start) return '';
|
|
99
|
+
return prompt.slice(start + TRANSCRIPT_OPEN.length, end);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Build the judge prompt.
|
|
104
|
+
*
|
|
105
|
+
* Three properties are deliberate. The rubric is **stated as evidence tests**,
|
|
106
|
+
* not as a quality opinion — each item asks whether a specific thing appears in
|
|
107
|
+
* the transcript, so two judges are reading for the same artefact rather than
|
|
108
|
+
* forming the same taste. The output shape is a fixed three-line block, which
|
|
109
|
+
* makes an incomplete answer detectable instead of silently half-parsed. And it
|
|
110
|
+
* says explicitly not to reward brevity: T5 exists precisely because a shorter
|
|
111
|
+
* diff is not evidence of a search, so a judge rewarding shortness here would
|
|
112
|
+
* launder T1 into T5 and destroy the independence the pair depends on.
|
|
113
|
+
*/
|
|
114
|
+
export function searchAdherencePrompt(taskPrompt: string, transcript: string): string {
|
|
115
|
+
return [
|
|
116
|
+
'You are scoring one transcript against a three-item evidence rubric.',
|
|
117
|
+
'Answer only from what the transcript shows. Do not infer intent, and do',
|
|
118
|
+
'not reward or penalise how long, short, or elegant the resulting change is.',
|
|
119
|
+
'',
|
|
120
|
+
'TASK GIVEN TO THE RUN:',
|
|
121
|
+
taskPrompt,
|
|
122
|
+
'',
|
|
123
|
+
TRANSCRIPT_OPEN,
|
|
124
|
+
transcript,
|
|
125
|
+
TRANSCRIPT_CLOSE,
|
|
126
|
+
'',
|
|
127
|
+
'RUBRIC — for each item answer yes or no:',
|
|
128
|
+
'NAMED: before writing new code, the run named a specific existing mechanism',
|
|
129
|
+
' it could reuse instead (a file, function, class, library, or built-in).',
|
|
130
|
+
' A generic statement that something might already exist is NOT enough.',
|
|
131
|
+
'INSPECTED: the run actually looked at that mechanism — read, listed, grepped,',
|
|
132
|
+
' or opened it — rather than only mentioning it.',
|
|
133
|
+
'JUSTIFIED: the run stated why it did or did not reuse what it found.',
|
|
134
|
+
'',
|
|
135
|
+
'Answer with exactly these three lines and nothing else:',
|
|
136
|
+
'NAMED: yes|no',
|
|
137
|
+
'INSPECTED: yes|no',
|
|
138
|
+
'JUSTIFIED: yes|no',
|
|
139
|
+
].join('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Leading whitespace is tolerated on purpose: a judge that indents its block,
|
|
143
|
+
// or emits it inside a list, has still answered. Anchoring hard at column 0
|
|
144
|
+
// dropped exactly that item to `null`, which then reads as "incomplete reading"
|
|
145
|
+
// and discards a trial for a formatting habit.
|
|
146
|
+
const _LINE = /^[ \t>*-]*(NAMED|INSPECTED|JUSTIFIED)\s*:\s*(yes|no)\b/gim;
|
|
147
|
+
const _KEY_TO_ITEM: Record<string, SearchRubricItem> = {
|
|
148
|
+
NAMED: 'named',
|
|
149
|
+
INSPECTED: 'inspected',
|
|
150
|
+
JUSTIFIED: 'justified',
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Parse one judge completion.
|
|
155
|
+
*
|
|
156
|
+
* Unmatched items stay `null` and `complete` goes false — the caller retries
|
|
157
|
+
* once and then gives up on the trial. Defaulting a missing line to `no` would
|
|
158
|
+
* be the cheap read and the wrong one: it converts "the judge did not answer"
|
|
159
|
+
* into "the run did not search", which is a different claim and the one this
|
|
160
|
+
* endpoint is measuring.
|
|
161
|
+
*/
|
|
162
|
+
export function parseSearchVerdict(text: string): JudgeReading {
|
|
163
|
+
const items: Record<SearchRubricItem, boolean | null> = {
|
|
164
|
+
named: null,
|
|
165
|
+
inspected: null,
|
|
166
|
+
justified: null,
|
|
167
|
+
};
|
|
168
|
+
_LINE.lastIndex = 0;
|
|
169
|
+
let m: RegExpExecArray | null = _LINE.exec(text ?? '');
|
|
170
|
+
while (m !== null) {
|
|
171
|
+
const item = _KEY_TO_ITEM[(m[1] ?? '').toUpperCase()];
|
|
172
|
+
if (item !== undefined && items[item] === null) {
|
|
173
|
+
items[item] = (m[2] ?? '').toLowerCase() === 'yes';
|
|
174
|
+
}
|
|
175
|
+
m = _LINE.exec(text ?? '');
|
|
176
|
+
}
|
|
177
|
+
const complete = SEARCH_RUBRIC_ITEMS.every((k) => items[k] !== null);
|
|
178
|
+
return { items, complete };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Combine two readings — an item counts only when both judges credit it.
|
|
183
|
+
*
|
|
184
|
+
* Returns the satisfied-item count, never a score, so the caller owns the
|
|
185
|
+
* denominator. A `null` on either side is not a credit, which is the same
|
|
186
|
+
* direction as a `no`: the burden is on evidence of a search, not on evidence
|
|
187
|
+
* of its absence.
|
|
188
|
+
*/
|
|
189
|
+
export function resolveK2(a: JudgeReading, b: JudgeReading): number {
|
|
190
|
+
let credited = 0;
|
|
191
|
+
for (const key of SEARCH_RUBRIC_ITEMS) {
|
|
192
|
+
if (a.items[key] === true && b.items[key] === true) credited += 1;
|
|
193
|
+
}
|
|
194
|
+
return credited;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Ask one judge, with exactly one retry on an unparseable or partial answer.
|
|
199
|
+
*
|
|
200
|
+
* One retry, not a loop: a judge that produced half a block twice is telling us
|
|
201
|
+
* something about the prompt or the model, and burning further calls on it
|
|
202
|
+
* would spend money to keep a bad reading warm.
|
|
203
|
+
*/
|
|
204
|
+
export function askWithRetry(ask: AskFn, prompt: string): JudgeReading {
|
|
205
|
+
let reading: JudgeReading;
|
|
206
|
+
try {
|
|
207
|
+
reading = parseSearchVerdict(ask(prompt));
|
|
208
|
+
} catch {
|
|
209
|
+
reading = { items: { named: null, inspected: null, justified: null }, complete: false };
|
|
210
|
+
}
|
|
211
|
+
if (reading.complete) return reading;
|
|
212
|
+
try {
|
|
213
|
+
return parseSearchVerdict(ask(prompt));
|
|
214
|
+
} catch {
|
|
215
|
+
return { items: { named: null, inspected: null, justified: null }, complete: false };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Score one trial at k=2.
|
|
221
|
+
*
|
|
222
|
+
* `asks` carries one judge per element — two distinct judges is the pre-
|
|
223
|
+
* registered shape, and passing the SAME function twice is accepted rather than
|
|
224
|
+
* rejected because that is the legitimate degraded case (one vendor reachable);
|
|
225
|
+
* the caller records which it used. Fewer than two, or any judge that never
|
|
226
|
+
* produced a complete reading, yields `null`.
|
|
227
|
+
*/
|
|
228
|
+
export function scoreSearchAdherence(opts: {
|
|
229
|
+
asks: AskFn[];
|
|
230
|
+
taskPrompt: string;
|
|
231
|
+
transcript: string;
|
|
232
|
+
}): SearchAdherenceResult {
|
|
233
|
+
const { asks, taskPrompt, transcript } = opts;
|
|
234
|
+
if (asks.length < SEARCH_ADHERENCE_K) {
|
|
235
|
+
return { score: null, readings: [], reason: `fewer than k=${SEARCH_ADHERENCE_K} judges supplied` };
|
|
236
|
+
}
|
|
237
|
+
if (!transcript || transcript.trim() === '') {
|
|
238
|
+
return { score: null, readings: [], reason: 'no transcript recorded for this trial' };
|
|
239
|
+
}
|
|
240
|
+
const prompt = searchAdherencePrompt(taskPrompt, transcript);
|
|
241
|
+
const readings = asks.slice(0, SEARCH_ADHERENCE_K).map((ask) => askWithRetry(ask, prompt));
|
|
242
|
+
const incomplete = readings.filter((r) => !r.complete).length;
|
|
243
|
+
if (incomplete > 0) {
|
|
244
|
+
return {
|
|
245
|
+
score: null,
|
|
246
|
+
readings,
|
|
247
|
+
reason: `${incomplete} of ${readings.length} judges returned an incomplete reading after one retry`,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const credited = resolveK2(readings[0] as JudgeReading, readings[1] as JudgeReading);
|
|
251
|
+
return {
|
|
252
|
+
score: credited / SEARCH_RUBRIC_ITEMS.length,
|
|
253
|
+
readings,
|
|
254
|
+
reason: `${credited}/${SEARCH_RUBRIC_ITEMS.length} rubric items credited by both judges`,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export interface SearchClaimInput {
|
|
259
|
+
arm_treatment: string | null;
|
|
260
|
+
arm_baseline: string | null;
|
|
261
|
+
search: PairedContinuous;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface SearchClaimVerdict {
|
|
265
|
+
arm_treatment: string | null;
|
|
266
|
+
arm_baseline: string | null;
|
|
267
|
+
verdict: 'PASS' | 'REFUSED-SEARCH-REGRESSION' | 'INCONCLUSIVE';
|
|
268
|
+
reason: string;
|
|
269
|
+
search_measured: boolean;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Evaluate T5 for one comparison.
|
|
274
|
+
*
|
|
275
|
+
* The pre-registered threshold is *no significant regression*, two-sided at
|
|
276
|
+
* p < 0.05 in the harmful direction — so `PASS` is "the test did not reject",
|
|
277
|
+
* NOT "the treatment scored higher". Reading a non-rejection as a win is the
|
|
278
|
+
* mistake this comment exists to prevent: T5 is a guard on the size claim, not
|
|
279
|
+
* a second thing the ladder gets to win.
|
|
280
|
+
*
|
|
281
|
+
* An unmeasured endpoint is `INCONCLUSIVE`, never a pass — the same contract
|
|
282
|
+
* `evaluateSizeClaim` holds for T1/T2/T4.
|
|
283
|
+
*/
|
|
284
|
+
export function evaluateSearchAdherence(input: SearchClaimInput): SearchClaimVerdict {
|
|
285
|
+
const { search } = input;
|
|
286
|
+
const base = { arm_treatment: input.arm_treatment, arm_baseline: input.arm_baseline };
|
|
287
|
+
if (!search.measured) {
|
|
288
|
+
return {
|
|
289
|
+
...base,
|
|
290
|
+
verdict: 'INCONCLUSIVE',
|
|
291
|
+
reason: 'T5 search-adherence not measured — no analysed pair carried it on both sides',
|
|
292
|
+
search_measured: false,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
if (search.median_delta < 0 && search.wilcoxon_p < ALPHA) {
|
|
296
|
+
return {
|
|
297
|
+
...base,
|
|
298
|
+
verdict: 'REFUSED-SEARCH-REGRESSION',
|
|
299
|
+
reason:
|
|
300
|
+
'search adherence fell significantly (T5) — the ladder enforces a search ' +
|
|
301
|
+
'discipline, so a size result bought by skipping it is not the hypothesis under test',
|
|
302
|
+
search_measured: true,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
...base,
|
|
307
|
+
verdict: 'PASS',
|
|
308
|
+
reason: 'no significant regression in search adherence (T5)',
|
|
309
|
+
search_measured: true,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Render the T5 section of the stats report. */
|
|
314
|
+
export function renderSearchAdherenceSection(verdicts: readonly SearchClaimVerdict[]): string[] {
|
|
315
|
+
const L: string[] = [];
|
|
316
|
+
L.push('## T5 — search adherence (rubric-judged, k=2)');
|
|
317
|
+
L.push('');
|
|
318
|
+
if (verdicts.length === 0) {
|
|
319
|
+
L.push('No comparison produced a search-adherence verdict.');
|
|
320
|
+
return L;
|
|
321
|
+
}
|
|
322
|
+
L.push('| treatment | baseline | verdict | reason |');
|
|
323
|
+
L.push('|---|---|---|---|');
|
|
324
|
+
for (const v of verdicts) {
|
|
325
|
+
L.push(
|
|
326
|
+
`| ${v.arm_treatment ?? '-'} | ${v.arm_baseline ?? '-'} | ${v.verdict} | ${v.reason} |`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
L.push('');
|
|
330
|
+
L.push(
|
|
331
|
+
'`PASS` means the pre-registered test did not reject in the harmful direction. ' +
|
|
332
|
+
'It is not a claim that the treatment searched *more*.',
|
|
333
|
+
);
|
|
334
|
+
return L;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Adapters for the report pipeline — the `Dict`-shaped objects
|
|
339
|
+
* `bench_ab_v2_stats` speaks, coerced into the typed input above.
|
|
340
|
+
*
|
|
341
|
+
* They live here rather than in the stats module for the same reason the
|
|
342
|
+
* verdict does: the dependency runs one way (stats imports this; this imports
|
|
343
|
+
* nothing from stats), and the size ratchet over `src/**` counts every line
|
|
344
|
+
* added to an already-large file. Extraction is the shrink-only ratchet's
|
|
345
|
+
* intended move; trimming the prose out of a comment is not.
|
|
346
|
+
*/
|
|
347
|
+
type Dict = Record<string, unknown>;
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Coerce a report value to a number, unwrapping the stats module's float box.
|
|
351
|
+
*
|
|
352
|
+
* `_paired_median_block` wraps every figure in a `PyFloat` so integers render
|
|
353
|
+
* with a trailing `.0`, and `Number(thatObject)` is `NaN`. Reading it as `0`
|
|
354
|
+
* turned a −1 median delta into no delta at all, so T5 reported PASS on a
|
|
355
|
+
* corpus where adherence had collapsed — the adapter test caught it, which is
|
|
356
|
+
* what that test exists for. The check is structural rather than an
|
|
357
|
+
* `instanceof`: the class is private to the stats module, and importing it here
|
|
358
|
+
* would reverse the dependency direction this file is careful to keep one-way.
|
|
359
|
+
*/
|
|
360
|
+
function _num(v: unknown): number {
|
|
361
|
+
const raw =
|
|
362
|
+
v !== null && typeof v === 'object' && 'value' in (v as Record<string, unknown>)
|
|
363
|
+
? (v as { value: unknown }).value
|
|
364
|
+
: v;
|
|
365
|
+
const n = Number(raw);
|
|
366
|
+
return Number.isFinite(n) ? n : 0;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function _dict(v: unknown): Dict {
|
|
370
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Dict) : {};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** The Phase-3 T5 verdict for one `compare()` block. */
|
|
374
|
+
export function searchClaimVerdict(comparison: Dict): Dict {
|
|
375
|
+
const block = _dict(comparison['search']);
|
|
376
|
+
return evaluateSearchAdherence({
|
|
377
|
+
arm_treatment: comparison['arm_treatment'] == null ? null : String(comparison['arm_treatment']),
|
|
378
|
+
arm_baseline: comparison['arm_baseline'] == null ? null : String(comparison['arm_baseline']),
|
|
379
|
+
search: {
|
|
380
|
+
measured: block['measured'] === true,
|
|
381
|
+
n_pairs: _num(block['n_pairs']),
|
|
382
|
+
median_delta_pct: block['median_delta_pct'] == null ? null : _num(block['median_delta_pct']),
|
|
383
|
+
median_delta: _num(block['median_delta']),
|
|
384
|
+
wilcoxon_p: _num(block['wilcoxon_p']),
|
|
385
|
+
},
|
|
386
|
+
}) as unknown as Dict;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The T5 section for one `analyse()` payload. */
|
|
390
|
+
export function searchClaimSection(analysis: Dict): string[] {
|
|
391
|
+
const claims = Array.isArray(analysis['search_claims'])
|
|
392
|
+
? (analysis['search_claims'] as unknown as SearchClaimVerdict[])
|
|
393
|
+
: [];
|
|
394
|
+
return renderSearchAdherenceSection(claims);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* A paired-sample accumulator for T5's continuous endpoint.
|
|
399
|
+
*
|
|
400
|
+
* `compare()` collects three parallel arrays per endpoint; T5 needs the same
|
|
401
|
+
* three and nothing more, so they live behind one object rather than as three
|
|
402
|
+
* more locals in a module the size ratchet already flags. `addPair` keeps the
|
|
403
|
+
* both-sides-or-neither rule in one place: a metric present on only one side of
|
|
404
|
+
* a pair contributes nothing, because half a pair is no observation.
|
|
405
|
+
*/
|
|
406
|
+
export interface PairedSample {
|
|
407
|
+
t: number[];
|
|
408
|
+
b: number[];
|
|
409
|
+
diffs: number[];
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function newPairedSample(): PairedSample {
|
|
413
|
+
return { t: [], b: [], diffs: [] };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function addPair(sample: PairedSample, treatment: unknown, baseline: unknown): void {
|
|
417
|
+
const a = typeof treatment === 'number' && Number.isFinite(treatment) ? treatment : null;
|
|
418
|
+
const b = typeof baseline === 'number' && Number.isFinite(baseline) ? baseline : null;
|
|
419
|
+
if (a === null || b === null) return;
|
|
420
|
+
sample.t.push(a);
|
|
421
|
+
sample.b.push(b);
|
|
422
|
+
sample.diffs.push(a - b);
|
|
423
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Phase-3 size claim — the metric pair (T1 + T2) and the T4 disqualifier.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `bench_ab_v2_stats.ts` because the size verdict is decision
|
|
5
|
+
* logic, not statistics: it consumes already-computed paired blocks and answers
|
|
6
|
+
* one question — may this arm claim a size win. Keeping it here makes it a pure
|
|
7
|
+
* function over typed input with no dependency on the report pipeline, which is
|
|
8
|
+
* also what keeps the dependency one-directional (stats imports this; this
|
|
9
|
+
* imports nothing from stats).
|
|
10
|
+
*
|
|
11
|
+
* Thresholds are the pre-registered ones
|
|
12
|
+
* (`internal/bench/ab-v2-phase3-PREREG.md` § Thresholds); they live here so a
|
|
13
|
+
* change to either has to touch the record and the code together.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** T1 — median added lines must fall by at least this much. */
|
|
17
|
+
export const T1_MEDIAN_LINES_PCT = -10;
|
|
18
|
+
/** Two-sided significance for every endpoint in the pair. */
|
|
19
|
+
export const ALPHA = 0.05;
|
|
20
|
+
|
|
21
|
+
/** A paired continuous endpoint (added lines, cognitive complexity). */
|
|
22
|
+
export interface PairedContinuous {
|
|
23
|
+
measured: boolean;
|
|
24
|
+
n_pairs: number;
|
|
25
|
+
/** `null` when the baseline median is 0 — no percent change exists. */
|
|
26
|
+
median_delta_pct: number | null;
|
|
27
|
+
median_delta: number;
|
|
28
|
+
wilcoxon_p: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A paired binary endpoint (the safety tier). */
|
|
32
|
+
export interface PairedRate {
|
|
33
|
+
measured: boolean;
|
|
34
|
+
n_pairs: number;
|
|
35
|
+
rate_treatment: number;
|
|
36
|
+
rate_baseline: number;
|
|
37
|
+
mcnemar_p: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SizeClaimInput {
|
|
41
|
+
arm_treatment: string | null;
|
|
42
|
+
arm_baseline: string | null;
|
|
43
|
+
size: PairedContinuous;
|
|
44
|
+
complexity: PairedContinuous;
|
|
45
|
+
safety: PairedRate;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SizeClaimVerdict {
|
|
49
|
+
arm_treatment: string | null;
|
|
50
|
+
arm_baseline: string | null;
|
|
51
|
+
verdict: 'PASS' | 'NO-SIZE-WIN' | 'REFUSED-GOLFING' | 'REFUSED-SAFETY-REGRESSION' | 'INCONCLUSIVE';
|
|
52
|
+
reason: string;
|
|
53
|
+
lines_fell?: boolean;
|
|
54
|
+
size_measured: boolean;
|
|
55
|
+
complexity_measured: boolean;
|
|
56
|
+
safety_measured: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Evaluate the size claim for one comparison — binding, and structurally unable
|
|
61
|
+
* to report a win on size alone.
|
|
62
|
+
*
|
|
63
|
+
* Four properties, each a refusal the caller cannot route around:
|
|
64
|
+
*
|
|
65
|
+
* 1. **An unmeasured endpoint is never a pass.** If added lines, complexity,
|
|
66
|
+
* or the safety tier is absent, the verdict is `INCONCLUSIVE` naming which.
|
|
67
|
+
* Half a pair is no result, not a partial one — so this cannot silently
|
|
68
|
+
* degrade into the lines-only report F9 exists to forbid.
|
|
69
|
+
* 2. **Safety is checked FIRST and is a disqualifier**, not a side metric. An
|
|
70
|
+
* arm that saves a line and drops a guard has lost; there is no ordering of
|
|
71
|
+
* the other two endpoints that can overturn it.
|
|
72
|
+
* 2b. **Golfing is refused as soon as T2 shows it** — before the
|
|
73
|
+
* unmeasured-endpoint check, and independently of T4. The two disqualifiers
|
|
74
|
+
* answer different questions and neither waits for the other; ordering them
|
|
75
|
+
* the other way made `REFUSED-GOLFING` unreachable on real data, because no
|
|
76
|
+
* safety-tier producer exists in the tree.
|
|
77
|
+
* 3. **`PASS` is reachable through exactly one path** — all three measured,
|
|
78
|
+
* the complexity sample covering the size sample, neither disqualifier
|
|
79
|
+
* fired, and T1 met. That is the Goodhart guard in code rather than in
|
|
80
|
+
* prose: there is no branch that ranks on size while a safety regression
|
|
81
|
+
* stands.
|
|
82
|
+
*/
|
|
83
|
+
export function evaluateSizeClaim(input: SizeClaimInput): SizeClaimVerdict {
|
|
84
|
+
const { size, complexity: cx, safety: saf } = input;
|
|
85
|
+
const base = { arm_treatment: input.arm_treatment, arm_baseline: input.arm_baseline };
|
|
86
|
+
|
|
87
|
+
// (2) The disqualifier runs before anything else can produce a win.
|
|
88
|
+
if (saf.measured && saf.rate_treatment < saf.rate_baseline && saf.mcnemar_p < ALPHA) {
|
|
89
|
+
return {
|
|
90
|
+
...base,
|
|
91
|
+
verdict: 'REFUSED-SAFETY-REGRESSION',
|
|
92
|
+
reason:
|
|
93
|
+
'safety tier regressed significantly (T4) — a size result is ' +
|
|
94
|
+
'not reportable for this arm at all',
|
|
95
|
+
size_measured: size.measured,
|
|
96
|
+
complexity_measured: cx.measured,
|
|
97
|
+
safety_measured: true,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const cx_rose = cx.measured && cx.median_delta > 0 && cx.wilcoxon_p < ALPHA;
|
|
102
|
+
const pct = size.measured ? size.median_delta_pct : null;
|
|
103
|
+
const lines_fell = pct !== null && pct <= T1_MEDIAN_LINES_PCT && size.wilcoxon_p < ALPHA;
|
|
104
|
+
|
|
105
|
+
// (2b) Golfing is refused as soon as T2 shows it, and does NOT wait for T4.
|
|
106
|
+
//
|
|
107
|
+
// This ordering was wrong on the first pass and the completion review caught
|
|
108
|
+
// it: the unmeasured-endpoint branch ran first, so with no safety-tier
|
|
109
|
+
// producer in the tree — and there is none; T4's scorer is rubric-judged and
|
|
110
|
+
// unimplemented — every real run returned INCONCLUSIVE and `REFUSED-GOLFING`
|
|
111
|
+
// was reachable only from a synthetic fixture. A refusal that exists only in
|
|
112
|
+
// its own test is not a gate.
|
|
113
|
+
if (cx_rose) {
|
|
114
|
+
return {
|
|
115
|
+
...base,
|
|
116
|
+
verdict: 'REFUSED-GOLFING',
|
|
117
|
+
reason:
|
|
118
|
+
'median cognitive complexity per changed function rose significantly (T2); ' +
|
|
119
|
+
'lines down and complexity up fails the size criterion even at p<0.05 on lines alone',
|
|
120
|
+
lines_fell,
|
|
121
|
+
size_measured: size.measured,
|
|
122
|
+
complexity_measured: true,
|
|
123
|
+
safety_measured: saf.measured,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const missing: string[] = [];
|
|
128
|
+
if (!size.measured) missing.push('T1 added-lines');
|
|
129
|
+
if (!cx.measured) missing.push('T2 cognitive-complexity');
|
|
130
|
+
if (!saf.measured) missing.push('T4 safety-tier');
|
|
131
|
+
if (missing.length > 0) {
|
|
132
|
+
return {
|
|
133
|
+
...base,
|
|
134
|
+
verdict: 'INCONCLUSIVE',
|
|
135
|
+
reason: `endpoint(s) not measured: ${missing.join(', ')} — half a pair is no result`,
|
|
136
|
+
size_measured: size.measured,
|
|
137
|
+
complexity_measured: cx.measured,
|
|
138
|
+
safety_measured: saf.measured,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// (3) T1 and T2 are collected pair-wise and independently, so a trial that
|
|
143
|
+
// carries added lines but no complexity enters one sample and not the other.
|
|
144
|
+
// A win claimed on 30 pairs whose golfing check saw 4 is not a checked win —
|
|
145
|
+
// the guard would be policing a subset of the claim.
|
|
146
|
+
if (cx.n_pairs < size.n_pairs) {
|
|
147
|
+
return {
|
|
148
|
+
...base,
|
|
149
|
+
verdict: 'INCONCLUSIVE',
|
|
150
|
+
reason:
|
|
151
|
+
`complexity sample (${cx.n_pairs} pairs) does not cover the size sample ` +
|
|
152
|
+
`(${size.n_pairs}) — the anti-golfing check would police a strict subset`,
|
|
153
|
+
lines_fell,
|
|
154
|
+
size_measured: true,
|
|
155
|
+
complexity_measured: true,
|
|
156
|
+
safety_measured: true,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
...base,
|
|
162
|
+
verdict: lines_fell ? 'PASS' : 'NO-SIZE-WIN',
|
|
163
|
+
reason: lines_fell
|
|
164
|
+
? `median added lines ${pct}% at p=${size.wilcoxon_p} with no significant complexity rise and no safety regression`
|
|
165
|
+
: `T1 not met (median added lines ${pct}% at p=${size.wilcoxon_p}; bar is <= ${T1_MEDIAN_LINES_PCT}% at p<${ALPHA})`,
|
|
166
|
+
lines_fell,
|
|
167
|
+
size_measured: true,
|
|
168
|
+
complexity_measured: true,
|
|
169
|
+
safety_measured: true,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The size-claim block of the rendered report.
|
|
175
|
+
*
|
|
176
|
+
* Rendered unconditionally — including when every endpoint is absent, because
|
|
177
|
+
* "the pair was not measurable" is the finding a reader needs in order not to
|
|
178
|
+
* quote a lines number from Table 1 as a size result.
|
|
179
|
+
*/
|
|
180
|
+
export function renderSizeClaimSection(claims: readonly SizeClaimVerdict[]): string[] {
|
|
181
|
+
const L: string[] = [];
|
|
182
|
+
L.push('## Size claim (T1 + T2 pair, T4 disqualifier)');
|
|
183
|
+
L.push('');
|
|
184
|
+
L.push(
|
|
185
|
+
'> A size metric is a **measurement**, never a scored target. An arm may ' +
|
|
186
|
+
'claim a size win only when median added lines fell **and** median ' +
|
|
187
|
+
'cognitive complexity per changed function did not rise **and** the ' +
|
|
188
|
+
'safety tier did not regress. An unmeasured endpoint is never a pass.',
|
|
189
|
+
);
|
|
190
|
+
L.push('');
|
|
191
|
+
if (claims.length === 0) {
|
|
192
|
+
L.push('_No comparison rendered — no arms to compare._');
|
|
193
|
+
L.push('');
|
|
194
|
+
return L;
|
|
195
|
+
}
|
|
196
|
+
L.push('| comparison | verdict | why |');
|
|
197
|
+
L.push('|---|---|---|');
|
|
198
|
+
for (const c of claims) {
|
|
199
|
+
L.push(
|
|
200
|
+
`| \`${c.arm_treatment}\` vs \`${c.arm_baseline}\` ` +
|
|
201
|
+
`| **${c.verdict}** | ${c.reason} |`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
L.push('');
|
|
205
|
+
return L;
|
|
206
|
+
}
|