@event4u/agent-config 7.2.0 → 7.4.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 +74 -0
- package/README.md +9 -10
- package/dist/agent-src/commands/fix/pr-comments.md +42 -13
- package/dist/agent-src/rules/cli-output-handling.md +18 -0
- package/dist/agent-src/skills/bug-analyzer/evals/evals.json +13 -0
- package/dist/agent-src/skills/code-review/evals/evals.json +21 -0
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +4 -4
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/router.json +1 -1
- package/dist/server/schemas/settings.js +3 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/contracts/governance-enforcement-projection.md +154 -0
- package/docs/enforcement-by-host.md +44 -0
- package/docs/governance-advantage.md +72 -0
- package/package.json +2 -1
- package/src/config/agent-settings.template.yml +9 -0
- package/src/scripts/_cli/cmd_analyze_session.ts +347 -0
- package/src/scripts/_dispatch.bash +8 -0
- package/src/scripts/_lib/eval_discrimination.ts +95 -0
- package/src/scripts/_lib/token_count.ts +110 -35
- package/src/scripts/_lib/trigger_routers.ts +268 -0
- package/src/scripts/audit_initial_context.ts +37 -12
- package/src/scripts/bench_ab_v2_run.ts +38 -0
- package/src/scripts/bench_quality_run.ts +253 -0
- package/src/scripts/bench_rule_load_latency.ts +178 -0
- package/src/scripts/check_kernel_prefix_stability.ts +0 -0
- package/src/scripts/check_quality_regression.ts +244 -0
- package/src/scripts/check_token_quality_golden.ts +253 -0
- package/src/scripts/check_token_regression.ts +266 -0
- package/src/scripts/cross_model_smoke.ts +245 -0
- package/src/scripts/hook_manifest.yaml +15 -3
- package/src/scripts/hooks/rtk_wrap_hook.ts +228 -0
- package/src/scripts/probe_host_compliance.ts +145 -0
- package/src/scripts/run_skill_evals.ts +38 -0
- package/src/scripts/skill_linter.ts +26 -10
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* On-demand rule-load latency micro-benchmark (token-saving Phase 0).
|
|
4
|
+
*
|
|
5
|
+
* Under thin projection, a non-kernel rule body is a router-resolved pointer
|
|
6
|
+
* that the agent loads ON DEMAND when the rule fires. This measures the
|
|
7
|
+
* mechanical cost the suite controls: resolve the rule id → read its body file
|
|
8
|
+
* from `dist/agent-src/rules/<id>.md`. It reports the TAIL (p50/p95/p99), not
|
|
9
|
+
* just the mean — a fat tail on pointer resolution would erode the thin-
|
|
10
|
+
* projection win.
|
|
11
|
+
*
|
|
12
|
+
* Honest scope: this is the file-IO + locate cost, NOT the live-agent
|
|
13
|
+
* round-trip (model re-reading context). It bounds the part the package owns;
|
|
14
|
+
* it does not model host latency.
|
|
15
|
+
*
|
|
16
|
+
* Kernel rules are always-loaded (never on-demand), so they are excluded.
|
|
17
|
+
*
|
|
18
|
+
* CLI:
|
|
19
|
+
* ./scripts-run src/scripts/bench_rule_load_latency
|
|
20
|
+
* ./scripts-run src/scripts/bench_rule_load_latency --iterations 100 --json
|
|
21
|
+
* ./scripts-run src/scripts/bench_rule_load_latency --write # → internal/bench/reports/rule-load-latency.json
|
|
22
|
+
*
|
|
23
|
+
* Exit codes: 0 measured · 1 file error. Measurement only — never gates.
|
|
24
|
+
*/
|
|
25
|
+
import * as fs from 'node:fs';
|
|
26
|
+
import * as path from 'node:path';
|
|
27
|
+
import { performance } from 'node:perf_hooks';
|
|
28
|
+
import process from 'node:process';
|
|
29
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
30
|
+
|
|
31
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
32
|
+
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
33
|
+
const RULES_SOURCE = path.join(REPO_ROOT, 'dist/agent-src/rules');
|
|
34
|
+
const ROUTER = path.join(REPO_ROOT, 'dist/router.json');
|
|
35
|
+
const REPORT = path.join(REPO_ROOT, 'internal/bench/reports/rule-load-latency.json');
|
|
36
|
+
|
|
37
|
+
type Json = Record<string, unknown>;
|
|
38
|
+
|
|
39
|
+
/** Non-kernel (on-demand) rule ids from the router: tier_1 + tier_2. */
|
|
40
|
+
export function on_demand_rule_ids(router: Json): string[] {
|
|
41
|
+
const ids: string[] = [];
|
|
42
|
+
for (const key of ['tier_1', 'tier_2']) {
|
|
43
|
+
const arr = router[key];
|
|
44
|
+
if (!Array.isArray(arr)) continue;
|
|
45
|
+
for (const entry of arr) {
|
|
46
|
+
if (typeof entry === 'string') ids.push(entry);
|
|
47
|
+
else if (entry && typeof entry === 'object' && typeof (entry as Json).id === 'string') {
|
|
48
|
+
ids.push((entry as Json).id as string);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return ids;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface Summary {
|
|
56
|
+
count: number;
|
|
57
|
+
mean_ms: number;
|
|
58
|
+
p50_ms: number;
|
|
59
|
+
p95_ms: number;
|
|
60
|
+
p99_ms: number;
|
|
61
|
+
max_ms: number;
|
|
62
|
+
min_ms: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Nearest-rank percentile over an ascending-sorted sample (p in [0,100]). */
|
|
66
|
+
export function percentile(sortedAsc: number[], p: number): number {
|
|
67
|
+
if (sortedAsc.length === 0) return 0;
|
|
68
|
+
const rank = Math.ceil((p / 100) * sortedAsc.length);
|
|
69
|
+
const idx = Math.min(sortedAsc.length - 1, Math.max(0, rank - 1));
|
|
70
|
+
return sortedAsc[idx] ?? 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function summarize(samples: number[]): Summary {
|
|
74
|
+
if (samples.length === 0) {
|
|
75
|
+
return { count: 0, mean_ms: 0, p50_ms: 0, p95_ms: 0, p99_ms: 0, max_ms: 0, min_ms: 0 };
|
|
76
|
+
}
|
|
77
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
78
|
+
const sum = sorted.reduce((a, b) => a + b, 0);
|
|
79
|
+
const round = (x: number) => Math.round(x * 1000) / 1000; // µs precision
|
|
80
|
+
return {
|
|
81
|
+
count: sorted.length,
|
|
82
|
+
mean_ms: round(sum / sorted.length),
|
|
83
|
+
p50_ms: round(percentile(sorted, 50)),
|
|
84
|
+
p95_ms: round(percentile(sorted, 95)),
|
|
85
|
+
p99_ms: round(percentile(sorted, 99)),
|
|
86
|
+
max_ms: round(sorted[sorted.length - 1] ?? 0),
|
|
87
|
+
min_ms: round(sorted[0] ?? 0),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function main(argv: string[]): number {
|
|
92
|
+
let iterations = 50;
|
|
93
|
+
let asJson = false;
|
|
94
|
+
let write = false;
|
|
95
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
96
|
+
const a = argv[i];
|
|
97
|
+
if (a === '--json') asJson = true;
|
|
98
|
+
else if (a === '--write') write = true;
|
|
99
|
+
else if (a === '--iterations') {
|
|
100
|
+
const v = Number(argv[(i += 1)]);
|
|
101
|
+
if (!Number.isInteger(v) || v < 1) {
|
|
102
|
+
process.stderr.write('error: --iterations must be a positive integer\n');
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
iterations = v;
|
|
106
|
+
} else if (a === '-h' || a === '--help') {
|
|
107
|
+
process.stdout.write('usage: bench_rule_load_latency [--iterations N] [--json] [--write]\n');
|
|
108
|
+
return 0;
|
|
109
|
+
} else {
|
|
110
|
+
process.stderr.write(`error: unknown argument: ${a}\n`);
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let router: Json;
|
|
116
|
+
try {
|
|
117
|
+
router = JSON.parse(fs.readFileSync(ROUTER, 'utf-8')) as Json;
|
|
118
|
+
} catch {
|
|
119
|
+
process.stderr.write(`error: cannot read ${path.relative(REPO_ROOT, ROUTER)} — run \`task sync\` first\n`);
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
const ids = on_demand_rule_ids(router);
|
|
123
|
+
const paths: string[] = [];
|
|
124
|
+
for (const id of ids) {
|
|
125
|
+
const p = path.join(RULES_SOURCE, `${id}.md`);
|
|
126
|
+
if (fs.existsSync(p)) paths.push(p);
|
|
127
|
+
}
|
|
128
|
+
if (paths.length === 0) {
|
|
129
|
+
process.stderr.write('error: no on-demand rule bodies found under dist/agent-src/rules\n');
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Warm-up pass (page the files in) so we measure steady-state, not cold cache.
|
|
134
|
+
for (const p of paths) fs.readFileSync(p, 'utf-8');
|
|
135
|
+
|
|
136
|
+
const samples: number[] = [];
|
|
137
|
+
for (let it = 0; it < iterations; it += 1) {
|
|
138
|
+
for (const p of paths) {
|
|
139
|
+
const t0 = performance.now();
|
|
140
|
+
fs.readFileSync(p, 'utf-8'); // resolve + read = the on-demand load
|
|
141
|
+
samples.push(performance.now() - t0);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const summary = summarize(samples);
|
|
146
|
+
const payload = {
|
|
147
|
+
measured_at: new Date().toISOString(),
|
|
148
|
+
rules_measured: paths.length,
|
|
149
|
+
iterations,
|
|
150
|
+
note: 'mechanical resolve+read latency of on-demand (non-kernel) rule bodies; excludes live-agent round-trip',
|
|
151
|
+
...summary,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
if (write) {
|
|
155
|
+
fs.writeFileSync(REPORT, JSON.stringify(payload, null, 2) + '\n');
|
|
156
|
+
}
|
|
157
|
+
if (asJson) {
|
|
158
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
|
159
|
+
} else {
|
|
160
|
+
process.stdout.write(
|
|
161
|
+
`on-demand rule load (${paths.length} rules × ${iterations} iters, ${summary.count} samples):\n` +
|
|
162
|
+
` p50 ${summary.p50_ms}ms · p95 ${summary.p95_ms}ms · p99 ${summary.p99_ms}ms · ` +
|
|
163
|
+
`mean ${summary.mean_ms}ms · max ${summary.max_ms}ms\n` +
|
|
164
|
+
(write ? `→ wrote ${path.relative(REPO_ROOT, REPORT)}\n` : ''),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const _IS_MAIN =
|
|
171
|
+
process.argv[1] !== undefined &&
|
|
172
|
+
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
173
|
+
|
|
174
|
+
if (_IS_MAIN) {
|
|
175
|
+
process.exit(main(process.argv.slice(2)));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export { main };
|
|
Binary file
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Length-controlled paired judge + quality-regression gate (token-saving Phase 0).
|
|
4
|
+
*
|
|
5
|
+
* Proves OUTPUT QUALITY is held constant when a token lever changes the
|
|
6
|
+
* projection (thin vs eager). Hand-rolled on the suite's existing Wilcoxon
|
|
7
|
+
* layer (`bench_ab_v2_stats.wilcoxon`) — no `promptfoo` dependency (council
|
|
8
|
+
* 2026-06-26: the bias controls are custom either way; the package is lean).
|
|
9
|
+
*
|
|
10
|
+
* Bias controls (the part that makes an LLM judge trustworthy):
|
|
11
|
+
* - **Position**: every pair is judged in BOTH orders (thin-first AND
|
|
12
|
+
* eager-first). A verdict that does not survive the swap is `inconsistent`
|
|
13
|
+
* and excluded — this is the "reject the judge if it flips" control, and it
|
|
14
|
+
* subsumes order-randomisation (we are position-balanced by construction).
|
|
15
|
+
* - **Length / verbosity**: per decisive win we record whether the winner was
|
|
16
|
+
* also the LONGER answer; the aggregate surfaces the length-confound rate so
|
|
17
|
+
* a win driven by verbosity bias (measured +17.3% in LLM judges) is visible.
|
|
18
|
+
*
|
|
19
|
+
* Two layers:
|
|
20
|
+
* LIBRARY (here, unit-tested with a mock judge): `evaluatePair` / `aggregate`
|
|
21
|
+
* / `gateVerdict` — the deterministic harness an operator's live runner calls.
|
|
22
|
+
* GATE (CLI): reads a completed run report and fails if thin's win-rate < the
|
|
23
|
+
* threshold (default 0.48). The live run (generate thin+eager answers, call a
|
|
24
|
+
* judge model) is the operator/cost step — until its report exists the gate is
|
|
25
|
+
* INERT (exit 0), so CI stays green.
|
|
26
|
+
*
|
|
27
|
+
* quality-run.json (produced by the operator's live runner via this library):
|
|
28
|
+
* { "threshold": 0.48, "judge_model": "...",
|
|
29
|
+
* "results": [ { "id", "winner": thin|eager|tie|inconsistent,
|
|
30
|
+
* "length_delta": <int>, "winner_is_longer": bool|null } ] }
|
|
31
|
+
*
|
|
32
|
+
* CLI:
|
|
33
|
+
* ./scripts-run src/scripts/check_quality_regression # gate (inert w/o run data)
|
|
34
|
+
* ./scripts-run src/scripts/check_quality_regression --json
|
|
35
|
+
* ./scripts-run src/scripts/check_quality_regression --threshold 0.48
|
|
36
|
+
*
|
|
37
|
+
* Exit codes: 0 ok / inert · 1 file error · 2 quality regression (win-rate < threshold).
|
|
38
|
+
*/
|
|
39
|
+
import * as fs from 'node:fs';
|
|
40
|
+
import * as path from 'node:path';
|
|
41
|
+
import process from 'node:process';
|
|
42
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
43
|
+
|
|
44
|
+
import { wilcoxon } from './bench_ab_v2_stats.js';
|
|
45
|
+
|
|
46
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
47
|
+
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
48
|
+
const RUN_REPORT = path.join(REPO_ROOT, 'internal/bench/reports/quality-run.json');
|
|
49
|
+
const DEFAULT_THRESHOLD = 0.48;
|
|
50
|
+
|
|
51
|
+
export type Arm = 'thin' | 'eager';
|
|
52
|
+
/** A judge's preference between the two PRESENTED answers (order-relative). */
|
|
53
|
+
export type JudgeVerdict = 'first' | 'second' | 'tie';
|
|
54
|
+
/** The pluggable judge — a mock in tests, a model call in the live runner. */
|
|
55
|
+
export type JudgeFn = (
|
|
56
|
+
ctx: { id: string; rubric: string },
|
|
57
|
+
first: string,
|
|
58
|
+
second: string,
|
|
59
|
+
) => JudgeVerdict;
|
|
60
|
+
|
|
61
|
+
export type PairWinner = 'thin' | 'eager' | 'tie' | 'inconsistent';
|
|
62
|
+
|
|
63
|
+
export interface PairResult {
|
|
64
|
+
id: string;
|
|
65
|
+
winner: PairWinner;
|
|
66
|
+
length_delta: number; // chars(thin) - chars(eager)
|
|
67
|
+
winner_is_longer: boolean | null; // null when tie/inconsistent
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Judge one (thin, eager) answer pair in BOTH orders and resolve the winner.
|
|
72
|
+
* A verdict that flips with order is `inconsistent` (position bias → excluded).
|
|
73
|
+
*/
|
|
74
|
+
export function evaluatePair(
|
|
75
|
+
task: { id: string; rubric: string },
|
|
76
|
+
answerThin: string,
|
|
77
|
+
answerEager: string,
|
|
78
|
+
judge: JudgeFn,
|
|
79
|
+
): PairResult {
|
|
80
|
+
const v1 = judge(task, answerThin, answerEager); // first=thin, second=eager
|
|
81
|
+
const v2 = judge(task, answerEager, answerThin); // first=eager, second=thin
|
|
82
|
+
const w1: PairWinner = v1 === 'first' ? 'thin' : v1 === 'second' ? 'eager' : 'tie';
|
|
83
|
+
const w2: PairWinner = v2 === 'second' ? 'thin' : v2 === 'first' ? 'eager' : 'tie';
|
|
84
|
+
|
|
85
|
+
const length_delta = answerThin.length - answerEager.length;
|
|
86
|
+
let winner: PairWinner;
|
|
87
|
+
if (w1 === 'tie' && w2 === 'tie') {
|
|
88
|
+
winner = 'tie';
|
|
89
|
+
} else if (w1 === w2) {
|
|
90
|
+
winner = w1; // consistent decisive win (thin or eager)
|
|
91
|
+
} else {
|
|
92
|
+
winner = 'inconsistent'; // flipped or one-tie-one-decisive → not robust
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let winner_is_longer: boolean | null = null;
|
|
96
|
+
if (winner === 'thin') winner_is_longer = length_delta > 0;
|
|
97
|
+
else if (winner === 'eager') winner_is_longer = length_delta < 0;
|
|
98
|
+
|
|
99
|
+
return { id: task.id, winner, length_delta, winner_is_longer };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface QualityAggregate {
|
|
103
|
+
total: number;
|
|
104
|
+
thin_wins: number;
|
|
105
|
+
eager_wins: number;
|
|
106
|
+
ties: number;
|
|
107
|
+
inconsistent: number;
|
|
108
|
+
decisive: number;
|
|
109
|
+
thin_win_rate: number | null; // thin_wins / decisive
|
|
110
|
+
inconsistency_rate: number | null; // inconsistent / total (judge-reliability health)
|
|
111
|
+
length_confound_rate: number | null; // share of decisive wins where winner was longer
|
|
112
|
+
wilcoxon_p: number | null;
|
|
113
|
+
verdict: 'ok' | 'regression' | 'no-data';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Aggregate per-pair results into a win-rate + bias diagnostics + Wilcoxon. */
|
|
117
|
+
export function aggregate(results: PairResult[], threshold = DEFAULT_THRESHOLD): QualityAggregate {
|
|
118
|
+
const total = results.length;
|
|
119
|
+
let thin_wins = 0;
|
|
120
|
+
let eager_wins = 0;
|
|
121
|
+
let ties = 0;
|
|
122
|
+
let inconsistent = 0;
|
|
123
|
+
let longerWins = 0;
|
|
124
|
+
const diffs: number[] = [];
|
|
125
|
+
for (const r of results) {
|
|
126
|
+
if (r.winner === 'thin') {
|
|
127
|
+
thin_wins += 1;
|
|
128
|
+
diffs.push(1);
|
|
129
|
+
if (r.winner_is_longer) longerWins += 1;
|
|
130
|
+
} else if (r.winner === 'eager') {
|
|
131
|
+
eager_wins += 1;
|
|
132
|
+
diffs.push(-1);
|
|
133
|
+
if (r.winner_is_longer) longerWins += 1;
|
|
134
|
+
} else if (r.winner === 'tie') {
|
|
135
|
+
ties += 1;
|
|
136
|
+
diffs.push(0);
|
|
137
|
+
} else {
|
|
138
|
+
inconsistent += 1;
|
|
139
|
+
diffs.push(0);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const decisive = thin_wins + eager_wins;
|
|
143
|
+
const thin_win_rate = decisive > 0 ? thin_wins / decisive : null;
|
|
144
|
+
const wil = wilcoxon(diffs);
|
|
145
|
+
const verdict: QualityAggregate['verdict'] =
|
|
146
|
+
thin_win_rate === null ? 'no-data' : thin_win_rate < threshold ? 'regression' : 'ok';
|
|
147
|
+
return {
|
|
148
|
+
total,
|
|
149
|
+
thin_wins,
|
|
150
|
+
eager_wins,
|
|
151
|
+
ties,
|
|
152
|
+
inconsistent,
|
|
153
|
+
decisive,
|
|
154
|
+
thin_win_rate,
|
|
155
|
+
inconsistency_rate: total > 0 ? inconsistent / total : null,
|
|
156
|
+
length_confound_rate: decisive > 0 ? longerWins / decisive : null,
|
|
157
|
+
wilcoxon_p: wil.n > 0 ? wil.p : null,
|
|
158
|
+
verdict,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Gate exit code from an aggregate. */
|
|
163
|
+
export function gateVerdict(agg: QualityAggregate): 0 | 2 {
|
|
164
|
+
return agg.verdict === 'regression' ? 2 : 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface RunReport {
|
|
168
|
+
threshold?: number;
|
|
169
|
+
results?: PairResult[];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function main(argv: string[]): number {
|
|
173
|
+
let asJson = false;
|
|
174
|
+
let threshold = DEFAULT_THRESHOLD;
|
|
175
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
176
|
+
const a = argv[i];
|
|
177
|
+
if (a === '--json') asJson = true;
|
|
178
|
+
else if (a === '--threshold') {
|
|
179
|
+
const v = Number(argv[(i += 1)]);
|
|
180
|
+
if (!Number.isFinite(v) || v < 0 || v > 1) {
|
|
181
|
+
process.stderr.write('error: --threshold must be in [0,1]\n');
|
|
182
|
+
return 1;
|
|
183
|
+
}
|
|
184
|
+
threshold = v;
|
|
185
|
+
} else if (a === '-h' || a === '--help') {
|
|
186
|
+
process.stdout.write('usage: check_quality_regression [--json] [--threshold F]\n');
|
|
187
|
+
return 0;
|
|
188
|
+
} else {
|
|
189
|
+
process.stderr.write(`error: unknown argument: ${a}\n`);
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!fs.existsSync(RUN_REPORT)) {
|
|
195
|
+
const msg =
|
|
196
|
+
`⚠️ no quality-run report (${path.relative(REPO_ROOT, RUN_REPORT)}) — ` +
|
|
197
|
+
'gate inert. Produce it with a thin-vs-eager judge run (operator/cost gate).';
|
|
198
|
+
if (asJson) process.stdout.write(JSON.stringify({ verdict: 'no-data', inert: true }, null, 2) + '\n');
|
|
199
|
+
else process.stdout.write(msg + '\n');
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let report: RunReport;
|
|
204
|
+
try {
|
|
205
|
+
report = JSON.parse(fs.readFileSync(RUN_REPORT, 'utf-8')) as RunReport;
|
|
206
|
+
} catch (e) {
|
|
207
|
+
process.stderr.write(`error: cannot parse ${path.relative(REPO_ROOT, RUN_REPORT)}: ${(e as Error).message}\n`);
|
|
208
|
+
return 1;
|
|
209
|
+
}
|
|
210
|
+
const results = Array.isArray(report.results) ? report.results : [];
|
|
211
|
+
const thr = typeof report.threshold === 'number' ? report.threshold : threshold;
|
|
212
|
+
const agg = aggregate(results, thr);
|
|
213
|
+
|
|
214
|
+
if (asJson) {
|
|
215
|
+
process.stdout.write(JSON.stringify({ ...agg, threshold: thr }, null, 2) + '\n');
|
|
216
|
+
} else {
|
|
217
|
+
process.stdout.write(
|
|
218
|
+
`quality run: ${agg.total} pairs · thin ${agg.thin_wins} / eager ${agg.eager_wins} / ` +
|
|
219
|
+
`tie ${agg.ties} / inconsistent ${agg.inconsistent}\n` +
|
|
220
|
+
` thin win-rate ${agg.thin_win_rate === null ? 'n/a' : (agg.thin_win_rate * 100).toFixed(1) + '%'} ` +
|
|
221
|
+
`(threshold ${(thr * 100).toFixed(0)}%) · ` +
|
|
222
|
+
`length-confound ${agg.length_confound_rate === null ? 'n/a' : (agg.length_confound_rate * 100).toFixed(0) + '%'} · ` +
|
|
223
|
+
`inconsistency ${agg.inconsistency_rate === null ? 'n/a' : (agg.inconsistency_rate * 100).toFixed(0) + '%'}\n`,
|
|
224
|
+
);
|
|
225
|
+
if (agg.verdict === 'regression') {
|
|
226
|
+
process.stdout.write(`❌ quality regression: thin win-rate below ${(thr * 100).toFixed(0)}%.\n`);
|
|
227
|
+
} else if (agg.verdict === 'no-data') {
|
|
228
|
+
process.stdout.write(`⚠️ no decisive pairs — inconclusive.\n`);
|
|
229
|
+
} else {
|
|
230
|
+
process.stdout.write(`✅ quality held: thin win-rate within tolerance.\n`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return gateVerdict(agg);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const _IS_MAIN =
|
|
237
|
+
process.argv[1] !== undefined &&
|
|
238
|
+
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
239
|
+
|
|
240
|
+
if (_IS_MAIN) {
|
|
241
|
+
process.exit(main(process.argv.slice(2)));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export { main };
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Validator + coverage reporter for the token-quality golden set
|
|
4
|
+
* (token-saving Phase 0). Loads `internal/bench/corpora/token-quality-golden.yaml`,
|
|
5
|
+
* validates each task against the schema (TOKEN-QUALITY-GOLDEN-SCHEMA.md), and
|
|
6
|
+
* reports rule-coverage vs `dist/router.json` + label-status counts.
|
|
7
|
+
*
|
|
8
|
+
* The `expected` anchors are HAND-LABELLED operator work — so an unfilled
|
|
9
|
+
* (`stub`) task or an uncovered rule is REPORTED, not a failure, by default.
|
|
10
|
+
* Structural errors (unknown rule id, bad scenario, a `labelled` task with a
|
|
11
|
+
* TODO rubric / no anchors) always fail. `--require-complete` is the operator-
|
|
12
|
+
* completion gate (Phase 0 exit): it also fails on any remaining stub, any
|
|
13
|
+
* uncovered rule, or a missing required scenario.
|
|
14
|
+
*
|
|
15
|
+
* CLI:
|
|
16
|
+
* ./scripts-run src/scripts/check_token_quality_golden
|
|
17
|
+
* ./scripts-run src/scripts/check_token_quality_golden --json
|
|
18
|
+
* ./scripts-run src/scripts/check_token_quality_golden --require-complete
|
|
19
|
+
*
|
|
20
|
+
* Exit codes: 0 valid (scaffold or complete) · 1 file error · 2 invalid / incomplete-when-required.
|
|
21
|
+
*/
|
|
22
|
+
import * as fs from 'node:fs';
|
|
23
|
+
import * as path from 'node:path';
|
|
24
|
+
import process from 'node:process';
|
|
25
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
26
|
+
|
|
27
|
+
import yaml from 'js-yaml';
|
|
28
|
+
|
|
29
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
30
|
+
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
31
|
+
const CORPUS = path.join(REPO_ROOT, 'internal/bench/corpora/token-quality-golden.yaml');
|
|
32
|
+
const ROUTER = path.join(REPO_ROOT, 'dist/router.json');
|
|
33
|
+
|
|
34
|
+
const SCENARIOS = ['single', 'multi-turn', 'conflicting-rule', 'corner-case'];
|
|
35
|
+
const REQUIRED_SCENARIOS = ['multi-turn', 'conflicting-rule', 'corner-case'];
|
|
36
|
+
const LABEL_STATUS = ['stub', 'labelled'];
|
|
37
|
+
|
|
38
|
+
type Json = Record<string, unknown>;
|
|
39
|
+
|
|
40
|
+
/** Collect every rule id from the router (kernel strings + tier_* objects). */
|
|
41
|
+
export function router_rule_ids(router: Json): Set<string> {
|
|
42
|
+
const ids = new Set<string>();
|
|
43
|
+
for (const key of ['kernel', 'tier_1', 'tier_2']) {
|
|
44
|
+
const arr = router[key];
|
|
45
|
+
if (!Array.isArray(arr)) continue;
|
|
46
|
+
for (const entry of arr) {
|
|
47
|
+
if (typeof entry === 'string') ids.add(entry);
|
|
48
|
+
else if (entry && typeof entry === 'object' && typeof (entry as Json).id === 'string') {
|
|
49
|
+
ids.add((entry as Json).id as string);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return ids;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface GoldenReport {
|
|
57
|
+
ok: boolean;
|
|
58
|
+
errors: string[];
|
|
59
|
+
task_count: number;
|
|
60
|
+
labelled: number;
|
|
61
|
+
stubs: number;
|
|
62
|
+
covered: number;
|
|
63
|
+
uncovered: string[];
|
|
64
|
+
scenarios: Record<string, number>;
|
|
65
|
+
missing_scenarios: string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function _isTodoRubric(rubric: unknown): boolean {
|
|
69
|
+
return (
|
|
70
|
+
typeof rubric !== 'string' ||
|
|
71
|
+
rubric.trim() === '' ||
|
|
72
|
+
/^todo\b/i.test(rubric.trim())
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Pure validation + coverage. `ruleIds` is the authoritative rule universe. */
|
|
77
|
+
export function validate(corpus: Json, ruleIds: Set<string>): GoldenReport {
|
|
78
|
+
const errors: string[] = [];
|
|
79
|
+
const tasks = Array.isArray(corpus.tasks) ? (corpus.tasks as Json[]) : null;
|
|
80
|
+
if (corpus.corpus_id !== 'token-quality-golden') {
|
|
81
|
+
errors.push(`corpus_id must be "token-quality-golden" (got ${JSON.stringify(corpus.corpus_id)})`);
|
|
82
|
+
}
|
|
83
|
+
if (tasks === null) {
|
|
84
|
+
errors.push('`tasks` must be a list');
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
errors,
|
|
88
|
+
task_count: 0,
|
|
89
|
+
labelled: 0,
|
|
90
|
+
stubs: 0,
|
|
91
|
+
covered: 0,
|
|
92
|
+
uncovered: [...ruleIds].sort(),
|
|
93
|
+
scenarios: {},
|
|
94
|
+
missing_scenarios: [...REQUIRED_SCENARIOS],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const seenIds = new Set<string>();
|
|
99
|
+
const covered = new Set<string>();
|
|
100
|
+
const scenarios: Record<string, number> = {};
|
|
101
|
+
let labelled = 0;
|
|
102
|
+
let stubs = 0;
|
|
103
|
+
|
|
104
|
+
for (const [i, task] of tasks.entries()) {
|
|
105
|
+
const where = `task[${i}]${typeof task.id === 'string' ? ` (${task.id})` : ''}`;
|
|
106
|
+
if (typeof task.id !== 'string' || !/^tq-[a-z0-9-]+-\d+$/.test(task.id)) {
|
|
107
|
+
errors.push(`${where}: id must match tq-<area>-NN`);
|
|
108
|
+
} else if (seenIds.has(task.id)) {
|
|
109
|
+
errors.push(`${where}: duplicate id`);
|
|
110
|
+
} else {
|
|
111
|
+
seenIds.add(task.id);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const rules = Array.isArray(task.rules) ? (task.rules as unknown[]) : [];
|
|
115
|
+
if (rules.length === 0) errors.push(`${where}: rules must be a non-empty list`);
|
|
116
|
+
for (const r of rules) {
|
|
117
|
+
if (typeof r !== 'string' || !ruleIds.has(r)) {
|
|
118
|
+
errors.push(`${where}: unknown rule id ${JSON.stringify(r)}`);
|
|
119
|
+
} else {
|
|
120
|
+
covered.add(r);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (typeof task.scenario !== 'string' || !SCENARIOS.includes(task.scenario)) {
|
|
125
|
+
errors.push(`${where}: scenario must be one of ${SCENARIOS.join(' | ')}`);
|
|
126
|
+
} else {
|
|
127
|
+
scenarios[task.scenario] = (scenarios[task.scenario] ?? 0) + 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (typeof task.prompt !== 'string' || task.prompt.trim() === '') {
|
|
131
|
+
errors.push(`${where}: prompt must be a non-empty string`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const status = task.label_status;
|
|
135
|
+
if (typeof status !== 'string' || !LABEL_STATUS.includes(status)) {
|
|
136
|
+
errors.push(`${where}: label_status must be one of ${LABEL_STATUS.join(' | ')}`);
|
|
137
|
+
} else if (status === 'labelled') {
|
|
138
|
+
labelled += 1;
|
|
139
|
+
} else {
|
|
140
|
+
stubs += 1;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const expected = (task.expected ?? {}) as Json;
|
|
144
|
+
const mustInclude = Array.isArray(expected.must_include) ? expected.must_include : [];
|
|
145
|
+
// A LABELLED task must carry a real rubric + ≥1 anchor; a stub need not.
|
|
146
|
+
if (status === 'labelled') {
|
|
147
|
+
if (_isTodoRubric(expected.rubric)) {
|
|
148
|
+
errors.push(`${where}: labelled task has a TODO/empty rubric`);
|
|
149
|
+
}
|
|
150
|
+
if (mustInclude.length === 0) {
|
|
151
|
+
errors.push(`${where}: labelled task needs ≥1 expected.must_include anchor`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const uncovered = [...ruleIds].filter((r) => !covered.has(r)).sort();
|
|
157
|
+
const missing_scenarios = REQUIRED_SCENARIOS.filter((s) => (scenarios[s] ?? 0) === 0);
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
ok: errors.length === 0,
|
|
161
|
+
errors,
|
|
162
|
+
task_count: tasks.length,
|
|
163
|
+
labelled,
|
|
164
|
+
stubs,
|
|
165
|
+
covered: covered.size,
|
|
166
|
+
uncovered,
|
|
167
|
+
scenarios,
|
|
168
|
+
missing_scenarios,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function _readJson(file: string): Json | null {
|
|
173
|
+
try {
|
|
174
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8')) as Json;
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function main(argv: string[]): number {
|
|
181
|
+
const asJson = argv.includes('--json');
|
|
182
|
+
const requireComplete = argv.includes('--require-complete');
|
|
183
|
+
for (const a of argv) {
|
|
184
|
+
if (!['--json', '--require-complete', '-h', '--help'].includes(a)) {
|
|
185
|
+
process.stderr.write(`error: unknown argument: ${a}\n`);
|
|
186
|
+
return 1;
|
|
187
|
+
}
|
|
188
|
+
if (a === '-h' || a === '--help') {
|
|
189
|
+
process.stdout.write('usage: check_token_quality_golden [--json] [--require-complete]\n');
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let corpus: Json | null;
|
|
195
|
+
try {
|
|
196
|
+
corpus = yaml.load(fs.readFileSync(CORPUS, 'utf-8')) as Json;
|
|
197
|
+
} catch (e) {
|
|
198
|
+
process.stderr.write(`error: cannot read/parse ${path.relative(REPO_ROOT, CORPUS)}: ${(e as Error).message}\n`);
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
const router = _readJson(ROUTER);
|
|
202
|
+
if (router === null) {
|
|
203
|
+
process.stderr.write(`error: cannot read ${path.relative(REPO_ROOT, ROUTER)} — run \`task sync\` first\n`);
|
|
204
|
+
return 1;
|
|
205
|
+
}
|
|
206
|
+
const ruleIds = router_rule_ids(router);
|
|
207
|
+
const report = validate(corpus, ruleIds);
|
|
208
|
+
|
|
209
|
+
const incomplete =
|
|
210
|
+
report.stubs > 0 || report.uncovered.length > 0 || report.missing_scenarios.length > 0;
|
|
211
|
+
|
|
212
|
+
if (asJson) {
|
|
213
|
+
process.stdout.write(JSON.stringify({ ...report, require_complete: requireComplete, incomplete }, null, 2) + '\n');
|
|
214
|
+
} else {
|
|
215
|
+
for (const e of report.errors) process.stdout.write(`❌ ${e}\n`);
|
|
216
|
+
process.stdout.write(
|
|
217
|
+
`golden set: ${report.task_count} task(s) · ${report.labelled} labelled · ${report.stubs} stub · ` +
|
|
218
|
+
`coverage ${report.covered}/${ruleIds.size} rules\n`,
|
|
219
|
+
);
|
|
220
|
+
if (report.uncovered.length > 0) {
|
|
221
|
+
process.stdout.write(`⚠️ ${report.uncovered.length} rule(s) uncovered (operator to add tasks)\n`);
|
|
222
|
+
}
|
|
223
|
+
if (report.missing_scenarios.length > 0) {
|
|
224
|
+
process.stdout.write(`⚠️ missing required scenario(s): ${report.missing_scenarios.join(', ')}\n`);
|
|
225
|
+
}
|
|
226
|
+
if (!report.ok) {
|
|
227
|
+
process.stdout.write(`❌ golden set has structural errors (see above).\n`);
|
|
228
|
+
} else if (incomplete) {
|
|
229
|
+
process.stdout.write(`✅ structurally valid — scaffold awaiting operator labels.\n`);
|
|
230
|
+
} else {
|
|
231
|
+
process.stdout.write(`✅ golden set complete.\n`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!report.ok) return 2;
|
|
236
|
+
if (requireComplete && incomplete) {
|
|
237
|
+
if (!asJson) {
|
|
238
|
+
process.stdout.write(`❌ --require-complete: golden set is not yet complete.\n`);
|
|
239
|
+
}
|
|
240
|
+
return 2;
|
|
241
|
+
}
|
|
242
|
+
return 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const _IS_MAIN =
|
|
246
|
+
process.argv[1] !== undefined &&
|
|
247
|
+
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
248
|
+
|
|
249
|
+
if (_IS_MAIN) {
|
|
250
|
+
process.exit(main(process.argv.slice(2)));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export { main };
|