@event4u/agent-config 9.25.0 → 9.26.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 +87 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/conformance.md +2 -2
- package/dist/agent-src/commands/review/changes.md +6 -5
- package/dist/agent-src/commands/roadmap/next.md +194 -0
- package/dist/agent-src/commands/roadmap.md +12 -4
- package/dist/agent-src/contexts/execution/subagent-routing.md +4 -1
- package/dist/agent-src/rules/delegation-policy.md +3 -2
- package/dist/agent-src/rules/design-fidelity.md +17 -1
- package/dist/agent-src/rules/design-review-after-ui-write.md +116 -0
- package/dist/agent-src/rules/evaluator-independence.md +9 -3
- package/dist/agent-src/rules/session-canary.md +2 -0
- package/dist/agent-src/rules/settings-ask-protocol.md +176 -0
- package/dist/agent-src/rules/token-budget-discipline.md +6 -3
- package/dist/agent-src/rules/ui-audit-gate.md +3 -0
- package/dist/agent-src/rules/user-interaction.md +3 -2
- package/dist/agent-src/skills/recursive-verification/SKILL.md +13 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/cmd_route_audit.js +4 -1
- package/dist/cli-delegate/cmd_route_explain.js +4 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +94 -23
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +11 -8
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +9 -6
- package/dist/hooks/dispatch.js +355 -63
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/router.json +1 -1
- package/dist/server/nicknameResolver.js +116 -0
- package/dist/server/nicknameResolver.js.map +1 -0
- package/dist/server/routes/ping.js +29 -11
- package/dist/server/routes/ping.js.map +1 -1
- package/dist/shared/interactiveContext.js +73 -0
- package/dist/shared/interactiveContext.js.map +1 -0
- package/dist/shared/nicknamePrefill.js +59 -0
- package/dist/shared/nicknamePrefill.js.map +1 -0
- package/dist/shared/settingsAsks.js +128 -0
- package/dist/shared/settingsAsks.js.map +1 -0
- package/dist/shared/settingsConsent.js +93 -0
- package/dist/shared/settingsConsent.js.map +1 -0
- package/package.json +1 -1
- package/src/config/agents-paths.yml +15 -0
- package/src/config/gate-coverage.yml +29 -1
- package/src/config/gate-violation-baselines.json +13 -7
- package/src/scripts/_cli/cmd_route_audit.ts +4 -1
- package/src/scripts/_cli/cmd_route_explain.ts +9 -1
- package/src/scripts/ai-video/stitch.sh +27 -9
- package/src/scripts/before_complete_hook.ts +23 -4
- package/src/scripts/bench_ab_v2_run.ts +272 -16
- package/src/scripts/bench_ab_v2_stats.ts +156 -3
- package/src/scripts/check_completion_review.ts +80 -3
- package/src/scripts/check_corpus_staleness.ts +692 -0
- package/src/scripts/check_gate_coverage.ts +67 -0
- package/src/scripts/check_hook_bundle_freshness.ts +183 -0
- package/src/scripts/conformance_scan.ts +47 -17
- package/src/scripts/dispatch_r2_reviewer.ts +31 -2
- package/src/scripts/git_authorization_hook.ts +75 -10
- package/src/scripts/hook_manifest.yaml +19 -0
- package/src/scripts/hooks/block_no_verify.ts +3 -1
- package/src/scripts/hooks/block_unauthorized_git.ts +133 -16
- package/src/scripts/hooks/design_slop_hook.ts +13 -1
- package/src/scripts/hooks/dispatch_hook.ts +97 -2
- package/src/scripts/hooks/evidence_independence.ts +40 -6
- package/src/scripts/language_mirror_hook.ts +200 -11
- package/src/scripts/lint_abstraction_thresholds.ts +41 -8
- package/src/scripts/lint_agents_layout.ts +2 -0
- package/src/scripts/lint_hook_manifest.ts +29 -0
- package/src/scripts/memory_learn_hook.ts +109 -6
- package/src/scripts/pr_url_reminder_hook.ts +17 -2
- package/src/scripts/release.ts +38 -1
- package/src/scripts/schemas/review-findings.schema.json +99 -0
- package/src/shared/interactiveContext.ts +98 -0
- package/src/shared/nicknamePrefill.ts +96 -0
- package/src/shared/settingsAsks.ts +176 -0
- package/src/shared/settingsConsent.ts +136 -0
|
@@ -95,6 +95,25 @@ interface ArmSpec {
|
|
|
95
95
|
setting_sources: string | null;
|
|
96
96
|
inject: string | null; // None | "rdp" | "placebo"
|
|
97
97
|
recursive?: boolean; // ADR-106 D₂ arm: depth-bounded attempt→critic→re-attempt loop
|
|
98
|
+
/**
|
|
99
|
+
* Per-arm override for the footprint-lift audit, or `null` to declare that
|
|
100
|
+
* lift is **not a valid signal** for this arm.
|
|
101
|
+
*
|
|
102
|
+
* `audit_activation`'s cross-arm direction catches a treatment surface that
|
|
103
|
+
* COLLAPSED to baseline, by requiring the arm's prompt footprint to sit at
|
|
104
|
+
* least `ACTIVATION_MIN_LIFT_RATIO` above the paired vanilla run. That test
|
|
105
|
+
* presupposes a treatment surface large enough to move the footprint. An arm
|
|
106
|
+
* whose treatment is deliberately *minimal* — `bare-principle` is one
|
|
107
|
+
* sentence — has no lift to detect, so including it would fail legitimate
|
|
108
|
+
* runs: the exact failure the ratio's own calibration note warns about.
|
|
109
|
+
*
|
|
110
|
+
* `null` narrows the audit for that arm to the per-trial TEXT direction,
|
|
111
|
+
* which is checked both ways (`activation_verdict`: a text arm must carry an
|
|
112
|
+
* injection, a non-text arm must not). It never leaves the arm unaudited —
|
|
113
|
+
* and `bench_ab_v2_run.test.ts` pins both halves, so neither an
|
|
114
|
+
* always-firing nor a never-firing audit passes.
|
|
115
|
+
*/
|
|
116
|
+
min_lift_ratio?: number | null;
|
|
98
117
|
}
|
|
99
118
|
|
|
100
119
|
// Arm -> (setting_sources, inject) where inject ∈ {None, "rdp", "placebo"}.
|
|
@@ -118,6 +137,28 @@ interface ArmSpec {
|
|
|
118
137
|
// shipped `balanced` router profile (which does NOT include downstream-changes
|
|
119
138
|
// — tier_2 — so this arm also tests whether that profile cut loses the lift).
|
|
120
139
|
// Both are opt-in: NOT in the default arm list; existing runs unchanged.
|
|
140
|
+
//
|
|
141
|
+
// `package-ladder` / `bare-principle` (road-to-solution-minimalism Phase 3 — the
|
|
142
|
+
// two arms its Arms step names and `ARMS` lacked). Also opt-in, so existing runs
|
|
143
|
+
// and golden-parity outputs are unchanged.
|
|
144
|
+
//
|
|
145
|
+
// `package-ladder` is NOT redundant with `package`, and the reason is measurable
|
|
146
|
+
// rather than stylistic: the ladder ships inside `improve-before-implement`,
|
|
147
|
+
// which is `type: auto`, `tier: 2b`, `alwaysApply: false`, keyword-triggered on
|
|
148
|
+
// `refactor|implement|migration`, and is absent from `dist/router.json`'s
|
|
149
|
+
// preloaded tier lists. So under `package` the ladder reaches the model only when
|
|
150
|
+
// a task's own wording happens to trip one of those keywords — unmeasured, and
|
|
151
|
+
// per-task. That is finding F1's failure class exactly (an activation gap that a
|
|
152
|
+
// null cannot be distinguished from), which is why the pair is: `package` = the
|
|
153
|
+
// shipped reality, `package-ladder` = the same config with the ladder rule body
|
|
154
|
+
// injected via sysprompt so it is guaranteed in context. The contrast measures
|
|
155
|
+
// the ladder, not the trigger set.
|
|
156
|
+
//
|
|
157
|
+
// `bare-principle` is finding F6's control: the naked principle with NO floors
|
|
158
|
+
// routed, plugin scoped away. Its text is authored HERE and is deliberately not
|
|
159
|
+
// the external source's own prompt — per `code-provenance`, a borrowed prompt is
|
|
160
|
+
// re-derived rather than copied, and the arm's job is to be a floor-free
|
|
161
|
+
// principle statement, not a specific artefact.
|
|
121
162
|
export const ARMS: Record<string, ArmSpec> = {
|
|
122
163
|
vanilla: { setting_sources: 'project,local', inject: null },
|
|
123
164
|
package: { setting_sources: null, inject: null },
|
|
@@ -128,6 +169,8 @@ export const ARMS: Record<string, ArmSpec> = {
|
|
|
128
169
|
'hardened-placebo': { setting_sources: null, inject: 'hardened-placebo' },
|
|
129
170
|
'rules-kernel-dc': { setting_sources: 'project,local', inject: 'rules-kernel-dc' },
|
|
130
171
|
'rules-balanced': { setting_sources: 'project,local', inject: 'rules-balanced' },
|
|
172
|
+
'package-ladder': { setting_sources: null, inject: 'ladder' },
|
|
173
|
+
'bare-principle': { setting_sources: 'project,local', inject: 'bare-principle', min_lift_ratio: null },
|
|
131
174
|
};
|
|
132
175
|
|
|
133
176
|
/**
|
|
@@ -202,7 +245,71 @@ export function rules_subset_text(subset: 'rules-kernel-dc' | 'rules-balanced'):
|
|
|
202
245
|
return bodies.join('\n\n---\n\n');
|
|
203
246
|
}
|
|
204
247
|
|
|
248
|
+
/**
|
|
249
|
+
* The `bare-principle` arm's whole treatment — finding F6's control.
|
|
250
|
+
*
|
|
251
|
+
* One sentence, no floors, no routing, no ladder rungs. It exists so the
|
|
252
|
+
* benchmark can separate "the principle" from "the principle with this package's
|
|
253
|
+
* safety floors routed behind it": F6 measured a bare critic prompt nearly
|
|
254
|
+
* matching a full minimalism artefact on size *while being the only arm that
|
|
255
|
+
* dropped a safety guard*, so a report without this arm asserts the floors'
|
|
256
|
+
* contribution instead of measuring it.
|
|
257
|
+
*
|
|
258
|
+
* Authored here on purpose. The external source's own prompt is not reproduced —
|
|
259
|
+
* `code-provenance` requires a conscious borrow to be re-derived rather than
|
|
260
|
+
* copied, and nothing about the arm's function depends on its exact wording.
|
|
261
|
+
* Deterministic (no interpolation) so the injected bytes are stable per checkout.
|
|
262
|
+
*/
|
|
263
|
+
export function bare_principle_text(): string {
|
|
264
|
+
return 'Prefer the smallest solution that fully solves the stated problem; do not add what was not asked for.';
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The ladder rule body, read from the projection — the `package-ladder` arm.
|
|
269
|
+
*
|
|
270
|
+
* Reads `dist/agent-src/rules/improve-before-implement.md`, the same source
|
|
271
|
+
* `rules_subset_text` uses, so the injected text is exactly the rule the package
|
|
272
|
+
* would load if its keyword triggers fired. Reading the projection rather than
|
|
273
|
+
* restating the ladder keeps the arm honest: a hand-copied summary would measure
|
|
274
|
+
* the summary.
|
|
275
|
+
*/
|
|
276
|
+
export function ladder_rule_text(): string {
|
|
277
|
+
return fs.readFileSync(path.join(RULES_DIR, 'improve-before-implement.md'), 'utf-8').trim();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Which arms the cross-arm FOOTPRINT-lift direction of the activation audit
|
|
282
|
+
* applies to.
|
|
283
|
+
*
|
|
284
|
+
* Three exclusions, each for a different reason:
|
|
285
|
+
* - `vanilla` is the baseline the ratio is measured against.
|
|
286
|
+
* - an arm with no treatment surface (`expected_injection === 'none'`) has no
|
|
287
|
+
* lift to require.
|
|
288
|
+
* - an arm declaring `min_lift_ratio: null` has a treatment too small for lift to
|
|
289
|
+
* be a signal at all (see `ArmSpec.min_lift_ratio`); requiring one there fails
|
|
290
|
+
* legitimate runs. Its per-trial TEXT direction is unaffected and still runs
|
|
291
|
+
* both ways.
|
|
292
|
+
*
|
|
293
|
+
* Pure and exported so the exclusion set is asserted rather than trusted — a
|
|
294
|
+
* silent widening of it would be a reach reduction in the audit.
|
|
295
|
+
*/
|
|
296
|
+
export function lift_audit_arms(arms: readonly string[]): string[] {
|
|
297
|
+
return arms.filter((a) => {
|
|
298
|
+
const spec = ARMS[a] as ArmSpec | undefined;
|
|
299
|
+
if (!spec || a === 'vanilla') {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
return expected_injection(spec) !== 'none' && spec.min_lift_ratio !== null;
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
205
306
|
export function injected_text(inject: string | null, placebo_chars: number): string | null {
|
|
307
|
+
if (inject === 'bare-principle') {
|
|
308
|
+
return bare_principle_text();
|
|
309
|
+
}
|
|
310
|
+
if (inject === 'ladder') {
|
|
311
|
+
return ladder_rule_text();
|
|
312
|
+
}
|
|
206
313
|
if (inject === 'rdp') {
|
|
207
314
|
return v1.system_prompt_for('with-rdp');
|
|
208
315
|
}
|
|
@@ -222,10 +329,38 @@ export function injected_text(inject: string | null, placebo_chars: number): str
|
|
|
222
329
|
return null;
|
|
223
330
|
}
|
|
224
331
|
|
|
225
|
-
/**
|
|
226
|
-
|
|
332
|
+
/**
|
|
333
|
+
* Workspace directory for one trial — delta #7 of the S0.3 spike.
|
|
334
|
+
*
|
|
335
|
+
* Keyed by `task|arm|seed`, not by task alone. The old task-only key meant every
|
|
336
|
+
* arm and every seed of a task reused ONE directory that the next trial deleted,
|
|
337
|
+
* so at the end of a sweep exactly one workspace survived per task — the last one
|
|
338
|
+
* written, with no record of which arm or seed it belonged to.
|
|
339
|
+
*
|
|
340
|
+
* That is not a tidiness problem. Phase 3's anti-golfing gate is specified as
|
|
341
|
+
* retro-fittable onto already-completed runs by offline re-scoring, and the
|
|
342
|
+
* roadmap calls that gate cheap *because* the workspaces are preserved. Under the
|
|
343
|
+
* old key there was nothing to re-score, so the claim was false. A distinct
|
|
344
|
+
* directory per trial is what makes it true.
|
|
345
|
+
*
|
|
346
|
+
* `arm` is sanitised because arm names are used verbatim as a path segment and
|
|
347
|
+
* one of them would otherwise be free to escape the root.
|
|
348
|
+
*/
|
|
349
|
+
export function workspace_dir(task_id: string, arm: string, seed: number): string {
|
|
350
|
+
const safe_arm = arm.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
351
|
+
return path.join(WORK_ROOT, `${task_id}__${safe_arm}__seed${seed}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Copy the task's pristine fixture into this trial's own working clone.
|
|
356
|
+
*
|
|
357
|
+
* The clone is re-created from the fixture on every call, so a resumed or
|
|
358
|
+
* repeated trial still starts pristine — the per-trial key changes *which*
|
|
359
|
+
* directory that is, never whether it is clean.
|
|
360
|
+
*/
|
|
361
|
+
export function reset_fixture(task: Dict, arm: string, seed: number): [string, string] {
|
|
227
362
|
const fixture = path.join(FIXTURES_ROOT, String(task['fixture']));
|
|
228
|
-
const dest =
|
|
363
|
+
const dest = workspace_dir(String(task['id']), arm, seed);
|
|
229
364
|
if (fs.existsSync(dest)) {
|
|
230
365
|
fs.rmSync(dest, { recursive: true, force: true });
|
|
231
366
|
}
|
|
@@ -277,6 +412,13 @@ interface RunOneOpts {
|
|
|
277
412
|
sp_dir: string;
|
|
278
413
|
max_depth?: number | null; // recursion arm only — hard cap on correction rounds (default 1)
|
|
279
414
|
host?: string; // 'claude' (default) | 'codex' — P2 non-Claude replication (ADR-110 roadmap Phase 4)
|
|
415
|
+
/**
|
|
416
|
+
* Trial seed — delta #7. Part of the workspace key, so each (task, arm, seed)
|
|
417
|
+
* gets its own preserved clone. Defaults to 0 so an existing caller that does
|
|
418
|
+
* not thread it still gets a stable, arm-scoped directory rather than the old
|
|
419
|
+
* task-only collision.
|
|
420
|
+
*/
|
|
421
|
+
seed?: number;
|
|
280
422
|
}
|
|
281
423
|
|
|
282
424
|
// ── codex host adapter (P2 non-Claude weak-host replication) ────────────────
|
|
@@ -302,6 +444,10 @@ export const CODEX_VALID_ARMS: readonly string[] = [
|
|
|
302
444
|
'placebo',
|
|
303
445
|
'rules-kernel-dc',
|
|
304
446
|
'rules-balanced',
|
|
447
|
+
// Pure-injection, so it carries to a non-Claude host unchanged. Its
|
|
448
|
+
// plugin-bearing sibling `package-ladder` deliberately is NOT here — it
|
|
449
|
+
// depends on the real plugin, which this host cannot load.
|
|
450
|
+
'bare-principle',
|
|
305
451
|
];
|
|
306
452
|
|
|
307
453
|
export function codex_executable(): string | null {
|
|
@@ -505,7 +651,7 @@ export function run_one(task: Dict, arm: string, opts: RunOneOpts): Dict {
|
|
|
505
651
|
return run_one_recursive(task, opts);
|
|
506
652
|
}
|
|
507
653
|
const host = opts.host ?? 'claude';
|
|
508
|
-
const [clone, fixture] = reset_fixture(task);
|
|
654
|
+
const [clone, fixture] = reset_fixture(task, arm, opts.seed ?? 0);
|
|
509
655
|
const sp_text = injected_text(spec.inject, opts.placebo_chars);
|
|
510
656
|
let run: Dict;
|
|
511
657
|
if (host === 'codex') {
|
|
@@ -539,6 +685,10 @@ export function run_one(task: Dict, arm: string, opts: RunOneOpts): Dict {
|
|
|
539
685
|
discipline_pass: score.discipline_pass,
|
|
540
686
|
metrics: trajectory_metrics(run, score),
|
|
541
687
|
injected_chars: sp_text ? sp_text.length : 0,
|
|
688
|
+
// Delta #7 — where this trial's evidence lives. Recorded on the trial
|
|
689
|
+
// rather than recomputed later, so an offline re-scorer reads the path the
|
|
690
|
+
// sweep actually used instead of re-deriving a key that may have changed.
|
|
691
|
+
workspace: clone,
|
|
542
692
|
...integrity_fields(run, spec, sp_text ? sp_text.length : 0, opts.model),
|
|
543
693
|
};
|
|
544
694
|
}
|
|
@@ -567,6 +717,96 @@ export interface RecursiveAttempt {
|
|
|
567
717
|
* threading). Whether recursion actually lifts capability/discipline is the
|
|
568
718
|
* empirical question the live `bench:ab` run answers — not asserted here.
|
|
569
719
|
*/
|
|
720
|
+
// ── delta #8 — the no-network selftest ──────────────────────────────────────
|
|
721
|
+
//
|
|
722
|
+
// `--mode dry-run` printed a run count and returned before the CLI check, the
|
|
723
|
+
// fixtures, the scorer, the activation audit and the report writer — so it could
|
|
724
|
+
// not tell a working harness from a broken one. The roadmap's Reproducibility
|
|
725
|
+
// step asks for a `--selftest` that "runs green with no network and no key", and
|
|
726
|
+
// a mode that exercises nothing cannot answer that.
|
|
727
|
+
//
|
|
728
|
+
// `--mode selftest` substitutes EXACTLY ONE thing: the model call. Fixture
|
|
729
|
+
// cloning, the deterministic scorer, the per-trial activation stamp, the
|
|
730
|
+
// cross-arm audit, the report writer and every exit code run for real. What it
|
|
731
|
+
// therefore proves is the harness, not the hypothesis — and that is the whole
|
|
732
|
+
// distinction between a self-test and a measurement.
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Deterministic synthetic usage for one selftest trial.
|
|
736
|
+
*
|
|
737
|
+
* Derived from the arm's real treatment size rather than invented, so the
|
|
738
|
+
* footprint relationships the activation audit reads are the ones the arm
|
|
739
|
+
* actually implies: a plugin-bearing arm shows the package's footprint, a text arm
|
|
740
|
+
* shows its injection, and `vanilla` shows neither.
|
|
741
|
+
*
|
|
742
|
+
* The plugin figure is a stand-in for a surface this process cannot observe
|
|
743
|
+
* without the real CLI. It is deliberately far above `ACTIVATION_MIN_LIFT_RATIO`
|
|
744
|
+
* so a selftest failure means the wiring is broken, never that a synthetic
|
|
745
|
+
* constant drifted under a threshold.
|
|
746
|
+
*/
|
|
747
|
+
export function selftest_usage(arm: string, seed: number, placebo_chars: number): TokensBreakdown {
|
|
748
|
+
const spec = ARMS[arm] as ArmSpec | undefined;
|
|
749
|
+
const injected = spec ? (injected_text(spec.inject, placebo_chars)?.length ?? 0) : 0;
|
|
750
|
+
const plugin = spec && spec.setting_sources === null ? 4000 : 0;
|
|
751
|
+
return {
|
|
752
|
+
input_tokens: 1000 + plugin + Math.floor(injected / 4),
|
|
753
|
+
output_tokens: 200 + seed,
|
|
754
|
+
cache_read_input_tokens: 0,
|
|
755
|
+
cache_creation_input_tokens: 0,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* One selftest trial — the real pipeline around a synthetic model call.
|
|
761
|
+
*
|
|
762
|
+
* `usage_fn` is injectable so a test can force the failure directions (a
|
|
763
|
+
* collapsed footprint, a model mismatch) and assert that the audit still fires.
|
|
764
|
+
* Without that seam the selftest could only ever be observed passing, which is
|
|
765
|
+
* the "gate that scans nothing" shape this repo's own discipline warns about.
|
|
766
|
+
*/
|
|
767
|
+
export function selftest_run(
|
|
768
|
+
task: Dict,
|
|
769
|
+
arm: string,
|
|
770
|
+
opts: RunOneOpts,
|
|
771
|
+
usage_fn: (arm: string, seed: number, placebo_chars: number) => TokensBreakdown = selftest_usage,
|
|
772
|
+
): Dict {
|
|
773
|
+
const spec = ARMS[arm] as ArmSpec;
|
|
774
|
+
const seed = opts.seed ?? 0;
|
|
775
|
+
// Real fixture clone — this is also what exercises delta #7's per-trial key.
|
|
776
|
+
const [clone, fixture] = reset_fixture(task, arm, seed);
|
|
777
|
+
const sp_text = injected_text(spec.inject, opts.placebo_chars);
|
|
778
|
+
const run: Dict = {
|
|
779
|
+
errored: false,
|
|
780
|
+
reason: null,
|
|
781
|
+
transcript: `selftest transcript · ${String(task['id'])} · ${arm} · seed ${seed}`,
|
|
782
|
+
num_turns: 1,
|
|
783
|
+
wall_time_seconds: 0,
|
|
784
|
+
tokens: 0,
|
|
785
|
+
tokens_breakdown: usage_fn(arm, seed, opts.placebo_chars),
|
|
786
|
+
// The synthetic envelope bills exactly the pinned model, so `model_check`
|
|
787
|
+
// passes for a healthy run and fails if the pin is ever dropped.
|
|
788
|
+
models_seen: opts.model ? [opts.model] : [],
|
|
789
|
+
};
|
|
790
|
+
// Real scorer — no model call, reads the fixture and the clone off disk.
|
|
791
|
+
const score = scoring.score_task_v2(task, {
|
|
792
|
+
fixture_root: fixture,
|
|
793
|
+
clone_root: clone,
|
|
794
|
+
transcript: String(run['transcript'] ?? ''),
|
|
795
|
+
});
|
|
796
|
+
return {
|
|
797
|
+
errored: false,
|
|
798
|
+
reason: null,
|
|
799
|
+
synthetic: true,
|
|
800
|
+
capability_pass: score.capability_pass,
|
|
801
|
+
discipline_score: new PyFloat(score.discipline_score),
|
|
802
|
+
discipline_pass: score.discipline_pass,
|
|
803
|
+
metrics: trajectory_metrics(run, score),
|
|
804
|
+
injected_chars: sp_text ? sp_text.length : 0,
|
|
805
|
+
workspace: clone,
|
|
806
|
+
...integrity_fields(run, spec, sp_text ? sp_text.length : 0, opts.model),
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
|
|
570
810
|
export function run_one_recursive(
|
|
571
811
|
task: Dict,
|
|
572
812
|
opts: RunOneOpts,
|
|
@@ -577,7 +817,9 @@ export function run_one_recursive(
|
|
|
577
817
|
const makeAttempt =
|
|
578
818
|
attemptFn ??
|
|
579
819
|
((depth: number, priorVerdict: string | null): RecursiveAttempt => {
|
|
580
|
-
|
|
820
|
+
// Each correction round gets its own preserved workspace too — a
|
|
821
|
+
// depth-2 attempt must not overwrite the depth-1 evidence.
|
|
822
|
+
const [clone, fixture] = reset_fixture(task, `package-recursive-d${depth}`, opts.seed ?? 0);
|
|
581
823
|
let sp_file: string | null = null;
|
|
582
824
|
if (priorVerdict) {
|
|
583
825
|
sp_file = path.join(opts.sp_dir, `.sp-recursive-${depth}.txt`);
|
|
@@ -941,7 +1183,11 @@ export function main(argv: string[] | null = null): number {
|
|
|
941
1183
|
return 0;
|
|
942
1184
|
}
|
|
943
1185
|
|
|
944
|
-
|
|
1186
|
+
const selftest = args.mode === 'selftest';
|
|
1187
|
+
|
|
1188
|
+
// The selftest exists to run with no network and no key, so requiring the
|
|
1189
|
+
// host CLI here would defeat it. Every other gate below still applies.
|
|
1190
|
+
if (!selftest && v1.claude_executable() === null) {
|
|
945
1191
|
process.stderr.write('claude CLI not found\n');
|
|
946
1192
|
return 1;
|
|
947
1193
|
}
|
|
@@ -988,15 +1234,18 @@ export function main(argv: string[] | null = null): number {
|
|
|
988
1234
|
tasks,
|
|
989
1235
|
arms,
|
|
990
1236
|
args.seeds,
|
|
991
|
-
(task, _arm, _seed) =>
|
|
992
|
-
|
|
1237
|
+
(task, _arm, _seed) => {
|
|
1238
|
+
const runOpts: RunOneOpts = {
|
|
993
1239
|
model: args.model,
|
|
994
1240
|
max_budget,
|
|
995
1241
|
timeout: args.timeout,
|
|
996
1242
|
placebo_chars,
|
|
997
1243
|
sp_dir,
|
|
998
1244
|
host: args.host,
|
|
999
|
-
|
|
1245
|
+
seed: _seed,
|
|
1246
|
+
};
|
|
1247
|
+
return selftest ? selftest_run(task, _arm, runOpts) : run_one(task, _arm, runOpts);
|
|
1248
|
+
},
|
|
1000
1249
|
ckpt,
|
|
1001
1250
|
undefined,
|
|
1002
1251
|
(r) => budget.add(r['tokens_breakdown'] as TokensBreakdown | undefined),
|
|
@@ -1006,8 +1255,7 @@ export function main(argv: string[] | null = null): number {
|
|
|
1006
1255
|
// the paired plugin direction needs the whole record set, so it cannot be a
|
|
1007
1256
|
// per-trial check alone. `vanilla` is the baseline; every arm that is
|
|
1008
1257
|
// supposed to carry a surface the baseline lacks is a lift arm.
|
|
1009
|
-
const
|
|
1010
|
-
const audit = audit_activation(records, { baseline_arm: 'vanilla', lift_arms });
|
|
1258
|
+
const audit = audit_activation(records, { baseline_arm: 'vanilla', lift_arms: lift_audit_arms(arms) });
|
|
1011
1259
|
|
|
1012
1260
|
const stamp = v1.utc_stamp();
|
|
1013
1261
|
const payload: Dict = {
|
|
@@ -1022,6 +1270,12 @@ export function main(argv: string[] | null = null): number {
|
|
|
1022
1270
|
placebo_chars,
|
|
1023
1271
|
corpus: 'ab-trackb-v2',
|
|
1024
1272
|
host: args.host,
|
|
1273
|
+
// Hygiene (F4/F7): a selftest report must be unmistakable for a measured
|
|
1274
|
+
// one. Both the tier marker and the filename say so, because a number is
|
|
1275
|
+
// only ever allowed to render from a report and this report's numbers are
|
|
1276
|
+
// synthetic by construction.
|
|
1277
|
+
tier: selftest ? 'selftest' : 'live',
|
|
1278
|
+
synthetic: selftest,
|
|
1025
1279
|
activation_audit: {
|
|
1026
1280
|
checked: audit.checked,
|
|
1027
1281
|
skipped: audit.skipped,
|
|
@@ -1030,7 +1284,7 @@ export function main(argv: string[] | null = null): number {
|
|
|
1030
1284
|
aborted: aborted ?? null,
|
|
1031
1285
|
records,
|
|
1032
1286
|
};
|
|
1033
|
-
const out = path.join(REPORTS_DIR, `${stamp}-ab-v2-paired.json`);
|
|
1287
|
+
const out = path.join(REPORTS_DIR, `${stamp}-ab-v2-paired${selftest ? '-selftest' : ''}.json`);
|
|
1034
1288
|
fs.writeFileSync(out, _jsonDumps(_toJson(payload), 2) + '\n');
|
|
1035
1289
|
// The paired report now holds everything — retire the checkpoint so a
|
|
1036
1290
|
// finished sweep leaves no residue and a later identical sweep starts fresh.
|
|
@@ -1039,7 +1293,9 @@ export function main(argv: string[] | null = null): number {
|
|
|
1039
1293
|
}
|
|
1040
1294
|
process.stdout.write(
|
|
1041
1295
|
`bench_ab_v2: wrote ${_relToRootPosix(out)} ` +
|
|
1042
|
-
`(${records.length} tasks, ${total} runs,
|
|
1296
|
+
`(${records.length} tasks, ${total} runs, ` +
|
|
1297
|
+
(selftest ? 'SELFTEST — synthetic runs, no spend, not publishable' : `spend $${budget.spent_usd.toFixed(2)}`) +
|
|
1298
|
+
`)\n`,
|
|
1043
1299
|
);
|
|
1044
1300
|
|
|
1045
1301
|
// The report is always written — an aborted or integrity-failed sweep still
|
|
@@ -1102,7 +1358,7 @@ function parse_args(argv: string[]): ParsedArgs {
|
|
|
1102
1358
|
checkpoint: true,
|
|
1103
1359
|
fresh: false,
|
|
1104
1360
|
};
|
|
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`;
|
|
1361
|
+
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,selftest}] [--host {claude,codex}] [--no-checkpoint] [--fresh]\n`;
|
|
1106
1362
|
const argErr = (msg: string): never => {
|
|
1107
1363
|
process.stderr.write(usage);
|
|
1108
1364
|
process.stderr.write(`${prog}: error: ${msg}\n`);
|
|
@@ -1161,7 +1417,7 @@ function parse_args(argv: string[]): ParsedArgs {
|
|
|
1161
1417
|
} else if (a.startsWith('--timeout=')) {
|
|
1162
1418
|
out.timeout = _pyInt(a.slice('--timeout='.length), '--timeout');
|
|
1163
1419
|
} else if (a === '--mode') {
|
|
1164
|
-
out.mode = _choice(need(i, '--mode'), '--mode', ['live', 'dry-run'], argErr);
|
|
1420
|
+
out.mode = _choice(need(i, '--mode'), '--mode', ['live', 'dry-run', 'selftest'], argErr);
|
|
1165
1421
|
i += 1;
|
|
1166
1422
|
} else if (a === '--host') {
|
|
1167
1423
|
out.host = _choice(need(i, '--host'), '--host', ['claude', 'codex'], argErr);
|
|
@@ -1169,7 +1425,7 @@ function parse_args(argv: string[]): ParsedArgs {
|
|
|
1169
1425
|
} else if (a.startsWith('--host=')) {
|
|
1170
1426
|
out.host = _choice(a.slice('--host='.length), '--host', ['claude', 'codex'], argErr);
|
|
1171
1427
|
} else if (a.startsWith('--mode=')) {
|
|
1172
|
-
out.mode = _choice(a.slice('--mode='.length), '--mode', ['live', 'dry-run'], argErr);
|
|
1428
|
+
out.mode = _choice(a.slice('--mode='.length), '--mode', ['live', 'dry-run', 'selftest'], argErr);
|
|
1173
1429
|
} else if (a === '--no-checkpoint') {
|
|
1174
1430
|
out.checkpoint = false;
|
|
1175
1431
|
} else if (a === '--fresh') {
|
|
@@ -4,9 +4,16 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Ported from the retired Python `src/scripts/bench_ab_v2_stats.py` (ADR-200 Python→TS
|
|
6
6
|
* migration). Mirrors the CLI contract EXACTLY: positional `report` arg, the
|
|
7
|
-
* `--json` / `--markdown PATH` flags, exit codes (0 ok / 1 no report found),
|
|
8
|
-
* byte-identical stdout/stderr
|
|
9
|
-
*
|
|
7
|
+
* `--json` / `--markdown PATH` flags, exit codes (0 ok / 1 no report found), and
|
|
8
|
+
* byte-identical stdout/stderr.
|
|
9
|
+
*
|
|
10
|
+
* The port itself introduced no behaviour change. Since then ONE additive change
|
|
11
|
+
* has landed, named here so the paragraph above stops reading as a standing
|
|
12
|
+
* promise it no longer keeps: S0.3 **delta #6** adds a `cost` block to the
|
|
13
|
+
* analysis JSON and a `Table 3b` to the rendered markdown (see `cost_by_arm`).
|
|
14
|
+
* Both are additions — every pre-existing key and table is untouched, and
|
|
15
|
+
* consumers that read named keys (`render_benchmark_composite`,
|
|
16
|
+
* `render_benchmark_md`, `bench_ab_diff`) are unaffected.
|
|
10
17
|
*
|
|
11
18
|
* Reads a v2 paired report (bench_ab_v2_run.py output) and computes, for each
|
|
12
19
|
* arm comparison, paired significance + effect size on:
|
|
@@ -29,11 +36,15 @@ import * as path from 'node:path';
|
|
|
29
36
|
import process from 'node:process';
|
|
30
37
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
31
38
|
|
|
39
|
+
import { cost_usd, tier_for_model, type TierRates } from './_lib/bench_ab_activation.js';
|
|
40
|
+
import { load_pricing } from './_lib/bench_cost.js';
|
|
41
|
+
|
|
32
42
|
const _HERE = fileURLToPath(import.meta.url);
|
|
33
43
|
|
|
34
44
|
// bench_ab_v2_stats.ts → parents[2] is repo root (script lives in src/scripts/).
|
|
35
45
|
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
36
46
|
const REPORTS_DIR = path.join(REPO_ROOT, 'internal', 'bench', 'reports', 'ab-v2');
|
|
47
|
+
const PRICING_PATH = path.join(REPO_ROOT, 'internal', 'bench', 'pricing.yaml');
|
|
37
48
|
|
|
38
49
|
type Dict = Record<string, unknown>;
|
|
39
50
|
|
|
@@ -442,6 +453,89 @@ export function mean_tokens_by_arm(records: Dict[], arms: string[]): Dict {
|
|
|
442
453
|
return out;
|
|
443
454
|
}
|
|
444
455
|
|
|
456
|
+
// ── delta #6 — the cost sheet ───────────────────────────────────────────────
|
|
457
|
+
//
|
|
458
|
+
// Table 3 reports mean TOKENS, which is not a cost: the four usage buckets differ
|
|
459
|
+
// in price by up to 125×, so a run that is mostly cache-read and a run that is
|
|
460
|
+
// mostly output can share a token total and differ in dollars by two orders of
|
|
461
|
+
// magnitude. S0.3's own estimate carries a ~10× spread on the treatment arm for
|
|
462
|
+
// exactly this reason, and it names closing that spread as this delta.
|
|
463
|
+
//
|
|
464
|
+
// The inputs already exist and are reused rather than rebuilt: `tokens_breakdown`
|
|
465
|
+
// on every trial (delta #2), `cost_usd` which prices the buckets separately, and
|
|
466
|
+
// `tier_for_model` (both delta #4). What was missing was wiring them into the
|
|
467
|
+
// REPORT, so the cost axis was priceable per run and unpriced per arm.
|
|
468
|
+
|
|
469
|
+
/** Per-arm cost, priced bucket-by-bucket, plus the provenance of the prices. */
|
|
470
|
+
export function cost_by_arm(records: Dict[], arms: string[], model: string | null, pricingPath: string): Dict {
|
|
471
|
+
const [rates, sourced_on] = load_pricing(pricingPath);
|
|
472
|
+
const tier = model ? tier_for_model(model) : null;
|
|
473
|
+
const tier_rates: TierRates | null = tier && rates[tier] ? (rates[tier] as TierRates) : null;
|
|
474
|
+
|
|
475
|
+
const per_arm: Dict = {};
|
|
476
|
+
for (const arm of arms) {
|
|
477
|
+
let n = 0;
|
|
478
|
+
let total = 0;
|
|
479
|
+
const buckets = { input: 0, output: 0, cache_write: 0, cache_read: 0 };
|
|
480
|
+
for (const rec of records) {
|
|
481
|
+
const armRuns = _dictOr(rec['arms'])[arm];
|
|
482
|
+
for (const r of Array.isArray(armRuns) ? (armRuns as Dict[]) : []) {
|
|
483
|
+
// Same exclusion as the token axis: an errored or truncated run's
|
|
484
|
+
// usage is capped by the budget, not representative of the work.
|
|
485
|
+
if (_pyTruthy(r['errored'])) {
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
n += 1;
|
|
489
|
+
const b = (r['tokens_breakdown'] ?? {}) as Record<string, unknown>;
|
|
490
|
+
buckets.input += _orZero(b['input_tokens']);
|
|
491
|
+
buckets.output += _orZero(b['output_tokens']);
|
|
492
|
+
buckets.cache_write += _orZero(b['cache_creation_input_tokens']);
|
|
493
|
+
buckets.cache_read += _orZero(b['cache_read_input_tokens']);
|
|
494
|
+
if (tier_rates) {
|
|
495
|
+
total += cost_usd(b as never, tier_rates);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
per_arm[arm] = {
|
|
500
|
+
n,
|
|
501
|
+
// `null` rather than 0 when the model is unpriceable: a zero would
|
|
502
|
+
// read as "this arm was free", which is a different claim from "we
|
|
503
|
+
// cannot price it". pricing.yaml's own contract says surface the gap.
|
|
504
|
+
total_usd: tier_rates ? PF(_pyRound(total, 4)) : null,
|
|
505
|
+
mean_usd: tier_rates && n ? PF(_pyRound(total / n, 4)) : null,
|
|
506
|
+
tokens_by_bucket: buckets,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
tier: tier ?? 'unknown',
|
|
512
|
+
priced: tier_rates !== null,
|
|
513
|
+
pricing_sourced_on: sourced_on,
|
|
514
|
+
per_arm,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Whole days between the price sourcing date and the report's own stamp.
|
|
520
|
+
*
|
|
521
|
+
* Measured against the STAMP, not against today: a report is a fixed artefact, so
|
|
522
|
+
* re-rendering it must not change the number. Returns null when either date is
|
|
523
|
+
* unreadable — an invented age would be worse than an absent one.
|
|
524
|
+
*/
|
|
525
|
+
export function pricing_age_days(sourced_on: string | null, stamp: string | null): number | null {
|
|
526
|
+
if (!sourced_on || !stamp) {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
const src = Date.parse(sourced_on.slice(0, 10));
|
|
530
|
+
// Report stamps are `YYYY-MM-DDTHH-MM-SSZ` — the time separators are dashes,
|
|
531
|
+
// so only the date half is parseable without rewriting it.
|
|
532
|
+
const rep = Date.parse(stamp.slice(0, 10));
|
|
533
|
+
if (Number.isNaN(src) || Number.isNaN(rep)) {
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
return Math.floor((rep - src) / 86_400_000);
|
|
537
|
+
}
|
|
538
|
+
|
|
445
539
|
export function bucket_rates(records: Dict[], arms: string[]): Dict {
|
|
446
540
|
const out: Dict = {};
|
|
447
541
|
for (const arm of arms) {
|
|
@@ -482,6 +576,7 @@ export function analyse(payload: Dict): Dict {
|
|
|
482
576
|
comparisons: comps,
|
|
483
577
|
status_buckets: bucket_rates(records, arms),
|
|
484
578
|
mean_tokens: mean_tokens_by_arm(records, arms),
|
|
579
|
+
cost: cost_by_arm(records, arms, payload['model'] ? String(payload['model']) : null, PRICING_PATH),
|
|
485
580
|
};
|
|
486
581
|
}
|
|
487
582
|
|
|
@@ -640,6 +735,50 @@ export function to_markdown(analysis: Dict, payload: Dict): string {
|
|
|
640
735
|
L.push('|---|---|---|---|');
|
|
641
736
|
L.push(`| mean tokens | ${_thousands(tb)} | ${_thousands(tt)} | ${_thousandsSigned(tt - tb)} |`);
|
|
642
737
|
L.push('');
|
|
738
|
+
|
|
739
|
+
// Delta #6 — the dollar half of the cost axis. Tokens above are a volume;
|
|
740
|
+
// these are a price, and the two rank arms differently because the four
|
|
741
|
+
// usage buckets differ in cost by up to 125×.
|
|
742
|
+
const cost = _dictOr(a['cost']);
|
|
743
|
+
const costArms = _dictOr(cost['per_arm']);
|
|
744
|
+
const cb = _dictOr(costArms[String(cmp['arm_baseline'])]);
|
|
745
|
+
const ct = _dictOr(costArms[String(cmp['arm_treatment'])]);
|
|
746
|
+
L.push('### Table 3b — cost axis in dollars (bucket-priced, non-errored)');
|
|
747
|
+
L.push('');
|
|
748
|
+
if (!_pyTruthy(cost['priced'])) {
|
|
749
|
+
L.push(
|
|
750
|
+
`> **Unpriced.** No pricing row matches model \`${_pyStr(a['model'])}\` ` +
|
|
751
|
+
`(tier \`${_pyStr(cost['tier'])}\`), so no dollar figure is shown. This is a ` +
|
|
752
|
+
'gap, not a zero — add the row to `internal/bench/pricing.yaml` rather than ' +
|
|
753
|
+
'reading the absence as "free".',
|
|
754
|
+
);
|
|
755
|
+
} else {
|
|
756
|
+
L.push('| metric | baseline | treatment | Δ |');
|
|
757
|
+
L.push('|---|---|---|---|');
|
|
758
|
+
L.push(
|
|
759
|
+
`| mean USD/run | ${_usd(cb['mean_usd'])} | ${_usd(ct['mean_usd'])} ` +
|
|
760
|
+
`| ${_usdSigned(_pyFloat(ct['mean_usd']) - _pyFloat(cb['mean_usd']))} |`,
|
|
761
|
+
);
|
|
762
|
+
L.push(
|
|
763
|
+
`| total USD | ${_usd(cb['total_usd'])} | ${_usd(ct['total_usd'])} ` +
|
|
764
|
+
`| ${_usdSigned(_pyFloat(ct['total_usd']) - _pyFloat(cb['total_usd']))} |`,
|
|
765
|
+
);
|
|
766
|
+
L.push('');
|
|
767
|
+
const age = pricing_age_days(
|
|
768
|
+
cost['pricing_sourced_on'] ? String(cost['pricing_sourced_on']) : null,
|
|
769
|
+
a['stamp'] ? String(a['stamp']) : null,
|
|
770
|
+
);
|
|
771
|
+
const src = cost['pricing_sourced_on'] ? String(cost['pricing_sourced_on']) : 'unknown';
|
|
772
|
+
L.push(
|
|
773
|
+
`> Priced per bucket (input / output / cache-write / cache-read) at the ` +
|
|
774
|
+
`\`${_pyStr(cost['tier'])}\` tier, sourced ${src}` +
|
|
775
|
+
(age === null ? '' : ` — **${age} days before this report**`) +
|
|
776
|
+
'. A blended rate over the token total is not an approximation of this, it is ' +
|
|
777
|
+
'a different number. Stale prices make these figures stale: re-source them ' +
|
|
778
|
+
'before quoting a dollar amount.',
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
L.push('');
|
|
643
782
|
const at = _dictOr(cmp['attrition']);
|
|
644
783
|
L.push('### Table 4 — attrition (dropped pairs are not missing-at-random)');
|
|
645
784
|
L.push('');
|
|
@@ -738,6 +877,20 @@ function _thousandsSigned(n: number): string {
|
|
|
738
877
|
return t < 0 ? body : `+${body}`;
|
|
739
878
|
}
|
|
740
879
|
|
|
880
|
+
/**
|
|
881
|
+
* Render a cost cell — `n/a` when the model could not be priced, never `$0.00`.
|
|
882
|
+
*
|
|
883
|
+
* Unwraps through `_pyFloat` because the analysis carries costs as `PyFloat`
|
|
884
|
+
* (a plain `{value}` wrapper with no `valueOf`), so `Number(cell)` yields NaN.
|
|
885
|
+
*/
|
|
886
|
+
function _usd(v: unknown): string {
|
|
887
|
+
return v === null || v === undefined ? 'n/a' : `$${_pyFloat(v).toFixed(4)}`;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function _usdSigned(n: number): string {
|
|
891
|
+
return `${n >= 0 ? '+' : '-'}$${Math.abs(n).toFixed(4)}`;
|
|
892
|
+
}
|
|
893
|
+
|
|
741
894
|
/** `mt.get(arm, {}).get("mean_tokens", 0)`. */
|
|
742
895
|
function _meanTokens(arm: unknown): number {
|
|
743
896
|
const d = _dictOr(arm);
|