@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.
@@ -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
- ): { records: Dict[]; executed: number; reused: number } {
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)}). No spend.\n`,
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 { records } = collect_records(
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)} ` + `(${records.length} tasks, ${total} runs)\n`,
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
- if (!_pyTruthy(rt['errored']) && !_pyTruthy(rb['errored'])) {
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));