@tangle-network/agent-bench 0.3.7 → 0.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.
Files changed (211) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/HARNESS.md +43 -0
  3. package/README.md +7 -0
  4. package/dist/adapters.js +23 -23
  5. package/dist/benchmarks/_harness.d.ts +1 -1
  6. package/dist/benchmarks/_harness.js +1 -1
  7. package/dist/benchmarks/aec-bench.js +2 -2
  8. package/dist/benchmarks/agentbench.js +2 -2
  9. package/dist/benchmarks/appworld.js +2 -2
  10. package/dist/benchmarks/bfcl.js +2 -2
  11. package/dist/benchmarks/commit0.js +2 -2
  12. package/dist/benchmarks/crag.js +2 -2
  13. package/dist/benchmarks/dabstep.js +2 -2
  14. package/dist/benchmarks/enterpriseops-gym.js +2 -2
  15. package/dist/benchmarks/finresearchbench.js +2 -2
  16. package/dist/benchmarks/nomiracl.js +2 -2
  17. package/dist/benchmarks/open-rag-bench.js +2 -2
  18. package/dist/benchmarks/programbench.js +2 -2
  19. package/dist/benchmarks/ragbench.js +2 -2
  20. package/dist/benchmarks/swe-bench.js +2 -2
  21. package/dist/benchmarks/t2-ragbench.js +2 -2
  22. package/dist/benchmarks/tau-bench-shared.js +2 -2
  23. package/dist/benchmarks/tau2-bench.js +3 -3
  24. package/dist/benchmarks/tau3-banking.js +3 -3
  25. package/dist/benchmarks/terminal-bench.js +2 -2
  26. package/dist/benchmarks/toollm.js +2 -2
  27. package/dist/benchmarks/webarena-verified.js +2 -2
  28. package/dist/{chunk-CKUVRZ2T.js → chunk-3U5TXJZS.js} +2 -2
  29. package/dist/{chunk-YCGY7UIZ.js → chunk-7GRVHU22.js} +2 -2
  30. package/dist/{chunk-Z7ML6L77.js → chunk-HWST3SED.js} +2 -2
  31. package/dist/{chunk-SYDW647C.js → chunk-IA2FBTWC.js} +2 -2
  32. package/dist/{chunk-R67DFVLO.js → chunk-IFVINJ4B.js} +2 -2
  33. package/dist/{chunk-R36V2VP7.js → chunk-IZ5M6OAC.js} +2 -2
  34. package/dist/{chunk-ODT47UAY.js → chunk-K3BQGZCT.js} +2 -2
  35. package/dist/{chunk-IFAV6KEM.js → chunk-KP5KD6EN.js} +2 -2
  36. package/dist/{chunk-ZEWMTR5M.js → chunk-MQMRLGOG.js} +2 -2
  37. package/dist/{chunk-TSWPNOYM.js → chunk-NQG5XDSB.js} +2 -2
  38. package/dist/{chunk-7WSD27QQ.js → chunk-PB64GYIG.js} +2 -2
  39. package/dist/{chunk-HBSWHQNJ.js → chunk-RCYQEFNX.js} +3 -3
  40. package/dist/{chunk-J3KDJNX2.js → chunk-RH5F53JT.js} +2 -2
  41. package/dist/{chunk-UAIOHCUK.js → chunk-SFLA7OH3.js} +3 -3
  42. package/dist/{chunk-Y6O2OCUO.js → chunk-SHM6MRRF.js} +2 -2
  43. package/dist/{chunk-KDIKRJGB.js → chunk-SHYIRB7I.js} +2 -2
  44. package/dist/{chunk-HHXFIHXC.js → chunk-SVR2LKYI.js} +2 -2
  45. package/dist/{chunk-PWQVGAJB.js → chunk-V7AEBY6U.js} +21 -21
  46. package/dist/{chunk-LRRD7NAG.js → chunk-WSKWVEQB.js} +18 -2
  47. package/dist/chunk-WSKWVEQB.js.map +1 -0
  48. package/dist/{chunk-2PVVP7GN.js → chunk-XKEFIFIC.js} +2 -2
  49. package/dist/{chunk-JRWWGMK7.js → chunk-XYA4XSNU.js} +2 -2
  50. package/dist/{chunk-X5YKXC6V.js → chunk-YSMEKBTD.js} +2 -2
  51. package/dist/{chunk-2XU6OGEN.js → chunk-Z4TZ76N7.js} +2 -2
  52. package/dist/index.js +23 -23
  53. package/package.json +5 -5
  54. package/scripts/trata-hedge/README.md +6 -5
  55. package/scripts/verify-packed-consumer.mjs +1 -1
  56. package/src/benchmarks/_harness.ts +20 -2
  57. package/src/benchmarks/humaneval.test.mts +2 -2
  58. package/src/gate.ts +1 -1
  59. package/src/hev-eval.mts +5 -2
  60. package/src/hev-improve.mts +118 -73
  61. package/src/official-optimizer-config.mts +89 -0
  62. package/src/official-optimizer-config.test.mts +88 -0
  63. package/src/profiles.ts +2 -2
  64. package/src/quant-arena/README.md +144 -0
  65. package/src/quant-arena/backtest.test.mts +135 -0
  66. package/src/quant-arena/backtest.ts +218 -0
  67. package/src/quant-arena/data.test.mts +44 -0
  68. package/src/quant-arena/data.ts +141 -0
  69. package/src/quant-arena/driver.test.mts +253 -0
  70. package/src/quant-arena/driver.ts +219 -0
  71. package/src/quant-arena/fixtures/data/PROVENANCE.md +26 -0
  72. package/src/quant-arena/fixtures/data/holdout/IDX.csv +523 -0
  73. package/src/quant-arena/fixtures/data/holdout/S01.csv +523 -0
  74. package/src/quant-arena/fixtures/data/holdout/S02.csv +523 -0
  75. package/src/quant-arena/fixtures/data/holdout/S03.csv +523 -0
  76. package/src/quant-arena/fixtures/data/holdout/S04.csv +523 -0
  77. package/src/quant-arena/fixtures/data/holdout/S05.csv +523 -0
  78. package/src/quant-arena/fixtures/data/holdout/S06.csv +523 -0
  79. package/src/quant-arena/fixtures/data/holdout/S07.csv +523 -0
  80. package/src/quant-arena/fixtures/data/holdout/S08.csv +523 -0
  81. package/src/quant-arena/fixtures/data/holdout/S09.csv +523 -0
  82. package/src/quant-arena/fixtures/data/holdout/S10.csv +523 -0
  83. package/src/quant-arena/fixtures/data/insample/IDX.csv +2087 -0
  84. package/src/quant-arena/fixtures/data/insample/S01.csv +2087 -0
  85. package/src/quant-arena/fixtures/data/insample/S02.csv +2087 -0
  86. package/src/quant-arena/fixtures/data/insample/S03.csv +2087 -0
  87. package/src/quant-arena/fixtures/data/insample/S04.csv +2087 -0
  88. package/src/quant-arena/fixtures/data/insample/S05.csv +2087 -0
  89. package/src/quant-arena/fixtures/data/insample/S06.csv +2087 -0
  90. package/src/quant-arena/fixtures/data/insample/S07.csv +2087 -0
  91. package/src/quant-arena/fixtures/data/insample/S08.csv +2087 -0
  92. package/src/quant-arena/fixtures/data/insample/S09.csv +2087 -0
  93. package/src/quant-arena/fixtures/data/insample/S10.csv +2087 -0
  94. package/src/quant-arena/fixtures/demo-campaign/cost-ledger.jsonl +16 -0
  95. package/src/quant-arena/fixtures/demo-campaign/notebook.jsonl +5 -0
  96. package/src/quant-arena/fixtures/demo-campaign/rollout-manifest.json +171 -0
  97. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-001-default-author/strategy.ts +119 -0
  98. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-002-default-author/strategy.ts +119 -0
  99. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-003-quant-researcher/strategy.ts +105 -0
  100. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-004-quant-researcher/strategy.ts +102 -0
  101. package/src/quant-arena/fixtures/demo-campaign-v2/cost-ledger.jsonl +4 -0
  102. package/src/quant-arena/fixtures/demo-campaign-v2/notebook.jsonl +2 -0
  103. package/src/quant-arena/fixtures/demo-campaign-v2/rollout-manifest.json +84 -0
  104. package/src/quant-arena/fixtures/demo-campaign-v2/strategies/cand-001-quant-researcher/strategy.ts +117 -0
  105. package/src/quant-arena/holdout-certify.mts +206 -0
  106. package/src/quant-arena/holdout-certify.test.mts +82 -0
  107. package/src/quant-arena/leak-audit.test.mts +79 -0
  108. package/src/quant-arena/leak-audit.ts +95 -0
  109. package/src/quant-arena/make-fixtures.mts +161 -0
  110. package/src/quant-arena/multiplicity.test.mts +68 -0
  111. package/src/quant-arena/multiplicity.ts +87 -0
  112. package/src/quant-arena/nautilus-certify.ts +31 -0
  113. package/src/quant-arena/oms.ts +90 -0
  114. package/src/quant-arena/profiles/quant-researcher.profile.json +7 -0
  115. package/src/quant-arena/python/pyproject.toml +8 -0
  116. package/src/quant-arena/python/uv.lock +1297 -0
  117. package/src/quant-arena/python/vbt-worker.py +192 -0
  118. package/src/quant-arena/quant-loop.mts +813 -0
  119. package/src/quant-arena/quant-loop.test.mts +75 -0
  120. package/src/quant-arena/strategies/buy-hold-index/strategy.ts +11 -0
  121. package/src/quant-arena/strategies/equal-weight/strategy.ts +20 -0
  122. package/src/quant-arena/strategies/sma-crossover/strategy.ts +42 -0
  123. package/src/quant-arena/types.ts +133 -0
  124. package/src/quant-arena/vbt-client.ts +321 -0
  125. package/src/quant-arena/vbt-parity.test.mts +183 -0
  126. package/src/quant-arena/windows.test.mts +45 -0
  127. package/src/quant-arena/windows.ts +54 -0
  128. package/src/rollout-ledger/backfill-swe-arena.mts +606 -0
  129. package/src/rollout-ledger/backfill-swe-arena.test.mts +342 -0
  130. package/src/rollout-ledger/settle-capture.mts +442 -0
  131. package/src/rollout-ledger/settle-capture.test.mts +270 -0
  132. package/src/smoke-structural-rollout.mts +15 -9
  133. package/src/swe-arena/activation.mts +225 -0
  134. package/src/swe-arena/activation.test.mts +300 -0
  135. package/src/swe-arena/arms.ts +30 -14
  136. package/src/swe-arena/briefing.mts +217 -0
  137. package/src/swe-arena/briefing.test.mts +178 -0
  138. package/src/swe-arena/calibrate.ts +102 -1
  139. package/src/swe-arena/cell-evidence.mts +41 -9
  140. package/src/swe-arena/factory-command-container.ts +284 -0
  141. package/src/swe-arena/factory-judge-child.mts +228 -0
  142. package/src/swe-arena/factory.test.mts +643 -0
  143. package/src/swe-arena/fixtures/factory/agent-eval-309/calibration.md +51 -0
  144. package/src/swe-arena/fixtures/factory/agent-eval-309/manifest.json +29 -0
  145. package/src/swe-arena/fixtures/factory/agent-eval-309/spec.md +64 -0
  146. package/src/swe-arena/fixtures/factory/agent-runtime-232/calibration.md +48 -0
  147. package/src/swe-arena/fixtures/factory/agent-runtime-232/manifest.json +29 -0
  148. package/src/swe-arena/fixtures/factory/agent-runtime-232/spec.md +48 -0
  149. package/src/swe-arena/fixtures/factory/loops-28/calibration.md +47 -0
  150. package/src/swe-arena/fixtures/factory/loops-28/manifest.json +30 -0
  151. package/src/swe-arena/fixtures/factory/loops-28/spec.md +50 -0
  152. package/src/swe-arena/fixtures/run-report/README.md +43 -0
  153. package/src/swe-arena/fixtures/run-report/factory-agent-eval-309-FSUP0.json +173 -0
  154. package/src/swe-arena/fixtures/run-report/factory-agent-eval-309-FSUP0.md +100 -0
  155. package/src/swe-arena/fixtures/run-report/gen3-rollup.json +551 -0
  156. package/src/swe-arena/fixtures/run-report/gen3-rollup.md +64 -0
  157. package/src/swe-arena/fixtures.ts +135 -2
  158. package/src/swe-arena/gepa-seat.mts +877 -0
  159. package/src/swe-arena/gepa-seat.test.mts +1059 -0
  160. package/src/swe-arena/implementation-ref.test.mts +64 -0
  161. package/src/swe-arena/implementation-ref.ts +62 -0
  162. package/src/swe-arena/ledger-orphans.mts +77 -0
  163. package/src/swe-arena/ledger-orphans.test.mts +147 -0
  164. package/src/swe-arena/outer-loop.mts +710 -33
  165. package/src/swe-arena/premeasured-from-cells.mts +281 -0
  166. package/src/swe-arena/premeasured-from-cells.test.mts +180 -0
  167. package/src/swe-arena/proposer-fanout.mts +327 -31
  168. package/src/swe-arena/proposer-fanout.test.mts +246 -0
  169. package/src/swe-arena/proposer-provenance.mts +172 -0
  170. package/src/swe-arena/proposer-provenance.test.mts +106 -0
  171. package/src/swe-arena/run-experiment.mts +367 -1
  172. package/src/swe-arena/run-report.mts +75 -0
  173. package/src/swe-arena/run-supervisor.test.mts +5 -3
  174. package/src/swe-arena/score-split.mts +140 -0
  175. package/src/swe-arena/score-split.test.mts +123 -0
  176. package/src/swe-arena/scratch-worktree.test.mts +55 -0
  177. package/src/swe-arena/scratch-worktree.ts +34 -0
  178. package/src/swe-arena/types.ts +52 -0
  179. package/src/swe-code-improve.mts +24 -25
  180. package/src/swe-improve.mts +129 -96
  181. package/src/swe-local-proof.mts +6 -1
  182. package/src/swe-stream.mts +4 -2
  183. package/src/swe-structural.mts +245 -837
  184. package/src/tb-container-executor.test.mts +30 -6
  185. package/src/tb-supervisor-sidecar.mts +2 -1
  186. package/src/trata-gepa.mts +182 -245
  187. package/dist/chunk-LRRD7NAG.js.map +0 -1
  188. package/src/live-improve-campaign-mbpp.mts +0 -641
  189. package/src/live-improve-campaign.mts +0 -500
  190. /package/dist/{chunk-CKUVRZ2T.js.map → chunk-3U5TXJZS.js.map} +0 -0
  191. /package/dist/{chunk-YCGY7UIZ.js.map → chunk-7GRVHU22.js.map} +0 -0
  192. /package/dist/{chunk-Z7ML6L77.js.map → chunk-HWST3SED.js.map} +0 -0
  193. /package/dist/{chunk-SYDW647C.js.map → chunk-IA2FBTWC.js.map} +0 -0
  194. /package/dist/{chunk-R67DFVLO.js.map → chunk-IFVINJ4B.js.map} +0 -0
  195. /package/dist/{chunk-R36V2VP7.js.map → chunk-IZ5M6OAC.js.map} +0 -0
  196. /package/dist/{chunk-ODT47UAY.js.map → chunk-K3BQGZCT.js.map} +0 -0
  197. /package/dist/{chunk-IFAV6KEM.js.map → chunk-KP5KD6EN.js.map} +0 -0
  198. /package/dist/{chunk-ZEWMTR5M.js.map → chunk-MQMRLGOG.js.map} +0 -0
  199. /package/dist/{chunk-TSWPNOYM.js.map → chunk-NQG5XDSB.js.map} +0 -0
  200. /package/dist/{chunk-7WSD27QQ.js.map → chunk-PB64GYIG.js.map} +0 -0
  201. /package/dist/{chunk-HBSWHQNJ.js.map → chunk-RCYQEFNX.js.map} +0 -0
  202. /package/dist/{chunk-J3KDJNX2.js.map → chunk-RH5F53JT.js.map} +0 -0
  203. /package/dist/{chunk-UAIOHCUK.js.map → chunk-SFLA7OH3.js.map} +0 -0
  204. /package/dist/{chunk-Y6O2OCUO.js.map → chunk-SHM6MRRF.js.map} +0 -0
  205. /package/dist/{chunk-KDIKRJGB.js.map → chunk-SHYIRB7I.js.map} +0 -0
  206. /package/dist/{chunk-HHXFIHXC.js.map → chunk-SVR2LKYI.js.map} +0 -0
  207. /package/dist/{chunk-PWQVGAJB.js.map → chunk-V7AEBY6U.js.map} +0 -0
  208. /package/dist/{chunk-2PVVP7GN.js.map → chunk-XKEFIFIC.js.map} +0 -0
  209. /package/dist/{chunk-JRWWGMK7.js.map → chunk-XYA4XSNU.js.map} +0 -0
  210. /package/dist/{chunk-X5YKXC6V.js.map → chunk-YSMEKBTD.js.map} +0 -0
  211. /package/dist/{chunk-2XU6OGEN.js.map → chunk-Z4TZ76N7.js.map} +0 -0
