@tangle-network/agent-bench 0.3.6 → 0.3.8

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 (265) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/HARNESS.md +43 -0
  3. package/dist/adapters.js +24 -24
  4. package/dist/benchmarks/_harness.d.ts +1 -1
  5. package/dist/benchmarks/_harness.js +1 -1
  6. package/dist/benchmarks/aec-bench.js +2 -2
  7. package/dist/benchmarks/agentbench.js +2 -2
  8. package/dist/benchmarks/appworld.js +2 -2
  9. package/dist/benchmarks/bfcl.js +2 -2
  10. package/dist/benchmarks/commit0.js +2 -2
  11. package/dist/benchmarks/crag.js +2 -2
  12. package/dist/benchmarks/dabstep.js +2 -2
  13. package/dist/benchmarks/enterpriseops-gym.js +2 -2
  14. package/dist/benchmarks/finresearchbench.js +2 -2
  15. package/dist/benchmarks/humaneval.d.ts +10 -1
  16. package/dist/benchmarks/humaneval.js +5 -3
  17. package/dist/benchmarks/nomiracl.js +2 -2
  18. package/dist/benchmarks/open-rag-bench.js +2 -2
  19. package/dist/benchmarks/programbench.js +2 -2
  20. package/dist/benchmarks/ragbench.js +2 -2
  21. package/dist/benchmarks/swe-bench.js +2 -2
  22. package/dist/benchmarks/t2-ragbench.js +2 -2
  23. package/dist/benchmarks/tau-bench-shared.js +2 -2
  24. package/dist/benchmarks/tau2-bench.js +3 -3
  25. package/dist/benchmarks/tau3-banking.js +3 -3
  26. package/dist/benchmarks/terminal-bench.js +2 -2
  27. package/dist/benchmarks/toollm.js +2 -2
  28. package/dist/benchmarks/webarena-verified.js +2 -2
  29. package/dist/{chunk-CKUVRZ2T.js → chunk-3U5TXJZS.js} +2 -2
  30. package/dist/{chunk-PPYSEKFM.js → chunk-5H5XV76F.js} +73 -15
  31. package/dist/chunk-5H5XV76F.js.map +1 -0
  32. package/dist/{chunk-YCGY7UIZ.js → chunk-7GRVHU22.js} +2 -2
  33. package/dist/{chunk-Z7ML6L77.js → chunk-HWST3SED.js} +2 -2
  34. package/dist/{chunk-SYDW647C.js → chunk-IA2FBTWC.js} +2 -2
  35. package/dist/{chunk-R67DFVLO.js → chunk-IFVINJ4B.js} +2 -2
  36. package/dist/{chunk-R36V2VP7.js → chunk-IZ5M6OAC.js} +2 -2
  37. package/dist/{chunk-ODT47UAY.js → chunk-K3BQGZCT.js} +2 -2
  38. package/dist/{chunk-IFAV6KEM.js → chunk-KP5KD6EN.js} +2 -2
  39. package/dist/{chunk-ZEWMTR5M.js → chunk-MQMRLGOG.js} +2 -2
  40. package/dist/{chunk-TSWPNOYM.js → chunk-NQG5XDSB.js} +2 -2
  41. package/dist/{chunk-7WSD27QQ.js → chunk-PB64GYIG.js} +2 -2
  42. package/dist/{chunk-HBSWHQNJ.js → chunk-RCYQEFNX.js} +3 -3
  43. package/dist/{chunk-J3KDJNX2.js → chunk-RH5F53JT.js} +2 -2
  44. package/dist/{chunk-UAIOHCUK.js → chunk-SFLA7OH3.js} +3 -3
  45. package/dist/{chunk-Y6O2OCUO.js → chunk-SHM6MRRF.js} +2 -2
  46. package/dist/{chunk-KDIKRJGB.js → chunk-SHYIRB7I.js} +2 -2
  47. package/dist/{chunk-HHXFIHXC.js → chunk-SVR2LKYI.js} +2 -2
  48. package/dist/{chunk-5SBJCB6W.js → chunk-V7AEBY6U.js} +22 -22
  49. package/dist/{chunk-LRRD7NAG.js → chunk-WSKWVEQB.js} +18 -2
  50. package/dist/chunk-WSKWVEQB.js.map +1 -0
  51. package/dist/{chunk-2PVVP7GN.js → chunk-XKEFIFIC.js} +2 -2
  52. package/dist/{chunk-JRWWGMK7.js → chunk-XYA4XSNU.js} +2 -2
  53. package/dist/{chunk-X5YKXC6V.js → chunk-YSMEKBTD.js} +2 -2
  54. package/dist/{chunk-2XU6OGEN.js → chunk-Z4TZ76N7.js} +2 -2
  55. package/dist/index.js +24 -24
  56. package/package.json +6 -5
  57. package/scripts/run-package-tests.mjs +30 -8
  58. package/scripts/verify-packed-consumer.mjs +1 -1
  59. package/scripts/verify-pier-agent.mts +1 -0
  60. package/src/benchmarks/_harness.ts +20 -2
  61. package/src/benchmarks/humaneval.test.mts +122 -0
  62. package/src/benchmarks/humaneval.ts +100 -27
  63. package/src/david-attribution.mts +78 -0
  64. package/src/david-goliath.mts +149 -0
  65. package/src/hev-improve.mts +25 -6
  66. package/src/humaneval-object-ablation.mts +201 -0
  67. package/src/live-improve-campaign-mbpp.mts +641 -0
  68. package/src/live-improve-campaign.mts +500 -0
  69. package/src/mbpp-structural.mts +12 -7
  70. package/src/quant-arena/README.md +144 -0
  71. package/src/quant-arena/backtest.test.mts +135 -0
  72. package/src/quant-arena/backtest.ts +218 -0
  73. package/src/quant-arena/data.test.mts +44 -0
  74. package/src/quant-arena/data.ts +141 -0
  75. package/src/quant-arena/driver.test.mts +253 -0
  76. package/src/quant-arena/driver.ts +219 -0
  77. package/src/quant-arena/fixtures/data/PROVENANCE.md +26 -0
  78. package/src/quant-arena/fixtures/data/holdout/IDX.csv +523 -0
  79. package/src/quant-arena/fixtures/data/holdout/S01.csv +523 -0
  80. package/src/quant-arena/fixtures/data/holdout/S02.csv +523 -0
  81. package/src/quant-arena/fixtures/data/holdout/S03.csv +523 -0
  82. package/src/quant-arena/fixtures/data/holdout/S04.csv +523 -0
  83. package/src/quant-arena/fixtures/data/holdout/S05.csv +523 -0
  84. package/src/quant-arena/fixtures/data/holdout/S06.csv +523 -0
  85. package/src/quant-arena/fixtures/data/holdout/S07.csv +523 -0
  86. package/src/quant-arena/fixtures/data/holdout/S08.csv +523 -0
  87. package/src/quant-arena/fixtures/data/holdout/S09.csv +523 -0
  88. package/src/quant-arena/fixtures/data/holdout/S10.csv +523 -0
  89. package/src/quant-arena/fixtures/data/insample/IDX.csv +2087 -0
  90. package/src/quant-arena/fixtures/data/insample/S01.csv +2087 -0
  91. package/src/quant-arena/fixtures/data/insample/S02.csv +2087 -0
  92. package/src/quant-arena/fixtures/data/insample/S03.csv +2087 -0
  93. package/src/quant-arena/fixtures/data/insample/S04.csv +2087 -0
  94. package/src/quant-arena/fixtures/data/insample/S05.csv +2087 -0
  95. package/src/quant-arena/fixtures/data/insample/S06.csv +2087 -0
  96. package/src/quant-arena/fixtures/data/insample/S07.csv +2087 -0
  97. package/src/quant-arena/fixtures/data/insample/S08.csv +2087 -0
  98. package/src/quant-arena/fixtures/data/insample/S09.csv +2087 -0
  99. package/src/quant-arena/fixtures/data/insample/S10.csv +2087 -0
  100. package/src/quant-arena/fixtures/demo-campaign/cost-ledger.jsonl +16 -0
  101. package/src/quant-arena/fixtures/demo-campaign/notebook.jsonl +5 -0
  102. package/src/quant-arena/fixtures/demo-campaign/rollout-manifest.json +171 -0
  103. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-001-default-author/strategy.ts +119 -0
  104. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-002-default-author/strategy.ts +119 -0
  105. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-003-quant-researcher/strategy.ts +105 -0
  106. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-004-quant-researcher/strategy.ts +102 -0
  107. package/src/quant-arena/fixtures/demo-campaign-v2/cost-ledger.jsonl +4 -0
  108. package/src/quant-arena/fixtures/demo-campaign-v2/notebook.jsonl +2 -0
  109. package/src/quant-arena/fixtures/demo-campaign-v2/rollout-manifest.json +84 -0
  110. package/src/quant-arena/fixtures/demo-campaign-v2/strategies/cand-001-quant-researcher/strategy.ts +117 -0
  111. package/src/quant-arena/holdout-certify.mts +206 -0
  112. package/src/quant-arena/holdout-certify.test.mts +82 -0
  113. package/src/quant-arena/leak-audit.test.mts +79 -0
  114. package/src/quant-arena/leak-audit.ts +95 -0
  115. package/src/quant-arena/make-fixtures.mts +161 -0
  116. package/src/quant-arena/multiplicity.test.mts +68 -0
  117. package/src/quant-arena/multiplicity.ts +87 -0
  118. package/src/quant-arena/nautilus-certify.ts +31 -0
  119. package/src/quant-arena/oms.ts +90 -0
  120. package/src/quant-arena/profiles/quant-researcher.profile.json +7 -0
  121. package/src/quant-arena/python/pyproject.toml +8 -0
  122. package/src/quant-arena/python/uv.lock +1297 -0
  123. package/src/quant-arena/python/vbt-worker.py +192 -0
  124. package/src/quant-arena/quant-loop.mts +813 -0
  125. package/src/quant-arena/quant-loop.test.mts +75 -0
  126. package/src/quant-arena/strategies/buy-hold-index/strategy.ts +11 -0
  127. package/src/quant-arena/strategies/equal-weight/strategy.ts +20 -0
  128. package/src/quant-arena/strategies/sma-crossover/strategy.ts +42 -0
  129. package/src/quant-arena/types.ts +133 -0
  130. package/src/quant-arena/vbt-client.ts +321 -0
  131. package/src/quant-arena/vbt-parity.test.mts +183 -0
  132. package/src/quant-arena/windows.test.mts +45 -0
  133. package/src/quant-arena/windows.ts +54 -0
  134. package/src/rollout-ledger/backfill-swe-arena.mts +606 -0
  135. package/src/rollout-ledger/backfill-swe-arena.test.mts +338 -0
  136. package/src/rollout-ledger/settle-capture.mts +442 -0
  137. package/src/rollout-ledger/settle-capture.test.mts +270 -0
  138. package/src/stream-observe.py +45 -0
  139. package/src/stream-observe.tpl.html +247 -0
  140. package/src/supervisor-arena.mts +816 -0
  141. package/src/swe-arena/activation.mts +228 -0
  142. package/src/swe-arena/activation.test.mts +303 -0
  143. package/src/swe-arena/analyze.ts +211 -0
  144. package/src/swe-arena/arms.ts +804 -0
  145. package/src/swe-arena/bootstrap-meta.mts +188 -0
  146. package/src/swe-arena/bootstrap-meta.test.mts +51 -0
  147. package/src/swe-arena/briefing.mts +217 -0
  148. package/src/swe-arena/briefing.test.mts +178 -0
  149. package/src/swe-arena/calibrate.ts +217 -0
  150. package/src/swe-arena/capabilities.mts +76 -0
  151. package/src/swe-arena/capabilities.test.mts +57 -0
  152. package/src/swe-arena/capacity.ts +194 -0
  153. package/src/swe-arena/cell-evidence.mts +437 -0
  154. package/src/swe-arena/cell-evidence.test.mts +248 -0
  155. package/src/swe-arena/diagnosis-ensemble.test.mts +210 -0
  156. package/src/swe-arena/diagnosis-ensemble.ts +520 -0
  157. package/src/swe-arena/execution.test.mts +1170 -0
  158. package/src/swe-arena/factory-command-container.ts +284 -0
  159. package/src/swe-arena/factory-judge-child.mts +228 -0
  160. package/src/swe-arena/factory.test.mts +643 -0
  161. package/src/swe-arena/fixtures/analyze.py +80 -0
  162. package/src/swe-arena/fixtures/excludes.txt +8 -0
  163. package/src/swe-arena/fixtures/factory/agent-eval-309/calibration.md +51 -0
  164. package/src/swe-arena/fixtures/factory/agent-eval-309/manifest.json +29 -0
  165. package/src/swe-arena/fixtures/factory/agent-eval-309/spec.md +64 -0
  166. package/src/swe-arena/fixtures/factory/agent-runtime-232/calibration.md +48 -0
  167. package/src/swe-arena/fixtures/factory/agent-runtime-232/manifest.json +29 -0
  168. package/src/swe-arena/fixtures/factory/agent-runtime-232/spec.md +48 -0
  169. package/src/swe-arena/fixtures/factory/loops-28/calibration.md +47 -0
  170. package/src/swe-arena/fixtures/factory/loops-28/manifest.json +30 -0
  171. package/src/swe-arena/fixtures/factory/loops-28/spec.md +50 -0
  172. package/src/swe-arena/fixtures/gen1-salvage/README.md +45 -0
  173. package/src/swe-arena/fixtures/gen1-salvage/cand0-e6d7361.diff +116 -0
  174. package/src/swe-arena/fixtures/gen1-salvage/cand1-76a8590.diff +293 -0
  175. package/src/swe-arena/fixtures/holdout-preregister.log +12 -0
  176. package/src/swe-arena/fixtures/holdout.json +44 -0
  177. package/src/swe-arena/fixtures/instances.json +146 -0
  178. package/src/swe-arena/fixtures/ledger.jsonl +12 -0
  179. package/src/swe-arena/fixtures/patches/pallets__flask-5014.solo.patch +36 -0
  180. package/src/swe-arena/fixtures/patches/pydata__xarray-4687.sup.patch +33 -0
  181. package/src/swe-arena/fixtures/rejudge.jsonl +15 -0
  182. package/src/swe-arena/fixtures/rematch.jsonl +3 -0
  183. package/src/swe-arena/fixtures/rematch2.jsonl +3 -0
  184. package/src/swe-arena/fixtures/rematch3.jsonl +3 -0
  185. package/src/swe-arena/fixtures/run-report/README.md +43 -0
  186. package/src/swe-arena/fixtures/run-report/factory-agent-eval-309-FSUP0.json +173 -0
  187. package/src/swe-arena/fixtures/run-report/factory-agent-eval-309-FSUP0.md +100 -0
  188. package/src/swe-arena/fixtures/run-report/gen3-rollup.json +551 -0
  189. package/src/swe-arena/fixtures/run-report/gen3-rollup.md +64 -0
  190. package/src/swe-arena/fixtures/sup-journal-true.json +19 -0
  191. package/src/swe-arena/fixtures/verify/astropy__astropy-13033.sh +48 -0
  192. package/src/swe-arena/fixtures/verify/django__django-11532.sh +50 -0
  193. package/src/swe-arena/fixtures/verify/matplotlib__matplotlib-20826.sh +76 -0
  194. package/src/swe-arena/fixtures/verify/pydata__xarray-4687.sh +44 -0
  195. package/src/swe-arena/fixtures/verify/pytest-dev__pytest-6197.sh +32 -0
  196. package/src/swe-arena/fixtures/verify/sphinx-doc__sphinx-9658.sh +51 -0
  197. package/src/swe-arena/fixtures/worker-tokens.json +42 -0
  198. package/src/swe-arena/fixtures.ts +237 -0
  199. package/src/swe-arena/gepa-seat.mts +583 -0
  200. package/src/swe-arena/gepa-seat.test.mts +635 -0
  201. package/src/swe-arena/holdout-certify.mts +408 -0
  202. package/src/swe-arena/holdout-certify.test.mts +160 -0
  203. package/src/swe-arena/judge-child.mts +37 -0
  204. package/src/swe-arena/ledger-orphans.mts +77 -0
  205. package/src/swe-arena/ledger-orphans.test.mts +147 -0
  206. package/src/swe-arena/lineage-record.mts +164 -0
  207. package/src/swe-arena/lineage-record.test.mts +115 -0
  208. package/src/swe-arena/manifest.mts +293 -0
  209. package/src/swe-arena/manifest.test.mts +169 -0
  210. package/src/swe-arena/materialize.ts +142 -0
  211. package/src/swe-arena/outer-loop.mts +2795 -0
  212. package/src/swe-arena/outer-loop.test.mts +696 -0
  213. package/src/swe-arena/parity.test.mts +87 -0
  214. package/src/swe-arena/premeasured-from-cells.mts +281 -0
  215. package/src/swe-arena/premeasured-from-cells.test.mts +180 -0
  216. package/src/swe-arena/proc.test.mts +174 -0
  217. package/src/swe-arena/proc.ts +260 -0
  218. package/src/swe-arena/profiles/default-author.profile.json +4 -0
  219. package/src/swe-arena/proposer-fanout.mts +770 -0
  220. package/src/swe-arena/proposer-fanout.test.mts +619 -0
  221. package/src/swe-arena/proposer-provenance.mts +177 -0
  222. package/src/swe-arena/proposer-provenance.test.mts +106 -0
  223. package/src/swe-arena/reconcile.ts +0 -0
  224. package/src/swe-arena/replay.mts +183 -0
  225. package/src/swe-arena/replay.test.mts +300 -0
  226. package/src/swe-arena/run-experiment.mts +727 -0
  227. package/src/swe-arena/run-report.mts +75 -0
  228. package/src/swe-arena/run-supervisor.mjs +297 -0
  229. package/src/swe-arena/run-supervisor.test.mts +500 -0
  230. package/src/swe-arena/score-split.mts +140 -0
  231. package/src/swe-arena/score-split.test.mts +123 -0
  232. package/src/swe-arena/serialized-judge.ts +414 -0
  233. package/src/swe-arena/types.ts +218 -0
  234. package/src/swe-code-improve.mts +328 -0
  235. package/src/swe-emit-patch.mts +104 -0
  236. package/src/swe-improve.mts +232 -0
  237. package/src/swe-jail.ts +2 -2
  238. package/src/swe-local-proof.mts +169 -0
  239. package/src/swe-repro-calibrate.mts +446 -0
  240. package/src/swe-stream.mts +1497 -0
  241. package/src/swe-structural.mts +245 -837
  242. package/dist/chunk-LRRD7NAG.js.map +0 -1
  243. package/dist/chunk-PPYSEKFM.js.map +0 -1
  244. /package/dist/{chunk-CKUVRZ2T.js.map → chunk-3U5TXJZS.js.map} +0 -0
  245. /package/dist/{chunk-YCGY7UIZ.js.map → chunk-7GRVHU22.js.map} +0 -0
  246. /package/dist/{chunk-Z7ML6L77.js.map → chunk-HWST3SED.js.map} +0 -0
  247. /package/dist/{chunk-SYDW647C.js.map → chunk-IA2FBTWC.js.map} +0 -0
  248. /package/dist/{chunk-R67DFVLO.js.map → chunk-IFVINJ4B.js.map} +0 -0
  249. /package/dist/{chunk-R36V2VP7.js.map → chunk-IZ5M6OAC.js.map} +0 -0
  250. /package/dist/{chunk-ODT47UAY.js.map → chunk-K3BQGZCT.js.map} +0 -0
  251. /package/dist/{chunk-IFAV6KEM.js.map → chunk-KP5KD6EN.js.map} +0 -0
  252. /package/dist/{chunk-ZEWMTR5M.js.map → chunk-MQMRLGOG.js.map} +0 -0
  253. /package/dist/{chunk-TSWPNOYM.js.map → chunk-NQG5XDSB.js.map} +0 -0
  254. /package/dist/{chunk-7WSD27QQ.js.map → chunk-PB64GYIG.js.map} +0 -0
  255. /package/dist/{chunk-HBSWHQNJ.js.map → chunk-RCYQEFNX.js.map} +0 -0
  256. /package/dist/{chunk-J3KDJNX2.js.map → chunk-RH5F53JT.js.map} +0 -0
  257. /package/dist/{chunk-UAIOHCUK.js.map → chunk-SFLA7OH3.js.map} +0 -0
  258. /package/dist/{chunk-Y6O2OCUO.js.map → chunk-SHM6MRRF.js.map} +0 -0
  259. /package/dist/{chunk-KDIKRJGB.js.map → chunk-SHYIRB7I.js.map} +0 -0
  260. /package/dist/{chunk-HHXFIHXC.js.map → chunk-SVR2LKYI.js.map} +0 -0
  261. /package/dist/{chunk-5SBJCB6W.js.map → chunk-V7AEBY6U.js.map} +0 -0
  262. /package/dist/{chunk-2PVVP7GN.js.map → chunk-XKEFIFIC.js.map} +0 -0
  263. /package/dist/{chunk-JRWWGMK7.js.map → chunk-XYA4XSNU.js.map} +0 -0
  264. /package/dist/{chunk-X5YKXC6V.js.map → chunk-YSMEKBTD.js.map} +0 -0
  265. /package/dist/{chunk-2XU6OGEN.js.map → chunk-Z4TZ76N7.js.map} +0 -0
