@tangle-network/agent-eval 0.196.0 → 0.198.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 (155) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/active-curriculum-B_Lc9kfO.js +61 -0
  3. package/dist/active-curriculum-B_Lc9kfO.js.map +1 -0
  4. package/dist/adapters/http.d.ts +2 -2
  5. package/dist/analyst/index.d.ts +5 -5
  6. package/dist/analyst/index.js +3 -3
  7. package/dist/{analyze-runs-B6gv7vAT.js → analyze-runs-DFbp2gB8.js} +5 -5
  8. package/dist/{analyze-runs-B6gv7vAT.js.map → analyze-runs-DFbp2gB8.js.map} +1 -1
  9. package/dist/{benchmark-command-Bw1he2JM.js → benchmark-command-D8E5TaPC.js} +6 -5
  10. package/dist/{benchmark-command-Bw1he2JM.js.map → benchmark-command-D8E5TaPC.js.map} +1 -1
  11. package/dist/benchmarks/index.d.ts +4 -3
  12. package/dist/benchmarks/index.d.ts.map +1 -1
  13. package/dist/benchmarks/index.js +3 -3
  14. package/dist/campaign/index.d.ts +5 -4
  15. package/dist/campaign/index.js +13 -11
  16. package/dist/{campaign-qe1WC3Sz.js → campaign-Dlg2iOzH.js} +13 -12
  17. package/dist/{campaign-qe1WC3Sz.js.map → campaign-Dlg2iOzH.js.map} +1 -1
  18. package/dist/{campaign-evidence-DJc60F9C.js → campaign-evidence-jL_U7Ikv.js} +2 -2
  19. package/dist/{campaign-evidence-DJc60F9C.js.map → campaign-evidence-jL_U7Ikv.js.map} +1 -1
  20. package/dist/cli.js +243 -13
  21. package/dist/cli.js.map +1 -1
  22. package/dist/contract/index.d.ts +7 -6
  23. package/dist/contract/index.js +9 -9
  24. package/dist/{default-registry-D2csUBBQ.d.ts → default-registry-oK9hHfX_.d.ts} +3 -3
  25. package/dist/{default-registry-D2csUBBQ.d.ts.map → default-registry-oK9hHfX_.d.ts.map} +1 -1
  26. package/dist/{define-agent-eval-DwAaPvvH.d.ts → define-agent-eval-By9PImpa.d.ts} +5 -4
  27. package/dist/define-agent-eval-By9PImpa.d.ts.map +1 -0
  28. package/dist/{define-agent-eval-h2qYe0xY.js → define-agent-eval-DvBgSzAI.js} +6 -6
  29. package/dist/{define-agent-eval-h2qYe0xY.js.map → define-agent-eval-DvBgSzAI.js.map} +1 -1
  30. package/dist/diagnosis/index.js +1 -1
  31. package/dist/{dspy-rlm-engine-k_QdEBZo.js → dspy-rlm-engine-B5T43BkZ.js} +2 -2
  32. package/dist/{dspy-rlm-engine-k_QdEBZo.js.map → dspy-rlm-engine-B5T43BkZ.js.map} +1 -1
  33. package/dist/{engine-De2hfuiQ.d.ts → engine-Dc996kAg.d.ts} +2 -2
  34. package/dist/{engine-De2hfuiQ.d.ts.map → engine-Dc996kAg.d.ts.map} +1 -1
  35. package/dist/{eval-campaign-CXT2WYUe.js → eval-campaign-GapmPHIA.js} +2 -2
  36. package/dist/{eval-campaign-CXT2WYUe.js.map → eval-campaign-GapmPHIA.js.map} +1 -1
  37. package/dist/evaluation.d.ts +1 -1
  38. package/dist/experiment/index.d.ts +1 -1
  39. package/dist/experiment/index.js +8 -6
  40. package/dist/experiment/index.js.map +1 -1
  41. package/dist/{external-optimizer-process-_YIECYJ7.js → external-optimizer-process-CEXcCKqd.js} +2 -2
  42. package/dist/{external-optimizer-process-_YIECYJ7.js.map → external-optimizer-process-CEXcCKqd.js.map} +1 -1
  43. package/dist/{external-optimizer-subprocess-BrghTvEp.js → external-optimizer-subprocess-laM1-UDV.js} +2 -61
  44. package/dist/external-optimizer-subprocess-laM1-UDV.js.map +1 -0
  45. package/dist/fuzz.d.ts.map +1 -1
  46. package/dist/fuzz.js +2 -3
  47. package/dist/fuzz.js.map +1 -1
  48. package/dist/hosted/index.js +1 -1
  49. package/dist/{index-CigojbNH.d.ts → index-BhoOCduB.d.ts} +38 -945
  50. package/dist/index-BhoOCduB.d.ts.map +1 -0
  51. package/dist/{index-CpCIXObe.d.ts → index-D-tI1vgf.d.ts} +6 -6
  52. package/dist/{index-CpCIXObe.d.ts.map → index-D-tI1vgf.d.ts.map} +1 -1
  53. package/dist/{index-kKECJfib.d.ts → index-lbwvO9vJ.d.ts} +3 -3
  54. package/dist/{index-kKECJfib.d.ts.map → index-lbwvO9vJ.d.ts.map} +1 -1
  55. package/dist/index.d.ts +9 -9
  56. package/dist/index.js +15 -13
  57. package/dist/index.js.map +1 -1
  58. package/dist/jev-protocol.d.ts +17 -9
  59. package/dist/jev-protocol.d.ts.map +1 -1
  60. package/dist/jev-protocol.js +22 -14
  61. package/dist/jev-protocol.js.map +1 -1
  62. package/dist/jev.d.ts +3 -3
  63. package/dist/jev.js +3 -7
  64. package/dist/jev.js.map +1 -1
  65. package/dist/{llm-judge-CxfoYqdk.js → llm-judge-BYWXsxhZ.js} +529 -2079
  66. package/dist/llm-judge-BYWXsxhZ.js.map +1 -0
  67. package/dist/{matrix-C1X-fIVX.d.ts → matrix-BnksRaDD.d.ts} +2 -2
  68. package/dist/{matrix-C1X-fIVX.d.ts.map → matrix-BnksRaDD.d.ts.map} +1 -1
  69. package/dist/multiplicity-D9cjmStq.js +101 -0
  70. package/dist/multiplicity-D9cjmStq.js.map +1 -0
  71. package/dist/multishot/golden/index.d.ts +1 -1
  72. package/dist/multishot/golden/index.js +1 -1
  73. package/dist/multishot/index.d.ts +2 -2
  74. package/dist/multishot/index.js +1 -1
  75. package/dist/openapi.json +1 -1
  76. package/dist/{paired-arms-JSDcs2zx.js → paired-arms-DKZFVPt8.js} +4 -398
  77. package/dist/paired-arms-DKZFVPt8.js.map +1 -0
  78. package/dist/paired-binary-fzkwENI4.js +399 -0
  79. package/dist/paired-binary-fzkwENI4.js.map +1 -0
  80. package/dist/{paired-promotion-decision-DTKl6-G8.js → paired-promotion-decision-D5B1Cxc2.js} +78 -5
  81. package/dist/paired-promotion-decision-D5B1Cxc2.js.map +1 -0
  82. package/dist/{power-and-mde-BJOpt6pq.js → power-and-mde-K_CmO3dc.js} +3 -101
  83. package/dist/power-and-mde-K_CmO3dc.js.map +1 -0
  84. package/dist/{produced-state-B0QMdiLh.js → produced-state-DOfmCEwP.js} +3 -3
  85. package/dist/{produced-state-B0QMdiLh.js.map → produced-state-DOfmCEwP.js.map} +1 -1
  86. package/dist/{promotion-policy-Cy5OV8BV.js → promotion-policy-C6FAJvmZ.js} +3 -3
  87. package/dist/{promotion-policy-Cy5OV8BV.js.map → promotion-policy-C6FAJvmZ.js.map} +1 -1
  88. package/dist/reporting.js +3 -3
  89. package/dist/rl.d.ts +17 -39
  90. package/dist/rl.d.ts.map +1 -1
  91. package/dist/rl.js +5 -5
  92. package/dist/search/index.d.ts +1162 -0
  93. package/dist/search/index.d.ts.map +1 -0
  94. package/dist/search/index.js +187 -0
  95. package/dist/search/index.js.map +1 -0
  96. package/dist/search-kernel-CuvbakC_.d.ts +1176 -0
  97. package/dist/search-kernel-CuvbakC_.d.ts.map +1 -0
  98. package/dist/search-kernel-D8_kxChy.js +2399 -0
  99. package/dist/search-kernel-D8_kxChy.js.map +1 -0
  100. package/dist/{search-ledger-9W0g5FP6.js → search-ledger-BnUytR8X.js} +3 -1
  101. package/dist/search-ledger-BnUytR8X.js.map +1 -0
  102. package/dist/{search-shipper-CYbmJQx8.js → search-shipper-Cn8cxcyp.js} +2 -2
  103. package/dist/{search-shipper-CYbmJQx8.js.map → search-shipper-Cn8cxcyp.js.map} +1 -1
  104. package/dist/search-summary-Dmppg8kV.js +133 -0
  105. package/dist/search-summary-Dmppg8kV.js.map +1 -0
  106. package/dist/{semantic-concept-judge-Bh5lvKGg.js → semantic-concept-judge-2SE_kXGF.js} +2 -2
  107. package/dist/{semantic-concept-judge-Bh5lvKGg.js.map → semantic-concept-judge-2SE_kXGF.js.map} +1 -1
  108. package/dist/{sequential-C21_PP7F.js → sequential-Yz3OSskl.js} +2 -2
  109. package/dist/{sequential-C21_PP7F.js.map → sequential-Yz3OSskl.js.map} +1 -1
  110. package/dist/single-run-lock-fVu3MZWH.js +62 -0
  111. package/dist/single-run-lock-fVu3MZWH.js.map +1 -0
  112. package/dist/{skillopt-optimization-method-IbEhlA1V.js → skillopt-optimization-method-BMP2qV_1.js} +46 -33
  113. package/dist/{skillopt-optimization-method-IbEhlA1V.js.map → skillopt-optimization-method-BMP2qV_1.js.map} +1 -1
  114. package/dist/{statistical-heldout-DANVmyug.js → statistical-heldout-BAFDCo07.js} +2 -2
  115. package/dist/{statistical-heldout-DANVmyug.js.map → statistical-heldout-BAFDCo07.js.map} +1 -1
  116. package/dist/{statistical-heldout-BedVSZb2.d.ts → statistical-heldout-CMpxMjlQ.d.ts} +2 -2
  117. package/dist/{statistical-heldout-BedVSZb2.d.ts.map → statistical-heldout-CMpxMjlQ.d.ts.map} +1 -1
  118. package/dist/{store-tool-spans-CawP-WxM.d.ts → store-tool-spans-CUwWSXoj.d.ts} +3 -3
  119. package/dist/{store-tool-spans-CawP-WxM.d.ts.map → store-tool-spans-CUwWSXoj.d.ts.map} +1 -1
  120. package/dist/{summary-report-BMvou2oZ.js → summary-report-BeYnvddy.js} +4 -3
  121. package/dist/{summary-report-BMvou2oZ.js.map → summary-report-BeYnvddy.js.map} +1 -1
  122. package/dist/{tool-groups-B4JVrH3g.d.ts → tool-groups-DVJAR1GE.d.ts} +2 -2
  123. package/dist/tool-groups-DVJAR1GE.d.ts.map +1 -0
  124. package/dist/trace-repair/index.d.ts +1 -1
  125. package/dist/traces.d.ts +2 -2
  126. package/dist/tree-DHHWsH-X.js +4045 -0
  127. package/dist/tree-DHHWsH-X.js.map +1 -0
  128. package/dist/{types-B3jzCp0p.js → types-B3jzCp0p2.js} +1 -1
  129. package/dist/types-B3jzCp0p2.js.map +1 -0
  130. package/dist/{types-CjFDKMDe.d.ts → types-D94EanDL.d.ts} +2 -2
  131. package/dist/{types-CjFDKMDe.d.ts.map → types-D94EanDL.d.ts.map} +1 -1
  132. package/dist/{types-B2673PBj.d.ts → types-EBUav9Bu.d.ts} +8 -4
  133. package/dist/types-EBUav9Bu.d.ts.map +1 -0
  134. package/docs/campaign-proposers.md +2 -0
  135. package/docs/design/statistics-decisions.md +1 -1
  136. package/docs/jev.md +3 -2
  137. package/docs/search-ledger.md +145 -8
  138. package/package.json +6 -1
  139. package/dist/active-curriculum-OjIWgrUJ.js +0 -204
  140. package/dist/active-curriculum-OjIWgrUJ.js.map +0 -1
  141. package/dist/define-agent-eval-DwAaPvvH.d.ts.map +0 -1
  142. package/dist/external-optimizer-subprocess-BrghTvEp.js.map +0 -1
  143. package/dist/index-CigojbNH.d.ts.map +0 -1
  144. package/dist/llm-judge-CxfoYqdk.js.map +0 -1
  145. package/dist/paired-arms-JSDcs2zx.js.map +0 -1
  146. package/dist/paired-delta-test-BF-FEP8V.js +0 -78
  147. package/dist/paired-delta-test-BF-FEP8V.js.map +0 -1
  148. package/dist/paired-promotion-decision-DTKl6-G8.js.map +0 -1
  149. package/dist/power-and-mde-BJOpt6pq.js.map +0 -1
  150. package/dist/search-ledger-9W0g5FP6.js.map +0 -1
  151. package/dist/search-summary-CcavvYLe.js +0 -378
  152. package/dist/search-summary-CcavvYLe.js.map +0 -1
  153. package/dist/tool-groups-B4JVrH3g.d.ts.map +0 -1
  154. package/dist/types-B2673PBj.d.ts.map +0 -1
  155. package/dist/types-B3jzCp0p.js.map +0 -1