@@ -24,9 +24,14 @@
24
24
  */
25
25
 
26
26
  import { appendFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
27
- import { join } from 'node:path'
27
+ import { tmpdir } from 'node:os'
28
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
28
29
  import { pathToFileURL, fileURLToPath } from 'node:url'
29
30
  import { createSweBenchAdapter } from '../benchmarks/swe-bench.ts'
31
+ import { exportBaseTree } from './factory-judge-child.mts'
32
+ import { runFactoryCommand } from './factory-command-container.ts'
33
+ import { loadFactoryInstances, type LoadedFactoryInstance } from './fixtures.ts'
34
+ import { run, runOk } from './proc.ts'
30
35
  import {
31
36
  extractPatch,
32
37
  loadExcludes,
@@ -39,6 +44,7 @@ import {
39
44
  type SupervisorArmResult,
40
45
  type SupervisorArmSpec,
41
46
  } from './arms.ts'
47
+ import type { FactoryJudgeResult } from './factory-judge-child.mts'
42
48
  import { applyPatchWithFallback } from './calibrate.ts'
43
49
  import { gatesForArmKind, waitForCapacity } from './capacity.ts'
44
50
  import { materializeWorkspace } from './materialize.ts'
@@ -47,6 +53,10 @@ import {
47
53
  type JudgeVerdict,
48
54
  type SerializedJudge,
49
55
  } from './serialized-judge.ts'
56
+ import {
57
+ reportSupervisorRound,
58
+ writeSupervisorRunReportSafe,
59
+ } from '@tangle-network/agent-eval/supervisor-run'
50
60
  import type { LedgerRow } from './types.ts'
51
61
 
52
62
  const fixturesDir = fileURLToPath(new URL('./fixtures', import.meta.url))
@@ -163,6 +173,12 @@ export interface ExperimentConfig {
163
173
  capacityModel?: string
164
174
  /** Pause between instances (orchestrate.sh: 15s, gentle on the shared key). */
165
175
  cooldownMs?: number
176
+ /**
177
+ * Run log the per-cell run-report headline is appended to. Defaults to
178
+ * `<outDir>/run.log`; the headline is always echoed to stdout as well, so a
179
+ * shell-redirected log gets it either way.
180
+ */
181
+ runLogPath?: string
166
182
  }
167
183
 
168
184
  function armPair(arms: ExecutableArmSpec[]): { solo: SoloArmSpec; sup: SupervisorArmSpec } {
@@ -242,8 +258,23 @@ export async function runExperiment(config: ExperimentConfig): Promise<void> {
242
258
  const row = buildLedgerRow(soloResult, soloVerdict, supResult, supVerdict)
243
259
  await appendFile(config.ledgerPath, JSON.stringify(row) + '\n')
244
260
  log(`LEDGER_ROW ${iid} solo=${row.solo_resolved} sup=${row.sup_resolved}`)
261
+
262
+ // Deterministic run observability: never hand-grep a journal for steers/waves/
263
+ // idle/cost again. Best-effort — a reporting failure can't lose a finished cell.
264
+ await writeSupervisorRunReportSafe(join(config.outDir, 'runs', iid, sup.name), {
265
+ appendHeadlineTo: config.runLogPath ?? join(config.outDir, 'run.log'),
266
+ ledgerPath: config.ledgerPath,
267
+ })
268
+
245
269
  await new Promise((r) => setTimeout(r, config.cooldownMs ?? 15_000))
246
270
  }
271
+
272
+ await reportSupervisorRound(config.outDir, {
273
+ appendHeadlineTo: config.runLogPath ?? join(config.outDir, 'run.log'),
274
+ ledgerPath: config.ledgerPath,
275
+ title: 'Round rollup — paired solo/supervisor experiment',
276
+ echo: true,
277
+ })
247
278
  }
248
279
 
249
280
  // ---------------------------------------------------------------------------
@@ -330,6 +361,320 @@ export async function replayPatchParity(
330
361
  return results
331
362
  }
332
363
 
364
+ // ---------------------------------------------------------------------------
365
+ // Factory-bench: worker workspace + experiment loop.
366
+ //
367
+ // The worker cell for a factory instance is the `git archive` export of the
368
+ // base commit re-initialized as a FRESH git repo with one synthetic commit —
369
+ // worker tooling that expects git works, but `git log`/refs cannot leak the
370
+ // real repo's future history (the PR's impl and tests live only on the
371
+ // judge-side mirror). SPEC.md (the rewritten PM-ticket spec) is part of that
372
+ // initial commit. Everything downstream — arm runners, budgets, serialized
373
+ // judge queue/ceiling, ledger resume — is the same machinery as swe-arena.
374
+ // ---------------------------------------------------------------------------
375
+
376
+ /** Ref name the synthetic initial commit is pinned to; the arm's diff base. */
377
+ export const FACTORY_BASE_REF = 'factory-base'
378
+
379
+ export interface FactoryWorkspace {
380
+ /** Sha of the synthetic initial commit (== FACTORY_BASE_REF). */
381
+ syntheticBase: string
382
+ }
383
+
384
+ /**
385
+ * Materialize a worker workspace for a factory instance: archive-export the
386
+ * base tree, add SPEC.md, re-init as a synthetic-history repo (single commit,
387
+ * no remotes), then pre-run `setup_cmds` so the worker starts on installed
388
+ * deps. The real repo's refs/objects are unreachable by construction — the
389
+ * leak test greps the workspace for them after setup.
390
+ */
391
+ export async function materializeFactoryWorkspace(
392
+ inst: LoadedFactoryInstance,
393
+ dest: string,
394
+ opts: { setup?: boolean } = {},
395
+ ): Promise<FactoryWorkspace> {
396
+ await rm(dest, { recursive: true, force: true })
397
+ await mkdir(dirname(dest), { recursive: true })
398
+ await exportBaseTree(inst.repo_local_mirror, inst.base_commit, dest)
399
+ await writeFile(join(dest, 'SPEC.md'), inst.spec)
400
+
401
+ await runOk('git', ['-C', dest, 'init', '-q', '-b', 'work'])
402
+ await runOk('git', ['-C', dest, 'config', 'user.email', 'factory-bench@local'])
403
+ await runOk('git', ['-C', dest, 'config', 'user.name', 'factory-bench'])
404
+ await runOk('git', ['-C', dest, 'add', '-A'])
405
+ await runOk('git', ['-C', dest, 'commit', '-q', '-m', 'baseline workspace'])
406
+ await runOk('git', ['-C', dest, 'branch', '-f', FACTORY_BASE_REF, 'HEAD'])
407
+ const syntheticBase = (await runOk('git', ['-C', dest, 'rev-parse', 'HEAD'])).stdout.trim()
408
+ if (syntheticBase === inst.base_commit) {
409
+ throw new Error(`factory workspace ${inst.id}: synthetic base equals the real base commit — history leaked`)
410
+ }
411
+
412
+ if (opts.setup !== false && inst.setup_cmds.length > 0) {
413
+ for (const cmd of inst.setup_cmds) {
414
+ const res = await runFactoryCommand(dest, cmd, {
415
+ image: inst.command_image,
416
+ network: 'enabled',
417
+ timeoutMs: inst.timeout_s * 1000,
418
+ })
419
+ if (res.code !== 0) {
420
+ throw new Error(
421
+ `factory workspace ${inst.id}: setup_cmd failed (rc=${res.code}): ${cmd}\n${(res.stderr || res.stdout).slice(-2000)}`,
422
+ )
423
+ }
424
+ }
425
+ }
426
+ return { syntheticBase }
427
+ }
428
+
429
+ /** One appended line of the factory ledger (JSONL, resume key = iid + rep). */
430
+ export interface FactoryLedgerRow {
431
+ at: string
432
+ iid: string
433
+ rep: number
434
+ arm: string
435
+ resolved: boolean
436
+ /** passed / calibrated total — the dense partial-credit signal. */
437
+ score: number
438
+ passed: number | null
439
+ total: number | null
440
+ verify_pass: boolean
441
+ patch_lines: number
442
+ wall_s: number
443
+ judge_secs: number | null
444
+ judge_attempts: number | null
445
+ driver_rc: number
446
+ sup_status: string | null
447
+ sup_verdict: string | null
448
+ delivered: boolean | null
449
+ spentTokens: number | null
450
+ spentUsd: number | null
451
+ spawned: number
452
+ workers: number
453
+ settled: number
454
+ patchPath: string
455
+ runDir: string
456
+ }
457
+
458
+ /** `iid#r<rep>` keys already in a factory ledger (resume-skip). */
459
+ export async function factoryLedgerKeys(ledgerPath: string): Promise<Set<string>> {
460
+ const raw = await readFile(ledgerPath, 'utf8').catch(() => '')
461
+ const keys = new Set<string>()
462
+ for (const line of raw.split('\n')) {
463
+ if (!line.trim()) continue
464
+ let row: { iid?: string; rep?: number }
465
+ try {
466
+ row = JSON.parse(line) as { iid?: string; rep?: number }
467
+ } catch {
468
+ throw new Error(`corrupt factory ledger line in ${ledgerPath}: ${line.slice(0, 120)}`)
469
+ }
470
+ if (typeof row.iid === 'string' && typeof row.rep === 'number') keys.add(`${row.iid}#r${row.rep}`)
471
+ }
472
+ return keys
473
+ }
474
+
475
+ export interface FactoryArmConfig {
476
+ workerModel: string
477
+ driverModel: string
478
+ budget?: number
479
+ maxSandboxes?: number
480
+ maxUsd?: number
481
+ maxDepth?: number
482
+ timeoutMs?: number
483
+ envKnobs?: Record<string, string>
484
+ }
485
+
486
+ export interface FactoryExperimentConfig {
487
+ /** Instance-dir root; relative paths resolve against the config file. */
488
+ instancesDir: string
489
+ /** Manifest ids to run (subset of instancesDir). */
490
+ instances: string[]
491
+ repsPerInstance: number
492
+ armName: string
493
+ arm: FactoryArmConfig
494
+ /** The loops checkout in the supervisor seat (baseline = loops main). */
495
+ loopsRepo: string
496
+ ledgerPath: string
497
+ outDir: string
498
+ secretsDir: string
499
+ envFiles: string[]
500
+ /**
501
+ * Worker-side self-check per instance (the supervisor's internal verify
502
+ * gate). NEVER the judge tests — those stay hidden. Default `true` (no gate).
503
+ */
504
+ verifyCmds?: Record<string, string>
505
+ judgeTimeoutMs?: number
506
+ cooldownMs?: number
507
+ gateWaitCeilingMs?: number
508
+ capacityModel?: string
509
+ /** Run log the per-cell run-report headline is appended to (default `<outDir>/run.log`). */
510
+ runLogPath?: string
511
+ }
512
+
513
+ const factoryJudgeChildPath = fileURLToPath(new URL('./factory-judge-child.mts', import.meta.url))
514
+ const benchRootDir = fileURLToPath(new URL('../..', import.meta.url))
515
+
516
+ /**
517
+ * Serialized judge whose child is factory-judge-child.mts — same JUDGE_RESULT
518
+ * line protocol, queue, retry, and SIGKILL ceiling as the swebench judge. The
519
+ * 1800s ceiling floor is kept as the backstop; the child self-enforces the
520
+ * manifest's (much smaller) per-command timeout_s inside it.
521
+ */
522
+ export function createFactoryJudge(
523
+ instances: LoadedFactoryInstance[],
524
+ opts: { timeoutMs?: number } = {},
525
+ ): SerializedJudge {
526
+ const dirById = new Map(instances.map((i) => [i.id, i.dir]))
527
+ return createSerializedJudge({
528
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
529
+ lockFile: join(tmpdir(), 'factory-arena-judge.lock'),
530
+ command: (iid, patchPath) => {
531
+ const dir = dirById.get(iid)
532
+ if (!dir) throw new Error(`factory judge: unknown instance id ${iid}`)
533
+ return { bin: 'node', argv: ['--import', 'tsx', factoryJudgeChildPath, dir, patchPath], cwd: benchRootDir }
534
+ },
535
+ })
536
+ }
537
+
538
+ /**
539
+ * Factory gen0 loop: per (instance × rep), sequentially — ledger resume →
540
+ * capacity gates → supervisor arm on a factory workspace → factory judge →
541
+ * one FactoryLedgerRow appended. Structure mirrors runExperiment.
542
+ */
543
+ export async function runFactoryExperiment(
544
+ config: FactoryExperimentConfig,
545
+ opts: { configDir?: string; only?: string[]; repsOverride?: number } = {},
546
+ ): Promise<void> {
547
+ const log = (msg: string): void => console.log(`[${new Date().toISOString().slice(11, 19)}] ${msg}`)
548
+ const baseDir = opts.configDir ?? process.cwd()
549
+ const instancesDir = isAbsolute(config.instancesDir) ? config.instancesDir : resolve(baseDir, config.instancesDir)
550
+ const all = loadFactoryInstances(instancesDir)
551
+ const byId = new Map(all.map((i) => [i.id, i]))
552
+ const wanted = (opts.only ?? config.instances).map((id) => {
553
+ const inst = byId.get(id)
554
+ if (!inst) throw new Error(`factory config: instance ${id} not found under ${instancesDir}`)
555
+ return inst
556
+ })
557
+ const reps = opts.repsOverride ?? config.repsPerInstance
558
+ if (!Number.isInteger(reps) || reps < 1) throw new Error(`repsPerInstance must be an integer ≥ 1, got ${reps}`)
559
+
560
+ const secrets: SecretsEnv = { secretsDir: config.secretsDir, envFiles: config.envFiles }
561
+ const judge = createFactoryJudge(all, {
562
+ ...(config.judgeTimeoutMs !== undefined ? { timeoutMs: config.judgeTimeoutMs } : {}),
563
+ })
564
+ await mkdir(config.outDir, { recursive: true })
565
+ await mkdir(dirname(config.ledgerPath), { recursive: true })
566
+ const done = await factoryLedgerKeys(config.ledgerPath)
567
+
568
+ for (const inst of wanted) {
569
+ for (let rep = 0; rep < reps; rep += 1) {
570
+ const key = `${inst.id}#r${rep}`
571
+ if (done.has(key)) {
572
+ log(`SKIP ${key} (already in ledger)`)
573
+ continue
574
+ }
575
+
576
+ const gateOpts = {
577
+ ...(config.gateWaitCeilingMs !== undefined ? { waitCeilingMs: config.gateWaitCeilingMs } : {}),
578
+ ...(config.capacityModel !== undefined ? { model: config.capacityModel } : {}),
579
+ onStatus: log,
580
+ }
581
+ for (const gate of gatesForArmKind('supervisor', secrets, gateOpts)) {
582
+ if (!(await waitForCapacity(gate))) {
583
+ log(`NO CAPACITY on ${gate.name} within ceiling — stopping before ${key} (resume later)`)
584
+ return
585
+ }
586
+ }
587
+
588
+ const spec: SupervisorArmSpec = {
589
+ kind: 'supervisor',
590
+ name: config.armName,
591
+ workerModel: config.arm.workerModel,
592
+ driverModel: config.arm.driverModel,
593
+ budget: config.arm.budget,
594
+ maxSandboxes: config.arm.maxSandboxes,
595
+ maxUsd: config.arm.maxUsd,
596
+ maxDepth: config.arm.maxDepth,
597
+ ...(config.arm.envKnobs ? { envKnobs: config.arm.envKnobs } : {}),
598
+ loopsRepo: config.loopsRepo,
599
+ timeoutMs: config.arm.timeoutMs,
600
+ }
601
+ const armOutDir = join(config.outDir, `rep-${rep}`)
602
+ log(`>>> ${config.armName} ${inst.id} rep=${rep}`)
603
+ const armRes = await runSupervisorArm(spec, {
604
+ instanceId: inst.id,
605
+ image: `factory-archive:${inst.id}`,
606
+ // The synthetic-history ref, NOT the real base sha: patch extraction
607
+ // diffs against the workspace's own single commit.
608
+ baseCommit: FACTORY_BASE_REF,
609
+ materialize: async (dest) => {
610
+ await materializeFactoryWorkspace(inst, dest)
611
+ },
612
+ problemStatement: inst.spec,
613
+ verifyCmd: config.verifyCmds?.[inst.id] ?? 'true',
614
+ outDir: armOutDir,
615
+ secrets,
616
+ // SPEC.md is workspace furniture, not worker product.
617
+ excludes: [':(exclude)SPEC.md'],
618
+ })
619
+
620
+ const factoryRunDir = join(armOutDir, 'runs', inst.id, config.armName)
621
+ const { ws: _ws, ...armSummary } = armRes
622
+ await writeFile(join(factoryRunDir, 'result.json'), JSON.stringify(armSummary, null, 1)).catch(() => {})
623
+
624
+ const verdict = await judge.judge(inst.id, armRes.patchPath, `${config.armName}-r${rep}`)
625
+ log(`${inst.id} r${rep} judged: ${JSON.stringify(verdict)}`)
626
+ await writeFile(join(factoryRunDir, 'judge.json'), JSON.stringify(verdict, null, 1)).catch(() => {})
627
+ if (verdict.resolved === null) {
628
+ throw new Error(`inconclusive factory judge verdict for ${key} (${verdict.error ?? 'unknown'}) — not writing a fabricated boolean`)
629
+ }
630
+ const fv = verdict as JudgeVerdict & Partial<FactoryJudgeResult>
631
+ const row: FactoryLedgerRow = {
632
+ at: new Date().toISOString(),
633
+ iid: inst.id,
634
+ rep,
635
+ arm: config.armName,
636
+ resolved: verdict.resolved,
637
+ score: typeof fv.score === 'number' ? fv.score : 0,
638
+ passed: typeof fv.passed === 'number' ? fv.passed : null,
639
+ total: typeof fv.total === 'number' ? fv.total : null,
640
+ verify_pass: armRes.verify_pass,
641
+ patch_lines: armRes.patch_lines,
642
+ wall_s: armRes.wall_s,
643
+ judge_secs: fv.secs ?? null,
644
+ judge_attempts: fv.attempts ?? null,
645
+ driver_rc: armRes.driver_rc,
646
+ sup_status: armRes.sup_status,
647
+ sup_verdict: armRes.sup_verdict,
648
+ delivered: armRes.delivered,
649
+ spentTokens: armRes.spentTokens,
650
+ spentUsd: armRes.spentUsd,
651
+ spawned: armRes.spawned,
652
+ workers: armRes.workers,
653
+ settled: armRes.settled,
654
+ patchPath: armRes.patchPath,
655
+ runDir: factoryRunDir,
656
+ }
657
+ await appendFile(config.ledgerPath, JSON.stringify(row) + '\n')
658
+ log(`LEDGER_ROW ${key} resolved=${row.resolved} score=${row.score} (${row.passed}/${row.total})`)
659
+
660
+ await writeSupervisorRunReportSafe(row.runDir, {
661
+ appendHeadlineTo: config.runLogPath ?? join(config.outDir, 'run.log'),
662
+ ledgerPath: config.ledgerPath,
663
+ patchPath: armRes.patchPath,
664
+ })
665
+
666
+ await new Promise((r) => setTimeout(r, config.cooldownMs ?? 15_000))
667
+ }
668
+ }
669
+
670
+ await reportSupervisorRound(config.outDir, {
671
+ appendHeadlineTo: config.runLogPath ?? join(config.outDir, 'run.log'),
672
+ ledgerPath: config.ledgerPath,
673
+ title: `Round rollup — factory ${config.armName}`,
674
+ echo: true,
675
+ })
676
+ }
677
+
333
678
  // ---------------------------------------------------------------------------
334
679
  // CLI.
335
680
  // ---------------------------------------------------------------------------
@@ -348,12 +693,33 @@ if (isMain) {
348
693
  `files(fixture=${r.fixtureFiles.join(',')} extracted=${r.extractedFiles.join(',')})`,
349
694
  )
350
695
  }
696
+ } else if (arg === '--factory') {
697
+ if (!extra) {
698
+ console.error('usage: tsx src/swe-arena/run-experiment.mts --factory <config.json> [--only <iid> ...] [--reps <n>]')
699
+ process.exit(2)
700
+ }
701
+ const rest = process.argv.slice(4)
702
+ const only: string[] = []
703
+ let repsOverride: number | undefined
704
+ for (let i = 0; i < rest.length; i += 1) {
705
+ if (rest[i] === '--only' && rest[i + 1]) only.push(rest[(i += 1)]!)
706
+ else if (rest[i] === '--reps' && rest[i + 1]) repsOverride = Number(rest[(i += 1)])
707
+ else throw new Error(`unknown --factory flag: ${rest[i]}`)
708
+ }
709
+ const configPath = resolve(extra)
710
+ const config = JSON.parse(await readFile(configPath, 'utf8')) as FactoryExperimentConfig
711
+ await runFactoryExperiment(config, {
712
+ configDir: dirname(configPath),
713
+ ...(only.length > 0 ? { only } : {}),
714
+ ...(repsOverride !== undefined ? { repsOverride } : {}),
715
+ })
351
716
  } else if (arg && !arg.startsWith('--')) {
352
717
  const config = JSON.parse(await readFile(arg, 'utf8')) as ExperimentConfig
353
718
  await runExperiment(config)
354
719
  } else {
355
720
  console.error(
356
721
  'usage: tsx src/swe-arena/run-experiment.mts <config.json>\n' +
722
+ ' tsx src/swe-arena/run-experiment.mts --factory <config.json> [--only <iid> ...] [--reps <n>]\n' +
357
723
  ' tsx src/swe-arena/run-experiment.mts --dry-run-parity [workDir]',
358
724
  )
359
725
  process.exit(2)
@@ -0,0 +1,75 @@
1
+ /**
2
+ * CLI over the supervisor-run reader. NO metric logic lives here.
3
+ *
4
+ * The reader itself is `@tangle-network/agent-eval/supervisor-run` — a
5
+ * supervision tree is a rollout trace with one more dimension, so it sits in
6
+ * the trace-analysis layer next to single-rollout analysis, beside the
7
+ * `tangle.rollout.v1` ledger whose row type its tree nodes ARE. This file is
8
+ * only argv parsing plus the entry points the arenas already call.
9
+ *
10
+ * Harness-session view of the same run (model calls, latency, stuck loops,
11
+ * tool errors):
12
+ * `npx @tangle-network/traces analyze --harness opencode --cwd <worker-clone-cwd>`.
13
+ */
14
+
15
+ import { pathToFileURL } from 'node:url'
16
+ import {
17
+ renderSupervisorRunMarkdown,
18
+ reportSupervisorRound,
19
+ type WriteSupervisorRunOptions,
20
+ writeSupervisorRunReport,
21
+ } from '@tangle-network/agent-eval/supervisor-run'
22
+
23
+ const isMain =
24
+ process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href
25
+
26
+ if (isMain) {
27
+ const args = process.argv.slice(2)
28
+ const usage =
29
+ 'usage:\n' +
30
+ ' tsx run-report.mts <cellDir> [--log <run.log>] [--patch <file>] [--ledger <ledger.jsonl>] [--report-dir <dir>] [--no-opencode]\n' +
31
+ ' tsx run-report.mts --round <outDir> [--log <run.log>] [--ledger <ledger.jsonl>] [--report-dir <dir>] [--no-opencode]\n' +
32
+ '\n--report-dir writes the reports outside the run directory (use it when the run dir is READ-ONLY).\n'
33
+ const noOpencode = args.includes('--no-opencode')
34
+ const flagValue = (flag: string): string | undefined => {
35
+ const i = args.indexOf(flag)
36
+ return i >= 0 ? args[i + 1] : undefined
37
+ }
38
+ const appendHeadlineTo = flagValue('--log')
39
+ const patchPath = flagValue('--patch')
40
+ const reportDir = flagValue('--report-dir')
41
+ const ledgerPath = flagValue('--ledger')
42
+ const flagValueIndices = new Set(
43
+ ['--log', '--patch', '--report-dir', '--ledger', '--round']
44
+ .map((f) => args.indexOf(f))
45
+ .filter((i) => i >= 0)
46
+ .map((i) => i + 1),
47
+ )
48
+ const positional = args.filter((a, i) => !a.startsWith('--') && !flagValueIndices.has(i))
49
+ const roundIdx = args.indexOf('--round')
50
+ const opts: WriteSupervisorRunOptions = {
51
+ ...(appendHeadlineTo !== undefined ? { appendHeadlineTo } : {}),
52
+ ...(patchPath !== undefined ? { patchPath } : {}),
53
+ ...(reportDir !== undefined ? { reportDir } : {}),
54
+ ...(ledgerPath !== undefined ? { ledgerPath } : {}),
55
+ ...(noOpencode ? { opencodeDb: null } : {}),
56
+ echo: true,
57
+ }
58
+ if (roundIdx >= 0) {
59
+ const outDir = args[roundIdx + 1]
60
+ if (outDir === undefined) {
61
+ console.error(usage)
62
+ process.exit(2)
63
+ }
64
+ await reportSupervisorRound(outDir, opts)
65
+ } else {
66
+ const cellDir = positional[0]
67
+ if (cellDir === undefined) {
68
+ console.error(usage)
69
+ process.exit(2)
70
+ }
71
+ const report = await writeSupervisorRunReport(cellDir, opts)
72
+ console.log('')
73
+ console.log(renderSupervisorRunMarkdown(report))
74
+ }
75
+ }
@@ -28,10 +28,10 @@ describe('supervisor driver cancellation', () => {
28
28
  const extension = join(dir, 'fake-extension.mjs')
29
29
  await mkdir(workspace)
30
30
  await writeFile(params, '{}')
31
- const workerScript = `const fs=require('node:fs'); const marker=process.argv[1]; process.on('SIGTERM',()=>setTimeout(()=>{fs.writeFileSync(marker,'clean'); process.exit(0)},30)); setInterval(()=>{},1000)`
31
+ const workerScript = `const fs=require('node:fs'); const marker=process.argv[1]; const ready=process.argv[2]; process.on('SIGTERM',()=>setTimeout(()=>{fs.writeFileSync(marker,'clean'); process.exit(0)},30)); fs.writeFileSync(ready,'ready'); setInterval(()=>{},1000)`
32
32
  await writeFile(extension, `
33
33
  import { spawn } from 'node:child_process'
34
- import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'
34
+ import { appendFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'
35
35
  import { join } from 'node:path'
36
36
 
37
37
  const id = 'sup-1-fake12'
@@ -47,11 +47,13 @@ export default function fakeExtension(pi) {
47
47
  writeFileSync(join(runDir, 'state.json'), JSON.stringify({ status: 'running', progress: 'driving' }))
48
48
  appendFileSync(join(runDir, 'journal.jsonl'), JSON.stringify({ kind: 'spawned', id, label: 'root' }) + '\\n')
49
49
  appendFileSync(join(runDir, 'journal.jsonl'), JSON.stringify({ kind: 'spawned', id: id + ':s1', parent: id, label: 'w-0' }) + '\\n')
50
- child = spawn(process.execPath, ['-e', ${JSON.stringify(workerScript)}, join(ctx.cwd, 'worker-cleaned.txt')], {
50
+ const readyPath = join(ctx.cwd, 'worker-ready.txt')
51
+ child = spawn(process.execPath, ['-e', ${JSON.stringify(workerScript)}, join(ctx.cwd, 'worker-cleaned.txt'), readyPath], {
51
52
  detached: true,
52
53
  stdio: 'ignore',
53
54
  })
54
55
  writeFileSync(join(ctx.cwd, 'worker.pid'), String(child.pid))
56
+ while (!existsSync(readyPath)) await new Promise((resolve) => setTimeout(resolve, 5))
55
57
  return text('spawned supervisor ' + id)
56
58
  },
57
59
  })
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Gen-5 public/private score split (SOTA adoption #1, AIDE2-style).
3
+ *
4
+ * Per generation the improvement instances are DETERMINISTICALLY split
5
+ * (seeded by runId) into a PUBLIC set — whose scores/evidence proposers and
6
+ * the pre-filter may see — and a PRIVATE set that is scored identically but
7
+ * NEVER surfaced to proposers. Winner selection stays on the COMBINED
8
+ * public+private score (the lib judge already averages every scenario), so
9
+ * the split changes only what feedback the search sees, exactly the
10
+ * anti-overfitting mechanism: a candidate that games the visible instances
11
+ * still has to survive the instances it never saw named.
12
+ *
13
+ * SMALL-N CAVEAT: with only 6 improvement instances the split is 4 public /
14
+ * 2 private. Two private instances give a coarse overfit signal (0, 1/2 or
15
+ * 2/2) and a single flake flips it — treat the private sub-score as a
16
+ * direction check, not a certification; the pre-registered 6-instance holdout
17
+ * remains the only promotion evidence.
18
+ *
19
+ * RESUME STABILITY: runId embeds a launch timestamp, so a resumed run gets a
20
+ * fresh runId. The split is therefore PERSISTED to <outDir>/score-split.json
21
+ * on first computation and reloaded verbatim afterwards — a resume can never
22
+ * rotate previously-private instances into view.
23
+ */
24
+
25
+ import { createHash } from 'node:crypto'
26
+ import { existsSync } from 'node:fs'
27
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
28
+ import { dirname, join } from 'node:path'
29
+
30
+ export const SCORE_SPLIT_SCHEMA = 'swe-arena.score-split.v1'
31
+ export const SCORE_SPLIT_FILENAME = 'score-split.json'
32
+
33
+ export interface ScoreSplitConfig {
34
+ /** Number of PUBLIC instances (the rest are private). Must satisfy
35
+ * 0 < publicCount < instances.length. */
36
+ publicCount: number
37
+ }
38
+
39
+ export interface ScoreSplit {
40
+ schema: typeof SCORE_SPLIT_SCHEMA
41
+ /** The runId that seeded the split (provenance; a resumed run keeps it). */
42
+ seededBy: string
43
+ publicInstances: string[]
44
+ privateInstances: string[]
45
+ }
46
+
47
+ /** Deterministic split: instances are ranked by sha256(runId + ':' + iid) and
48
+ * the first `publicCount` become public. Pure — same (runId, instances,
49
+ * publicCount) always yields the same split; instance-list ORDER does not
50
+ * matter (ranking is content-derived). */
51
+ export function splitInstances(runId: string, instances: readonly string[], publicCount: number): ScoreSplit {
52
+ if (!Number.isInteger(publicCount) || publicCount <= 0 || publicCount >= instances.length) {
53
+ throw new Error(
54
+ `score-split: publicCount must be an integer in (0, ${instances.length}), got ${JSON.stringify(publicCount)}`,
55
+ )
56
+ }
57
+ if (new Set(instances).size !== instances.length) {
58
+ throw new Error('score-split: duplicate instance ids')
59
+ }
60
+ const ranked = [...instances]
61
+ .map((iid) => ({ iid, rank: createHash('sha256').update(`${runId}:${iid}`).digest('hex') }))
62
+ .sort((a, b) => (a.rank < b.rank ? -1 : a.rank > b.rank ? 1 : 0))
63
+ const pub = ranked.slice(0, publicCount).map((r) => r.iid)
64
+ const priv = ranked.slice(publicCount).map((r) => r.iid)
65
+ return {
66
+ schema: SCORE_SPLIT_SCHEMA,
67
+ seededBy: runId,
68
+ publicInstances: [...pub].sort(),
69
+ privateInstances: [...priv].sort(),
70
+ }
71
+ }
72
+
73
+ /** Load the persisted split for this outDir, or compute + persist it. The
74
+ * persisted file always wins (resume stability); it is validated against the
75
+ * current instance set so a config edit mid-outDir fails loud. */
76
+ export async function loadOrCreateScoreSplit(args: {
77
+ outDir: string
78
+ runId: string
79
+ instances: readonly string[]
80
+ publicCount: number
81
+ }): Promise<ScoreSplit> {
82
+ const path = join(args.outDir, SCORE_SPLIT_FILENAME)
83
+ if (existsSync(path)) {
84
+ const split = JSON.parse(await readFile(path, 'utf8')) as ScoreSplit
85
+ if (split.schema !== SCORE_SPLIT_SCHEMA) {
86
+ throw new Error(`score-split: ${path} has unknown schema ${JSON.stringify(split.schema)}`)
87
+ }
88
+ const persisted = [...split.publicInstances, ...split.privateInstances].sort()
89
+ const expected = [...args.instances].sort()
90
+ if (JSON.stringify(persisted) !== JSON.stringify(expected)) {
91
+ throw new Error(
92
+ `score-split: persisted split at ${path} covers [${persisted.join(', ')}] but the config ` +
93
+ `names [${expected.join(', ')}] — refusing to silently re-split; move the outDir or fix the config`,
94
+ )
95
+ }
96
+ if (split.publicInstances.length !== args.publicCount) {
97
+ throw new Error(
98
+ `score-split: persisted split has ${split.publicInstances.length} public instances, config wants ` +
99
+ `${args.publicCount} — refusing to silently re-split`,
100
+ )
101
+ }
102
+ return split
103
+ }
104
+ const split = splitInstances(args.runId, args.instances, args.publicCount)
105
+ await mkdir(dirname(path), { recursive: true })
106
+ await writeFile(path, JSON.stringify(split, null, 2))
107
+ return split
108
+ }
109
+
110
+ /** True when `text` mentions any private instance id — the NEVER-SURFACED
111
+ * invariant's checkable form. Used by tests and by the evidence-index writer
112
+ * as a final guard on proposer-visible text. */
113
+ export function leaksPrivateInstance(text: string, split: Pick<ScoreSplit, 'privateInstances'>): string[] {
114
+ return split.privateInstances.filter((iid) => text.includes(iid))
115
+ }
116
+
117
+ /** Filter per-instance evidence records to the PUBLIC set. Identity function
118
+ * when no split is configured (split === null). */
119
+ export function publicOnly<T>(
120
+ records: readonly T[],
121
+ iidOf: (record: T) => string,
122
+ split: Pick<ScoreSplit, 'privateInstances'> | null,
123
+ ): T[] {
124
+ if (split === null) return [...records]
125
+ const priv = new Set(split.privateInstances)
126
+ return records.filter((r) => !priv.has(iidOf(r)))
127
+ }
128
+
129
+ /** Public/private sub-scores for one candidate, from per-instance AND-verdicts
130
+ * (fail-closed: an instance with no conclusive full-reps verdict counts 0). */
131
+ export function subScores(
132
+ verdicts: Record<string, boolean>,
133
+ split: Pick<ScoreSplit, 'publicInstances' | 'privateInstances'>,
134
+ ): { publicResolvedCount: number; privateResolvedCount: number } {
135
+ const count = (iids: readonly string[]): number => iids.filter((iid) => verdicts[iid] === true).length
136
+ return {
137
+ publicResolvedCount: count(split.publicInstances),
138
+ privateResolvedCount: count(split.privateInstances),
139
+ }
140
+ }