@event4u/agent-config 9.21.0 → 9.22.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 +35 -0
- package/dist/agent-src/commands/worktree/cleanup.md +17 -0
- package/dist/agent-src/commands/worktree/status.md +6 -0
- package/dist/agent-src/skills/worktree-lifecycle/SKILL.md +22 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +5 -5
- 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 +1 -1
- package/dist/mcp/server.json +2 -2
- package/package.json +1 -1
- package/src/scripts/_lib/bench_ab_activation.ts +368 -0
- package/src/scripts/bench_ab_task_runner.ts +18 -0
- package/src/scripts/bench_ab_v2_run.ts +147 -6
- package/src/scripts/bench_ab_v2_stats.ts +74 -2
- package/src/scripts/worktree_cleanup_check.ts +319 -4
|
@@ -194,6 +194,12 @@ interface RunResult {
|
|
|
194
194
|
tokens?: number;
|
|
195
195
|
/** Empty object `{}` on dry-run (key absent in Python); breakdown otherwise. */
|
|
196
196
|
tokens_breakdown?: TokensBreakdown | Record<string, never>;
|
|
197
|
+
/**
|
|
198
|
+
* Model ids the envelope actually billed (`modelUsage` keys) — the only
|
|
199
|
+
* place a run states which model answered it. Empty when the envelope
|
|
200
|
+
* omits the block. Feeds the delta-#3 model-id check.
|
|
201
|
+
*/
|
|
202
|
+
models_seen?: string[];
|
|
197
203
|
errored?: boolean;
|
|
198
204
|
num_turns?: number;
|
|
199
205
|
subtype?: string;
|
|
@@ -295,6 +301,7 @@ export function run_live(
|
|
|
295
301
|
const returncode = result.status ?? -1;
|
|
296
302
|
let transcript: string = stdout;
|
|
297
303
|
let tokens = 0;
|
|
304
|
+
let modelsSeen: string[] = [];
|
|
298
305
|
let isError = false;
|
|
299
306
|
let errReason = 'ok';
|
|
300
307
|
let numTurns = 0;
|
|
@@ -337,6 +344,16 @@ export function run_live(
|
|
|
337
344
|
breakdown.output_tokens +
|
|
338
345
|
breakdown.cache_read_input_tokens +
|
|
339
346
|
breakdown.cache_creation_input_tokens;
|
|
347
|
+
// `modelUsage` is keyed by the model ids the run actually billed. Record
|
|
348
|
+
// them unconditionally: the delta-#3 check needs the id even on a run
|
|
349
|
+
// whose top-level `usage` was fine, and a mid-sweep model swap is exactly
|
|
350
|
+
// the case where the totals look ordinary.
|
|
351
|
+
modelsSeen = Object.keys(
|
|
352
|
+
(_isPlainObj(obj['modelUsage']) ? (obj['modelUsage'] as Record<string, unknown>) : {}) as Record<
|
|
353
|
+
string,
|
|
354
|
+
unknown
|
|
355
|
+
>,
|
|
356
|
+
);
|
|
340
357
|
// The top-level `usage` block is zeroed on a budget-capped / errored run
|
|
341
358
|
// (and unreliable even on some completions). `modelUsage` carries the
|
|
342
359
|
// authoritative per-model counts — sum it as the fallback so token deltas
|
|
@@ -387,6 +404,7 @@ export function run_live(
|
|
|
387
404
|
wall_time_seconds: _pyRound(duration, 3),
|
|
388
405
|
tokens,
|
|
389
406
|
tokens_breakdown: breakdown,
|
|
407
|
+
models_seen: modelsSeen,
|
|
390
408
|
errored: isError || returncode !== 0,
|
|
391
409
|
num_turns: numTurns,
|
|
392
410
|
subtype,
|
|
@@ -53,6 +53,18 @@ import { parse as parseYaml } from 'yaml';
|
|
|
53
53
|
|
|
54
54
|
import * as v1 from './bench_ab_task_runner.js';
|
|
55
55
|
import * as scoring from './_lib/bench_ab_scoring_v2.js';
|
|
56
|
+
import {
|
|
57
|
+
SweepBudget,
|
|
58
|
+
activation_verdict,
|
|
59
|
+
audit_activation,
|
|
60
|
+
expected_injection,
|
|
61
|
+
is_bare_alias,
|
|
62
|
+
tier_for_model,
|
|
63
|
+
verify_model_id,
|
|
64
|
+
type TierRates,
|
|
65
|
+
type TokensBreakdown,
|
|
66
|
+
} from './_lib/bench_ab_activation.js';
|
|
67
|
+
import { load_pricing } from './_lib/bench_cost.js';
|
|
56
68
|
|
|
57
69
|
const _HERE = fileURLToPath(import.meta.url);
|
|
58
70
|
|
|
@@ -66,6 +78,7 @@ const CORPUS_PATH = path.join(REPO_ROOT, 'internal', 'bench', 'corpora', 'ab-tra
|
|
|
66
78
|
const FIXTURES_ROOT = path.join(REPO_ROOT, 'internal', 'bench', 'ab');
|
|
67
79
|
const REPORTS_DIR = path.join(REPO_ROOT, 'internal', 'bench', 'reports', 'ab-v2');
|
|
68
80
|
const CHECKPOINTS_DIR = path.join(REPORTS_DIR, 'checkpoints');
|
|
81
|
+
const PRICING_PATH = path.join(REPO_ROOT, 'internal', 'bench', 'pricing.yaml');
|
|
69
82
|
// CRITICAL (2026-06-15): clones MUST live OUTSIDE the agent-config repo. A clone
|
|
70
83
|
// under the repo lets Claude discover the repo's own project surface (CLAUDE.md /
|
|
71
84
|
// AGENTS.md / .claude/rules+skills) walking up from the cwd — so the `vanilla`
|
|
@@ -461,6 +474,31 @@ export function run_live_codex(
|
|
|
461
474
|
};
|
|
462
475
|
}
|
|
463
476
|
|
|
477
|
+
/**
|
|
478
|
+
* Delta #1–#3 — stamp the measurement-integrity block onto a trial record.
|
|
479
|
+
*
|
|
480
|
+
* `tokens_breakdown` is preserved here (delta #2) because it is the ONLY
|
|
481
|
+
* observable that can see a plugin-arm collapse: `injected_chars` is the length
|
|
482
|
+
* of a file the harness wrote itself, and it is 0 for every arm that arrives
|
|
483
|
+
* through global settings. Discarding the breakdown made the audit
|
|
484
|
+
* unreconstructable even post-hoc.
|
|
485
|
+
*/
|
|
486
|
+
export function integrity_fields(run: Dict, spec: ArmSpec, injected_chars: number, model: string | null): Dict {
|
|
487
|
+
const breakdown = (run['tokens_breakdown'] ?? {}) as TokensBreakdown | Record<string, never>;
|
|
488
|
+
const models_seen = Array.isArray(run['models_seen']) ? (run['models_seen'] as string[]) : [];
|
|
489
|
+
return {
|
|
490
|
+
tokens_breakdown: breakdown,
|
|
491
|
+
models_seen,
|
|
492
|
+
activation: activation_verdict({
|
|
493
|
+
expected: expected_injection(spec),
|
|
494
|
+
tokens_breakdown: breakdown,
|
|
495
|
+
injected_chars,
|
|
496
|
+
errored: _pyBool(run['errored']),
|
|
497
|
+
}),
|
|
498
|
+
model_check: model ? verify_model_id(model, models_seen) : { ok: true, reason: 'no model pinned' },
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
464
502
|
export function run_one(task: Dict, arm: string, opts: RunOneOpts): Dict {
|
|
465
503
|
const spec = ARMS[arm] as ArmSpec;
|
|
466
504
|
if (spec.recursive) {
|
|
@@ -501,6 +539,7 @@ export function run_one(task: Dict, arm: string, opts: RunOneOpts): Dict {
|
|
|
501
539
|
discipline_pass: score.discipline_pass,
|
|
502
540
|
metrics: trajectory_metrics(run, score),
|
|
503
541
|
injected_chars: sp_text ? sp_text.length : 0,
|
|
542
|
+
...integrity_fields(run, spec, sp_text ? sp_text.length : 0, opts.model),
|
|
504
543
|
};
|
|
505
544
|
}
|
|
506
545
|
|
|
@@ -618,6 +657,10 @@ export function run_one_recursive(
|
|
|
618
657
|
injected_chars: last_verdict_len,
|
|
619
658
|
depth_reached: depth,
|
|
620
659
|
stop_reason,
|
|
660
|
+
// `injected_chars` here counts the re-attempt VERDICT text, not an arm
|
|
661
|
+
// injection — the recursive arm's channel is the plugin. Pass 0 so the
|
|
662
|
+
// audit reads the arm's real channel instead of flagging the verdict.
|
|
663
|
+
...integrity_fields(run, ARMS['package-recursive'] as ArmSpec, 0, opts.model),
|
|
621
664
|
};
|
|
622
665
|
}
|
|
623
666
|
|
|
@@ -767,17 +810,28 @@ export function collect_records(
|
|
|
767
810
|
run_fn: (task: Dict, arm: string, seed: number) => Dict,
|
|
768
811
|
ckpt: CheckpointIO | null = null,
|
|
769
812
|
log: (msg: string) => void = (m) => process.stderr.write(m),
|
|
770
|
-
|
|
813
|
+
/**
|
|
814
|
+
* Delta #4 — sweep-level spend guard. Called after each executed run with
|
|
815
|
+
* that run's record; a non-null return aborts the sweep. Records collected
|
|
816
|
+
* so far are still returned (and are already on the checkpoint), so an
|
|
817
|
+
* abort costs no completed work.
|
|
818
|
+
*/
|
|
819
|
+
guard: ((record: Dict) => string | null) | null = null,
|
|
820
|
+
): { records: Dict[]; executed: number; reused: number; aborted: string | null } {
|
|
771
821
|
const total = tasks.length * arms.length * seeds;
|
|
772
822
|
let done = 0;
|
|
773
823
|
let executed = 0;
|
|
774
824
|
let reused = 0;
|
|
825
|
+
let aborted: string | null = null;
|
|
775
826
|
const records: Dict[] = [];
|
|
776
827
|
for (const task of tasks) {
|
|
777
828
|
const per_arm: Record<string, Dict[]> = {};
|
|
778
829
|
for (const arm of arms) {
|
|
779
830
|
const seed_runs: Dict[] = [];
|
|
780
831
|
for (let seed = 0; seed < seeds; seed += 1) {
|
|
832
|
+
if (aborted) {
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
781
835
|
done += 1;
|
|
782
836
|
const key = run_key(String(task['id']), arm, seed);
|
|
783
837
|
const cached = ckpt?.completed.get(key);
|
|
@@ -794,6 +848,7 @@ export function collect_records(
|
|
|
794
848
|
append_checkpoint(ckpt.path, key, r);
|
|
795
849
|
}
|
|
796
850
|
seed_runs.push(r);
|
|
851
|
+
aborted = guard?.(r) ?? null;
|
|
797
852
|
}
|
|
798
853
|
per_arm[arm] = seed_runs;
|
|
799
854
|
}
|
|
@@ -803,8 +858,11 @@ export function collect_records(
|
|
|
803
858
|
rule: task['rule'],
|
|
804
859
|
arms: per_arm,
|
|
805
860
|
});
|
|
861
|
+
if (aborted) {
|
|
862
|
+
break;
|
|
863
|
+
}
|
|
806
864
|
}
|
|
807
|
-
return { records, executed, reused };
|
|
865
|
+
return { records, executed, reused, aborted };
|
|
808
866
|
}
|
|
809
867
|
|
|
810
868
|
export function main(argv: string[] | null = null): number {
|
|
@@ -841,11 +899,44 @@ export function main(argv: string[] | null = null): number {
|
|
|
841
899
|
}
|
|
842
900
|
}
|
|
843
901
|
|
|
902
|
+
// Delta #3 — refuse a bare alias BEFORE any spend. An alias resolves
|
|
903
|
+
// server-side to whatever is current, so a report produced under one is
|
|
904
|
+
// unreproducible: nothing in it records which model actually answered.
|
|
905
|
+
if (is_bare_alias(args.model)) {
|
|
906
|
+
process.stderr.write(
|
|
907
|
+
`bench_ab_v2: refusing bare model alias "${args.model}" — pin a full id ` +
|
|
908
|
+
`(e.g. claude-sonnet-4-6). An alias makes the report unreproducible.\n`,
|
|
909
|
+
);
|
|
910
|
+
return 2;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Delta #4 — sweep-level cap. `--max-budget-usd` is PER RUN, so 30×4×3 at
|
|
914
|
+
// --budget 3.5 has a $1,260 ceiling with nothing to stop it. Prices come
|
|
915
|
+
// from internal/bench/pricing.yaml; an unrecognised model tier leaves the
|
|
916
|
+
// guard inert rather than silently mispricing the sweep.
|
|
917
|
+
//
|
|
918
|
+
// Resolved HERE, with the other argument validation, rather than next to
|
|
919
|
+
// its use below: a cap the harness cannot price is a bad argument, and a
|
|
920
|
+
// bad argument must be reported whether or not the host CLI is installed.
|
|
921
|
+
// Behind the CLI-presence check it was unreachable on any machine without
|
|
922
|
+
// `claude` — including CI.
|
|
923
|
+
const [rates] = load_pricing(PRICING_PATH);
|
|
924
|
+
const tier = tier_for_model(args.model);
|
|
925
|
+
const tier_rates: TierRates | null = tier && rates[tier] ? (rates[tier] as TierRates) : null;
|
|
926
|
+
if (args.max_usd !== null && tier_rates === null) {
|
|
927
|
+
process.stderr.write(
|
|
928
|
+
`bench_ab_v2: --max-usd given but no pricing row matches model "${args.model}" ` +
|
|
929
|
+
`(${_relToRootPosix(PRICING_PATH)}) — the sweep cap cannot be enforced.\n`,
|
|
930
|
+
);
|
|
931
|
+
return 2;
|
|
932
|
+
}
|
|
933
|
+
|
|
844
934
|
if (args.mode === 'dry-run') {
|
|
845
935
|
process.stdout.write(
|
|
846
936
|
`bench_ab_v2: DRY — ${tasks.length} tasks × ${arms.length} arms × ` +
|
|
847
937
|
`${args.seeds} seeds = ${tasks.length * arms.length * args.seeds} runs ` +
|
|
848
|
-
`(model=${args.model}, budget=${_pyNumStr(args.budget)}
|
|
938
|
+
`(model=${args.model}, budget=${_pyNumStr(args.budget)}, ` +
|
|
939
|
+
`sweep cap=${args.max_usd === null ? 'none' : `$${_pyNumStr(args.max_usd)}`}). No spend.\n`,
|
|
849
940
|
);
|
|
850
941
|
return 0;
|
|
851
942
|
}
|
|
@@ -891,7 +982,9 @@ export function main(argv: string[] | null = null): number {
|
|
|
891
982
|
ckpt = { path: ckpt_path, completed };
|
|
892
983
|
}
|
|
893
984
|
|
|
894
|
-
const
|
|
985
|
+
const budget = new SweepBudget(args.max_usd, tier_rates);
|
|
986
|
+
|
|
987
|
+
const { records, aborted } = collect_records(
|
|
895
988
|
tasks,
|
|
896
989
|
arms,
|
|
897
990
|
args.seeds,
|
|
@@ -905,8 +998,17 @@ export function main(argv: string[] | null = null): number {
|
|
|
905
998
|
host: args.host,
|
|
906
999
|
}),
|
|
907
1000
|
ckpt,
|
|
1001
|
+
undefined,
|
|
1002
|
+
(r) => budget.add(r['tokens_breakdown'] as TokensBreakdown | undefined),
|
|
908
1003
|
);
|
|
909
1004
|
|
|
1005
|
+
// Delta #1 — the activation audit. Runs BEFORE the report is judged usable:
|
|
1006
|
+
// the paired plugin direction needs the whole record set, so it cannot be a
|
|
1007
|
+
// per-trial check alone. `vanilla` is the baseline; every arm that is
|
|
1008
|
+
// supposed to carry a surface the baseline lacks is a lift arm.
|
|
1009
|
+
const lift_arms = arms.filter((a) => a !== 'vanilla' && expected_injection(ARMS[a] as ArmSpec) !== 'none');
|
|
1010
|
+
const audit = audit_activation(records, { baseline_arm: 'vanilla', lift_arms });
|
|
1011
|
+
|
|
910
1012
|
const stamp = v1.utc_stamp();
|
|
911
1013
|
const payload: Dict = {
|
|
912
1014
|
schema: 'ab-bench-v2/0.1',
|
|
@@ -915,9 +1017,17 @@ export function main(argv: string[] | null = null): number {
|
|
|
915
1017
|
seeds: args.seeds,
|
|
916
1018
|
arms,
|
|
917
1019
|
budget_usd_per_run: args.budget,
|
|
1020
|
+
sweep_cap_usd: args.max_usd,
|
|
1021
|
+
sweep_spend_usd: new PyFloat(Number(budget.spent_usd.toFixed(4))),
|
|
918
1022
|
placebo_chars,
|
|
919
1023
|
corpus: 'ab-trackb-v2',
|
|
920
1024
|
host: args.host,
|
|
1025
|
+
activation_audit: {
|
|
1026
|
+
checked: audit.checked,
|
|
1027
|
+
skipped: audit.skipped,
|
|
1028
|
+
violations: audit.violations as unknown as Json,
|
|
1029
|
+
},
|
|
1030
|
+
aborted: aborted ?? null,
|
|
921
1031
|
records,
|
|
922
1032
|
};
|
|
923
1033
|
const out = path.join(REPORTS_DIR, `${stamp}-ab-v2-paired.json`);
|
|
@@ -928,8 +1038,31 @@ export function main(argv: string[] | null = null): number {
|
|
|
928
1038
|
fs.rmSync(ckpt.path);
|
|
929
1039
|
}
|
|
930
1040
|
process.stdout.write(
|
|
931
|
-
`bench_ab_v2: wrote ${_relToRootPosix(out)} ` +
|
|
1041
|
+
`bench_ab_v2: wrote ${_relToRootPosix(out)} ` +
|
|
1042
|
+
`(${records.length} tasks, ${total} runs, spend $${budget.spent_usd.toFixed(2)})\n`,
|
|
932
1043
|
);
|
|
1044
|
+
|
|
1045
|
+
// The report is always written — an aborted or integrity-failed sweep still
|
|
1046
|
+
// cost money and its raw runs stay inspectable. The EXIT CODE is what makes
|
|
1047
|
+
// the failure non-ignorable, so a caller cannot mistake an invalid sweep for
|
|
1048
|
+
// a publishable one.
|
|
1049
|
+
if (aborted) {
|
|
1050
|
+
process.stderr.write(`bench_ab_v2: ${aborted}\n`);
|
|
1051
|
+
return 2;
|
|
1052
|
+
}
|
|
1053
|
+
if (audit.violations.length > 0) {
|
|
1054
|
+
process.stderr.write(
|
|
1055
|
+
`bench_ab_v2: ACTIVATION AUDIT FAILED — ${audit.violations.length} violation(s) ` +
|
|
1056
|
+
`over ${audit.checked} checked pair(s). This report is NOT publishable.\n`,
|
|
1057
|
+
);
|
|
1058
|
+
for (const v of audit.violations.slice(0, 20)) {
|
|
1059
|
+
process.stderr.write(` ${v.kind}: ${v.task_id} · ${v.arm} · seed ${v.seed} — ${v.detail}\n`);
|
|
1060
|
+
}
|
|
1061
|
+
if (audit.violations.length > 20) {
|
|
1062
|
+
process.stderr.write(` … ${audit.violations.length - 20} more (see the report)\n`);
|
|
1063
|
+
}
|
|
1064
|
+
return 2;
|
|
1065
|
+
}
|
|
933
1066
|
return 0;
|
|
934
1067
|
}
|
|
935
1068
|
|
|
@@ -942,6 +1075,8 @@ interface ParsedArgs {
|
|
|
942
1075
|
limit: number;
|
|
943
1076
|
model: string;
|
|
944
1077
|
budget: number;
|
|
1078
|
+
/** Sweep-wide USD cap (delta #4); `null` = uncapped, the pre-existing behaviour. */
|
|
1079
|
+
max_usd: number | null;
|
|
945
1080
|
timeout: number;
|
|
946
1081
|
mode: string;
|
|
947
1082
|
host: string;
|
|
@@ -960,13 +1095,14 @@ function parse_args(argv: string[]): ParsedArgs {
|
|
|
960
1095
|
limit: 0,
|
|
961
1096
|
model: 'claude-sonnet-4-6',
|
|
962
1097
|
budget: 1.0,
|
|
1098
|
+
max_usd: null,
|
|
963
1099
|
timeout: 180,
|
|
964
1100
|
mode: 'live',
|
|
965
1101
|
host: 'claude',
|
|
966
1102
|
checkpoint: true,
|
|
967
1103
|
fresh: false,
|
|
968
1104
|
};
|
|
969
|
-
const usage = `usage: ${prog} [-h] [--arms ARMS] [--seeds SEEDS] [--tasks TASKS] [--limit LIMIT] [--model MODEL] [--budget BUDGET] [--timeout TIMEOUT] [--mode {live,dry-run}] [--host {claude,codex}] [--no-checkpoint] [--fresh]\n`;
|
|
1105
|
+
const usage = `usage: ${prog} [-h] [--arms ARMS] [--seeds SEEDS] [--tasks TASKS] [--limit LIMIT] [--model MODEL] [--budget BUDGET] [--max-usd MAX_USD] [--timeout TIMEOUT] [--mode {live,dry-run}] [--host {claude,codex}] [--no-checkpoint] [--fresh]\n`;
|
|
970
1106
|
const argErr = (msg: string): never => {
|
|
971
1107
|
process.stderr.write(usage);
|
|
972
1108
|
process.stderr.write(`${prog}: error: ${msg}\n`);
|
|
@@ -1014,6 +1150,11 @@ function parse_args(argv: string[]): ParsedArgs {
|
|
|
1014
1150
|
i += 1;
|
|
1015
1151
|
} else if (a.startsWith('--budget=')) {
|
|
1016
1152
|
out.budget = _pyFloatArg(a.slice('--budget='.length), '--budget');
|
|
1153
|
+
} else if (a === '--max-usd') {
|
|
1154
|
+
out.max_usd = _pyFloatArg(need(i, '--max-usd'), '--max-usd');
|
|
1155
|
+
i += 1;
|
|
1156
|
+
} else if (a.startsWith('--max-usd=')) {
|
|
1157
|
+
out.max_usd = _pyFloatArg(a.slice('--max-usd='.length), '--max-usd');
|
|
1017
1158
|
} else if (a === '--timeout') {
|
|
1018
1159
|
out.timeout = _pyInt(need(i, '--timeout'), '--timeout');
|
|
1019
1160
|
i += 1;
|
|
@@ -291,8 +291,37 @@ export function compare(records: Dict[], arm_t: string, arm_b: string): Dict {
|
|
|
291
291
|
let dis_t_sum = 0.0;
|
|
292
292
|
let dis_b_sum = 0.0;
|
|
293
293
|
let disn = 0.0;
|
|
294
|
+
// Delta #5 — attrition. A dropped pair is not missing-at-random: a budget
|
|
295
|
+
// cap or a timeout fires preferentially on the arm doing MORE work, so
|
|
296
|
+
// silently excluding those pairs biases the surviving sample toward the
|
|
297
|
+
// baseline. Report which side died and in which status bucket, so a reader
|
|
298
|
+
// can see whether the drops are balanced before trusting the estimate.
|
|
299
|
+
let pairs_seen = 0;
|
|
300
|
+
let dropped_treatment_only = 0;
|
|
301
|
+
let dropped_baseline_only = 0;
|
|
302
|
+
let dropped_both = 0;
|
|
303
|
+
const dropped_buckets: Record<string, number> = {};
|
|
294
304
|
for (const { rt, rb } of _pairs(records, arm_t, arm_b)) {
|
|
295
|
-
|
|
305
|
+
pairs_seen += 1;
|
|
306
|
+
const t_err = _pyTruthy(rt['errored']);
|
|
307
|
+
const b_err = _pyTruthy(rb['errored']);
|
|
308
|
+
if (t_err || b_err) {
|
|
309
|
+
if (t_err && b_err) {
|
|
310
|
+
dropped_both += 1;
|
|
311
|
+
} else if (t_err) {
|
|
312
|
+
dropped_treatment_only += 1;
|
|
313
|
+
} else {
|
|
314
|
+
dropped_baseline_only += 1;
|
|
315
|
+
}
|
|
316
|
+
for (const r of [t_err ? rt : null, b_err ? rb : null]) {
|
|
317
|
+
if (r === null) {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const bucket = _strOr(_dictOr(r['metrics'])['status_bucket'], 'unknown');
|
|
321
|
+
dropped_buckets[bucket] = (dropped_buckets[bucket] ?? 0) + 1;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (!t_err && !b_err) {
|
|
296
325
|
const t = _pyTruthy(rt['capability_pass']);
|
|
297
326
|
const bb = _pyTruthy(rb['capability_pass']);
|
|
298
327
|
capn += 1;
|
|
@@ -341,6 +370,18 @@ export function compare(records: Dict[], arm_t: string, arm_b: string): Dict {
|
|
|
341
370
|
rank_biserial: PF(wil.rank_biserial),
|
|
342
371
|
n_nonzero: wil.n,
|
|
343
372
|
},
|
|
373
|
+
attrition: {
|
|
374
|
+
pairs_seen,
|
|
375
|
+
pairs_analysed: capn,
|
|
376
|
+
pairs_dropped: pairs_seen - capn,
|
|
377
|
+
dropped_treatment_only,
|
|
378
|
+
dropped_baseline_only,
|
|
379
|
+
dropped_both,
|
|
380
|
+
// Positive = the treatment arm died more often than the baseline,
|
|
381
|
+
// i.e. the surviving sample is biased toward the baseline.
|
|
382
|
+
drop_asymmetry: dropped_treatment_only - dropped_baseline_only,
|
|
383
|
+
dropped_by_status_bucket: dropped_buckets,
|
|
384
|
+
},
|
|
344
385
|
};
|
|
345
386
|
}
|
|
346
387
|
|
|
@@ -509,7 +550,9 @@ export function to_markdown(analysis: Dict, payload: Dict): string {
|
|
|
509
550
|
);
|
|
510
551
|
L.push(
|
|
511
552
|
'> 4. **Paired design**, errored runs excluded; McNemar (capability) + ' +
|
|
512
|
-
'Wilcoxon signed-rank (discipline) + effect sizes.'
|
|
553
|
+
'Wilcoxon signed-rank (discipline) + effect sizes. Exclusion is NOT ' +
|
|
554
|
+
'assumed harmless — see each comparison’s Table 4 for how many pairs ' +
|
|
555
|
+
'were dropped and whether the drops favour one arm.',
|
|
513
556
|
);
|
|
514
557
|
L.push(
|
|
515
558
|
'> 5. **Not comparable to SWE-bench / GAIA / Fable scores** — a different ' +
|
|
@@ -597,6 +640,29 @@ export function to_markdown(analysis: Dict, payload: Dict): string {
|
|
|
597
640
|
L.push('|---|---|---|---|');
|
|
598
641
|
L.push(`| mean tokens | ${_thousands(tb)} | ${_thousands(tt)} | ${_thousandsSigned(tt - tb)} |`);
|
|
599
642
|
L.push('');
|
|
643
|
+
const at = _dictOr(cmp['attrition']);
|
|
644
|
+
L.push('### Table 4 — attrition (dropped pairs are not missing-at-random)');
|
|
645
|
+
L.push('');
|
|
646
|
+
L.push('| pairs seen | analysed | dropped | treatment-only | baseline-only | both | asymmetry |');
|
|
647
|
+
L.push('|---|---|---|---|---|---|---|');
|
|
648
|
+
L.push(
|
|
649
|
+
`| ${_pyStr(at['pairs_seen'])} | ${_pyStr(at['pairs_analysed'])} | ${_pyStr(at['pairs_dropped'])} ` +
|
|
650
|
+
`| ${_pyStr(at['dropped_treatment_only'])} | ${_pyStr(at['dropped_baseline_only'])} ` +
|
|
651
|
+
`| ${_pyStr(at['dropped_both'])} | ${_fmtSignedInt(at['drop_asymmetry'])} |`,
|
|
652
|
+
);
|
|
653
|
+
const dropBuckets = Object.entries(_dictOr(at['dropped_by_status_bucket']))
|
|
654
|
+
.map(([k, v]) => `${k}:${_pyStr(v)}`)
|
|
655
|
+
.join(', ');
|
|
656
|
+
L.push('');
|
|
657
|
+
L.push(`Dropped runs by status bucket: ${dropBuckets || '_none_'}.`);
|
|
658
|
+
L.push('');
|
|
659
|
+
L.push(
|
|
660
|
+
'> A positive asymmetry means the treatment arm errored out more often than ' +
|
|
661
|
+
'the baseline. Budget caps and timeouts fire preferentially on the arm doing ' +
|
|
662
|
+
'more work, so the surviving sample is then biased TOWARD the baseline and the ' +
|
|
663
|
+
'measured lift is a floor, not an estimate.',
|
|
664
|
+
);
|
|
665
|
+
L.push('');
|
|
600
666
|
}
|
|
601
667
|
L.push('## Status buckets (trajectory)');
|
|
602
668
|
L.push('');
|
|
@@ -655,6 +721,12 @@ function _fmtSigned3(v: unknown): string {
|
|
|
655
721
|
return body.startsWith('-') ? body : `+${body}`;
|
|
656
722
|
}
|
|
657
723
|
|
|
724
|
+
/** `f"{n:+d}"` over an int-ish JSON value (attrition asymmetry). */
|
|
725
|
+
function _fmtSignedInt(v: unknown): string {
|
|
726
|
+
const n = _pyIntTrunc(_pyFloat(v));
|
|
727
|
+
return n < 0 ? String(n) : `+${n}`;
|
|
728
|
+
}
|
|
729
|
+
|
|
658
730
|
/** `f"{n:,}"` over `int(value)`. */
|
|
659
731
|
function _thousands(n: number): string {
|
|
660
732
|
return _pyThousands(_pyIntTrunc(n));
|