@@ -0,0 +1,2399 @@
1
+ import { a as hashCanonical, i as compareCodeUnits, r as canonicalString } from "./canonical-BsZVgBhW.js";
2
+ import { t as mulberry32 } from "./random-Dn5fPWkt.js";
3
+ import { t as confidenceInterval } from "./descriptive-1V17A-qa.js";
4
+ import { c as wilson } from "./paired-binary-fzkwENI4.js";
5
+ import { n as DECISION_PAIRED_DELTA_STATISTIC } from "./paired-tests-C8iCsioC.js";
6
+ import { i as pairedDeltaTest, r as minimumPairsForPairedDeltaTest, t as decidePairedPromotion } from "./paired-promotion-decision-D5B1Cxc2.js";
7
+ import { a as SearchLedgerError } from "./search-ledger-file-DY0AXX3W.js";
8
+ import { f as redactText } from "./redact-Dk1ZdJEm.js";
9
+ import { f as searchUnitScores, l as searchCellId, t as FileSearchLedger } from "./search-ledger-BnUytR8X.js";
10
+ import { t as acquireSingleRunLock } from "./single-run-lock-fVu3MZWH.js";
11
+ //#region src/pareto.ts
12
+ /** Does candidate A weakly dominate B — strictly better on at least one objective and no worse on any? */
13
+ function dominates(a, b, objectives) {
14
+ let strictlyBetter = false;
15
+ for (const obj of objectives) {
16
+ const av = obj.value(a);
17
+ const bv = obj.value(b);
18
+ if (!Number.isFinite(av) || !Number.isFinite(bv)) return false;
19
+ const aIsBetter = obj.direction === "maximize" ? av > bv : av < bv;
20
+ if (obj.direction === "maximize" ? av < bv : av > bv) return false;
21
+ if (aIsBetter) strictlyBetter = true;
22
+ }
23
+ return strictlyBetter;
24
+ }
25
+ /**
26
+ * Compute the non-dominated frontier. Candidates with NaN/Infinity on any
27
+ * objective are excluded (can't rank them). A candidate enters the frontier
28
+ * iff no other candidate dominates it.
29
+ */
30
+ function paretoFrontier(candidates, objectives) {
31
+ if (objectives.length === 0) throw new Error("paretoFrontier: at least 1 objective required");
32
+ const valid = candidates.filter((c) => objectives.every((o) => Number.isFinite(o.value(c))));
33
+ const frontier = [];
34
+ const dominated = [];
35
+ for (const c of valid) if (valid.some((other) => other !== c && dominates(other, c, objectives))) dominated.push(c);
36
+ else frontier.push(c);
37
+ return {
38
+ frontier,
39
+ dominated,
40
+ dominanceMap: frontier.map((d) => ({
41
+ dominator: d,
42
+ dominated: dominated.filter((x) => dominates(d, x, objectives))
43
+ }))
44
+ };
45
+ }
46
+ /**
47
+ * NSGA-II crowding distance — secondary sort for ties on the frontier.
48
+ *
49
+ * When the Pareto front collapses to a single point (or many candidates tie
50
+ * on dominance), naive selection picks arbitrarily and the population
51
+ * degenerates over generations. NSGA-II preserves diversity by preferring
52
+ * candidates with more empty space around them on the frontier.
53
+ *
54
+ * Returns an array of `{ candidate, distance }` in the SAME order as the
55
+ * input. Higher distance = more isolated = should be preferred when
56
+ * preserving diversity.
57
+ */
58
+ function crowdingDistance(candidates, objectives) {
59
+ const distances = new Map(candidates.map((c) => [c, 0]));
60
+ for (const obj of objectives) {
61
+ const sorted = [...candidates].sort((a, b) => obj.value(a) - obj.value(b));
62
+ const min = obj.value(sorted[0]);
63
+ const range = obj.value(sorted[sorted.length - 1]) - min || 1;
64
+ distances.set(sorted[0], Infinity);
65
+ distances.set(sorted[sorted.length - 1], Infinity);
66
+ for (let i = 1; i < sorted.length - 1; i++) {
67
+ const prev = obj.value(sorted[i - 1]);
68
+ const next = obj.value(sorted[i + 1]);
69
+ const current = distances.get(sorted[i]);
70
+ if (current === Infinity) continue;
71
+ distances.set(sorted[i], current + (next - prev) / range);
72
+ }
73
+ }
74
+ return candidates.map((c) => ({
75
+ candidate: c,
76
+ distance: distances.get(c) ?? 0
77
+ }));
78
+ }
79
+ /**
80
+ * Pareto frontier with tie-break by crowding distance — the canonical
81
+ * NSGA-II selection step. Returns the frontier sorted by descending crowding
82
+ * distance so callers can `.slice(0, k)` to pick K diverse winners.
83
+ */
84
+ function paretoFrontierWithCrowding(candidates, objectives) {
85
+ const { frontier } = paretoFrontier(candidates, objectives);
86
+ if (frontier.length === 0) return [];
87
+ return crowdingDistance(frontier, objectives).sort((a, b) => b.distance - a.distance);
88
+ }
89
+ //#endregion
90
+ //#region src/campaign/estimate-node.ts
91
+ /**
92
+ * Per-node statistics of a search.
93
+ *
94
+ * `estimateNode` is the one paired contrast of two nodes: it averages each
95
+ * node's cells inside their units, pairs the units both nodes scored, and runs
96
+ * `pairedDeltaTest` on the per-unit deltas. `estimateNodeFromCells` is the same
97
+ * computation over cells a caller already holds, such as rows a server read
98
+ * from its database. The bootstrap seed comes from `cellSetDigest`, the digest
99
+ * of exactly the cells used, so a producer and a verifier that hold the same
100
+ * cells report the same bits.
101
+ *
102
+ * `searchPosterior` gives every node a normal posterior on its improvement over
103
+ * the root, which expansion policies sample parents from. Its numbers steer
104
+ * spend and claim nothing.
105
+ */
106
+ const CONFIDENCE = .95;
107
+ const RESAMPLES$1 = 2e3;
108
+ /** 6 at 95 %: the smallest sample an exact one-sided sign test can resolve. */
109
+ const DESCRIPTIVE_FROM = minimumPairsForPairedDeltaTest(CONFIDENCE);
110
+ /** The estimator every `NodeEstimate` names. */
111
+ const SEARCH_ESTIMATOR = {
112
+ uri: "npm:@tangle-network/agent-eval#estimateNode",
113
+ revision: hashCanonical({
114
+ name: "tangle.estimate-node.2026-09",
115
+ unit: "mean of the unit’s scored cells, summed in cellId order",
116
+ pairing: "units both nodes scored, in unitId order",
117
+ statistic: DECISION_PAIRED_DELTA_STATISTIC,
118
+ interval: "percentile paired bootstrap",
119
+ confidence: CONFIDENCE,
120
+ resamples: RESAMPLES$1,
121
+ rng: "mulberry32",
122
+ seed: "the first 32 bits of cellSetDigest",
123
+ signTest: "exact, one-sided toward improvement in the objective direction",
124
+ methods: {
125
+ none: "below 2 pairs",
126
+ insufficient: `below ${DESCRIPTIVE_FROM} pairs`,
127
+ descriptive: `below 20 pairs`,
128
+ bootstrap: `from 20 pairs`
129
+ },
130
+ indeterminate: "every paired delta equal: no interval and no sign p"
131
+ })
132
+ };
133
+ /** The method a paired sample of `pairs` units supports. */
134
+ function searchEstimateMethod(pairs) {
135
+ if (pairs < 2) return "none";
136
+ if (pairs < DESCRIPTIVE_FROM) return "insufficient";
137
+ if (pairs < 20) return "descriptive";
138
+ return "bootstrap";
139
+ }
140
+ /**
141
+ * Digest of the cells an estimate reads, with the contrast they serve. Cells
142
+ * are listed in cellId order, so the digest names the set, not the order it
143
+ * was read in.
144
+ */
145
+ function searchCellSetDigest(input) {
146
+ const list = (cells) => [...cells].sort((left, right) => compareCodeUnits(left.cellId, right.cellId)).map((cell) => [
147
+ cell.cellId,
148
+ cell.unitId,
149
+ cell.attempt,
150
+ cell.score
151
+ ]);
152
+ return hashCanonical({
153
+ contrast: [input.nodeId, input.against],
154
+ split: input.split,
155
+ nodeCells: list(input.nodeCells),
156
+ againstCells: list(input.againstCells)
157
+ });
158
+ }
159
+ /**
160
+ * The paired contrast of `nodeId` against `against` on the units both scored.
161
+ *
162
+ * `delta` is the mean per-unit difference, node minus `against`, in the
163
+ * metric's own units; for a `minimize` objective an improvement is negative.
164
+ * The exact sign test is one-sided toward improvement in the objective's
165
+ * direction. Unscored cells (errored, cancelled or in flight) are absent from
166
+ * the inputs, never zero.
167
+ *
168
+ * - `none` (0 or 1 pair): no delta.
169
+ * - `insufficient` (2 to 5): the delta only.
170
+ * - `descriptive` (6 to 19): the bootstrap interval as spread, and the sign-test p.
171
+ * - `bootstrap` (20 or more): the decision-grade bootstrap interval.
172
+ *
173
+ * When every paired delta is equal the interval would have zero width, which
174
+ * is an absence of evidence; the estimate is then `indeterminate` and carries
175
+ * neither an interval nor a p-value.
176
+ */
177
+ function estimateNodeFromCells(input) {
178
+ const { nodeId, against, split, direction } = input;
179
+ if (nodeId === against) throw new SearchLedgerError(`node ${nodeId} cannot be estimated against itself`);
180
+ assertDistinctCells([...input.nodeCells, ...input.againstCells], nodeId, against);
181
+ const cellSetDigest = searchCellSetDigest(input);
182
+ const nodeUnits = searchUnitScores(input.nodeCells);
183
+ const { node, other } = pairUnits(nodeUnits, unitMeans(searchUnitScores(input.againstCells)));
184
+ const pairs = node.length;
185
+ const method = searchEstimateMethod(pairs);
186
+ const base = {
187
+ against,
188
+ split,
189
+ units: nodeUnits.length,
190
+ pairs,
191
+ method,
192
+ cellSetDigest,
193
+ estimator: SEARCH_ESTIMATOR
194
+ };
195
+ if (method === "none") return {
196
+ ...base,
197
+ delta: null,
198
+ interval: null,
199
+ exactSignP: null,
200
+ indeterminate: false
201
+ };
202
+ const maximize = direction === "maximize";
203
+ const test = pairedDeltaTest(maximize ? other : node, maximize ? node : other, {
204
+ statistic: DECISION_PAIRED_DELTA_STATISTIC,
205
+ confidence: CONFIDENCE,
206
+ resamples: RESAMPLES$1,
207
+ seed: seedFromDigest(cellSetDigest)
208
+ });
209
+ const { mean, low, high } = test.bootstrap;
210
+ const indeterminate = test.indeterminate;
211
+ const spread = (method === "descriptive" || method === "bootstrap") && !indeterminate;
212
+ return {
213
+ ...base,
214
+ delta: plain$1(maximize ? mean : -mean),
215
+ interval: spread ? maximize ? [plain$1(low), plain$1(high)] : [plain$1(-high), plain$1(-low)] : null,
216
+ exactSignP: method === "descriptive" && !indeterminate ? test.pValue : null,
217
+ indeterminate
218
+ };
219
+ }
220
+ /**
221
+ * `estimateNodeFromCells` over the cells a search state holds: `nodeId`
222
+ * against `against` on one split, in the direction the search's objective
223
+ * declares.
224
+ */
225
+ function estimateNode(state, nodeId, options) {
226
+ const header = state.header;
227
+ if (!header) throw new SearchLedgerError(`search ${state.searchId} has not been opened`);
228
+ for (const id of [nodeId, options.against]) if (!state.hasNode(id)) throw new SearchLedgerError(`node ${id} is not in search ${state.searchId}`);
229
+ return estimateNodeFromCells({
230
+ nodeId,
231
+ against: options.against,
232
+ split: options.split,
233
+ direction: header.objective.direction,
234
+ nodeCells: state.scoredCells(nodeId, options.split),
235
+ againstCells: state.scoredCells(options.against, options.split)
236
+ });
237
+ }
238
+ /**
239
+ * The posterior every node's parent-selection draw uses. Each node's mean is
240
+ * its improvement over the root on shared units; its variance is the search's
241
+ * pooled between-unit variance divided by its unit count, so a node measured
242
+ * on one unit has a wide posterior rather than none. The root is the reference
243
+ * and sits at exactly 0.
244
+ */
245
+ function searchPosterior(state, options = {}) {
246
+ const split = options.split ?? "selection";
247
+ const header = state.header;
248
+ if (!header) throw new SearchLedgerError(`search ${state.searchId} has not been opened`);
249
+ const rootNodeId = state.rootNodeId;
250
+ if (rootNodeId === null) throw new SearchLedgerError(`search ${state.searchId} has no root node yet`);
251
+ const sign = header.objective.direction === "maximize" ? 1 : -1;
252
+ const root = searchUnitScores(state.scoredCells(rootNodeId, split));
253
+ const rootMeans = unitMeans(root);
254
+ const nodes = [];
255
+ let squares = 0;
256
+ let degreesOfFreedom = 0;
257
+ for (const nodeId of state.nodeIds()) {
258
+ if (nodeId === rootNodeId) {
259
+ const measured = root.length > 0;
260
+ nodes.push({
261
+ nodeId,
262
+ pairs: root.length,
263
+ mean: measured ? 0 : null,
264
+ variance: measured ? 0 : null
265
+ });
266
+ continue;
267
+ }
268
+ const { node, other } = pairUnits(searchUnitScores(state.scoredCells(nodeId, split)), rootMeans);
269
+ const gains = node.map((value, index) => sign * (value - other[index]));
270
+ const mean = gains.length > 0 ? sum(gains) / gains.length : null;
271
+ if (mean !== null && gains.length >= 2) {
272
+ squares += sum(gains.map((gain) => (gain - mean) ** 2));
273
+ degreesOfFreedom += gains.length - 1;
274
+ }
275
+ nodes.push({
276
+ nodeId,
277
+ pairs: gains.length,
278
+ mean: mean === null ? null : plain$1(mean),
279
+ variance: null
280
+ });
281
+ }
282
+ const pooledVariance = degreesOfFreedom > 0 ? squares / degreesOfFreedom : null;
283
+ if (pooledVariance !== null) {
284
+ for (const posterior of nodes) if (posterior.nodeId !== rootNodeId && posterior.pairs > 0) posterior.variance = pooledVariance / posterior.pairs;
285
+ }
286
+ return {
287
+ split,
288
+ rootNodeId,
289
+ pooledVariance,
290
+ degreesOfFreedom,
291
+ nodes
292
+ };
293
+ }
294
+ function unitMeans(units) {
295
+ return new Map(units.map((unit) => [unit.unitId, unit.mean]));
296
+ }
297
+ /** Unit means of two nodes on the units both scored, aligned in the node's
298
+ * unitId order. */
299
+ function pairUnits(node, otherMeans) {
300
+ const paired = {
301
+ node: [],
302
+ other: []
303
+ };
304
+ for (const unit of node) {
305
+ const mean = otherMeans.get(unit.unitId);
306
+ if (mean === void 0) continue;
307
+ paired.node.push(unit.mean);
308
+ paired.other.push(mean);
309
+ }
310
+ return paired;
311
+ }
312
+ function assertDistinctCells(cells, nodeId, against) {
313
+ const seen = /* @__PURE__ */ new Set();
314
+ for (const cell of cells) {
315
+ if (seen.has(cell.cellId)) throw new SearchLedgerError(`cell ${cell.cellId} appears twice in the estimate of ${nodeId} against ${against}`);
316
+ if (!Number.isFinite(cell.score)) throw new SearchLedgerError(`cell ${cell.cellId} has a non-finite score ${cell.score}`);
317
+ seen.add(cell.cellId);
318
+ }
319
+ }
320
+ /** The first 32 bits of a `sha256:` digest, as a bootstrap's seed. */
321
+ function seedFromDigest(digest) {
322
+ return Number.parseInt(digest.slice(7, 15), 16) | 0;
323
+ }
324
+ function sum(values) {
325
+ let total = 0;
326
+ for (const value of values) total += value;
327
+ return total;
328
+ }
329
+ /** Canonical JSON has one zero; never hand it a negative one. */
330
+ function plain$1(value) {
331
+ return value === 0 ? 0 : value;
332
+ }
333
+ //#endregion
334
+ //#region src/search/lenses/shared.ts
335
+ /** The search's own ranking split: selection when the search declares one,
336
+ * else train. Matches `searchPolicyView` and `agent-eval search show`, so a
337
+ * lens ranks nodes on exactly the split the search itself ranks them on. */
338
+ function rankingSplit(state) {
339
+ const header = state.header;
340
+ return header && header.splits.selection.tasks.length > 0 ? "selection" : "train";
341
+ }
342
+ /** The smallest sample an exact one-sided sign test can resolve at 95%: the
343
+ * same gate `NodeEstimate.method` uses for `insufficient`. */
344
+ const INSUFFICIENT_FROM = minimumPairsForPairedDeltaTest(.95);
345
+ function summarizeSamples(samples, seed) {
346
+ const n = samples.length;
347
+ if (n === 0) return {
348
+ n,
349
+ mean: null,
350
+ method: "none",
351
+ interval: null
352
+ };
353
+ const mean = samples.reduce((a, b) => a + b, 0) / n;
354
+ if (n < INSUFFICIENT_FROM) return {
355
+ n,
356
+ mean,
357
+ method: "insufficient",
358
+ interval: null
359
+ };
360
+ const { lower, upper } = confidenceInterval([...samples], .95, seed === void 0 ? {} : { seed });
361
+ return {
362
+ n,
363
+ mean,
364
+ method: n < 20 ? "descriptive" : "bootstrap",
365
+ interval: [lower, upper]
366
+ };
367
+ }
368
+ //#endregion
369
+ //#region src/search/lenses/operator-yield.ts
370
+ /**
371
+ * `operatorYield(state)`: outcome counts and yield per known dollar, by the
372
+ * operator each edge names (search-tree-design §12).
373
+ *
374
+ * "Yield" reuses `searchPosterior` (E3, `../../campaign/estimate-node`): its
375
+ * per-node mean is already the node's improvement over the root, oriented so
376
+ * larger is always better regardless of the objective's direction, and it is
377
+ * null exactly when the node shares no unit with the root — this lens adds
378
+ * no second orientation or "no shared unit" rule of its own. A child's
379
+ * sample is `posteriorMean / knownCostUsd`, and only enters the sample when
380
+ * both are known: `costKnown` requires every one of the node's cells to have
381
+ * a known-cost attempt (`SearchSpend.unknownCostCells === 0`), so an
382
+ * unpriced cell never floors a node to an artificially cheap, inflated
383
+ * yield. A $0-known-cost node is excluded from yield (division by zero is
384
+ * not a yield), not treated as free.
385
+ */
386
+ /** The operators an expansion may choose (`SearchExpansion.operator` in
387
+ * `search-policy.ts`): every edge operator except `seed` (the search's own
388
+ * start) and `derive` (a cross-search edge a proposer never chooses). */
389
+ const EXPANSION_OPERATORS = [
390
+ "draft",
391
+ "improve",
392
+ "debug",
393
+ "merge"
394
+ ];
395
+ /** The bandit's own gate: an operator's weight is used only once it has this
396
+ * many yield-eligible outcomes; below it, the policy falls back to a fixed
397
+ * weight for that operator. Same threshold `NodeEstimate.method` uses for
398
+ * `insufficient` (`minimumPairsForPairedDeltaTest(0.95)` = 6). */
399
+ const MIN_OUTCOMES_FOR_WEIGHT = 6;
400
+ function operatorYield(state, options = {}) {
401
+ const split = options.split ?? rankingSplit(state);
402
+ const posteriorByNode = new Map(searchPosterior(state, { split }).nodes.map((node) => [node.nodeId, node]));
403
+ const grouped = /* @__PURE__ */ new Map();
404
+ const group = (operator) => {
405
+ let existing = grouped.get(operator);
406
+ if (!existing) {
407
+ existing = {
408
+ outcomes: {
409
+ advanced: 0,
410
+ finalist: 0,
411
+ selected: 0,
412
+ pruned: 0,
413
+ rejected: 0,
414
+ invalid: 0,
415
+ undecided: 0
416
+ },
417
+ excluded: 0,
418
+ samples: []
419
+ };
420
+ grouped.set(operator, existing);
421
+ }
422
+ return existing;
423
+ };
424
+ for (const edge of state.edges()) {
425
+ const child = state.node(edge.childNodeId);
426
+ if (!child) continue;
427
+ const bucket = group(edge.operator);
428
+ bucket.outcomes[statusKey(child.status)] += 1;
429
+ const posterior = posteriorByNode.get(child.nodeId);
430
+ const costKnown = child.spend.unknownCostCells === 0;
431
+ if (!posterior || posterior.mean === null || !costKnown || child.spend.knownUsd <= 0) {
432
+ bucket.excluded += 1;
433
+ continue;
434
+ }
435
+ bucket.samples.push(posterior.mean / child.spend.knownUsd);
436
+ }
437
+ const rows = [...grouped.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([operator, bucket]) => ({
438
+ operator,
439
+ proposals: sumCounts(bucket.outcomes),
440
+ outcomes: bucket.outcomes,
441
+ excluded: bucket.excluded,
442
+ yield: summarizeSamples(bucket.samples)
443
+ }));
444
+ const signalValue = {};
445
+ for (const operator of EXPANSION_OPERATORS) {
446
+ const row = rows.find((r) => r.operator === operator);
447
+ signalValue[operator] = row && row.yield.n >= 6 && row.yield.mean !== null ? row.yield.mean : null;
448
+ }
449
+ return {
450
+ data: {
451
+ split,
452
+ rows
453
+ },
454
+ signal: {
455
+ name: "operatorYield.weights",
456
+ value: signalValue
457
+ }
458
+ };
459
+ }
460
+ function statusKey(status) {
461
+ return status ?? "undecided";
462
+ }
463
+ function sumCounts(counts) {
464
+ return Object.values(counts).reduce((a, b) => a + b, 0);
465
+ }
466
+ //#endregion
467
+ //#region src/paired-promotion-power.ts
468
+ /**
469
+ * @module
470
+ * Joint power of a configured {@link decidePairedPromotion} call, computed by
471
+ * running that call on data drawn from a registered alternative.
472
+ *
473
+ * Why a simulation and not a formula: `decidePairedPromotion` is not one test.
474
+ * It routes by sample size (exact sign test below `BOOTSTRAP_GATE_MIN_N`,
475
+ * paired bootstrap at or above it) and by shape (Tango's score interval on a
476
+ * two-point outcome), and on top of the deciding interval it applies a
477
+ * sufficiency floor, a zero-width refusal and McNemar's exact veto. A
478
+ * preregistration usually seals two such calls, a continuous primary and a
479
+ * pass/fail veto drawn from the same episodes, and promotes only when both
480
+ * promote. The sample size at which THAT procedure reaches a target power is
481
+ * not the power of any one channel: the sign-test power at n can be 0.8 while
482
+ * the promotion probability is well below it because the veto binds
483
+ * (agent-eval#785). Closed forms exist for single channels only, and each one
484
+ * shipped here under-sized the call it stood in for (`mcnemarRequiredN` is
485
+ * Lachin's normal approximation, 0.65 exact power where it promised 0.8 at 14
486
+ * pairs; `requiredPairedSampleSize` sizes a paired t the procedure never runs).
487
+ * So the only faithful power is the procedure's own promotion rate under the
488
+ * alternative, and that is what this module computes: draw n pairs from the
489
+ * registered law, run every configured call on them exactly as the caller
490
+ * will, repeat, count.
491
+ *
492
+ * The alternative is DECLARATIVE and serializable, never a sampler function,
493
+ * so a preregistration can seal it as bytes: a mixture of cells, each with a
494
+ * probability, an optional pass/fail outcome per arm for the binary calls,
495
+ * and a law for the candidate-minus-baseline delta on the continuous scale.
496
+ * Drawing the cell first and the delta given the cell is what makes the two
497
+ * channels one joint law rather than two marginals.
498
+ *
499
+ * Everything is seeded (`mulberry32`), so a sizing is reproducible from its
500
+ * inputs, and every estimate carries its Monte Carlo standard error and a
501
+ * Wilson interval: a power read at 0.80 from 1,000 simulations is 0.80 ± 0.013.
502
+ */
503
+ /**
504
+ * Joint power of the configured calls at `n` under the registered alternative:
505
+ * the fraction of simulated data sets on which every call promotes.
506
+ */
507
+ function pairedPromotionPower(options) {
508
+ const n = options.n;
509
+ if (!Number.isInteger(n) || n < 1) throw new Error(`pairedPromotionPower: n must be a positive integer, got ${n}`);
510
+ const simulations = options.simulations ?? 1e3;
511
+ if (!Number.isInteger(simulations) || simulations < 1) throw new Error(`pairedPromotionPower: simulations must be a positive integer, got ${simulations}`);
512
+ const seed = options.seed ?? 1;
513
+ if (!Number.isFinite(seed)) throw new Error(`pairedPromotionPower: seed must be finite, got ${seed}`);
514
+ const calls = validateCalls(options.calls);
515
+ const alternative = validateAlternative(options.alternative, calls);
516
+ const rng = mulberry32(seed);
517
+ let promotions = 0;
518
+ let refusals = 0;
519
+ const perCall = calls.map(() => ({
520
+ promote: 0,
521
+ insufficient: 0,
522
+ indeterminate: 0,
523
+ exactTestVetoes: 0,
524
+ hold: 0,
525
+ methods: /* @__PURE__ */ new Map()
526
+ }));
527
+ for (let s = 0; s < simulations; s++) {
528
+ const pairs = drawPairs(alternative, n, rng);
529
+ let all = true;
530
+ let anyRefusal = false;
531
+ for (let c = 0; c < calls.length; c++) {
532
+ const call = calls[c];
533
+ const decision = runCall(call, pairs, n);
534
+ const tally = perCall[c];
535
+ tally.methods.set(decision.method, (tally.methods.get(decision.method) ?? 0) + 1);
536
+ if (decision.promote) tally.promote++;
537
+ else {
538
+ if (!decision.sufficient) tally.insufficient++;
539
+ if (decision.indeterminate) tally.indeterminate++;
540
+ if (decision.exactTestVetoes) tally.exactTestVetoes++;
541
+ if (decision.sufficient && !decision.indeterminate && !decision.exactTestVetoes) tally.hold++;
542
+ }
543
+ if (!decision.promote) all = false;
544
+ if (!decision.sufficient || decision.indeterminate || decision.exactTestVetoes) anyRefusal = true;
545
+ }
546
+ if (all) promotions++;
547
+ else if (anyRefusal) refusals++;
548
+ }
549
+ const power = promotions / simulations;
550
+ const interval = wilson(promotions, simulations, .95);
551
+ return {
552
+ n,
553
+ simulations,
554
+ seed,
555
+ power,
556
+ standardError: Math.sqrt(power * (1 - power) / simulations),
557
+ low: interval.lower,
558
+ high: interval.upper,
559
+ refuse: refusals / simulations,
560
+ hold: (simulations - promotions - refusals) / simulations,
561
+ calls: perCall.map((tally, c) => ({
562
+ outcome: calls[c].outcome,
563
+ promote: tally.promote / simulations,
564
+ insufficient: tally.insufficient / simulations,
565
+ indeterminate: tally.indeterminate / simulations,
566
+ exactTestVetoes: tally.exactTestVetoes / simulations,
567
+ hold: tally.hold / simulations,
568
+ methods: Object.fromEntries([...tally.methods.entries()].map(([method, count]) => [method, count / simulations]))
569
+ }))
570
+ };
571
+ }
572
+ /**
573
+ * First n at which {@link pairedPromotionPower} reaches the target, scanning
574
+ * every n from `minPairs` upward. Both the point-estimate answer and the
575
+ * confirmed lower-bound answer are returned; the scan runs until the latter is
576
+ * reached (so the curve past `n` is retained) or `maxPairs` is exhausted. A
577
+ * candidate selected by the scan's bound is certified only by an independent
578
+ * simulation at that n on a different seed; a candidate that fails it is
579
+ * recorded and the scan continues.
580
+ */
581
+ function requiredPairsForPairedPromotion(options) {
582
+ const target = options.target ?? .8;
583
+ if (!Number.isFinite(target) || target <= 0 || target >= 1) throw new Error(`requiredPairsForPairedPromotion: target must be in (0,1), got ${target}`);
584
+ const minPairs = options.minPairs ?? 1;
585
+ const maxPairs = options.maxPairs ?? 200;
586
+ if (!Number.isInteger(minPairs) || minPairs < 1) throw new Error(`requiredPairsForPairedPromotion: minPairs must be a positive integer, got ${minPairs}`);
587
+ if (!Number.isInteger(maxPairs) || maxPairs < minPairs) throw new Error(`requiredPairsForPairedPromotion: maxPairs must be an integer >= minPairs, got ${maxPairs}`);
588
+ const seed = options.seed ?? 1;
589
+ const curve = [];
590
+ const rejected = [];
591
+ let n = null;
592
+ let nAtLowerBound = null;
593
+ let confirmation = null;
594
+ for (let candidate = minPairs; candidate <= maxPairs; candidate++) {
595
+ const result = pairedPromotionPower({
596
+ n: candidate,
597
+ alternative: options.alternative,
598
+ calls: options.calls,
599
+ simulations: options.simulations,
600
+ seed
601
+ });
602
+ curve.push(result);
603
+ if (n === null && result.power >= target) n = candidate;
604
+ if (result.low >= target) {
605
+ const check = pairedPromotionPower({
606
+ n: candidate,
607
+ alternative: options.alternative,
608
+ calls: options.calls,
609
+ simulations: options.simulations,
610
+ seed: confirmationSeed(seed, candidate)
611
+ });
612
+ if (check.low >= target) {
613
+ nAtLowerBound = candidate;
614
+ confirmation = check;
615
+ break;
616
+ }
617
+ rejected.push(check);
618
+ }
619
+ }
620
+ return {
621
+ target,
622
+ n,
623
+ nAtLowerBound,
624
+ confirmation,
625
+ curve,
626
+ rejected
627
+ };
628
+ }
629
+ /** A seed for the confirmation at `n` that never equals the scan seed. */
630
+ function confirmationSeed(seed, n) {
631
+ return (Math.imul((seed | 0) ^ 2654435769, 2246822507) + Math.imul(n, 3266489909) | 1) >>> 0;
632
+ }
633
+ function drawPairs(alternative, n, rng) {
634
+ const baseline = alternative.baseline ?? {
635
+ kind: "point",
636
+ value: 0
637
+ };
638
+ const control = new Array(n);
639
+ const candidate = new Array(n);
640
+ const controlPass = new Array(n);
641
+ const candidatePass = new Array(n);
642
+ for (let i = 0; i < n; i++) {
643
+ const cell = drawCell(alternative.cells, rng);
644
+ const base = drawValue(baseline, rng);
645
+ control[i] = base;
646
+ candidate[i] = base + drawValue(cell.delta, rng);
647
+ controlPass[i] = cell.pass?.control ?? false;
648
+ candidatePass[i] = cell.pass?.treatment ?? false;
649
+ }
650
+ return {
651
+ control,
652
+ candidate,
653
+ controlPass,
654
+ candidatePass
655
+ };
656
+ }
657
+ function drawCell(cells, rng) {
658
+ const u = rng();
659
+ let cumulative = 0;
660
+ for (const cell of cells) {
661
+ cumulative += cell.probability;
662
+ if (u < cumulative) return cell;
663
+ }
664
+ return cells[cells.length - 1];
665
+ }
666
+ function drawValue(law, rng) {
667
+ switch (law.kind) {
668
+ case "point": return law.value;
669
+ case "atoms": {
670
+ const u = rng();
671
+ let cumulative = 0;
672
+ for (const atom of law.atoms) {
673
+ cumulative += atom.probability;
674
+ if (u < cumulative) return atom.value;
675
+ }
676
+ return law.atoms[law.atoms.length - 1].value;
677
+ }
678
+ case "normal": {
679
+ const u1 = 1 - rng();
680
+ const u2 = rng();
681
+ return law.mean + law.sd * Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
682
+ }
683
+ }
684
+ }
685
+ function runCall(call, pairs, n) {
686
+ const options = {
687
+ minPairs: n,
688
+ ...call.options
689
+ };
690
+ if (call.outcome === "pass") {
691
+ const scale = call.options.binaryScale;
692
+ return decidePairedPromotion(pairs.controlPass.map((p) => p ? scale : 0), pairs.candidatePass.map((p) => p ? scale : 0), options);
693
+ }
694
+ return decidePairedPromotion(pairs.control, pairs.candidate, options);
695
+ }
696
+ function validateCalls(calls) {
697
+ if (!Array.isArray(calls) || calls.length === 0) throw new Error("pairedPromotionPower: at least one call is required");
698
+ calls.forEach((call, i) => {
699
+ if (call.outcome !== "delta" && call.outcome !== "pass") throw new Error(`pairedPromotionPower: calls[${i}].outcome must be 'delta' or 'pass'`);
700
+ if (call.options === null || typeof call.options !== "object") throw new Error(`pairedPromotionPower: calls[${i}].options must be an object`);
701
+ if (call.options.minPairs !== void 0 && (!Number.isInteger(call.options.minPairs) || call.options.minPairs < 1)) throw new Error(`pairedPromotionPower: calls[${i}].options.minPairs must be a positive integer`);
702
+ if (call.outcome === "pass") {
703
+ const scale = call.options.binaryScale;
704
+ if (scale === void 0 || !Number.isFinite(scale) || scale <= 0) throw new Error(`pairedPromotionPower: calls[${i}] is a 'pass' call and must declare a finite positive options.binaryScale`);
705
+ if (call.options.continuous) throw new Error(`pairedPromotionPower: calls[${i}] is a 'pass' call; continuous cannot accompany it`);
706
+ if (call.options.statistic === "median") throw new Error(`pairedPromotionPower: calls[${i}] is a 'pass' call; binaryScale requires the mean statistic, not median`);
707
+ } else if (call.options.binaryScale !== void 0) throw new Error(`pairedPromotionPower: calls[${i}] is a 'delta' call and cannot declare binaryScale; use a 'pass' call for a two-point outcome`);
708
+ });
709
+ return calls;
710
+ }
711
+ function validateAlternative(alternative, calls) {
712
+ if (!alternative || !Array.isArray(alternative.cells) || alternative.cells.length === 0) throw new Error("pairedPromotionPower: alternative.cells must be a non-empty array");
713
+ const needsPass = calls.some((call) => call.outcome === "pass");
714
+ let total = 0;
715
+ alternative.cells.forEach((cell, i) => {
716
+ if (!Number.isFinite(cell.probability) || cell.probability < 0) throw new Error(`pairedPromotionPower: cells[${i}].probability must be a finite number >= 0`);
717
+ total += cell.probability;
718
+ if (needsPass) {
719
+ if (!cell.pass || typeof cell.pass.control !== "boolean" || typeof cell.pass.treatment !== "boolean") throw new Error(`pairedPromotionPower: cells[${i}].pass must give control and treatment booleans when a 'pass' call is configured`);
720
+ }
721
+ validateLaw(cell.delta, `cells[${i}].delta`);
722
+ });
723
+ if (Math.abs(total - 1) > 1e-9) throw new Error(`pairedPromotionPower: cell probabilities must sum to 1, got ${total}`);
724
+ if (alternative.baseline !== void 0) validateLaw(alternative.baseline, "baseline");
725
+ return alternative;
726
+ }
727
+ function validateLaw(law, where) {
728
+ if (!law || typeof law !== "object") throw new Error(`pairedPromotionPower: ${where} must be a law object`);
729
+ switch (law.kind) {
730
+ case "point":
731
+ if (!Number.isFinite(law.value)) throw new Error(`pairedPromotionPower: ${where}.value must be finite`);
732
+ return;
733
+ case "atoms": {
734
+ if (!Array.isArray(law.atoms) || law.atoms.length === 0) throw new Error(`pairedPromotionPower: ${where}.atoms must be a non-empty array`);
735
+ let total = 0;
736
+ law.atoms.forEach((atom, i) => {
737
+ if (!Number.isFinite(atom.value)) throw new Error(`pairedPromotionPower: ${where}.atoms[${i}].value must be finite`);
738
+ if (!Number.isFinite(atom.probability) || atom.probability < 0) throw new Error(`pairedPromotionPower: ${where}.atoms[${i}].probability must be a finite number >= 0`);
739
+ total += atom.probability;
740
+ });
741
+ if (Math.abs(total - 1) > 1e-9) throw new Error(`pairedPromotionPower: ${where}.atoms probabilities must sum to 1, got ${total}`);
742
+ return;
743
+ }
744
+ case "normal":
745
+ if (!Number.isFinite(law.mean) || !Number.isFinite(law.sd) || law.sd < 0) throw new Error(`pairedPromotionPower: ${where} needs a finite mean and a finite sd >= 0`);
746
+ return;
747
+ default: throw new Error(`pairedPromotionPower: ${where}.kind must be 'point', 'atoms' or 'normal'`);
748
+ }
749
+ }
750
+ //#endregion
751
+ //#region src/campaign/search-claim.ts
752
+ /**
753
+ * The claim: the one statement a search makes about its nodes, made once, on
754
+ * its sealed test split, after the search stops expanding.
755
+ *
756
+ * `planSearchClaim` fixes the claim's design from the ledger before any test
757
+ * cell runs: up to 3 finalists ranked by selection mean, the estimator the
758
+ * paired decision will use, the number of finalists the family-wise confidence
759
+ * is divided among, and a power check on selection data. The kernel stores the
760
+ * plan on the ledger's `claim` operation, so the ledger proves the design was
761
+ * fixed before the test data existed. `decideSearchClaim` then reads the test
762
+ * cells of the root and the finalists, which ran together, and decides each
763
+ * finalist against the root with `decidePairedPromotion` at the Bonferroni
764
+ * confidence `1 - 0.05 / k`.
765
+ *
766
+ * Every number is a pure function of the ledger: the power simulation is
767
+ * seeded from the search's seed and the decisions from the digest of the test
768
+ * cells they read, so a verifier with the same ledger gets the same claim.
769
+ */
770
+ const FAMILY_CONFIDENCE = .95;
771
+ const TARGET_POWER = .8;
772
+ const POWER_SIMULATIONS = 500;
773
+ const RESAMPLES = 2e3;
774
+ /**
775
+ * Every rule and parameter the claim depends on. Its digest is the claim's
776
+ * revision; change a value here whenever the procedure changes, so the
777
+ * revision moves with it.
778
+ */
779
+ const CLAIM_DEFINITION = {
780
+ name: "tangle.search-claim.2026-09",
781
+ finalists: `up to 3 non-root nodes not decided invalid that scored every selection unit and beat the root's mean on the selection units they share, ranked by selection mean in the objective's direction, then registration order`,
782
+ familyConfidence: FAMILY_CONFIDENCE,
783
+ correction: "Bonferroni: each of k finalists against the root at 1 - (1 - familyConfidence) / k",
784
+ finalistCount: "the largest k whose test cells the unspent claim reserve and headroom cover and whose power reaches the target",
785
+ estimator: "binary with scale s when every train, selection and test unit has one task at one repeat and every pre-test score is 0 or s; otherwise continuous",
786
+ power: {
787
+ method: "pairedPromotionPower of the claim decision, simulated at the claim minimumEffect",
788
+ continuous: "per-unit improvement normal with the pooled selection variance",
789
+ binary: "discordant units at the pooled selection variance over 2 s^2 per side, shifted by minimumEffect / s; concordant units pass at the root selection pass rate",
790
+ targetPower: TARGET_POWER,
791
+ simulations: POWER_SIMULATIONS,
792
+ seed: "the first 32 bits of hashCanonical([search seed, k])"
793
+ },
794
+ decision: {
795
+ call: "decidePairedPromotion(root, finalist) on per-unit test means in the objective direction",
796
+ threshold: 0,
797
+ minPairs: "every test unit",
798
+ resamples: RESAMPLES,
799
+ seed: "the first 32 bits of the finalist's test cellSetDigest"
800
+ },
801
+ select: "the promoted finalist with the largest improvement; ties to the better selection rank",
802
+ ship: "a promoted finalist that scored every test unit, with the root, on held-out units, under a pinned judge"
803
+ };
804
+ /** The claim procedure every `SearchClaim` and claim plan names. */
805
+ const SEARCH_CLAIM_RULE = {
806
+ uri: "npm:@tangle-network/agent-eval#searchClaim",
807
+ revision: hashCanonical(CLAIM_DEFINITION)
808
+ };
809
+ /** The rule name decisions made by the claim record. */
810
+ const SEARCH_CLAIM_RULE_NAME = CLAIM_DEFINITION.name;
811
+ /**
812
+ * The claim reserve a search needs: the root and 3 finalists on every test
813
+ * task at `reps` repeats, each held at `cellUsd`. Declare at least this as
814
+ * `budget.reservedClaimUsd`; `runSearch` refuses to start a capped search
815
+ * with less.
816
+ */
817
+ function searchClaimReserveUsd(input) {
818
+ return 4 * input.testTasks * input.reps * input.cellUsd;
819
+ }
820
+ /** Fix the claim's design from a search that stopped expanding. */
821
+ function planSearchClaim(state, input) {
822
+ const header = requireHeader(state);
823
+ const root = state.rootNodeId;
824
+ const plan = (finalists, power, test, reason) => ({
825
+ kind: "search-claim-plan",
826
+ rule: SEARCH_CLAIM_RULE,
827
+ stopReason: input.stopReason,
828
+ finalists,
829
+ power,
830
+ test,
831
+ reason
832
+ });
833
+ const selectionUnits = unitIds(header.splits.selection.tasks);
834
+ const testUnits = unitIds(header.splits.test.tasks);
835
+ if (testUnits.length === 0) return plan([], { unknown: "the search has no test split" }, "hold", "the search has no test split, so it makes no claim");
836
+ if (selectionUnits.length === 0) return plan([], { unknown: "the search has no selection split to choose finalists on" }, "hold", "the search has no selection split, so no finalist can be chosen without the test data");
837
+ const candidates = rankFinalists(state, selectionUnits);
838
+ if (candidates.length === 0) return plan([], { unknown: "no node went to test" }, "hold", "no node scored every selection unit and beat the root's selection mean, so the search keeps the root");
839
+ const minimumEffect = header.objective.claim.minimumEffect;
840
+ if (minimumEffect === void 0) return plan(candidates.slice(0, 1), { unknown: "the claim declares no minimumEffect" }, "test-cannot-resolve", "the claim declares no minimumEffect, so no test split can be shown to resolve it");
841
+ const { pooledVariance } = searchPosterior(state, { split: "selection" });
842
+ if (pooledVariance === null) return plan(candidates.slice(0, 1), { unknown: "no node shares 2 selection units with the root, so the variance is unknown" }, "test-cannot-resolve", "the selection split gives no variance for the power check, so the test cannot be shown to resolve the minimum effect");
843
+ const estimator = claimEstimator(state, input.reps);
844
+ const rootPassRate = passRate(state.unitScores(root, "selection"), estimator);
845
+ let best = null;
846
+ let budgetBound = false;
847
+ for (let k = candidates.length; k >= 1; k--) {
848
+ const power = claimPower({
849
+ units: testUnits.length,
850
+ finalists: k,
851
+ minimumEffect,
852
+ pooledVariance,
853
+ estimator,
854
+ rootPassRate,
855
+ seed: header.policy.seed
856
+ });
857
+ best = power;
858
+ if (!power.adequate) continue;
859
+ if (!input.affordable(k)) {
860
+ budgetBound = true;
861
+ continue;
862
+ }
863
+ return plan(candidates.slice(0, k), power, "run", `${k} finalist${k === 1 ? "" : "s"} go to test: power ${round(power.powerAtMinimumEffect)} at an improvement of ${minimumEffect} on ${testUnits.length} test units reaches ${TARGET_POWER} at confidence ${round(bonferroni(k))} each`);
864
+ }
865
+ const power = best;
866
+ return plan(candidates.slice(0, 1), power, "test-cannot-resolve", budgetBound ? "the unspent claim reserve and headroom do not cover the test cells of the root and one finalist" : `even one finalist reaches power ${round("adequate" in power ? power.powerAtMinimumEffect : 0)} at an improvement of ${minimumEffect} on ${testUnits.length} test units, below ${TARGET_POWER}`);
867
+ }
868
+ /**
869
+ * Decide the claim from the test cells of the root and the finalists. Call it
870
+ * once every claim cell is settled or cancelled. It returns the claim and the
871
+ * terminal decisions of the root and every finalist.
872
+ */
873
+ function decideSearchClaim(state, plan) {
874
+ const header = requireHeader(state);
875
+ const root = state.rootNodeId;
876
+ const rootScored = [
877
+ "train",
878
+ "selection",
879
+ "test"
880
+ ].some((split) => state.scoredCells(root, split).length > 0);
881
+ const keepRoot = rootScored ? root : null;
882
+ const rule = SEARCH_CLAIM_RULE_NAME;
883
+ const decisions = [];
884
+ const claimOf = (finalists, selected, decision, reason) => ({
885
+ rule: SEARCH_CLAIM_RULE,
886
+ confidence: FAMILY_CONFIDENCE,
887
+ power: plan.power,
888
+ finalists,
889
+ selected,
890
+ decision,
891
+ reason
892
+ });
893
+ if (plan.test !== "run") {
894
+ for (const finalist of plan.finalists) decisions.push({
895
+ nodeId: finalist.nodeId,
896
+ decision: { status: "rejected" },
897
+ basis: null,
898
+ rule,
899
+ reason: `no test cell ran: ${plan.reason}`
900
+ });
901
+ decisions.push(rootDecision(root, rootScored ? "kept" : "unscored", rule, plan.reason));
902
+ return {
903
+ claim: claimOf(plan.finalists.map((finalist) => ({
904
+ nodeId: finalist.nodeId,
905
+ estimate: null,
906
+ test: null,
907
+ promote: false
908
+ })), keepRoot, plan.test, plan.reason),
909
+ decisions
910
+ };
911
+ }
912
+ const power = plan.power;
913
+ if (!("adequate" in power)) throw new Error("a claim that runs its test cells has a known power");
914
+ const k = plan.finalists.length;
915
+ const confidence = bonferroni(k);
916
+ const testUnits = unitIds(header.splits.test.tasks);
917
+ const maximize = header.objective.direction === "maximize";
918
+ const rootUnits = state.unitScores(root, "test");
919
+ const tested = plan.finalists.map(({ nodeId }) => {
920
+ const estimate = estimateNode(state, nodeId, {
921
+ against: root,
922
+ split: "test"
923
+ });
924
+ return {
925
+ nodeId,
926
+ estimate,
927
+ ...pairedTest({
928
+ root: rootUnits,
929
+ node: state.unitScores(nodeId, "test"),
930
+ testUnits,
931
+ confidence,
932
+ estimator: power.estimator,
933
+ maximize,
934
+ seed: seedFromDigest(estimate.cellSetDigest)
935
+ })
936
+ };
937
+ });
938
+ const winner = tested.map((entry, rank) => ({
939
+ ...entry,
940
+ rank
941
+ })).filter((entry) => entry.promote).sort((a, b) => b.improvement - a.improvement || a.rank - b.rank)[0] ?? null;
942
+ const blockers = [];
943
+ if (!header.splits.heldOutUnits) blockers.push("the test units also appear in train or selection");
944
+ if ("unknown" in header.objective.judge) blockers.push("the judge is not pinned");
945
+ const ship = winner !== null && blockers.length === 0;
946
+ const selected = ship ? winner.nodeId : keepRoot;
947
+ const reason = ship ? `finalist ${winner.nodeId} beat the root on the ${testUnits.length} test units at confidence ${round(confidence)} (${k} finalist${k === 1 ? "" : "s"}, family-wise ${FAMILY_CONFIDENCE})` : winner !== null ? `finalist ${winner.nodeId} beat the root, but the claim cannot ship: ${blockers.join("; ")}` : `no finalist beat the root on the test split at confidence ${round(confidence)} (${k} finalist${k === 1 ? "" : "s"}, family-wise ${FAMILY_CONFIDENCE})`;
948
+ for (const entry of tested) decisions.push({
949
+ nodeId: entry.nodeId,
950
+ decision: { status: ship && entry.nodeId === winner.nodeId ? "selected" : "rejected" },
951
+ basis: entry.estimate,
952
+ rule,
953
+ reason: ship && entry.nodeId === winner.nodeId ? reason : entry.promote ? ship ? `promoted, but finalist ${winner.nodeId} improved more on the test split` : reason : entry.why
954
+ });
955
+ decisions.push(rootDecision(root, ship ? "replaced" : rootScored ? "kept" : "unscored", rule, reason));
956
+ return {
957
+ claim: claimOf(tested.map((entry) => ({
958
+ nodeId: entry.nodeId,
959
+ estimate: entry.estimate,
960
+ test: entry.test,
961
+ promote: entry.promote
962
+ })), selected, ship ? "ship" : "hold", reason),
963
+ decisions
964
+ };
965
+ }
966
+ /**
967
+ * Make a closed search's claim again from its ledger alone and compare it with
968
+ * the recorded one, byte for byte: the power check, the finalists, each
969
+ * finalist's test estimate and deciding interval, the selection, the decision
970
+ * and the reason. A producer's claim is not evidence until this holds; a store
971
+ * keeps what this function derives, not what the producer wrote.
972
+ *
973
+ * It needs no blob, so it works on a store that keeps digests only. The
974
+ * finalists are the nodes the ledger decided `finalist`, in decision order.
975
+ * The one input the ledger cannot replay is whether the budget covered k
976
+ * finalists' test cells; the claim cells that ran answer it (none ran: the
977
+ * budget covered none). Test repeats come from the claim cells too; without
978
+ * test cells, the recorded estimator stands for them (binary needs one repeat,
979
+ * so any other count reproduces continuous).
980
+ *
981
+ * Returns null for a search that made no claim.
982
+ */
983
+ function verifySearchClaim(state) {
984
+ const closed = state.closed;
985
+ if (!closed) throw new Error(`search ${state.searchId} is open; its claim is made when it closes`);
986
+ const recorded = closed.claim;
987
+ if (recorded === null) return null;
988
+ if (recorded.rule.revision !== SEARCH_CLAIM_RULE.revision) return {
989
+ status: "unknown",
990
+ reason: `the claim names rule revision ${recorded.rule.revision}; this code implements ${SEARCH_CLAIM_RULE.revision}`
991
+ };
992
+ const claimCells = state.cells().filter((cell) => cell.stage === "claim");
993
+ const decided = state.nodes().flatMap((node) => {
994
+ const first = node.decisions.find((record) => record.decision.status === "finalist");
995
+ return first ? [{
996
+ nodeId: node.nodeId,
997
+ sequence: first.sequence
998
+ }] : [];
999
+ }).sort((a, b) => a.sequence - b.sequence).map((entry) => entry.nodeId);
1000
+ const reps = claimCells.length > 0 ? 1 + Math.max(...claimCells.map((cell) => cell.rep)) : "adequate" in recorded.power && recorded.power.estimator.kind === "binary" ? 1 : 2;
1001
+ const plan = planSearchClaim(state, {
1002
+ stopReason: closed.reason,
1003
+ reps,
1004
+ affordable: (finalists) => claimCells.length > 0 && finalists <= decided.length
1005
+ });
1006
+ const differences = [];
1007
+ const planned = plan.finalists.map((finalist) => finalist.nodeId);
1008
+ if (canonicalString(planned) !== canonicalString(decided)) differences.push(`finalists: the rule picks [${planned.join(", ")}], the ledger decided [${decided.join(", ")}]`);
1009
+ if (plan.test === "run" !== claimCells.length > 0) differences.push(`test: the plan ${plan.test === "run" ? "runs" : "skips"} the test cells, and ${claimCells.length} ran`);
1010
+ const expected = decideSearchClaim(state, plan).claim;
1011
+ for (const key of [
1012
+ "decision",
1013
+ "selected",
1014
+ "confidence",
1015
+ "power",
1016
+ "reason"
1017
+ ]) if (canonicalString(expected[key]) !== canonicalString(recorded[key])) differences.push(`${key}: the ledger supports ${canonicalString(expected[key])}, the claim records ${canonicalString(recorded[key])}`);
1018
+ const count = Math.max(expected.finalists.length, recorded.finalists.length);
1019
+ for (let index = 0; index < count; index++) {
1020
+ const want = expected.finalists[index];
1021
+ const have = recorded.finalists[index];
1022
+ if (canonicalString(want ?? null) === canonicalString(have ?? null)) continue;
1023
+ const fields = [
1024
+ "nodeId",
1025
+ "promote",
1026
+ "test",
1027
+ "estimate"
1028
+ ].filter((field) => canonicalString(want?.[field] ?? null) !== canonicalString(have?.[field] ?? null));
1029
+ differences.push(`finalist ${index + 1} (${have?.nodeId ?? want?.nodeId}): ${fields.join(", ")} differ from what the ledger supports`);
1030
+ }
1031
+ return differences.length === 0 ? { status: "verified" } : {
1032
+ status: "mismatch",
1033
+ differences
1034
+ };
1035
+ }
1036
+ /** Per-finalist confidence for `k` finalists. */
1037
+ function bonferroni(k) {
1038
+ return 1 - (1 - FAMILY_CONFIDENCE) / k;
1039
+ }
1040
+ function rootDecision(root, fate, rule, reason) {
1041
+ return {
1042
+ nodeId: root,
1043
+ decision: { status: fate === "kept" ? "selected" : "rejected" },
1044
+ basis: null,
1045
+ rule,
1046
+ reason: fate === "kept" ? `the search keeps the root: ${reason}` : fate === "replaced" ? `a finalist replaces the root: ${reason}` : "the root has no scored cell, so the search keeps nothing"
1047
+ };
1048
+ }
1049
+ /** Nodes eligible to go to test, best first, at most `SEARCH_CLAIM_MAX_FINALISTS`. */
1050
+ function rankFinalists(state, selectionUnits) {
1051
+ const sign = requireHeader(state).objective.direction === "maximize" ? 1 : -1;
1052
+ const root = state.rootNodeId;
1053
+ const rootMeans = new Map(state.unitScores(root, "selection").map((unit) => [unit.unitId, unit.mean]));
1054
+ const ranked = [];
1055
+ for (const node of state.nodes()) {
1056
+ if (node.nodeId === root || node.status === "invalid") continue;
1057
+ const units = state.unitScores(node.nodeId, "selection");
1058
+ if (units.length !== selectionUnits.length) continue;
1059
+ let gain = 0;
1060
+ let shared = 0;
1061
+ for (const unit of units) {
1062
+ const rootMean = rootMeans.get(unit.unitId);
1063
+ if (rootMean === void 0) continue;
1064
+ gain += sign * (unit.mean - rootMean);
1065
+ shared += 1;
1066
+ }
1067
+ if (shared === 0 || gain <= 0) continue;
1068
+ ranked.push({
1069
+ nodeId: node.nodeId,
1070
+ selectionMean: mean(units.map((unit) => unit.mean)),
1071
+ ordinal: node.ordinal
1072
+ });
1073
+ }
1074
+ ranked.sort((a, b) => sign * (b.selectionMean - a.selectionMean) || a.ordinal - b.ordinal);
1075
+ return ranked.slice(0, 3).map(({ nodeId, selectionMean }) => ({
1076
+ nodeId,
1077
+ selectionMean
1078
+ }));
1079
+ }
1080
+ /**
1081
+ * The paired decision's estimator, fixed from pre-test data. A per-unit mean
1082
+ * is binary only when each unit holds one task at one repeat and every score
1083
+ * seen before the test is 0 or one positive value.
1084
+ */
1085
+ function claimEstimator(state, reps) {
1086
+ const header = requireHeader(state);
1087
+ if (![
1088
+ "train",
1089
+ "selection",
1090
+ "test"
1091
+ ].every((split) => {
1092
+ const tasks = header.splits[split].tasks;
1093
+ return unitIds(tasks).length === tasks.length;
1094
+ }) || reps !== 1) return { kind: "continuous" };
1095
+ let scale = null;
1096
+ for (const nodeId of state.nodeIds()) for (const split of ["train", "selection"]) for (const cell of state.scoredCells(nodeId, split)) {
1097
+ if (cell.score === 0) continue;
1098
+ if (cell.score < 0 || scale !== null && cell.score !== scale) return { kind: "continuous" };
1099
+ scale = cell.score;
1100
+ }
1101
+ return scale === null ? { kind: "continuous" } : {
1102
+ kind: "binary",
1103
+ scale
1104
+ };
1105
+ }
1106
+ function passRate(units, estimator) {
1107
+ if (estimator.kind !== "binary" || units.length === 0) return 0;
1108
+ return units.filter((unit) => unit.mean === estimator.scale).length / units.length;
1109
+ }
1110
+ function claimPower(input) {
1111
+ const { units, finalists, minimumEffect, pooledVariance, estimator } = input;
1112
+ const confidence = bonferroni(finalists);
1113
+ let alternative;
1114
+ let call;
1115
+ if (estimator.kind === "binary") {
1116
+ const s = estimator.scale;
1117
+ const shift = Math.min(1, minimumEffect / s);
1118
+ const discordance = Math.min(.5, pooledVariance / (2 * s * s));
1119
+ const loss = Math.max(0, discordance - shift / 2);
1120
+ const win = Math.min(1 - loss, loss + shift);
1121
+ const concordant = Math.max(0, 1 - win - loss);
1122
+ alternative = { cells: [
1123
+ {
1124
+ probability: win,
1125
+ pass: {
1126
+ control: false,
1127
+ treatment: true
1128
+ },
1129
+ delta: {
1130
+ kind: "point",
1131
+ value: s
1132
+ }
1133
+ },
1134
+ {
1135
+ probability: loss,
1136
+ pass: {
1137
+ control: true,
1138
+ treatment: false
1139
+ },
1140
+ delta: {
1141
+ kind: "point",
1142
+ value: -s
1143
+ }
1144
+ },
1145
+ {
1146
+ probability: concordant * input.rootPassRate,
1147
+ pass: {
1148
+ control: true,
1149
+ treatment: true
1150
+ },
1151
+ delta: {
1152
+ kind: "point",
1153
+ value: 0
1154
+ }
1155
+ },
1156
+ {
1157
+ probability: concordant * (1 - input.rootPassRate),
1158
+ pass: {
1159
+ control: false,
1160
+ treatment: false
1161
+ },
1162
+ delta: {
1163
+ kind: "point",
1164
+ value: 0
1165
+ }
1166
+ }
1167
+ ] };
1168
+ call = {
1169
+ outcome: "pass",
1170
+ options: {
1171
+ binaryScale: s,
1172
+ confidence,
1173
+ threshold: 0
1174
+ }
1175
+ };
1176
+ } else {
1177
+ alternative = { cells: [{
1178
+ probability: 1,
1179
+ delta: {
1180
+ kind: "normal",
1181
+ mean: minimumEffect,
1182
+ sd: Math.sqrt(pooledVariance)
1183
+ }
1184
+ }] };
1185
+ call = {
1186
+ outcome: "delta",
1187
+ options: {
1188
+ continuous: true,
1189
+ confidence,
1190
+ threshold: 0,
1191
+ resamples: RESAMPLES
1192
+ }
1193
+ };
1194
+ }
1195
+ const simulated = pairedPromotionPower({
1196
+ n: units,
1197
+ alternative,
1198
+ calls: [call],
1199
+ simulations: POWER_SIMULATIONS,
1200
+ seed: seedFromDigest(hashCanonical([input.seed, finalists]))
1201
+ });
1202
+ return {
1203
+ adequate: simulated.power >= TARGET_POWER,
1204
+ minimumEffect,
1205
+ powerAtMinimumEffect: simulated.power,
1206
+ targetPower: TARGET_POWER,
1207
+ units,
1208
+ finalists,
1209
+ pooledVariance,
1210
+ estimator
1211
+ };
1212
+ }
1213
+ /**
1214
+ * One finalist against the root on the test split, in the objective's
1215
+ * direction, requiring every test unit scored by both.
1216
+ */
1217
+ function pairedTest(input) {
1218
+ const rootMeans = new Map(input.root.map((unit) => [unit.unitId, unit.mean]));
1219
+ const before = [];
1220
+ const after = [];
1221
+ for (const unit of input.node) {
1222
+ const rootMean = rootMeans.get(unit.unitId);
1223
+ if (rootMean === void 0) continue;
1224
+ before.push(input.maximize ? rootMean : unit.mean);
1225
+ after.push(input.maximize ? unit.mean : rootMean);
1226
+ }
1227
+ const pairs = before.length;
1228
+ if (pairs === 0) return {
1229
+ test: null,
1230
+ promote: false,
1231
+ improvement: null,
1232
+ why: "no test unit was scored by both it and the root"
1233
+ };
1234
+ const { estimator } = input;
1235
+ if (estimator.kind === "binary" && [...before, ...after].some((value) => value !== 0 && value !== estimator.scale)) return {
1236
+ test: null,
1237
+ promote: false,
1238
+ improvement: null,
1239
+ why: `a test score left the two-point scale {0, ${estimator.scale}} the claim fixed before the test`
1240
+ };
1241
+ const decision = decidePairedPromotion(before, after, {
1242
+ confidence: input.confidence,
1243
+ threshold: 0,
1244
+ minPairs: input.testUnits.length,
1245
+ resamples: RESAMPLES,
1246
+ seed: input.seed,
1247
+ ...estimator.kind === "binary" ? { binaryScale: estimator.scale } : { continuous: true }
1248
+ });
1249
+ const test = {
1250
+ pairs,
1251
+ confidence: input.confidence,
1252
+ method: decision.method,
1253
+ delta: plain(input.maximize ? decision.delta : -decision.delta),
1254
+ interval: input.maximize ? [plain(decision.low), plain(decision.high)] : [plain(-decision.high), plain(-decision.low)]
1255
+ };
1256
+ const complete = pairs === input.testUnits.length;
1257
+ const promote = decision.promote && complete;
1258
+ const why = !complete ? `it and the root scored ${pairs} of ${input.testUnits.length} test units, and a claim needs every one` : decision.promote ? "promoted" : !decision.sufficient ? `${pairs} test units are below the ${decision.minimumPairs} its paired test needs` : decision.indeterminate ? `its test interval has no width: ${decision.indeterminateCause}` : decision.exactTestVetoes ? "McNemar's exact test vetoes the improvement" : `its improvement interval [${round(decision.low)}, ${round(decision.high)}] at confidence ${round(input.confidence)} does not exclude 0`;
1259
+ return {
1260
+ test,
1261
+ promote,
1262
+ improvement: decision.delta,
1263
+ why
1264
+ };
1265
+ }
1266
+ function requireHeader(state) {
1267
+ const header = state.header;
1268
+ if (!header) throw new Error(`search ${state.searchId} has not been opened`);
1269
+ if (state.rootNodeId === null) throw new Error(`search ${state.searchId} has no root node`);
1270
+ return header;
1271
+ }
1272
+ function unitIds(tasks) {
1273
+ return [...new Set(tasks.map((task) => task.unitId))];
1274
+ }
1275
+ function mean(values) {
1276
+ let total = 0;
1277
+ for (const value of values) total += value;
1278
+ return total / values.length;
1279
+ }
1280
+ function round(value) {
1281
+ return Math.round(value * 1e4) / 1e4;
1282
+ }
1283
+ /** Canonical JSON has one zero; never hand it a negative one. */
1284
+ function plain(value) {
1285
+ return value === 0 ? 0 : value;
1286
+ }
1287
+ /** The kernel every `runSearch` ledger names as its search implementation. */
1288
+ const SEARCH_KERNEL_SOURCE = {
1289
+ uri: "npm:@tangle-network/agent-eval#runSearch",
1290
+ revision: hashCanonical({
1291
+ name: "tangle.search-kernel.2026-09",
1292
+ dispatch: "claim, then rung and root, then screen, then train; first allocated first within a stage",
1293
+ expansion: "when no cell waits, fewer than twice the lanes capacity run, the cap admits one proposal and its expected screens, and overspend has not taken the search past its cap",
1294
+ parents: "a policy names at least one parent, and every parent finished its screen",
1295
+ reservation: {
1296
+ hard: "the lane per-cell maximum",
1297
+ estimate: "1.5 times the p99 of the lane settled cells once 20 settled, else the lane prior"
1298
+ },
1299
+ retries: "a retryable errored attempt runs again, up to maxAttempts",
1300
+ rungs: "when a node finishes its cells, the allocator names the nodes it advances, deepest rung first; each is recorded advanced and its rung allocated when the cap admits the rung",
1301
+ resume: "an unrecorded operation is recorded failed at an unknown cost with floor 0; a recorded proposal is finished from its stored output; an unsettled cell is offered to adopt before it runs",
1302
+ writers: "one kernel per ledger file on a host, by a pid lock beside the ledger",
1303
+ divergence: "a node whose train mean over shared units rises above its parent while its descriptive or bootstrap selection interval against the parent lies wholly on the worse side is decided invalid when its cells finish",
1304
+ claim: "with a test split: after expansion stops, the claim plan is stored on the claim operation, the finalists are decided, the root and finalist test cells run together, and the claim decides; the kernel refuses to start a claim without a selection split, a minimumEffect, a pinned judge, or (under a cap) a claim reserve for the root and 3 finalists plus a cap for one screening round",
1305
+ close: "the allocator prunes the nodes left waiting on a rung, except the policy leader; then, with a test split, the claim decides; without one, the policy leader is selected when it has a scored cell; every other undecided node is rejected",
1306
+ complete: "a node leads only when it has a scored cell on the ranked split and no cell there ended unscored: a final unscored outcome, or an error with no attempt left"
1307
+ })
1308
+ };
1309
+ const USD_TOLERANCE = 1e-9;
1310
+ /** The operation that holds the claim's plan. */
1311
+ const CLAIM_OPERATION = "claim";
1312
+ /** Settled cells a lane needs before its own cost distribution sets its estimate. */
1313
+ const ESTIMATE_FROM_CELLS = 20;
1314
+ const ESTIMATE_MARGIN = 1.5;
1315
+ const DEFAULT_MAX_ATTEMPTS = 3;
1316
+ /** Dispatch order: claim cells first, then rungs and the root, then screens, then train. */
1317
+ const STAGE_PRIORITY = {
1318
+ claim: 0,
1319
+ rung: 1,
1320
+ root: 1,
1321
+ screen: 2,
1322
+ train: 3,
1323
+ external: 3
1324
+ };
1325
+ /** Run a search to its close, or continue one from its ledger. */
1326
+ async function runSearch(options) {
1327
+ return new SearchKernel(options).run();
1328
+ }
1329
+ /** The expansion index a kernel operation id encodes, or null. */
1330
+ function searchExpansionIndex(operationId) {
1331
+ const match = /^expand-(\d+)$/.exec(operationId);
1332
+ return match ? Number(match[1]) : null;
1333
+ }
1334
+ var SearchKernel = class {
1335
+ options;
1336
+ recorder;
1337
+ maxAttempts;
1338
+ now;
1339
+ state;
1340
+ lanes = /* @__PURE__ */ new Map();
1341
+ inFlight = /* @__PURE__ */ new Map();
1342
+ proposal = null;
1343
+ proposalController = null;
1344
+ completions = [];
1345
+ wake = null;
1346
+ /** Unfinished cells per node. */
1347
+ pending = /* @__PURE__ */ new Map();
1348
+ /** Admitted nodes whose screen finished, in registration order. */
1349
+ screened = [];
1350
+ screenedSet = /* @__PURE__ */ new Set();
1351
+ admitted = /* @__PURE__ */ new Set();
1352
+ expansionOf = /* @__PURE__ */ new Map();
1353
+ artifacts = /* @__PURE__ */ new Map();
1354
+ /** Children a proposal returned, by artifact digest, until they are registered. */
1355
+ proposed = /* @__PURE__ */ new Map();
1356
+ /** Cells the ledger showed unsettled at start: an earlier process may hold their attempt. */
1357
+ adoptable = /* @__PURE__ */ new Set();
1358
+ operationCosts = [];
1359
+ /** Operations started: the next one's id. */
1360
+ expansions = 0;
1361
+ /** Proposals that completed, by operation index. A proposal an interrupted
1362
+ * process lost counts toward none of the stop rules, so a resumed search
1363
+ * makes the proposals the uninterrupted one would have made. */
1364
+ completed = [];
1365
+ stopReason = null;
1366
+ failure = null;
1367
+ /** The claim's design once expansion stopped; the search then only claims. */
1368
+ claimPlan = null;
1369
+ constructor(options) {
1370
+ this.options = options;
1371
+ this.recorder = options.recorder;
1372
+ this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
1373
+ this.now = options.now ?? Date.now;
1374
+ if (!Number.isSafeInteger(this.maxAttempts) || this.maxAttempts < 1) throw new TypeError("runSearch: maxAttempts must be a positive integer");
1375
+ const { maxExpansions } = options;
1376
+ if (maxExpansions !== void 0 && (!Number.isSafeInteger(maxExpansions) || maxExpansions < 0)) throw new TypeError("runSearch: maxExpansions must be a non-negative integer");
1377
+ for (const lane of options.executor.lanes()) {
1378
+ if (!Number.isSafeInteger(lane.capacity) || lane.capacity < 1) throw new TypeError(`runSearch: lane ${lane.name} needs a positive integer capacity`);
1379
+ if (!Number.isFinite(lane.cellUsd) || lane.cellUsd < 0) throw new TypeError(`runSearch: lane ${lane.name} needs a finite non-negative cellUsd`);
1380
+ if (this.lanes.has(lane.name)) throw new TypeError(`runSearch: lane ${lane.name} is declared twice`);
1381
+ this.lanes.set(lane.name, {
1382
+ ...lane,
1383
+ inFlight: 0,
1384
+ queues: [
1385
+ [],
1386
+ [],
1387
+ [],
1388
+ []
1389
+ ],
1390
+ settledCosts: []
1391
+ });
1392
+ }
1393
+ if (this.lanes.size === 0) throw new TypeError("runSearch: the executor declares no lane");
1394
+ }
1395
+ async run() {
1396
+ const { ledger } = this.recorder;
1397
+ const lock = ledger instanceof FileSearchLedger ? acquireSingleRunLock({ lockPath: `${ledger.path}.run.lock` }) : null;
1398
+ try {
1399
+ return await this.runLocked();
1400
+ } finally {
1401
+ lock?.release();
1402
+ }
1403
+ }
1404
+ async runLocked() {
1405
+ await this.refresh();
1406
+ const header = this.state.header;
1407
+ if (!header) throw new Error(`runSearch: search ${this.recorder.searchId} has not been opened`);
1408
+ if (header.policy.expansion !== this.options.policy.name || header.policy.allocation !== this.options.allocation.name) throw new Error(`runSearch: search ${this.recorder.searchId} was opened with policy ${header.policy.expansion} and allocation ${header.policy.allocation}, not ${this.options.policy.name} and ${this.options.allocation.name}`);
1409
+ if (this.state.closed) return this.closedResult();
1410
+ await this.restore();
1411
+ const { signal } = this.options;
1412
+ const onAbort = () => {
1413
+ for (const controller of this.inFlight.values()) controller.abort(signal?.reason);
1414
+ this.proposalController?.abort(signal?.reason);
1415
+ this.wakeUp();
1416
+ };
1417
+ signal?.addEventListener("abort", onAbort, { once: true });
1418
+ try {
1419
+ if (this.claimPlan) await this.applyClaimPlan(this.claimPlan);
1420
+ await this.loop();
1421
+ if (!this.failure && !this.claimPlan && this.state.header.splits.test.tasks.length > 0) {
1422
+ await this.beginClaim();
1423
+ await this.loop();
1424
+ }
1425
+ } finally {
1426
+ signal?.removeEventListener("abort", onAbort);
1427
+ }
1428
+ if (this.failure) throw this.failure.error;
1429
+ await this.close();
1430
+ return this.closedResult();
1431
+ }
1432
+ async restore() {
1433
+ const rootInput = this.options.codec.node(this.recorder, this.options.root);
1434
+ const rootId = this.state.rootNodeId;
1435
+ if (rootId === null) {
1436
+ const { nodeId } = await this.recorder.registerNode(rootInput);
1437
+ await this.recorder.recordEdge({
1438
+ childNodeId: nodeId,
1439
+ parents: [],
1440
+ operator: "seed",
1441
+ attribution: "explicit",
1442
+ proposer: null,
1443
+ proposalKey: "root",
1444
+ rationale: { unknown: "the root is the caller-supplied starting artifact" },
1445
+ diffs: [],
1446
+ label: "root"
1447
+ });
1448
+ await this.refresh();
1449
+ } else if (this.state.node(rootId).artifactDigest !== rootInput.artifactDigest) throw new Error(`runSearch: search ${this.recorder.searchId} started from another root artifact; a changed start is a derived search`);
1450
+ const root = this.state.rootNodeId;
1451
+ this.artifacts.set(root, this.options.root);
1452
+ this.expansionOf.set(root, -1);
1453
+ if (this.state.audit.cells.allocated === 0) this.assertClaimReady();
1454
+ await this.restoreClaimPlan();
1455
+ const recorded = [];
1456
+ for (let expansion = 0;; expansion++) {
1457
+ const operationId = `expand-${expansion}`;
1458
+ const operation = this.state.operation(operationId);
1459
+ if (!operation) break;
1460
+ this.expansions = expansion + 1;
1461
+ if (operation.recorded) {
1462
+ this.operationCosts.push(operation.spentUsd);
1463
+ recorded.push(expansion);
1464
+ if (operation.outcome === "completed") this.completed.push(expansion);
1465
+ continue;
1466
+ }
1467
+ await this.recorder.recordOperation({
1468
+ operationId,
1469
+ operationKind: "candidate-generation",
1470
+ execution: this.options.proposer.execution,
1471
+ outcome: {
1472
+ status: "failed",
1473
+ failure: {
1474
+ code: "interrupted",
1475
+ message: "the search stopped while this proposal ran; its output was lost"
1476
+ }
1477
+ },
1478
+ accounting: interruptedAccounting()
1479
+ });
1480
+ await this.refresh();
1481
+ }
1482
+ for (const node of this.state.nodes()) {
1483
+ if (node.edgeIds.length === 0) continue;
1484
+ const edge = this.state.edge(node.edgeIds[0]);
1485
+ const expansion = edge.proposer?.operationId ? searchExpansionIndex(edge.proposer.operationId) : null;
1486
+ this.expansionOf.set(node.nodeId, expansion ?? -1);
1487
+ if (node.status !== "invalid") this.admitted.add(node.nodeId);
1488
+ }
1489
+ for (const cell of this.state.cells()) {
1490
+ const lane = cell.lane === null ? void 0 : this.lanes.get(cell.lane);
1491
+ if (cell.final && lane) lane.settledCosts.push(cell.spentUsd);
1492
+ if (this.cellDone(cell)) continue;
1493
+ if (!lane) throw new Error(`runSearch: cell ${cell.cellId} was placed on lane ${String(cell.lane)}, which the executor does not declare`);
1494
+ this.adoptable.add(cell.cellId);
1495
+ this.enqueue(cell);
1496
+ if (cell.stage !== "claim") this.pending.set(cell.nodeId, (this.pending.get(cell.nodeId) ?? 0) + 1);
1497
+ }
1498
+ if (this.claimPlan) return;
1499
+ for (const expansion of recorded) await this.finishProposal(expansion);
1500
+ for (const node of this.state.nodes()) if (this.admitted.has(node.nodeId) && !isTerminal(node)) await this.allocateFor(node.nodeId);
1501
+ for (const nodeId of this.state.nodeIds()) {
1502
+ if (!this.admitted.has(nodeId)) continue;
1503
+ if (this.state.node(nodeId).status === "advanced") this.markScreened(nodeId);
1504
+ else if ((this.pending.get(nodeId) ?? 0) === 0) await this.screenDone(nodeId);
1505
+ }
1506
+ await this.advance();
1507
+ }
1508
+ /**
1509
+ * A search that will claim must be able to: finalists come from a selection
1510
+ * split, the power check needs the claim's minimum effect, a ship needs a
1511
+ * pinned judge, and under a cap the claim reserve must cover the root and 3
1512
+ * finalists on every test task while the rest of the cap covers one
1513
+ * screening round. Checked before the first cell, so a search that cannot
1514
+ * claim spends nothing.
1515
+ */
1516
+ assertClaimReady() {
1517
+ const header = this.state.header;
1518
+ const testTasks = header.splits.test.tasks.length;
1519
+ if (testTasks === 0) return;
1520
+ const refuse = (why) => {
1521
+ throw new Error(`runSearch: search ${this.recorder.searchId} has a test split, but ${why}`);
1522
+ };
1523
+ if (header.splits.selection.tasks.length === 0) refuse("no selection split to choose its finalists on");
1524
+ if (header.objective.claim.minimumEffect === void 0) refuse("its claim declares no minimumEffect for the power check");
1525
+ if ("unknown" in header.objective.judge) refuse("its judge is not pinned; a claim with an unknown judge cannot ship, and a resumed search could mix two judges");
1526
+ const { maxUsd, reservedClaimUsd } = header.budget;
1527
+ if (maxUsd === null) return;
1528
+ const cellUsd = this.maxCellReservation();
1529
+ const need = searchClaimReserveUsd({
1530
+ testTasks,
1531
+ reps: this.options.allocation.reps,
1532
+ cellUsd
1533
+ });
1534
+ if (reservedClaimUsd + USD_TOLERANCE < need) refuse(`its claim reserve $${reservedClaimUsd} is below the $${need} the root and 3 finalists need on ${testTasks} test tasks at $${cellUsd} a cell`);
1535
+ const rootCells = this.options.allocation.plan(this.state, this.state.rootNodeId, 0).length;
1536
+ const screens = (this.options.proposer.childrenPerProposal ?? 1) * this.options.allocation.screenSize(this.state);
1537
+ const round = this.operationReservation().usd + (rootCells + screens) * cellUsd;
1538
+ if (maxUsd - reservedClaimUsd + USD_TOLERANCE < round) refuse(`its cap $${maxUsd} less the claim reserve $${reservedClaimUsd} does not cover the root and one screening round ($${round})`);
1539
+ }
1540
+ /** A claim an earlier process began: continue it from its stored plan. */
1541
+ async restoreClaimPlan() {
1542
+ const operation = this.state.operation(CLAIM_OPERATION);
1543
+ if (!operation) return;
1544
+ const ref = operation.artifacts.find((artifact) => artifact.role === "claim-plan");
1545
+ if (!ref) throw new Error(`runSearch: the ${CLAIM_OPERATION} operation has no stored plan`);
1546
+ const plan = this.recorder.readBlob(ref);
1547
+ if (!operation.recorded) await this.recordClaimOperation();
1548
+ this.claimPlan = plan;
1549
+ this.stopReason = plan.stopReason;
1550
+ }
1551
+ async loop() {
1552
+ for (;;) {
1553
+ if (this.failure || this.options.signal?.aborted) {
1554
+ await this.drain(this.failure?.error ?? this.options.signal?.reason);
1555
+ this.failure ??= { error: this.options.signal?.reason ?? /* @__PURE__ */ new Error("aborted") };
1556
+ return;
1557
+ }
1558
+ if (this.pastDeadline()) {
1559
+ this.stopReason ??= "deadline";
1560
+ if (this.queuedCount() > 0) await this.cancelQueued("deadline");
1561
+ }
1562
+ this.dispatch();
1563
+ const blocked = this.stopReason === null && this.proposal === null ? await this.tryExpand() : null;
1564
+ if (!(this.inFlight.size > 0 || this.proposal !== null)) {
1565
+ if (this.queuedCount() > 0) continue;
1566
+ this.stopReason ??= blocked ?? "converged";
1567
+ return;
1568
+ }
1569
+ await this.handle(await this.next());
1570
+ }
1571
+ }
1572
+ /** The next completion, or null when an abort woke the loop without one. */
1573
+ async next() {
1574
+ if (this.completions.length === 0) await new Promise((resolve) => {
1575
+ this.wake = resolve;
1576
+ });
1577
+ return this.completions.shift() ?? null;
1578
+ }
1579
+ push(completion) {
1580
+ this.completions.push(completion);
1581
+ this.wakeUp();
1582
+ }
1583
+ wakeUp() {
1584
+ const wake = this.wake;
1585
+ this.wake = null;
1586
+ wake?.();
1587
+ }
1588
+ async handle(completion) {
1589
+ if (completion === null) return;
1590
+ if (completion.kind === "cell") await this.settle(completion);
1591
+ else await this.recordProposal(completion);
1592
+ }
1593
+ /** Abort everything in flight and record what still completes. */
1594
+ async drain(reason) {
1595
+ for (const controller of this.inFlight.values()) controller.abort(reason);
1596
+ this.proposalController?.abort(reason);
1597
+ while (this.inFlight.size > 0 || this.proposal !== null) await this.handle(await this.next());
1598
+ }
1599
+ /** Fill free lane slots with queued cells. A queued cell was admitted when
1600
+ * it was allocated, so starting it needs no further decision. */
1601
+ dispatch() {
1602
+ if (this.failure || this.options.signal?.aborted) return;
1603
+ const claimOnly = this.pastDeadline();
1604
+ const cap = this.state.header.budget.maxConcurrency;
1605
+ for (const lane of this.lanes.values()) while (lane.inFlight < lane.capacity && (cap === null || this.inFlight.size < cap)) {
1606
+ const cellId = (claimOnly ? lane.queues[STAGE_PRIORITY.claim] : lane.queues.find((waiting) => waiting.length > 0))?.shift();
1607
+ if (cellId === void 0) break;
1608
+ this.start(cellId, lane);
1609
+ }
1610
+ }
1611
+ start(cellId, lane) {
1612
+ const cell = this.state.cell(cellId);
1613
+ const attempt = cell.attempts + 1;
1614
+ const controller = new AbortController();
1615
+ const work = {
1616
+ searchId: this.recorder.searchId,
1617
+ cellId,
1618
+ nodeId: cell.nodeId,
1619
+ taskId: cell.taskId,
1620
+ unitId: cell.unitId,
1621
+ split: cell.split,
1622
+ rep: cell.rep,
1623
+ stage: cell.stage,
1624
+ lane: lane.name,
1625
+ attempt,
1626
+ runId: `${cellId}:${attempt}`,
1627
+ artifact: this.artifact(cell.nodeId),
1628
+ signal: controller.signal
1629
+ };
1630
+ const adopt = this.adoptable.delete(cellId);
1631
+ this.inFlight.set(cellId, controller);
1632
+ lane.inFlight += 1;
1633
+ const { executor } = this.options;
1634
+ (async () => (adopt ? await executor.adopt(work) : null) ?? await executor.run(work))().then((result) => this.push({
1635
+ kind: "cell",
1636
+ cellId,
1637
+ attempt,
1638
+ result
1639
+ }), (error) => this.push({
1640
+ kind: "cell",
1641
+ cellId,
1642
+ attempt,
1643
+ error
1644
+ }));
1645
+ }
1646
+ async settle(completion) {
1647
+ const { cellId, attempt } = completion;
1648
+ this.inFlight.delete(cellId);
1649
+ const before = this.state.cell(cellId);
1650
+ const lane = this.lanes.get(before.lane);
1651
+ lane.inFlight -= 1;
1652
+ if (!completion.result) {
1653
+ this.failure ??= { error: completion.error };
1654
+ return;
1655
+ }
1656
+ this.dispatch();
1657
+ if ((this.failure || this.options.signal?.aborted) && completion.result.outcome.status === "errored") return;
1658
+ await this.recorder.settleCell({
1659
+ ...completion.result,
1660
+ cellId,
1661
+ attempt
1662
+ });
1663
+ await this.refresh();
1664
+ const cell = this.state.cell(cellId);
1665
+ if (cell.final) lane.settledCosts.push(cell.spentUsd);
1666
+ if (!this.cellDone(cell)) {
1667
+ if (this.failure || this.options.signal?.aborted) return;
1668
+ this.enqueue(cell);
1669
+ return;
1670
+ }
1671
+ if (cell.stage === "claim") return;
1672
+ const left = (this.pending.get(cell.nodeId) ?? 1) - 1;
1673
+ this.pending.set(cell.nodeId, left);
1674
+ if (left === 0) await this.onNodeIdle(cell.nodeId);
1675
+ }
1676
+ /** Every cell allocated to the node is done: its screen, or a rung. */
1677
+ async onNodeIdle(nodeId) {
1678
+ if (!await this.screenDone(nodeId)) return;
1679
+ await this.advance();
1680
+ }
1681
+ /** Record the rank decisions the allocator now supports and allocate the
1682
+ * rungs they open. Past the deadline, once the claim began, or once the
1683
+ * search fails or is aborted, no rung opens; a search that stopped
1684
+ * expanding still measures the nodes that earned it. */
1685
+ async advance() {
1686
+ if (this.claimPlan || this.failure || this.options.signal?.aborted || this.pastDeadline()) return;
1687
+ for (const decision of this.options.allocation.advance(this.allocationView())) await this.allocateFor(decision.nodeId, decision);
1688
+ }
1689
+ /**
1690
+ * A node's allocated cells all finished. A node that diverges (its train
1691
+ * mean rose over its parent's while its selection interval against the
1692
+ * parent lies wholly on the worse side) is decided `invalid` and never
1693
+ * becomes a parent; any other node is screened. Returns whether it was.
1694
+ */
1695
+ async screenDone(nodeId) {
1696
+ if (!this.claimPlan && !this.screenedSet.has(nodeId) && this.state.node(nodeId).status !== "advanced") {
1697
+ const divergence = searchDivergence(this.state, nodeId);
1698
+ if (divergence) {
1699
+ await this.recorder.decideNode({
1700
+ nodeId,
1701
+ decision: { status: "invalid" },
1702
+ basis: divergence.basis,
1703
+ rule: "divergence",
1704
+ reason: divergence.reason
1705
+ });
1706
+ await this.refresh();
1707
+ this.admitted.delete(nodeId);
1708
+ return false;
1709
+ }
1710
+ }
1711
+ this.markScreened(nodeId);
1712
+ return true;
1713
+ }
1714
+ markScreened(nodeId) {
1715
+ if (this.screenedSet.has(nodeId)) return;
1716
+ this.screenedSet.add(nodeId);
1717
+ const ordinal = this.state.node(nodeId).ordinal;
1718
+ let index = this.screened.length;
1719
+ while (index > 0 && this.state.node(this.screened[index - 1]).ordinal > ordinal) index--;
1720
+ this.screened = [
1721
+ ...this.screened.slice(0, index),
1722
+ nodeId,
1723
+ ...this.screened.slice(index)
1724
+ ];
1725
+ }
1726
+ /**
1727
+ * Allocate the cells the allocator plans for a node through its rung and the
1728
+ * ledger lacks. With a rank decision, the rung is the decision's, and the
1729
+ * decision is recorded first, only once the cap admits the rung's cells.
1730
+ * Returns false when the cap or `maxCells` refused them.
1731
+ */
1732
+ async allocateFor(nodeId, decision) {
1733
+ const rung = decision?.decision.status === "advanced" ? decision.decision.rung : this.state.node(nodeId).rung ?? 0;
1734
+ const plans = this.options.allocation.plan(this.state, nodeId, rung).filter((plan) => !this.state.cell(searchCellId(this.recorder.searchId, nodeId, plan.taskId, plan.split, plan.rep)));
1735
+ if (plans.length === 0) {
1736
+ if (decision) await this.decide(decision);
1737
+ return true;
1738
+ }
1739
+ const placed = plans.map((plan) => {
1740
+ const laneName = this.options.executor.place({
1741
+ ...plan,
1742
+ nodeId
1743
+ });
1744
+ const lane = this.lanes.get(laneName);
1745
+ if (!lane) throw new Error(`runSearch: the executor placed a cell on unknown lane ${laneName}`);
1746
+ return {
1747
+ plan,
1748
+ lane,
1749
+ reservation: this.cellReservation(lane)
1750
+ };
1751
+ });
1752
+ const hold = placed.reduce((sum, { reservation }) => sum + reservation.usd, 0);
1753
+ const { headroomUsd } = this.state.budget;
1754
+ if (headroomUsd !== null && hold > headroomUsd + USD_TOLERANCE) return false;
1755
+ const { maxCells } = this.state.header.budget;
1756
+ if (maxCells !== null && this.state.audit.cells.allocated + placed.length > maxCells) return false;
1757
+ if (decision) await this.decide(decision);
1758
+ for (const { plan, lane, reservation } of placed) await this.recorder.allocateCell({
1759
+ nodeId,
1760
+ taskId: plan.taskId,
1761
+ split: plan.split,
1762
+ rep: plan.rep,
1763
+ stage: plan.stage,
1764
+ lane: lane.name,
1765
+ reservation
1766
+ });
1767
+ await this.refresh();
1768
+ for (const { plan } of placed) {
1769
+ const cellId = searchCellId(this.recorder.searchId, nodeId, plan.taskId, plan.split, plan.rep);
1770
+ this.enqueue(this.state.cell(cellId));
1771
+ }
1772
+ this.pending.set(nodeId, (this.pending.get(nodeId) ?? 0) + placed.length);
1773
+ this.dispatch();
1774
+ return true;
1775
+ }
1776
+ async decide(decision) {
1777
+ await this.recorder.decideNode(decision);
1778
+ await this.refresh();
1779
+ }
1780
+ enqueue(cell) {
1781
+ this.lanes.get(cell.lane).queues[STAGE_PRIORITY[cell.stage]].push(cell.cellId);
1782
+ }
1783
+ queuedCount() {
1784
+ let count = 0;
1785
+ for (const lane of this.lanes.values()) for (const queue of lane.queues) count += queue.length;
1786
+ return count;
1787
+ }
1788
+ /** Fix the claim's design, store it, and start its test cells. */
1789
+ async beginClaim() {
1790
+ await this.refresh();
1791
+ const stopReason = this.stopReason ?? "converged";
1792
+ const header = this.state.header;
1793
+ const reps = this.options.allocation.reps;
1794
+ const plan = planSearchClaim(this.state, {
1795
+ stopReason,
1796
+ reps,
1797
+ affordable: (finalists) => {
1798
+ const placed = this.claimCells(finalists, this.state.rootNodeId);
1799
+ const hold = placed.reduce((sum, cell) => sum + cell.reservation.usd, 0);
1800
+ const { headroomUsd, claimReserveUsd } = this.state.budget;
1801
+ const { maxCells } = header.budget;
1802
+ return (headroomUsd === null || hold <= headroomUsd + claimReserveUsd + USD_TOLERANCE) && (maxCells === null || this.state.audit.cells.allocated + placed.length <= maxCells);
1803
+ }
1804
+ });
1805
+ await this.recorder.startOperation({
1806
+ operationId: CLAIM_OPERATION,
1807
+ operationKind: "selection",
1808
+ reservation: null,
1809
+ artifacts: [this.recorder.blob("claim-plan", plan)]
1810
+ });
1811
+ await this.recordClaimOperation();
1812
+ this.claimPlan = plan;
1813
+ this.stopReason = stopReason;
1814
+ await this.applyClaimPlan(plan);
1815
+ }
1816
+ async recordClaimOperation() {
1817
+ await this.recorder.recordOperation({
1818
+ operationId: CLAIM_OPERATION,
1819
+ operationKind: "selection",
1820
+ execution: {
1821
+ kind: "deterministic",
1822
+ source: SEARCH_CLAIM_RULE
1823
+ },
1824
+ outcome: { status: "completed" },
1825
+ accounting: {
1826
+ tokens: {
1827
+ status: "known",
1828
+ inputTokens: 0,
1829
+ outputTokens: 0,
1830
+ cachedTokens: 0
1831
+ },
1832
+ cost: {
1833
+ status: "known",
1834
+ usd: 0,
1835
+ source: "free"
1836
+ }
1837
+ }
1838
+ });
1839
+ await this.refresh();
1840
+ }
1841
+ /**
1842
+ * Decide the plan's finalists and allocate the test cells of the root and
1843
+ * the finalists, interleaved by task so the arms of one task run side by
1844
+ * side. Allocation finishes before any claim cell starts. Idempotent.
1845
+ */
1846
+ async applyClaimPlan(plan) {
1847
+ const root = this.state.rootNodeId;
1848
+ const bases = plan.finalists.map((finalist) => estimateNode(this.state, finalist.nodeId, {
1849
+ against: root,
1850
+ split: "selection"
1851
+ }));
1852
+ for (const [rank, finalist] of plan.finalists.entries()) await this.recorder.decideNode({
1853
+ nodeId: finalist.nodeId,
1854
+ decision: { status: "finalist" },
1855
+ basis: bases[rank],
1856
+ rule: SEARCH_CLAIM_RULE_NAME,
1857
+ reason: `rank ${rank + 1} of ${plan.finalists.length} by selection mean (${finalist.selectionMean}); ${plan.reason}`
1858
+ });
1859
+ await this.refresh();
1860
+ if (plan.test !== "run") return;
1861
+ const missing = this.claimCells(plan.finalists.length, root, plan).map((cell) => ({
1862
+ ...cell,
1863
+ cellId: searchCellId(this.recorder.searchId, cell.nodeId, cell.plan.taskId, "test", cell.plan.rep)
1864
+ })).filter((cell) => !this.state.cell(cell.cellId));
1865
+ for (const cell of missing) await this.recorder.allocateCell({
1866
+ nodeId: cell.nodeId,
1867
+ taskId: cell.plan.taskId,
1868
+ split: "test",
1869
+ rep: cell.plan.rep,
1870
+ stage: "claim",
1871
+ lane: cell.lane.name,
1872
+ reservation: cell.reservation
1873
+ });
1874
+ await this.refresh();
1875
+ for (const cell of missing) this.enqueue(this.state.cell(cell.cellId));
1876
+ }
1877
+ /** The test cells of the root and the first `finalists` of the plan (or of
1878
+ * the finalist count being priced), task by task, with their lanes and holds. */
1879
+ claimCells(finalists, root, plan) {
1880
+ const header = this.state.header;
1881
+ const arms = [root, ...plan ? plan.finalists.map((entry) => entry.nodeId) : []];
1882
+ while (arms.length < finalists + 1) arms.push(root);
1883
+ const cells = [];
1884
+ for (const task of header.splits.test.tasks) for (let rep = 0; rep < this.options.allocation.reps; rep++) for (const nodeId of arms) {
1885
+ const cellPlan = {
1886
+ taskId: task.taskId,
1887
+ split: "test",
1888
+ rep,
1889
+ stage: "claim"
1890
+ };
1891
+ const laneName = this.options.executor.place({
1892
+ ...cellPlan,
1893
+ nodeId
1894
+ });
1895
+ const lane = this.lanes.get(laneName);
1896
+ if (!lane) throw new Error(`runSearch: the executor placed a cell on unknown lane ${laneName}`);
1897
+ cells.push({
1898
+ nodeId,
1899
+ plan: cellPlan,
1900
+ lane,
1901
+ reservation: this.cellReservation(lane)
1902
+ });
1903
+ }
1904
+ return cells;
1905
+ }
1906
+ async cancelQueued(reason) {
1907
+ for (const lane of this.lanes.values()) for (const [priority, queue] of lane.queues.entries()) {
1908
+ if (priority === STAGE_PRIORITY.claim) continue;
1909
+ for (const cellId of queue.splice(0)) {
1910
+ const cell = this.state.cell(cellId);
1911
+ if (cell.attempts === 0) {
1912
+ await this.recorder.cancelCell({
1913
+ cellId,
1914
+ reason
1915
+ });
1916
+ await this.refresh();
1917
+ }
1918
+ const left = (this.pending.get(cell.nodeId) ?? 1) - 1;
1919
+ this.pending.set(cell.nodeId, left);
1920
+ if (left === 0) this.markScreened(cell.nodeId);
1921
+ }
1922
+ }
1923
+ }
1924
+ /** No further attempt will run: a final outcome, a cancellation, or a
1925
+ * retryable error that used up its attempts. */
1926
+ cellDone(cell) {
1927
+ return cell.final || cell.cancelled !== null || cell.outcome === "errored" && cell.attempts >= this.maxAttempts;
1928
+ }
1929
+ /** Start a proposal when the rules admit one; otherwise say why not. */
1930
+ async tryExpand() {
1931
+ const { maxNodes, maxCells } = this.state.header.budget;
1932
+ if (maxNodes !== null && this.state.audit.nodes >= maxNodes) return "max-nodes";
1933
+ const { maxExpansions } = this.options;
1934
+ if (maxExpansions !== void 0 && this.completed.length >= maxExpansions) return "max-nodes";
1935
+ if (this.queuedCount() > 0) return "converged";
1936
+ if (this.inFlight.size >= 2 * this.capacity()) return "converged";
1937
+ const view = this.policyView();
1938
+ const { policy } = this.options;
1939
+ const leader = policy.leader(view);
1940
+ if (policy.patience !== void 0 && this.completed.filter((index) => index > (this.expansionOf.get(leader) ?? -1)).length >= policy.patience) return "patience";
1941
+ const screen = this.options.allocation.screenSize(this.state);
1942
+ const operation = this.operationReservation();
1943
+ const children = this.options.proposer.childrenPerProposal ?? 1;
1944
+ const need = operation.usd + children * screen * this.maxCellReservation();
1945
+ const { headroomUsd } = this.state.budget;
1946
+ if (headroomUsd !== null && (need > headroomUsd + USD_TOLERANCE || this.overCap())) return "budget";
1947
+ if (maxCells !== null && this.state.audit.cells.allocated + children * screen > maxCells) return "budget";
1948
+ const expansion = policy.expand(view);
1949
+ if (expansion === null) return "converged";
1950
+ const screened = new Set(view.screened);
1951
+ if (expansion.parents.length === 0) throw new Error(`runSearch: policy ${policy.name} proposed an expansion without a parent`);
1952
+ for (const parent of expansion.parents) if (!screened.has(parent)) throw new Error(`runSearch: policy ${policy.name} named parent ${parent}, which has not finished its screen; a node becomes a parent only after its screen completes`);
1953
+ const index = this.expansions++;
1954
+ const operationId = `expand-${index}`;
1955
+ await this.recorder.startOperation({
1956
+ operationId,
1957
+ operationKind: "candidate-generation",
1958
+ reservation: operation
1959
+ });
1960
+ await this.refresh();
1961
+ const controller = new AbortController();
1962
+ const request = {
1963
+ expansion: index,
1964
+ operationId,
1965
+ operator: expansion.operator,
1966
+ parents: expansion.parents.map((nodeId) => ({
1967
+ nodeId,
1968
+ artifact: this.artifact(nodeId)
1969
+ })),
1970
+ leader,
1971
+ signal: controller.signal
1972
+ };
1973
+ this.proposalController = controller;
1974
+ this.proposal = this.options.proposer.propose(request).then((result) => this.push({
1975
+ kind: "proposal",
1976
+ expansion: index,
1977
+ operationId,
1978
+ request,
1979
+ selection: expansion.selection,
1980
+ result
1981
+ }), (error) => this.push({
1982
+ kind: "proposal",
1983
+ expansion: index,
1984
+ operationId,
1985
+ request,
1986
+ selection: expansion.selection,
1987
+ error
1988
+ }));
1989
+ return null;
1990
+ }
1991
+ async recordProposal(completion) {
1992
+ this.proposal = null;
1993
+ this.proposalController = null;
1994
+ const { operationId, expansion } = completion;
1995
+ const { proposer, codec, admit } = this.options;
1996
+ const result = completion.result;
1997
+ if (!result) {
1998
+ await this.recorder.recordOperation({
1999
+ operationId,
2000
+ operationKind: "candidate-generation",
2001
+ execution: proposer.execution,
2002
+ outcome: {
2003
+ status: "failed",
2004
+ failure: {
2005
+ code: "proposer-error",
2006
+ message: errorMessage(completion.error)
2007
+ }
2008
+ },
2009
+ accounting: interruptedAccounting()
2010
+ });
2011
+ await this.refresh();
2012
+ this.failure ??= { error: completion.error };
2013
+ return;
2014
+ }
2015
+ const { maxNodes } = this.state.header.budget;
2016
+ const room = maxNodes === null ? Number.POSITIVE_INFINITY : maxNodes - this.state.audit.nodes;
2017
+ const parents = completion.request.parents;
2018
+ const blob = {
2019
+ kind: "search-proposal",
2020
+ operator: completion.request.operator,
2021
+ parents: parents.map((parent) => parent.nodeId),
2022
+ selection: completion.selection,
2023
+ children: result.children.slice(0, Math.max(0, room)).map((child) => ({
2024
+ node: codec.node(this.recorder, child.artifact),
2025
+ diffs: parents.map((parent) => codec.diff(this.recorder, parent.artifact, child.artifact)),
2026
+ label: redactText(child.label, { profile: "share" }),
2027
+ rationale: redactText(child.rationale, { profile: "share" }),
2028
+ attribution: child.attribution ?? null,
2029
+ invalid: admit?.(child.artifact) ?? null
2030
+ }))
2031
+ };
2032
+ await this.recorder.recordOperation({
2033
+ operationId,
2034
+ operationKind: "candidate-generation",
2035
+ execution: result.execution ?? proposer.execution,
2036
+ outcome: { status: "completed" },
2037
+ accounting: result.accounting,
2038
+ artifacts: [this.recorder.blob("proposal", blob)]
2039
+ });
2040
+ await this.refresh();
2041
+ this.operationCosts.push(this.state.operation(operationId).spentUsd);
2042
+ this.completed.push(expansion);
2043
+ for (const [index, child] of blob.children.entries()) this.proposed.set(child.node.artifactDigest, result.children[index].artifact);
2044
+ if (result.stop !== void 0 || blob.children.length === 0) this.stopReason ??= "converged";
2045
+ await this.finishProposal(expansion);
2046
+ }
2047
+ /** Register every child a recorded proposal produced, each once. */
2048
+ async finishProposal(expansion) {
2049
+ const operationId = `expand-${expansion}`;
2050
+ const operation = this.state.operation(operationId);
2051
+ const ref = operation?.artifacts.find((artifact) => artifact.role === "proposal");
2052
+ if (!operation?.recorded || operation.outcome !== "completed" || !ref) return;
2053
+ const blob = this.recorder.readBlob(ref);
2054
+ const { proposer } = this.options;
2055
+ for (const [index, child] of blob.children.entries()) {
2056
+ const { nodeId } = await this.recorder.registerNode(child.node);
2057
+ const held = this.proposed.get(child.node.artifactDigest);
2058
+ if (held !== void 0) {
2059
+ this.proposed.delete(child.node.artifactDigest);
2060
+ if (!this.artifacts.has(nodeId)) this.artifacts.set(nodeId, held);
2061
+ }
2062
+ const edgeId = await this.recorder.recordEdge({
2063
+ childNodeId: nodeId,
2064
+ parents: blob.parents,
2065
+ operator: blob.operator,
2066
+ attribution: "explicit",
2067
+ proposer: {
2068
+ kind: proposer.kind,
2069
+ name: proposer.name,
2070
+ operationId,
2071
+ source: proposer.source
2072
+ },
2073
+ proposalKey: `${operationId}:${index}`,
2074
+ selection: blob.selection,
2075
+ rationale: child.rationale,
2076
+ diffs: child.diffs,
2077
+ label: child.label
2078
+ });
2079
+ await this.refresh();
2080
+ const node = this.state.node(nodeId);
2081
+ if (node.edgeIds[0] !== edgeId) continue;
2082
+ this.expansionOf.set(nodeId, expansion);
2083
+ if (child.invalid !== null) {
2084
+ await this.recorder.decideNode({
2085
+ nodeId,
2086
+ decision: { status: "invalid" },
2087
+ rule: "admission",
2088
+ reason: child.invalid
2089
+ });
2090
+ await this.refresh();
2091
+ continue;
2092
+ }
2093
+ if (this.admitted.has(nodeId) || node.status === "invalid") continue;
2094
+ this.admitted.add(nodeId);
2095
+ await this.allocateFor(nodeId);
2096
+ if ((this.pending.get(nodeId) ?? 0) === 0) this.markScreened(nodeId);
2097
+ }
2098
+ }
2099
+ async close() {
2100
+ await this.refresh();
2101
+ const keep = this.options.policy.leader(this.policyView());
2102
+ for (const decision of this.options.allocation.prune(this.allocationView(), keep)) await this.decide(decision);
2103
+ if (this.claimPlan) {
2104
+ await this.closeWithClaim(this.claimPlan);
2105
+ return;
2106
+ }
2107
+ const view = this.policyView();
2108
+ const leader = this.options.policy.leader(view);
2109
+ const { split } = view;
2110
+ const root = this.state.rootNodeId;
2111
+ const rule = this.options.policy.name;
2112
+ const leaderUnits = view.unitScores(leader).map((unit) => unit.unitId);
2113
+ const decisions = this.state.nodes().filter((node) => !isTerminal(node)).map((node) => {
2114
+ const scored = this.state.scoredCells(node.nodeId, split).length > 0;
2115
+ const lead = node.nodeId === leader && scored;
2116
+ const against = node.nodeId === leader ? root : leader;
2117
+ const basis = node.nodeId === against || !scored ? null : estimateNode(this.state, node.nodeId, {
2118
+ against,
2119
+ split
2120
+ });
2121
+ const units = new Set(view.unitScores(node.nodeId).map((unit) => unit.unitId));
2122
+ return {
2123
+ nodeId: node.nodeId,
2124
+ decision: { status: lead ? "selected" : "rejected" },
2125
+ basis,
2126
+ rule,
2127
+ reason: lead ? `the leader when the search stopped (${this.stopReason}): a budget decision on the ${split} split, which claims nothing` : !this.screenedSet.has(node.nodeId) || !scored ? "the search stopped before this node was measured" : !view.complete(node.nodeId) ? "it left a unit unscored, so it could not lead" : leaderUnits.some((unitId) => !units.has(unitId)) ? "the search stopped before it was measured on every unit the leader was" : "it did not beat the leader on the units they share"
2128
+ };
2129
+ });
2130
+ for (const decision of decisions) await this.recorder.decideNode(decision);
2131
+ await this.recorder.close({
2132
+ reason: this.stopReason ?? "converged",
2133
+ claim: null
2134
+ });
2135
+ await this.refresh();
2136
+ }
2137
+ /** Close with the claim: the claim's decisions, then every other open node
2138
+ * rejected with its selection estimate against the root. */
2139
+ async closeWithClaim(plan) {
2140
+ const { claim, decisions } = decideSearchClaim(this.state, plan);
2141
+ const decided = new Set(decisions.map((decision) => decision.nodeId));
2142
+ const root = this.state.rootNodeId;
2143
+ const rest = this.state.nodes().filter((node) => !decided.has(node.nodeId) && !isTerminal(node)).map((node) => {
2144
+ const scored = this.state.scoredCells(node.nodeId, "selection").length > 0;
2145
+ return {
2146
+ nodeId: node.nodeId,
2147
+ decision: { status: "rejected" },
2148
+ basis: scored ? estimateNode(this.state, node.nodeId, {
2149
+ against: root,
2150
+ split: "selection"
2151
+ }) : null,
2152
+ rule: SEARCH_CLAIM_RULE_NAME,
2153
+ reason: scored ? "not a finalist: the claim tests at most 3 nodes that scored every selection unit and beat the root, best selection mean first" : "the search stopped before this node was measured on the selection split"
2154
+ };
2155
+ });
2156
+ for (const decision of [...decisions, ...rest]) await this.recorder.decideNode(decision);
2157
+ await this.recorder.close({
2158
+ reason: plan.stopReason,
2159
+ claim
2160
+ });
2161
+ await this.refresh();
2162
+ }
2163
+ /** The closed search, with its claim made again from the ledger alone. A
2164
+ * claim the ledger does not support throws: a tampered ledger, or a
2165
+ * producer and verifier that disagree. */
2166
+ closedResult() {
2167
+ const state = this.state;
2168
+ const claimVerification = verifySearchClaim(state);
2169
+ if (claimVerification?.status === "mismatch") throw new Error(`runSearch: search ${state.searchId} closed with a claim its ledger does not support: ${claimVerification.differences.join("; ")}`);
2170
+ return {
2171
+ state,
2172
+ leader: state.audit.selectedNodeId ?? state.rootNodeId,
2173
+ reason: state.closed.reason,
2174
+ claim: state.closed.claim,
2175
+ claimVerification
2176
+ };
2177
+ }
2178
+ policyView() {
2179
+ return searchPolicyView(this.state, {
2180
+ screened: this.screened,
2181
+ screening: this.admitted.size - this.screenedSet.size,
2182
+ expansions: this.completed.length,
2183
+ maxAttempts: this.maxAttempts
2184
+ });
2185
+ }
2186
+ allocationView() {
2187
+ return {
2188
+ state: this.state,
2189
+ idle: (nodeId) => this.admitted.has(nodeId) && (this.pending.get(nodeId) ?? 0) === 0
2190
+ };
2191
+ }
2192
+ artifact(nodeId) {
2193
+ const held = this.artifacts.get(nodeId);
2194
+ if (held !== void 0) return held;
2195
+ const artifact = this.options.codec.load(this.recorder, this.state.node(nodeId));
2196
+ this.artifacts.set(nodeId, artifact);
2197
+ return artifact;
2198
+ }
2199
+ capacity() {
2200
+ let total = 0;
2201
+ for (const lane of this.lanes.values()) total += lane.capacity;
2202
+ const cap = this.state.header.budget.maxConcurrency;
2203
+ return cap === null ? total : Math.min(cap, total);
2204
+ }
2205
+ /** A hard lane holds its enforced maximum. An estimate lane holds 1.5 times
2206
+ * the 99th percentile of its settled cells once 20 settled, else its prior. */
2207
+ cellReservation(lane) {
2208
+ if (lane.costCap === "hard") return {
2209
+ kind: "hard",
2210
+ usd: lane.cellUsd
2211
+ };
2212
+ return {
2213
+ kind: "estimate",
2214
+ usd: estimate(lane.settledCosts, lane.cellUsd)
2215
+ };
2216
+ }
2217
+ maxCellReservation() {
2218
+ let most = 0;
2219
+ for (const lane of this.lanes.values()) most = Math.max(most, this.cellReservation(lane).usd);
2220
+ return most;
2221
+ }
2222
+ operationReservation() {
2223
+ return {
2224
+ kind: "estimate",
2225
+ usd: estimate(this.operationCosts, this.options.proposer.reservationUsd ?? 0)
2226
+ };
2227
+ }
2228
+ /** Overspend took committed spend, open holds and the unspent claim reserve
2229
+ * past the cap. Nothing new is proposed then, even at a zero-dollar prior. */
2230
+ overCap() {
2231
+ const { maxUsd } = this.state.header.budget;
2232
+ if (maxUsd === null) return false;
2233
+ const { committedUsd, openReservationUsd } = this.state.audit.spend;
2234
+ return committedUsd + openReservationUsd + this.state.budget.claimReserveUsd > maxUsd + USD_TOLERANCE;
2235
+ }
2236
+ pastDeadline() {
2237
+ const deadline = this.state.header.budget.deadline;
2238
+ return deadline !== null && this.now() >= Date.parse(deadline);
2239
+ }
2240
+ async refresh() {
2241
+ this.state = await this.recorder.state();
2242
+ }
2243
+ };
2244
+ /**
2245
+ * A policy's read of a search: `screened` lists the admitted nodes whose screen
2246
+ * finished, in registration order; `screening` counts the admitted nodes still
2247
+ * being screened. Nodes are ranked on the selection split, or on train when
2248
+ * the search declares no selection split. The view reads no test cell.
2249
+ * `maxAttempts` (default 3, the kernel's) says when an errored cell has no
2250
+ * attempt left, which makes its unit a dodge.
2251
+ */
2252
+ function searchPolicyView(state, input) {
2253
+ const maxAttempts = input.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
2254
+ const header = state.header;
2255
+ if (!header) throw new Error(`search ${state.searchId} has not been opened`);
2256
+ const split = header.splits.selection.tasks.length > 0 ? "selection" : "train";
2257
+ let nodes;
2258
+ let operatorWeights;
2259
+ return {
2260
+ searchId: state.searchId,
2261
+ seed: header.policy.seed,
2262
+ direction: header.objective.direction,
2263
+ split,
2264
+ rootNodeId: state.rootNodeId,
2265
+ expansions: input.expansions,
2266
+ screened: [...input.screened],
2267
+ screening: input.screening,
2268
+ complete: (nodeId) => {
2269
+ let scored = false;
2270
+ for (const cell of state.cells({ nodeId })) {
2271
+ if (cell.split !== split) continue;
2272
+ if (cell.score !== null) scored = true;
2273
+ else if (cell.final || cell.outcome === "errored" && cell.attempts >= maxAttempts) return false;
2274
+ }
2275
+ return scored;
2276
+ },
2277
+ unitScores: (nodeId) => state.unitScores(nodeId, split),
2278
+ estimate: (nodeId, against) => estimateNode(state, nodeId, {
2279
+ against,
2280
+ split
2281
+ }),
2282
+ nodes: () => {
2283
+ nodes ??= policyNodes(state);
2284
+ return nodes;
2285
+ },
2286
+ get posterior() {
2287
+ let posterior = POSTERIORS.get(state);
2288
+ if (!posterior) {
2289
+ posterior = searchPosterior(state, { split });
2290
+ POSTERIORS.set(state, posterior);
2291
+ }
2292
+ return posterior;
2293
+ },
2294
+ get operatorWeights() {
2295
+ operatorWeights ??= operatorYield(state, { split }).signal.value;
2296
+ return operatorWeights;
2297
+ }
2298
+ };
2299
+ }
2300
+ /** One posterior per ledger state: a state view is retired when the ledger
2301
+ * moves on, so the view it was computed from names the sequence. */
2302
+ const POSTERIORS = /* @__PURE__ */ new WeakMap();
2303
+ /** Every node whose edge is recorded, with its lineage and its defects outside
2304
+ * the test split. */
2305
+ function policyNodes(state) {
2306
+ const nodes = [];
2307
+ for (const node of state.nodes()) {
2308
+ const edgeId = node.edgeIds[0];
2309
+ if (edgeId === void 0) continue;
2310
+ let outcomes = 0;
2311
+ let defects = 0;
2312
+ for (const cell of state.cells({ nodeId: node.nodeId })) {
2313
+ if (cell.split === "test" || !cell.final) continue;
2314
+ outcomes += 1;
2315
+ if (cell.outcome === "failed") defects += 1;
2316
+ }
2317
+ const edge = state.edge(edgeId);
2318
+ nodes.push({
2319
+ nodeId: node.nodeId,
2320
+ ordinal: node.ordinal,
2321
+ parent: node.primaryParentId,
2322
+ operator: edge.operator,
2323
+ rule: edge.selection?.rule ?? null,
2324
+ children: node.children,
2325
+ status: node.status,
2326
+ outcomes,
2327
+ defects
2328
+ });
2329
+ }
2330
+ return nodes;
2331
+ }
2332
+ /**
2333
+ * The divergence rule: a node whose train mean rose over its parent's on the
2334
+ * train units they share while its selection interval against the parent (6
2335
+ * or more units, not indeterminate) lies wholly on the worse side. Train is
2336
+ * what the proposer reads; a gain there that selection contradicts is the
2337
+ * signature of fitting the feedback instead of the task.
2338
+ */
2339
+ function searchDivergence(state, nodeId) {
2340
+ const header = state.header;
2341
+ const parent = state.node(nodeId)?.primaryParentId ?? null;
2342
+ if (!header || parent === null || header.splits.selection.tasks.length === 0) return null;
2343
+ const selection = estimateNode(state, nodeId, {
2344
+ against: parent,
2345
+ split: "selection"
2346
+ });
2347
+ if (selection.interval === null) return null;
2348
+ const maximize = header.objective.direction === "maximize";
2349
+ const [low, high] = selection.interval;
2350
+ if (maximize ? high >= 0 : low <= 0) return null;
2351
+ const parentTrain = new Map(state.unitScores(parent, "train").map((unit) => [unit.unitId, unit.mean]));
2352
+ let gain = 0;
2353
+ let shared = 0;
2354
+ for (const unit of state.unitScores(nodeId, "train")) {
2355
+ const before = parentTrain.get(unit.unitId);
2356
+ if (before === void 0) continue;
2357
+ gain += maximize ? unit.mean - before : before - unit.mean;
2358
+ shared += 1;
2359
+ }
2360
+ if (shared === 0 || gain <= 0) return null;
2361
+ return {
2362
+ basis: selection,
2363
+ reason: `its train mean rose ${Math.round(gain / shared * 1e4) / 1e4} over its parent's on ${shared} shared train unit${shared === 1 ? "" : "s"}, while its selection interval against the parent, [${round4(low)}, ${round4(high)}] on ${selection.pairs} units, lies wholly on the worse side`
2364
+ };
2365
+ }
2366
+ function round4(value) {
2367
+ return Math.round(value * 1e4) / 1e4;
2368
+ }
2369
+ function isTerminal(node) {
2370
+ return node.status !== null && node.status !== "advanced";
2371
+ }
2372
+ function estimate(costs, prior) {
2373
+ if (costs.length < ESTIMATE_FROM_CELLS) return prior;
2374
+ const sorted = [...costs].sort((a, b) => a - b);
2375
+ const p99 = sorted[Math.min(sorted.length - 1, Math.ceil(.99 * sorted.length) - 1)];
2376
+ return Math.round(ESTIMATE_MARGIN * p99 * 1e9) / 1e9;
2377
+ }
2378
+ /** Spend of work whose result was lost: unknown, with no proven floor. */
2379
+ function interruptedAccounting() {
2380
+ return {
2381
+ tokens: {
2382
+ status: "unknown",
2383
+ reason: "the result of this work was lost"
2384
+ },
2385
+ cost: {
2386
+ status: "unknown",
2387
+ knownLowerBoundUsd: 0,
2388
+ reason: "the result of this work was lost, so its spend is unknown"
2389
+ }
2390
+ };
2391
+ }
2392
+ function errorMessage(error) {
2393
+ const text = error instanceof Error ? error.message : String(error);
2394
+ return text.trim().length > 0 ? text : "the proposer failed without a message";
2395
+ }
2396
+ //#endregion
2397
+ export { searchPosterior as C, paretoFrontierWithCrowding as D, paretoFrontier as E, searchCellSetDigest as S, dominates as T, rankingSplit as _, searchPolicyView as a, estimateNode as b, planSearchClaim as c, pairedPromotionPower as d, requiredPairsForPairedPromotion as f, INSUFFICIENT_FROM as g, operatorYield as h, searchExpansionIndex as i, searchClaimReserveUsd as l, MIN_OUTCOMES_FOR_WEIGHT as m, runSearch as n, SEARCH_CLAIM_RULE as o, EXPANSION_OPERATORS as p, searchDivergence as r, decideSearchClaim as s, SEARCH_KERNEL_SOURCE as t, verifySearchClaim as u, summarizeSamples as v, seedFromDigest as w, estimateNodeFromCells as x, SEARCH_ESTIMATOR as y };
2398
+
2399
+ //# sourceMappingURL=search-kernel-D8_kxChy.js.map