@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,343 @@
|
|
|
1
|
+
#!/usr/bin/env -S npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* Offline re-scorer for the Phase-3 search-adherence endpoint — T5.
|
|
4
|
+
*
|
|
5
|
+
* Reads a finished `ab-v2` report, loads each trial's **preserved transcript**
|
|
6
|
+
* (the sibling file `bench_ab_v2_run.preserve_transcript` writes next to the
|
|
7
|
+
* clone), scores it against the k=2 rubric in
|
|
8
|
+
* `_lib/bench_ab_search_adherence.ts`, and writes one number into each trial's
|
|
9
|
+
* `metrics`:
|
|
10
|
+
*
|
|
11
|
+
* - `search_adherence` — fraction of rubric items **both** judges credited.
|
|
12
|
+
* Omitted, never zeroed, when the trial is unmeasurable.
|
|
13
|
+
*
|
|
14
|
+
* WHY OFFLINE. T5 is the one endpoint that genuinely needs model calls, which
|
|
15
|
+
* is exactly why it must not run inside the sweep: a judge failure or a rate
|
|
16
|
+
* limit mid-sweep would stall the metered path, and re-judging would mean
|
|
17
|
+
* re-running the arm. Freezing the transcript and judging afterwards is the
|
|
18
|
+
* `anchor_scoring_run.ts` shape — reproducibility comes from freezing, not from
|
|
19
|
+
* pinning a model.
|
|
20
|
+
*
|
|
21
|
+
* WHY IT COSTS MONEY AND WHAT THAT MEANS HERE. Two judge calls per trial (plus
|
|
22
|
+
* at most one retry each) is real spend, so **the default is the mock judge**:
|
|
23
|
+
* deterministic, no key, no network, exercised by the tests. Live judging is
|
|
24
|
+
* opt-in via `--live`, which is the only path that touches a key. There is no
|
|
25
|
+
* `--dry-run` flag — the absence of `--live` IS the dry run, and an earlier
|
|
26
|
+
* version of this line named a flag `main` does not parse.
|
|
27
|
+
*
|
|
28
|
+
* Usage:
|
|
29
|
+
* bench_ab_v2_search.ts <report.json> [--live] [--write] [--json]
|
|
30
|
+
*/
|
|
31
|
+
import * as fs from 'node:fs';
|
|
32
|
+
import * as path from 'node:path';
|
|
33
|
+
import { createRequire } from 'node:module';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
|
|
36
|
+
import { parse as parseYaml } from 'yaml';
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
type AskFn,
|
|
40
|
+
SEARCH_ADHERENCE_K,
|
|
41
|
+
scoreSearchAdherence,
|
|
42
|
+
transcriptFromPrompt,
|
|
43
|
+
} from './_lib/bench_ab_search_adherence.js';
|
|
44
|
+
|
|
45
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
46
|
+
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
47
|
+
const CORPUS_PATH = path.join(REPO_ROOT, 'internal', 'bench', 'corpora', 'ab-trackb-v2.yaml');
|
|
48
|
+
|
|
49
|
+
type Dict = Record<string, unknown>;
|
|
50
|
+
|
|
51
|
+
function _dictOr(v: unknown): Dict {
|
|
52
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Dict) : {};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** task id → the prompt the run was given; the judge needs it for context. */
|
|
56
|
+
export function loadTaskPrompts(corpusPath: string | null): Map<string, string> {
|
|
57
|
+
const byId = new Map<string, string>();
|
|
58
|
+
if (!corpusPath || !fs.existsSync(corpusPath)) return byId;
|
|
59
|
+
try {
|
|
60
|
+
const corpus = _dictOr(parseYaml(fs.readFileSync(corpusPath, 'utf8'), { version: '1.1' }));
|
|
61
|
+
const tasks = Array.isArray(corpus['tasks']) ? (corpus['tasks'] as Dict[]) : [];
|
|
62
|
+
for (const t of tasks) {
|
|
63
|
+
if (t['id'] !== undefined) byId.set(String(t['id']), String(t['prompt'] ?? ''));
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
return byId;
|
|
67
|
+
}
|
|
68
|
+
return byId;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface SearchRescore {
|
|
72
|
+
task: string;
|
|
73
|
+
arm: string;
|
|
74
|
+
seed: unknown;
|
|
75
|
+
search_adherence: number | null;
|
|
76
|
+
reason: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Re-score one report payload. Mutates `payload` only when `write` is true.
|
|
81
|
+
*
|
|
82
|
+
* On an unmeasured trial any stale key is **deleted** rather than set to 0 —
|
|
83
|
+
* `compare()` reads a missing metric as "not measured on this pair", while a 0
|
|
84
|
+
* would assert the run searched for nothing.
|
|
85
|
+
*/
|
|
86
|
+
export function rescoreSearch(
|
|
87
|
+
payload: Dict,
|
|
88
|
+
opts: {
|
|
89
|
+
asks: AskFn[];
|
|
90
|
+
write?: boolean;
|
|
91
|
+
corpusPath?: string | null;
|
|
92
|
+
readTranscript?: (p: string) => string | null;
|
|
93
|
+
},
|
|
94
|
+
): SearchRescore[] {
|
|
95
|
+
const prompts = loadTaskPrompts(opts.corpusPath === undefined ? CORPUS_PATH : opts.corpusPath);
|
|
96
|
+
const readTranscript =
|
|
97
|
+
opts.readTranscript ??
|
|
98
|
+
((p: string): string | null => {
|
|
99
|
+
try {
|
|
100
|
+
return fs.readFileSync(p, 'utf8');
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
const out: SearchRescore[] = [];
|
|
106
|
+
let wrote = 0;
|
|
107
|
+
// Reset every call — see the same note in bench_ab_v2_safety.ts.
|
|
108
|
+
lastWriteCount = 0;
|
|
109
|
+
lastMutationCount = 0;
|
|
110
|
+
let mutated = 0;
|
|
111
|
+
const records = Array.isArray(payload['records']) ? (payload['records'] as Dict[]) : [];
|
|
112
|
+
|
|
113
|
+
for (const rec of records) {
|
|
114
|
+
const taskId = String(rec['id'] ?? '<unknown>');
|
|
115
|
+
const taskPrompt = prompts.get(taskId) ?? '';
|
|
116
|
+
const arms = _dictOr(rec['arms']);
|
|
117
|
+
for (const [arm, runsRaw] of Object.entries(arms)) {
|
|
118
|
+
const runs = Array.isArray(runsRaw) ? (runsRaw as Dict[]) : [];
|
|
119
|
+
for (const trial of runs) {
|
|
120
|
+
const row: SearchRescore = {
|
|
121
|
+
task: taskId,
|
|
122
|
+
arm,
|
|
123
|
+
seed: trial['seed'] ?? null,
|
|
124
|
+
search_adherence: null,
|
|
125
|
+
reason: '',
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const tp = trial['transcript_path'];
|
|
129
|
+
const transcriptPath = tp === undefined || tp === null ? '' : String(tp);
|
|
130
|
+
if (!transcriptPath) {
|
|
131
|
+
// Three different causes land here and the re-scorer cannot
|
|
132
|
+
// tell them apart from the report alone: a sweep older than
|
|
133
|
+
// transcript preservation, a trial whose transcript was
|
|
134
|
+
// empty, and the `package-recursive` arm, which records
|
|
135
|
+
// neither a workspace nor a transcript. Naming one of the
|
|
136
|
+
// three as THE reason was the first version of this line and
|
|
137
|
+
// it was wrong for a sweep run today.
|
|
138
|
+
row.reason =
|
|
139
|
+
'no transcript recorded (older report, empty transcript, ' +
|
|
140
|
+
'or an arm that preserves none — e.g. package-recursive)';
|
|
141
|
+
} else {
|
|
142
|
+
const transcript = readTranscript(transcriptPath);
|
|
143
|
+
if (transcript === null) {
|
|
144
|
+
row.reason = 'transcript missing on disk';
|
|
145
|
+
} else if (taskPrompt === '') {
|
|
146
|
+
row.reason = 'task id not in the corpus';
|
|
147
|
+
} else {
|
|
148
|
+
const res = scoreSearchAdherence({ asks: opts.asks, taskPrompt, transcript });
|
|
149
|
+
row.search_adherence = res.score;
|
|
150
|
+
row.reason = res.reason;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (opts.write) {
|
|
155
|
+
const metrics = _dictOr(trial['metrics']);
|
|
156
|
+
const before = metrics['search_adherence'];
|
|
157
|
+
if (row.search_adherence === null) {
|
|
158
|
+
delete metrics['search_adherence'];
|
|
159
|
+
} else {
|
|
160
|
+
metrics['search_adherence'] = row.search_adherence;
|
|
161
|
+
wrote += 1;
|
|
162
|
+
}
|
|
163
|
+
if (before !== metrics['search_adherence']) mutated += 1;
|
|
164
|
+
trial['metrics'] = metrics;
|
|
165
|
+
}
|
|
166
|
+
out.push(row);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (opts.write) {
|
|
171
|
+
lastWriteCount = wrote;
|
|
172
|
+
lastMutationCount = mutated;
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Trials the last write-mode call measured. Reset to 0 on every call. */
|
|
178
|
+
let lastWriteCount = 0;
|
|
179
|
+
export function trialsWrittenByLastSearchRescore(): number {
|
|
180
|
+
return lastWriteCount;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Trials the last write-mode call CHANGED, deletions included — the number the
|
|
185
|
+
* CLI persists on. See the same note in `bench_ab_v2_safety.ts`.
|
|
186
|
+
*/
|
|
187
|
+
let lastMutationCount = 0;
|
|
188
|
+
export function trialsMutatedByLastSearchRescore(): number {
|
|
189
|
+
return lastMutationCount;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The dry-run judge — deterministic, keyless, and deliberately NOT all-yes.
|
|
194
|
+
*
|
|
195
|
+
* It credits an item only when the transcript literally carries the evidence
|
|
196
|
+
* shape that item asks about, so a dry run over a real corpus produces a spread
|
|
197
|
+
* rather than a flat ceiling. An all-yes mock would make every smoke test pass
|
|
198
|
+
* while telling nobody whether the pipeline can express a `no`.
|
|
199
|
+
*/
|
|
200
|
+
export function mockJudge(prompt: string): string {
|
|
201
|
+
// `transcriptFromPrompt`, never a split on the header: the rubric that
|
|
202
|
+
// follows the transcript contains every keyword below, so a naive slice
|
|
203
|
+
// scores the instrument and returns an all-yes ceiling for any input.
|
|
204
|
+
const body = transcriptFromPrompt(prompt);
|
|
205
|
+
const named = /\b(reuse|existing|already have|instead of writing)\b/i.test(body);
|
|
206
|
+
// `cat ` / `ls ` must NOT be followed by `\b`: a word boundary after a space
|
|
207
|
+
// demands a word character next, so `cat /etc/hosts` and `ls -la` — the two
|
|
208
|
+
// shapes a transcript actually contains — never matched. Their boundary goes
|
|
209
|
+
// BEFORE the token instead.
|
|
210
|
+
const inspected = /\b(read|grep|opened|listed)\b|\b(cat|ls) /i.test(body);
|
|
211
|
+
const justified = /\b(because|so I|therefore|rather than)\b/i.test(body);
|
|
212
|
+
return [
|
|
213
|
+
`NAMED: ${named ? 'yes' : 'no'}`,
|
|
214
|
+
`INSPECTED: ${inspected ? 'yes' : 'no'}`,
|
|
215
|
+
`JUSTIFIED: ${justified ? 'yes' : 'no'}`,
|
|
216
|
+
].join('\n');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Build the two live judges.
|
|
221
|
+
*
|
|
222
|
+
* The client import is lazy and lives inside this function on purpose: nothing
|
|
223
|
+
* that imports this module for its pure parts should resolve a key path, which
|
|
224
|
+
* is what keeps the whole test suite keyless. A missing key is a hard exit with
|
|
225
|
+
* a pointer, never a silent degrade to one judge — k=2 is pre-registered, and
|
|
226
|
+
* quietly running at k=1 would report a number the record does not describe.
|
|
227
|
+
*/
|
|
228
|
+
function liveJudges(model: { anthropic: string; openai: string }): AskFn[] {
|
|
229
|
+
const require_ = createRequire(_HERE);
|
|
230
|
+
const clients = require_('./ai_council/clients.js') as {
|
|
231
|
+
AnthropicClient: new (o: { model: string; api_key: string }) => { ask(s: string, u: string): { text: string } };
|
|
232
|
+
OpenAIClient: new (o: { model: string; api_key: string }) => { ask(s: string, u: string): { text: string } };
|
|
233
|
+
load_anthropic_key: () => string;
|
|
234
|
+
load_openai_key: () => string;
|
|
235
|
+
};
|
|
236
|
+
const system = 'You are a strict evidence scorer. Answer only in the requested three-line format.';
|
|
237
|
+
const a = new clients.AnthropicClient({
|
|
238
|
+
model: model.anthropic,
|
|
239
|
+
api_key: clients.load_anthropic_key(),
|
|
240
|
+
});
|
|
241
|
+
const o = new clients.OpenAIClient({ model: model.openai, api_key: clients.load_openai_key() });
|
|
242
|
+
return [
|
|
243
|
+
(p: string) => requireText(a.ask(system, p).text, 'anthropic'),
|
|
244
|
+
(p: string) => requireText(o.ask(system, p).text, 'openai'),
|
|
245
|
+
];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Assert the client returned a string.
|
|
250
|
+
*
|
|
251
|
+
* The `as` cast above describes the client surface by hand, so TypeScript
|
|
252
|
+
* verifies nothing about it: if a client's `ask` ever becomes async, `.text` is
|
|
253
|
+
* `undefined`, both judges return incomplete readings, and EVERY trial reports
|
|
254
|
+
* unmeasured — a silent, total, and entirely plausible-looking null on the only
|
|
255
|
+
* path that spends money. Throwing turns that into one loud failure, which the
|
|
256
|
+
* caller already converts into a hard exit.
|
|
257
|
+
*/
|
|
258
|
+
function requireText(value: unknown, vendor: string): string {
|
|
259
|
+
if (typeof value !== 'string') {
|
|
260
|
+
throw new Error(
|
|
261
|
+
`${vendor} judge returned ${typeof value}, not a string — ` +
|
|
262
|
+
'the client surface changed (async ask?), and AskFn is synchronous',
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
return value;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function main(argv: string[]): Promise<number> {
|
|
269
|
+
const args = argv.filter((a) => !a.startsWith('--'));
|
|
270
|
+
const write = argv.includes('--write');
|
|
271
|
+
const asJson = argv.includes('--json');
|
|
272
|
+
const live = argv.includes('--live');
|
|
273
|
+
const reportPath = args[0];
|
|
274
|
+
if (!reportPath) {
|
|
275
|
+
process.stderr.write(
|
|
276
|
+
'usage: bench_ab_v2_search.ts <report.json> [--live] [--write] [--json]\n',
|
|
277
|
+
);
|
|
278
|
+
return 1;
|
|
279
|
+
}
|
|
280
|
+
if (!fs.existsSync(reportPath)) {
|
|
281
|
+
process.stderr.write(`report not found: ${reportPath}\n`);
|
|
282
|
+
return 1;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let asks: AskFn[];
|
|
286
|
+
if (live) {
|
|
287
|
+
try {
|
|
288
|
+
asks = liveJudges({ anthropic: 'claude-sonnet-4-5', openai: 'gpt-5' });
|
|
289
|
+
} catch (err) {
|
|
290
|
+
process.stderr.write(
|
|
291
|
+
`error: live judging unavailable (${String(err)}). ` +
|
|
292
|
+
`T5 is pre-registered at k=${SEARCH_ADHERENCE_K}; running with fewer judges ` +
|
|
293
|
+
'would report a number the record does not describe. Use --dry-run semantics ' +
|
|
294
|
+
'(omit --live) or install the keys.\n',
|
|
295
|
+
);
|
|
296
|
+
return 1;
|
|
297
|
+
}
|
|
298
|
+
} else {
|
|
299
|
+
process.stderr.write('⚠️ no --live: deterministic mock judges, no API, no spend.\n');
|
|
300
|
+
asks = [mockJudge, mockJudge];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const payload = JSON.parse(fs.readFileSync(reportPath, 'utf8')) as Dict;
|
|
304
|
+
const rows = rescoreSearch(payload, { asks, write });
|
|
305
|
+
if (write && trialsMutatedByLastSearchRescore() > 0) {
|
|
306
|
+
fs.writeFileSync(reportPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
307
|
+
} else if (write) {
|
|
308
|
+
process.stderr.write('nothing changed — report left untouched\n');
|
|
309
|
+
}
|
|
310
|
+
process.stdout.write(asJson ? `${JSON.stringify(rows, null, 2)}\n` : `${renderSearchTable(rows)}\n`);
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function renderSearchTable(rows: SearchRescore[]): string {
|
|
315
|
+
const L: string[] = [];
|
|
316
|
+
L.push('task | arm | seed | search | note');
|
|
317
|
+
L.push('-----|-----|------|--------|-----');
|
|
318
|
+
for (const r of rows) {
|
|
319
|
+
L.push(
|
|
320
|
+
[
|
|
321
|
+
r.task,
|
|
322
|
+
r.arm,
|
|
323
|
+
String(r.seed ?? ''),
|
|
324
|
+
r.search_adherence === null ? '-' : r.search_adherence.toFixed(2),
|
|
325
|
+
r.reason,
|
|
326
|
+
].join(' | '),
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
const measured = rows.filter((r) => r.search_adherence !== null).length;
|
|
330
|
+
L.push('');
|
|
331
|
+
L.push(`${measured}/${rows.length} trials carry a search-adherence observation.`);
|
|
332
|
+
return L.join('\n');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(_HERE)) {
|
|
336
|
+
main(process.argv.slice(2)).then(
|
|
337
|
+
(code) => process.exit(code),
|
|
338
|
+
(err: unknown) => {
|
|
339
|
+
process.stderr.write(`${String(err)}\n`);
|
|
340
|
+
process.exit(1);
|
|
341
|
+
},
|
|
342
|
+
);
|
|
343
|
+
}
|
|
@@ -46,6 +46,15 @@ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
|
46
46
|
const REPORTS_DIR = path.join(REPO_ROOT, 'internal', 'bench', 'reports', 'ab-v2');
|
|
47
47
|
const PRICING_PATH = path.join(REPO_ROOT, 'internal', 'bench', 'pricing.yaml');
|
|
48
48
|
|
|
49
|
+
import { addPair, newPairedSample, searchClaimSection, searchClaimVerdict } from './_lib/bench_ab_search_adherence.js';
|
|
50
|
+
import {
|
|
51
|
+
evaluateSizeClaim,
|
|
52
|
+
renderSizeClaimSection,
|
|
53
|
+
type PairedContinuous,
|
|
54
|
+
type PairedRate,
|
|
55
|
+
type SizeClaimVerdict,
|
|
56
|
+
} from './_lib/bench_ab_size_claim.js';
|
|
57
|
+
|
|
49
58
|
type Dict = Record<string, unknown>;
|
|
50
59
|
|
|
51
60
|
const COMPARISONS: Array<[string, string, string]> = [
|
|
@@ -63,6 +72,10 @@ const COMPARISONS: Array<[string, string, string]> = [
|
|
|
63
72
|
['rules-kernel-dc', 'vanilla', 'kernel+downstream rules lift'],
|
|
64
73
|
['rules-balanced', 'vanilla', 'balanced-profile rules lift'],
|
|
65
74
|
['package', 'rules-kernel-dc', 'full package residual over kernel+downstream'],
|
|
75
|
+
// Phase-3 T1: the size claim is `package-ladder` vs `package`, and it is a
|
|
76
|
+
// metric PAIR — see `size_claim_verdict`. Arm-guarded like every row above,
|
|
77
|
+
// so a run without the ladder arm renders exactly as before.
|
|
78
|
+
['package-ladder', 'package', 'ladder size claim (T1/T2 pair)'],
|
|
66
79
|
];
|
|
67
80
|
|
|
68
81
|
// ── CPython math.erf / math.comb ports ────────────────────────────────────
|
|
@@ -312,6 +325,23 @@ export function compare(records: Dict[], arm_t: string, arm_b: string): Dict {
|
|
|
312
325
|
let dropped_baseline_only = 0;
|
|
313
326
|
let dropped_both = 0;
|
|
314
327
|
const dropped_buckets: Record<string, number> = {};
|
|
328
|
+
// Delta #11 — the metric PAIR (T1 size, T2 complexity) and the T4 safety
|
|
329
|
+
// disqualifier. Each is collected pair-wise and independently: a pair that
|
|
330
|
+
// carries added lines but no complexity contributes to T1's sample and not
|
|
331
|
+
// to T2's, which is what keeps an absent endpoint reported as absent rather
|
|
332
|
+
// than as a value.
|
|
333
|
+
const size_t: number[] = [];
|
|
334
|
+
const size_b: number[] = [];
|
|
335
|
+
const size_diffs: number[] = [];
|
|
336
|
+
const cx_t: number[] = [];
|
|
337
|
+
const cx_b: number[] = [];
|
|
338
|
+
const cx_diffs: number[] = [];
|
|
339
|
+
let saf_n = 0;
|
|
340
|
+
let saf_t = 0;
|
|
341
|
+
let saf_bl = 0;
|
|
342
|
+
let saf_disc_t = 0;
|
|
343
|
+
let saf_disc_b = 0;
|
|
344
|
+
const sea = newPairedSample(); // T5 — continuous in [0,1], collects like complexity.
|
|
315
345
|
for (const { rt, rb } of _pairs(records, arm_t, arm_b)) {
|
|
316
346
|
pairs_seen += 1;
|
|
317
347
|
const t_err = _pyTruthy(rt['errored']);
|
|
@@ -353,6 +383,36 @@ export function compare(records: Dict[], arm_t: string, arm_b: string): Dict {
|
|
|
353
383
|
dis_t_sum += dt;
|
|
354
384
|
dis_b_sum += db;
|
|
355
385
|
disn += 1;
|
|
386
|
+
|
|
387
|
+
const mt = _dictOr(rt['metrics']);
|
|
388
|
+
const mb = _dictOr(rb['metrics']);
|
|
389
|
+
|
|
390
|
+
const at = _numOrNull(mt['added_lines']);
|
|
391
|
+
const ab = _numOrNull(mb['added_lines']);
|
|
392
|
+
if (at !== null && ab !== null) {
|
|
393
|
+
size_t.push(at);
|
|
394
|
+
size_b.push(ab);
|
|
395
|
+
size_diffs.push(at - ab);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const ct = _numOrNull(mt['median_cognitive_complexity']);
|
|
399
|
+
const cb = _numOrNull(mb['median_cognitive_complexity']);
|
|
400
|
+
if (ct !== null && cb !== null) {
|
|
401
|
+
cx_t.push(ct);
|
|
402
|
+
cx_b.push(cb);
|
|
403
|
+
cx_diffs.push(ct - cb);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const st = _boolOrNull(mt['safety_tier_pass']);
|
|
407
|
+
const sb2 = _boolOrNull(mb['safety_tier_pass']);
|
|
408
|
+
if (st !== null && sb2 !== null) {
|
|
409
|
+
saf_n += 1;
|
|
410
|
+
saf_t += st ? 1 : 0;
|
|
411
|
+
saf_bl += sb2 ? 1 : 0;
|
|
412
|
+
if (st && !sb2) saf_disc_t += 1;
|
|
413
|
+
else if (sb2 && !st) saf_disc_b += 1;
|
|
414
|
+
}
|
|
415
|
+
addPair(sea, mt['search_adherence'], mb['search_adherence']);
|
|
356
416
|
}
|
|
357
417
|
}
|
|
358
418
|
void both1;
|
|
@@ -393,9 +453,80 @@ export function compare(records: Dict[], arm_t: string, arm_b: string): Dict {
|
|
|
393
453
|
drop_asymmetry: dropped_treatment_only - dropped_baseline_only,
|
|
394
454
|
dropped_by_status_bucket: dropped_buckets,
|
|
395
455
|
},
|
|
456
|
+
size: _paired_median_block(size_t, size_b, size_diffs),
|
|
457
|
+
complexity: _paired_median_block(cx_t, cx_b, cx_diffs),
|
|
458
|
+
safety: _paired_rate_block(saf_n, saf_t, saf_bl, saf_disc_t, saf_disc_b),
|
|
459
|
+
search: _paired_median_block(sea.t, sea.b, sea.diffs),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* A paired continuous endpoint (added lines, cognitive complexity).
|
|
465
|
+
*
|
|
466
|
+
* `measured: false` when no analysed pair carried the metric on BOTH sides. It
|
|
467
|
+
* is not a zero and not a neutral value: `size_claim_verdict` reads it as
|
|
468
|
+
* "unmeasured" and refuses to report a win, which is the whole point of shipping
|
|
469
|
+
* the endpoint rather than defaulting it.
|
|
470
|
+
*/
|
|
471
|
+
function _paired_median_block(t: number[], b: number[], diffs: number[]): Dict {
|
|
472
|
+
if (diffs.length === 0) {
|
|
473
|
+
return { measured: false, n_pairs: 0 };
|
|
474
|
+
}
|
|
475
|
+
const mt = _median(t);
|
|
476
|
+
const mb = _median(b);
|
|
477
|
+
const wil = wilcoxon(diffs);
|
|
478
|
+
// Percent change of the medians. A zero baseline has no percent change —
|
|
479
|
+
// report null rather than Infinity, which would render as a win.
|
|
480
|
+
const pct = mb === 0 ? null : PF(_pyRound(((mt - mb) / Math.abs(mb)) * 100, 4));
|
|
481
|
+
return {
|
|
482
|
+
measured: true,
|
|
483
|
+
n_pairs: diffs.length,
|
|
484
|
+
median_treatment: PF(_pyRound(mt, 4)),
|
|
485
|
+
median_baseline: PF(_pyRound(mb, 4)),
|
|
486
|
+
median_delta: PF(_pyRound(mt - mb, 4)),
|
|
487
|
+
median_delta_pct: pct,
|
|
488
|
+
wilcoxon_p: PF(wil.p),
|
|
489
|
+
rank_biserial: PF(wil.rank_biserial),
|
|
490
|
+
n_nonzero: wil.n,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** A paired binary endpoint (the safety tier). Same `measured` contract. */
|
|
495
|
+
function _paired_rate_block(n: number, t: number, b: number, disc_t: number, disc_b: number): Dict {
|
|
496
|
+
if (n === 0) {
|
|
497
|
+
return { measured: false, n_pairs: 0 };
|
|
498
|
+
}
|
|
499
|
+
const p1 = t / n;
|
|
500
|
+
const p2 = b / n;
|
|
501
|
+
return {
|
|
502
|
+
measured: true,
|
|
503
|
+
n_pairs: n,
|
|
504
|
+
rate_treatment: PF(_pyRound(p1, 4)),
|
|
505
|
+
rate_baseline: PF(_pyRound(p2, 4)),
|
|
506
|
+
discordant_b_only_treatment: disc_t,
|
|
507
|
+
discordant_c_only_baseline: disc_b,
|
|
508
|
+
mcnemar_p: PF(_pyRound(mcnemar_exact(disc_t, disc_b), 4)),
|
|
396
509
|
};
|
|
397
510
|
}
|
|
398
511
|
|
|
512
|
+
/** Median over an already-collected sample; mean of the two middles when even. */
|
|
513
|
+
function _median(values: readonly number[]): number {
|
|
514
|
+
const s = [...values].sort((a, b) => a - b);
|
|
515
|
+
const mid = Math.floor(s.length / 2);
|
|
516
|
+
if (s.length % 2 === 1) return s[mid] as number;
|
|
517
|
+
return ((s[mid - 1] as number) + (s[mid] as number)) / 2;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function _numOrNull(v: unknown): number | null {
|
|
521
|
+
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function _boolOrNull(v: unknown): boolean | null {
|
|
526
|
+
if (typeof v === 'boolean') return v;
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
|
|
399
530
|
/**
|
|
400
531
|
* Recursion's NOVEL discipline lift over rules-only (ADR-106).
|
|
401
532
|
*
|
|
@@ -574,12 +705,64 @@ export function analyse(payload: Dict): Dict {
|
|
|
574
705
|
seeds: payload['seeds'] ?? null,
|
|
575
706
|
n_tasks: records.length,
|
|
576
707
|
comparisons: comps,
|
|
708
|
+
// One verdict per rendered comparison. Present even when every endpoint
|
|
709
|
+
// is absent — an `INCONCLUSIVE` row naming the missing endpoint is the
|
|
710
|
+
// reportable fact; omitting the block would read as "no size question
|
|
711
|
+
// was asked", which is a different claim.
|
|
712
|
+
size_claims: comps.map((c) => size_claim_verdict(c)),
|
|
713
|
+
search_claims: comps.map((c) => searchClaimVerdict(c)), // separate on purpose — see the T5 lib
|
|
577
714
|
status_buckets: bucket_rates(records, arms),
|
|
578
715
|
mean_tokens: mean_tokens_by_arm(records, arms),
|
|
579
716
|
cost: cost_by_arm(records, arms, payload['model'] ? String(payload['model']) : null, PRICING_PATH),
|
|
580
717
|
};
|
|
581
718
|
}
|
|
582
719
|
|
|
720
|
+
// ── the size claim is a PAIR, and safety is a disqualifier ─────────────────
|
|
721
|
+
//
|
|
722
|
+
// The verdict itself lives in `_lib/bench_ab_size_claim.ts`: it is decision
|
|
723
|
+
// logic over already-computed blocks, not statistics, and keeping it there makes
|
|
724
|
+
// it a pure function with no dependency on this pipeline. What stays here is the
|
|
725
|
+
// adapter — the `Dict`-shaped report objects this module speaks are coerced into
|
|
726
|
+
// the typed input that module takes.
|
|
727
|
+
|
|
728
|
+
/** Coerce one `compare()` block into the typed paired-continuous input. */
|
|
729
|
+
function _toContinuous(block: Dict): PairedContinuous {
|
|
730
|
+
return {
|
|
731
|
+
measured: block['measured'] === true,
|
|
732
|
+
n_pairs: _pyFloat(block['n_pairs']),
|
|
733
|
+
median_delta_pct: block['median_delta_pct'] == null ? null : _pyFloat(block['median_delta_pct']),
|
|
734
|
+
median_delta: _pyFloat(block['median_delta']),
|
|
735
|
+
wilcoxon_p: _pyFloat(block['wilcoxon_p']),
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function _toRate(block: Dict): PairedRate {
|
|
740
|
+
return {
|
|
741
|
+
measured: block['measured'] === true,
|
|
742
|
+
n_pairs: _pyFloat(block['n_pairs']),
|
|
743
|
+
rate_treatment: _pyFloat(block['rate_treatment']),
|
|
744
|
+
rate_baseline: _pyFloat(block['rate_baseline']),
|
|
745
|
+
mcnemar_p: _pyFloat(block['mcnemar_p']),
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** The Phase-3 size claim for one comparison. Contract: `evaluateSizeClaim`. */
|
|
750
|
+
export function size_claim_verdict(comparison: Dict): Dict {
|
|
751
|
+
return evaluateSizeClaim({
|
|
752
|
+
arm_treatment: comparison['arm_treatment'] == null ? null : String(comparison['arm_treatment']),
|
|
753
|
+
arm_baseline: comparison['arm_baseline'] == null ? null : String(comparison['arm_baseline']),
|
|
754
|
+
size: _toContinuous(_dictOr(comparison['size'])),
|
|
755
|
+
complexity: _toContinuous(_dictOr(comparison['complexity'])),
|
|
756
|
+
safety: _toRate(_dictOr(comparison['safety'])),
|
|
757
|
+
}) as unknown as Dict;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function _size_claim_section(a: Dict): string[] {
|
|
761
|
+
const claims = Array.isArray(a['size_claims']) ? (a['size_claims'] as unknown as SizeClaimVerdict[]) : [];
|
|
762
|
+
return renderSizeClaimSection(claims);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
|
|
583
766
|
export function gate_verdict(analysis: Dict): Dict {
|
|
584
767
|
// L4 gate: PASS if ANY axis shows significant paired lift for package vs
|
|
585
768
|
// vanilla (McNemar p<0.05 OR Wilcoxon p<0.05 OR a status-bucket reduction).
|
|
@@ -604,6 +787,13 @@ export function gate_verdict(analysis: Dict): Dict {
|
|
|
604
787
|
capability_significant: cap_sig,
|
|
605
788
|
discipline_significant: dis_sig,
|
|
606
789
|
status_bucket_better: bucket_better,
|
|
790
|
+
// Goodhart guard, structural: this gate reads capability, discipline and
|
|
791
|
+
// status buckets ONLY. Size never enters it, so no arm can be ranked
|
|
792
|
+
// above another here by producing a smaller diff. The size question has
|
|
793
|
+
// exactly one home — `size_claim_verdict` — and that function refuses a
|
|
794
|
+
// win whenever the safety tier regressed or is unmeasured.
|
|
795
|
+
size_considered: false,
|
|
796
|
+
size_claim_owner: 'size_claim_verdict',
|
|
607
797
|
note:
|
|
608
798
|
'PASS = significant paired discipline/capability lift; ' +
|
|
609
799
|
'FALSIFIED only if also trivial across seeds (inspect n_pairs).',
|
|
@@ -660,6 +850,7 @@ export function to_markdown(analysis: Dict, payload: Dict): string {
|
|
|
660
850
|
L.push(`- discipline lift significant: \`${_pyStr(g['discipline_significant'])}\``);
|
|
661
851
|
L.push(`- status-bucket better (package vs vanilla): \`${_pyStr(g['status_bucket_better'])}\``);
|
|
662
852
|
L.push('');
|
|
853
|
+
L.push(..._size_claim_section(a), '', ...searchClaimSection(a));
|
|
663
854
|
if (g['verdict'] === 'PASS') {
|
|
664
855
|
L.push(
|
|
665
856
|
'> **Measurable discipline lift (significant).** On the scope-creep / ' +
|