@@ -0,0 +1,727 @@
1
+ /**
2
+ * Experiment runner CLI — the typed replacement for the experiment's
3
+ * `orchestrate.sh` + `run-instance.sh`:
4
+ *
5
+ * tsx src/swe-arena/run-experiment.mts <config.json>
6
+ * tsx src/swe-arena/run-experiment.mts --dry-run-parity [workDir]
7
+ *
8
+ * Per instance, sequentially: ledger-skip resume → endpoint capacity gates
9
+ * (supervisor arms gate on the ROUTER path too — probing z.ai alone was the
10
+ * proven blind spot) → solo arm → serialized judge → supervisor arm →
11
+ * serialized judge → append one typed LedgerRow (M1 schema) to the ledger.
12
+ *
13
+ * DRY-RUN PARITY (the M2 gate): `--dry-run-parity` executes NO arms and spends
14
+ * NO model tokens. It replays patch extraction + official judging for two
15
+ * committed fixture patches (pallets__flask-5014 SOLO — resolved;
16
+ * pydata__xarray-4687 SUP — unresolved) through materialize → apply →
17
+ * extractPatch → serialized-judge, and checks the verdicts against the pinned
18
+ * M1 fixtures. Docker time only.
19
+ *
20
+ * TODO(operator approval): full 12-instance parity re-run — re-execute both
21
+ * arms on the same 12 instances through this typed path and diff the resulting
22
+ * ledger against fixtures/ledger.jsonl. Costs ~$1 in model spend + ~4h wall;
23
+ * do not launch without an explicit operator go.
24
+ */
25
+
26
+ import { appendFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
27
+ import { tmpdir } from 'node:os'
28
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
29
+ import { pathToFileURL, fileURLToPath } from 'node:url'
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'
35
+ import {
36
+ extractPatch,
37
+ loadExcludes,
38
+ runSoloArm,
39
+ runSupervisorArm,
40
+ type ExecutableArmSpec,
41
+ type SecretsEnv,
42
+ type SoloArmResult,
43
+ type SoloArmSpec,
44
+ type SupervisorArmResult,
45
+ type SupervisorArmSpec,
46
+ } from './arms.ts'
47
+ import type { FactoryJudgeResult } from './factory-judge-child.mts'
48
+ import { applyPatchWithFallback } from './calibrate.ts'
49
+ import { gatesForArmKind, waitForCapacity } from './capacity.ts'
50
+ import { materializeWorkspace } from './materialize.ts'
51
+ import {
52
+ createSerializedJudge,
53
+ type JudgeVerdict,
54
+ type SerializedJudge,
55
+ } from './serialized-judge.ts'
56
+ import {
57
+ reportSupervisorRound,
58
+ writeSupervisorRunReportSafe,
59
+ } from '@tangle-network/agent-eval/supervisor-run'
60
+ import type { LedgerRow } from './types.ts'
61
+
62
+ const fixturesDir = fileURLToPath(new URL('./fixtures', import.meta.url))
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Instance images (fixtures/instances.json, vendored from the experiment).
66
+ // ---------------------------------------------------------------------------
67
+
68
+ export interface InstanceImageEntry {
69
+ repo: string
70
+ base_commit: string
71
+ image: string
72
+ environment_setup_commit: string | null
73
+ }
74
+
75
+ export async function loadInstanceImages(path?: string): Promise<Record<string, InstanceImageEntry>> {
76
+ const raw = await readFile(path ?? join(fixturesDir, 'instances.json'), 'utf8')
77
+ return JSON.parse(raw) as Record<string, InstanceImageEntry>
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Ledger row assembly + resume.
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /**
85
+ * One paired LedgerRow from the two arm results + judge verdicts — the exact
86
+ * field mapping run-instance.sh wrote. Throws on an inconclusive verdict
87
+ * (resolved: null): an infra failure must abort the pair, never be written
88
+ * into a boolean column.
89
+ */
90
+ export function buildLedgerRow(
91
+ solo: SoloArmResult,
92
+ soloVerdict: JudgeVerdict,
93
+ sup: SupervisorArmResult,
94
+ supVerdict: JudgeVerdict,
95
+ ): LedgerRow {
96
+ if (solo.iid !== sup.iid) throw new Error(`ledger row: arm iid mismatch ${solo.iid} vs ${sup.iid}`)
97
+ if (soloVerdict.resolved === null || supVerdict.resolved === null) {
98
+ throw new Error(
99
+ `ledger row ${solo.iid}: inconclusive judge verdict (solo=${soloVerdict.resolved}, sup=${supVerdict.resolved}) — not writing a fabricated boolean`,
100
+ )
101
+ }
102
+ const SUP_STATUSES = ['completed', 'running', 'failed', 'cancelled', null] as const
103
+ const SUP_VERDICTS = ['delivered', 'no-winner', 'best-effort', null] as const
104
+ if (!SUP_STATUSES.includes(sup.sup_status as (typeof SUP_STATUSES)[number])) {
105
+ throw new Error(`ledger row ${solo.iid}: unknown sup_status ${JSON.stringify(sup.sup_status)} — loops contract changed?`)
106
+ }
107
+ if (!SUP_VERDICTS.includes(sup.sup_verdict as (typeof SUP_VERDICTS)[number])) {
108
+ throw new Error(`ledger row ${solo.iid}: unknown sup_verdict ${JSON.stringify(sup.sup_verdict)} — loops contract changed?`)
109
+ }
110
+ return {
111
+ iid: solo.iid,
112
+ solo_resolved: soloVerdict.resolved,
113
+ sup_resolved: supVerdict.resolved,
114
+ solo_verify_pass: solo.verify_pass,
115
+ sup_verify_pass: sup.verify_pass,
116
+ solo_patch_lines: solo.patch_lines,
117
+ sup_patch_lines: sup.patch_lines,
118
+ solo_wall_s: solo.wall_s,
119
+ sup_wall_s: sup.wall_s,
120
+ solo_tokens: solo.usage.total_io,
121
+ solo_usage: solo.usage,
122
+ sup_spentTokens: sup.spentTokens,
123
+ sup_spentUsd: sup.spentUsd,
124
+ sup_spawned: sup.spawned,
125
+ sup_workers: sup.workers,
126
+ sup_settled: sup.settled,
127
+ sup_subtasks: sup.subtasks,
128
+ sup_delivered: sup.delivered,
129
+ sup_status: sup.sup_status as LedgerRow['sup_status'],
130
+ sup_verdict: sup.sup_verdict as LedgerRow['sup_verdict'],
131
+ solo_oc_rc: solo.oc_rc,
132
+ sup_driver_rc: sup.driver_rc,
133
+ solo_patch: solo.patchPath,
134
+ sup_patch: sup.patchPath,
135
+ }
136
+ }
137
+
138
+ /** iids already present in the ledger (resume-skip, orchestrate.sh semantics). */
139
+ export async function ledgerIids(ledgerPath: string): Promise<Set<string>> {
140
+ const raw = await readFile(ledgerPath, 'utf8').catch(() => '')
141
+ const iids = new Set<string>()
142
+ for (const line of raw.split('\n')) {
143
+ if (!line.trim()) continue
144
+ try {
145
+ const row = JSON.parse(line) as { iid?: string }
146
+ if (typeof row.iid === 'string') iids.add(row.iid)
147
+ } catch {
148
+ throw new Error(`corrupt ledger line in ${ledgerPath}: ${line.slice(0, 120)}`)
149
+ }
150
+ }
151
+ return iids
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Experiment config + loop.
156
+ // ---------------------------------------------------------------------------
157
+
158
+ export interface ExperimentConfig {
159
+ instances: string[]
160
+ /** Exactly one solo and one supervisor arm (the paired-ledger contract). */
161
+ arms: ExecutableArmSpec[]
162
+ ledgerPath: string
163
+ outDir: string
164
+ secretsDir: string
165
+ envFiles: string[]
166
+ /** Per-instance self-repro verify scripts: <verifyDir>/<iid>.sh. */
167
+ verifyDir: string
168
+ /** Override fixtures/instances.json (image map). */
169
+ instanceImagesPath?: string
170
+ judgeTimeoutMs?: number
171
+ gateWaitCeilingMs?: number
172
+ /** Probe model id. Defaults per-endpoint in capacity.ts. */
173
+ capacityModel?: string
174
+ /** Pause between instances (orchestrate.sh: 15s, gentle on the shared key). */
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
182
+ }
183
+
184
+ function armPair(arms: ExecutableArmSpec[]): { solo: SoloArmSpec; sup: SupervisorArmSpec } {
185
+ const solo = arms.filter((a): a is SoloArmSpec => a.kind === 'solo')
186
+ const sup = arms.filter((a): a is SupervisorArmSpec => a.kind === 'supervisor')
187
+ if (solo.length !== 1 || sup.length !== 1) {
188
+ throw new Error(`expected exactly one solo + one supervisor arm, got ${arms.map((a) => a.kind).join(', ')}`)
189
+ }
190
+ return { solo: solo[0], sup: sup[0] }
191
+ }
192
+
193
+ export async function runExperiment(config: ExperimentConfig): Promise<void> {
194
+ const { solo, sup } = armPair(config.arms)
195
+ const secrets: SecretsEnv = { secretsDir: config.secretsDir, envFiles: config.envFiles }
196
+ const excludes = await loadExcludes()
197
+ const images = await loadInstanceImages(config.instanceImagesPath)
198
+ const judge = createSerializedJudge({
199
+ ...(config.judgeTimeoutMs !== undefined ? { timeoutMs: config.judgeTimeoutMs } : {}),
200
+ })
201
+ const adapter = createSweBenchAdapter()
202
+ const log = (msg: string): void => console.log(`[${new Date().toISOString().slice(11, 19)}] ${msg}`)
203
+
204
+ const done = await ledgerIids(config.ledgerPath)
205
+ const pending = config.instances.filter((iid) => !done.has(iid))
206
+ for (const iid of config.instances.filter((i) => done.has(i))) log(`SKIP ${iid} (already in ledger)`)
207
+ if (pending.length === 0) {
208
+ log('nothing to do — all instances already in ledger')
209
+ return
210
+ }
211
+
212
+ // One dataset load for all pending instances (problem statements + metadata).
213
+ const tasks = await adapter.loadTasks({ ids: pending, split: 'test' })
214
+ const taskById = new Map(tasks.map((t) => [t.id, t]))
215
+
216
+ for (const iid of pending) {
217
+ const task = taskById.get(iid)
218
+ if (!task) throw new Error(`instance ${iid} not found in SWE-bench_Verified`)
219
+ const entry = images[iid]
220
+ if (!entry) throw new Error(`instance ${iid} has no image mapping (instances.json)`)
221
+ const problemStatement = String(task.metadata?.problem_statement ?? '')
222
+ if (!problemStatement) throw new Error(`instance ${iid}: empty problem_statement`)
223
+
224
+ // Capacity gates: worker path always; router path because a supervisor arm runs.
225
+ const gateOpts = {
226
+ ...(config.gateWaitCeilingMs !== undefined ? { waitCeilingMs: config.gateWaitCeilingMs } : {}),
227
+ ...(config.capacityModel !== undefined ? { model: config.capacityModel } : {}),
228
+ onStatus: log,
229
+ }
230
+ for (const gate of gatesForArmKind('supervisor', secrets, gateOpts)) {
231
+ if (!(await waitForCapacity(gate))) {
232
+ log(`NO CAPACITY on ${gate.name} within ceiling — stopping before ${iid} (resume later)`)
233
+ return
234
+ }
235
+ }
236
+
237
+ const ctx = {
238
+ instanceId: iid,
239
+ image: entry.image,
240
+ baseCommit: entry.base_commit,
241
+ problemStatement,
242
+ verifyCmd: `bash ${join(config.verifyDir, `${iid}.sh`)}`,
243
+ outDir: config.outDir,
244
+ secrets,
245
+ excludes,
246
+ }
247
+
248
+ log(`>>> ${iid} SOLO arm`)
249
+ const soloResult = await runSoloArm(solo, ctx)
250
+ const soloVerdict = await judge.judge(iid, soloResult.patchPath, 'solo')
251
+ log(`${iid} SOLO judged: ${JSON.stringify(soloVerdict)}`)
252
+
253
+ log(`>>> ${iid} SUP arm`)
254
+ const supResult = await runSupervisorArm(sup, ctx)
255
+ const supVerdict = await judge.judge(iid, supResult.patchPath, 'sup')
256
+ log(`${iid} SUP judged: ${JSON.stringify(supVerdict)}`)
257
+
258
+ const row = buildLedgerRow(soloResult, soloVerdict, supResult, supVerdict)
259
+ await appendFile(config.ledgerPath, JSON.stringify(row) + '\n')
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
+
269
+ await new Promise((r) => setTimeout(r, config.cooldownMs ?? 15_000))
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
+ })
278
+ }
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // Dry-run parity — the M2 gate. No arms, no tokens; docker only.
282
+ // ---------------------------------------------------------------------------
283
+
284
+ export interface ParityCaseSpec {
285
+ iid: string
286
+ arm: 'solo' | 'sup'
287
+ /** Committed patch fixture, relative to fixtures/ (e.g. patches/x.solo.patch). */
288
+ patchFixture: string
289
+ }
290
+
291
+ export interface ParityCaseResult {
292
+ iid: string
293
+ arm: 'solo' | 'sup'
294
+ applyRc: number
295
+ fixtureFiles: string[]
296
+ extractedFiles: string[]
297
+ extractedPatchLines: number
298
+ verdict: JudgeVerdict
299
+ }
300
+
301
+ /** The two pinned parity cases: one resolved SOLO patch, one unresolved SUP patch. */
302
+ export const PARITY_CASES: ParityCaseSpec[] = [
303
+ { iid: 'pallets__flask-5014', arm: 'solo', patchFixture: 'patches/pallets__flask-5014.solo.patch' },
304
+ { iid: 'pydata__xarray-4687', arm: 'sup', patchFixture: 'patches/pydata__xarray-4687.sup.patch' },
305
+ ]
306
+
307
+ /** Changed paths of a unified diff (b/ side), for extraction-parity checks. */
308
+ export function diffChangedFiles(patch: string): string[] {
309
+ const files = new Set<string>()
310
+ for (const m of patch.matchAll(/^diff --git a\/.+ b\/(.+)$/gm)) files.add(m[1])
311
+ return [...files].sort()
312
+ }
313
+
314
+ /**
315
+ * Replay extraction + judging for committed patches WITHOUT running any arm:
316
+ * materialize the instance workspace from its image, apply the committed
317
+ * patch, re-extract it via the arms.ts extraction path, then grade the
318
+ * re-extracted patch with the serialized judge. Byte-identical output is not
319
+ * required (git normalizes); the changed-file set and the official verdict are.
320
+ */
321
+ export async function replayPatchParity(
322
+ cases: ParityCaseSpec[],
323
+ opts: { workDir: string; judge?: SerializedJudge; keepWorkspaces?: boolean },
324
+ ): Promise<ParityCaseResult[]> {
325
+ const judge = opts.judge ?? createSerializedJudge()
326
+ const excludes = await loadExcludes()
327
+ const images = await loadInstanceImages()
328
+ const results: ParityCaseResult[] = []
329
+ for (const c of cases) {
330
+ const entry = images[c.iid]
331
+ if (!entry) throw new Error(`parity: no image mapping for ${c.iid}`)
332
+ const fixturePatchPath = join(fixturesDir, c.patchFixture)
333
+ const fixturePatch = await readFile(fixturePatchPath, 'utf8')
334
+ const ws = join(opts.workDir, `parity-${c.iid}-${c.arm}`)
335
+ try {
336
+ await materializeWorkspace({
337
+ instanceId: c.iid,
338
+ image: entry.image,
339
+ baseCommit: entry.base_commit,
340
+ dest: ws,
341
+ })
342
+ const applyRc = await applyPatchWithFallback(ws, fixturePatchPath)
343
+ if (applyRc !== 0) throw new Error(`parity ${c.iid}: committed patch failed to apply (rc=${applyRc})`)
344
+ const extracted = await extractPatch(ws, entry.base_commit, excludes)
345
+ const extractedPath = join(opts.workDir, `parity-${c.iid}.${c.arm}.extracted.patch`)
346
+ await writeFile(extractedPath, extracted)
347
+ const verdict = await judge.judge(c.iid, extractedPath, `parity-${c.arm}`)
348
+ results.push({
349
+ iid: c.iid,
350
+ arm: c.arm,
351
+ applyRc,
352
+ fixtureFiles: diffChangedFiles(fixturePatch),
353
+ extractedFiles: diffChangedFiles(extracted),
354
+ extractedPatchLines: extracted.length === 0 ? 0 : extracted.split('\n').length - 1,
355
+ verdict,
356
+ })
357
+ } finally {
358
+ if (!opts.keepWorkspaces) await rm(ws, { recursive: true, force: true })
359
+ }
360
+ }
361
+ return results
362
+ }
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
+
678
+ // ---------------------------------------------------------------------------
679
+ // CLI.
680
+ // ---------------------------------------------------------------------------
681
+
682
+ const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href
683
+
684
+ if (isMain) {
685
+ const [arg, extra] = process.argv.slice(2)
686
+ if (arg === '--dry-run-parity') {
687
+ const workDir = extra ?? join(process.env.TMPDIR ?? '/tmp', 'swe-arena-parity')
688
+ await mkdir(workDir, { recursive: true })
689
+ const results = await replayPatchParity(PARITY_CASES, { workDir })
690
+ for (const r of results) {
691
+ console.log(
692
+ `PARITY ${r.iid} [${r.arm}] resolved=${r.verdict.resolved} score=${r.verdict.score} ` +
693
+ `files(fixture=${r.fixtureFiles.join(',')} extracted=${r.extractedFiles.join(',')})`,
694
+ )
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
+ })
716
+ } else if (arg && !arg.startsWith('--')) {
717
+ const config = JSON.parse(await readFile(arg, 'utf8')) as ExperimentConfig
718
+ await runExperiment(config)
719
+ } else {
720
+ console.error(
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' +
723
+ ' tsx src/swe-arena/run-experiment.mts --dry-run-parity [workDir]',
724
+ )
725
+ process.exit(2)
726
+ }
727
+ }