@namzu/sdk 62.12.0 → 62.13.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 (174) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/connector/index.d.ts +1 -1
  3. package/dist/connector/index.d.ts.map +1 -1
  4. package/dist/connector/index.js +1 -1
  5. package/dist/connector/index.js.map +1 -1
  6. package/dist/connector/mcp/client.d.ts +96 -0
  7. package/dist/connector/mcp/client.d.ts.map +1 -1
  8. package/dist/connector/mcp/client.js +488 -13
  9. package/dist/connector/mcp/client.js.map +1 -1
  10. package/dist/connector/mcp/index.d.ts +1 -0
  11. package/dist/connector/mcp/index.d.ts.map +1 -1
  12. package/dist/connector/mcp/index.js +1 -0
  13. package/dist/connector/mcp/index.js.map +1 -1
  14. package/dist/connector/mcp/tasks.d.ts +40 -0
  15. package/dist/connector/mcp/tasks.d.ts.map +1 -0
  16. package/dist/connector/mcp/tasks.js +55 -0
  17. package/dist/connector/mcp/tasks.js.map +1 -0
  18. package/dist/constants/mcp/index.d.ts +1 -1
  19. package/dist/constants/mcp/index.d.ts.map +1 -1
  20. package/dist/constants/mcp/index.js +5 -0
  21. package/dist/constants/mcp/index.js.map +1 -1
  22. package/dist/eval/bootstrap.d.ts +122 -0
  23. package/dist/eval/bootstrap.d.ts.map +1 -0
  24. package/dist/eval/bootstrap.js +227 -0
  25. package/dist/eval/bootstrap.js.map +1 -0
  26. package/dist/eval/compare.d.ts +109 -0
  27. package/dist/eval/compare.d.ts.map +1 -0
  28. package/dist/eval/compare.js +324 -0
  29. package/dist/eval/compare.js.map +1 -0
  30. package/dist/eval/disclosure.d.ts +70 -0
  31. package/dist/eval/disclosure.d.ts.map +1 -0
  32. package/dist/eval/disclosure.js +64 -0
  33. package/dist/eval/disclosure.js.map +1 -0
  34. package/dist/eval/experiment.d.ts +23 -0
  35. package/dist/eval/experiment.d.ts.map +1 -1
  36. package/dist/eval/experiment.js +87 -15
  37. package/dist/eval/experiment.js.map +1 -1
  38. package/dist/eval/from-turn.d.ts.map +1 -1
  39. package/dist/eval/from-turn.js +5 -0
  40. package/dist/eval/from-turn.js.map +1 -1
  41. package/dist/eval/index.d.ts +13 -2
  42. package/dist/eval/index.d.ts.map +1 -1
  43. package/dist/eval/index.js +6 -1
  44. package/dist/eval/index.js.map +1 -1
  45. package/dist/eval/report-stats.d.ts +77 -0
  46. package/dist/eval/report-stats.d.ts.map +1 -0
  47. package/dist/eval/report-stats.js +184 -0
  48. package/dist/eval/report-stats.js.map +1 -0
  49. package/dist/eval/run-options.d.ts +30 -0
  50. package/dist/eval/run-options.d.ts.map +1 -0
  51. package/dist/eval/run-options.js +20 -0
  52. package/dist/eval/run-options.js.map +1 -0
  53. package/dist/eval/run-summary.d.ts +36 -0
  54. package/dist/eval/run-summary.d.ts.map +1 -0
  55. package/dist/eval/run-summary.js +57 -0
  56. package/dist/eval/run-summary.js.map +1 -0
  57. package/dist/eval/scorers.d.ts +32 -0
  58. package/dist/eval/scorers.d.ts.map +1 -1
  59. package/dist/eval/scorers.js +77 -0
  60. package/dist/eval/scorers.js.map +1 -1
  61. package/dist/eval/types.d.ts +74 -0
  62. package/dist/eval/types.d.ts.map +1 -1
  63. package/dist/manager/session/turn-recorder.d.ts +3 -0
  64. package/dist/manager/session/turn-recorder.d.ts.map +1 -1
  65. package/dist/manager/session/turn-recorder.js +18 -2
  66. package/dist/manager/session/turn-recorder.js.map +1 -1
  67. package/dist/public-runtime.d.ts +5 -2
  68. package/dist/public-runtime.d.ts.map +1 -1
  69. package/dist/public-runtime.js +7 -2
  70. package/dist/public-runtime.js.map +1 -1
  71. package/dist/public-types.d.ts +2 -0
  72. package/dist/public-types.d.ts.map +1 -1
  73. package/dist/registry/command/kernel-commands.d.ts +3 -0
  74. package/dist/registry/command/kernel-commands.d.ts.map +1 -1
  75. package/dist/registry/command/kernel-commands.js +76 -5
  76. package/dist/registry/command/kernel-commands.js.map +1 -1
  77. package/dist/runtime/query/index.d.ts.map +1 -1
  78. package/dist/runtime/query/index.js +15 -0
  79. package/dist/runtime/query/index.js.map +1 -1
  80. package/dist/runtime/query/iteration/index.d.ts +18 -1
  81. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  82. package/dist/runtime/query/iteration/index.js +80 -1
  83. package/dist/runtime/query/iteration/index.js.map +1 -1
  84. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  85. package/dist/runtime/query/iteration/phases/tool-review.js +18 -1
  86. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  87. package/dist/runtime/query/turn/answer-claims.d.ts +52 -0
  88. package/dist/runtime/query/turn/answer-claims.d.ts.map +1 -0
  89. package/dist/runtime/query/turn/answer-claims.js +348 -0
  90. package/dist/runtime/query/turn/answer-claims.js.map +1 -0
  91. package/dist/runtime/query/turn/outcome-ledger.d.ts +7 -0
  92. package/dist/runtime/query/turn/outcome-ledger.d.ts.map +1 -1
  93. package/dist/runtime/query/turn/outcome-ledger.js +13 -0
  94. package/dist/runtime/query/turn/outcome-ledger.js.map +1 -1
  95. package/dist/store/goal/index.d.ts +3 -1
  96. package/dist/store/goal/index.d.ts.map +1 -1
  97. package/dist/store/goal/index.js +6 -0
  98. package/dist/store/goal/index.js.map +1 -1
  99. package/dist/telemetry/runtime-accessors.d.ts +14 -0
  100. package/dist/telemetry/runtime-accessors.d.ts.map +1 -1
  101. package/dist/telemetry/runtime-accessors.js +32 -0
  102. package/dist/telemetry/runtime-accessors.js.map +1 -1
  103. package/dist/tools/goal/confirmation.d.ts +154 -0
  104. package/dist/tools/goal/confirmation.d.ts.map +1 -0
  105. package/dist/tools/goal/confirmation.js +290 -0
  106. package/dist/tools/goal/confirmation.js.map +1 -0
  107. package/dist/tools/goal/index.d.ts +4 -1
  108. package/dist/tools/goal/index.d.ts.map +1 -1
  109. package/dist/tools/goal/index.js +27 -2
  110. package/dist/tools/goal/index.js.map +1 -1
  111. package/dist/tools/hand-back/index.d.ts +48 -0
  112. package/dist/tools/hand-back/index.d.ts.map +1 -0
  113. package/dist/tools/hand-back/index.js +129 -0
  114. package/dist/tools/hand-back/index.js.map +1 -0
  115. package/dist/tools/schedules/schedule-tool.d.ts.map +1 -1
  116. package/dist/tools/schedules/schedule-tool.js +15 -1
  117. package/dist/tools/schedules/schedule-tool.js.map +1 -1
  118. package/dist/tools/schedules/types.d.ts +10 -0
  119. package/dist/tools/schedules/types.d.ts.map +1 -1
  120. package/dist/types/connector/mcp.d.ts +18 -0
  121. package/dist/types/connector/mcp.d.ts.map +1 -1
  122. package/dist/types/goal/index.d.ts +12 -0
  123. package/dist/types/goal/index.d.ts.map +1 -1
  124. package/dist/types/session/config.d.ts +8 -0
  125. package/dist/types/session/config.d.ts.map +1 -1
  126. package/dist/types/session/hand-back.d.ts +29 -0
  127. package/dist/types/session/hand-back.d.ts.map +1 -0
  128. package/dist/types/session/hand-back.js +11 -0
  129. package/dist/types/session/hand-back.js.map +1 -0
  130. package/dist/types/session/outcome.d.ts +37 -0
  131. package/dist/types/session/outcome.d.ts.map +1 -1
  132. package/dist/types/session/outcome.js.map +1 -1
  133. package/dist/types/session/turn.d.ts +19 -1
  134. package/dist/types/session/turn.d.ts.map +1 -1
  135. package/dist/types/session/turn.js.map +1 -1
  136. package/package.json +1 -1
  137. package/src/connector/index.ts +1 -0
  138. package/src/connector/mcp/client.ts +531 -14
  139. package/src/connector/mcp/index.ts +1 -0
  140. package/src/connector/mcp/tasks.ts +81 -0
  141. package/src/constants/mcp/index.ts +6 -1
  142. package/src/eval/bootstrap.ts +314 -0
  143. package/src/eval/compare.ts +473 -0
  144. package/src/eval/disclosure.ts +136 -0
  145. package/src/eval/experiment.ts +127 -15
  146. package/src/eval/from-turn.ts +5 -0
  147. package/src/eval/index.ts +51 -0
  148. package/src/eval/report-stats.ts +257 -0
  149. package/src/eval/run-options.ts +49 -0
  150. package/src/eval/run-summary.ts +104 -0
  151. package/src/eval/scorers.ts +83 -0
  152. package/src/eval/types.ts +76 -0
  153. package/src/manager/session/turn-recorder.ts +23 -2
  154. package/src/public-runtime.ts +53 -0
  155. package/src/public-types.ts +9 -0
  156. package/src/registry/command/kernel-commands.ts +94 -6
  157. package/src/runtime/query/index.ts +18 -0
  158. package/src/runtime/query/iteration/index.ts +85 -1
  159. package/src/runtime/query/iteration/phases/tool-review.ts +24 -1
  160. package/src/runtime/query/turn/answer-claims.ts +413 -0
  161. package/src/runtime/query/turn/outcome-ledger.ts +12 -0
  162. package/src/store/goal/index.ts +11 -0
  163. package/src/telemetry/runtime-accessors.ts +29 -0
  164. package/src/tools/goal/confirmation.ts +375 -0
  165. package/src/tools/goal/index.ts +37 -1
  166. package/src/tools/hand-back/index.ts +150 -0
  167. package/src/tools/schedules/schedule-tool.ts +19 -1
  168. package/src/tools/schedules/types.ts +10 -0
  169. package/src/types/connector/mcp.ts +18 -0
  170. package/src/types/goal/index.ts +13 -0
  171. package/src/types/session/config.ts +8 -0
  172. package/src/types/session/hand-back.ts +31 -0
  173. package/src/types/session/outcome.ts +42 -0
  174. package/src/types/session/turn.ts +19 -1
@@ -0,0 +1,473 @@
1
+ import {
2
+ type CaseTally,
3
+ type DifferenceIntervals,
4
+ type Interval,
5
+ type PairedTally,
6
+ bootstrapPairedDifference,
7
+ exactSignTestP,
8
+ pooledMeanAndRate,
9
+ validateSeed,
10
+ } from './bootstrap.js'
11
+ import { UNKNOWN } from './disclosure.js'
12
+ import { type CaseRuns, duplicateRun, groupRuns, passedEveryRun, tallyOf } from './report-stats.js'
13
+ import type { CaseResult } from './types.js'
14
+
15
+ /**
16
+ * Two eval reports, side by side, paired by suite and case id.
17
+ *
18
+ * Two pass rates a few points apart read as a difference, and at the size of
19
+ * a hand-built suite they are usually not one. The comparison pairs the cases
20
+ * both sides ran, takes the difference over those, and puts an interval on it
21
+ * by resampling the pairs. It says "no difference can be claimed at this
22
+ * size" whenever that interval includes zero, and it says what else differs
23
+ * between the two runs, because a difference that comes with a new model and
24
+ * a new harness version and a new effort cannot be credited to any of them.
25
+ */
26
+
27
+ /** One suite of a loaded report file. */
28
+ export interface LoadedSuite {
29
+ readonly id: string
30
+ readonly cases: readonly CaseRuns[]
31
+ readonly repeats: number
32
+ /** Whatever the file said about how the suite ran; absent in an older file. */
33
+ readonly facts: Readonly<Record<string, unknown>> | undefined
34
+ }
35
+
36
+ export interface LoadedReportFile {
37
+ readonly label: string
38
+ readonly suites: readonly LoadedSuite[]
39
+ }
40
+
41
+ function isRecord(value: unknown): value is Record<string, unknown> {
42
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
43
+ }
44
+
45
+ /**
46
+ * Read the JSON `namzu eval --out` writes. Tolerant of a file from an older
47
+ * version: only `suites[].suite` and each case's name, mean and status are
48
+ * required; repeats, splits and disclosure are read when they are there.
49
+ * Throws an Error whose message names what is wrong, for a person to read.
50
+ */
51
+ export function loadEvalReportFile(json: unknown, label: string): LoadedReportFile {
52
+ if (!isRecord(json) || !Array.isArray(json.suites)) {
53
+ throw new Error(`${label} is not an eval report: it has no "suites" list.`)
54
+ }
55
+ const suites: LoadedSuite[] = []
56
+ for (const [index, entry] of json.suites.entries()) {
57
+ if (!isRecord(entry) || typeof entry.suite !== 'string' || !isRecord(entry.report)) {
58
+ throw new Error(`${label}: suites[${index}] needs a "suite" id and a "report".`)
59
+ }
60
+ const report = entry.report
61
+ if (!Array.isArray(report.cases)) {
62
+ throw new Error(`${label}: suite "${entry.suite}" has no "cases" list.`)
63
+ }
64
+ const runs: CaseResult[] = []
65
+ for (const [caseIndex, run] of report.cases.entries()) {
66
+ if (!isRecord(run) || typeof run.case !== 'string' || !Number.isFinite(run.mean)) {
67
+ throw new Error(
68
+ `${label}: suite "${entry.suite}", case ${caseIndex} needs a name in "case" and a finite number in "mean".`,
69
+ )
70
+ }
71
+ runs.push(run as unknown as CaseResult)
72
+ }
73
+ const twice = duplicateRun(runs)
74
+ if (twice !== undefined) {
75
+ throw new Error(
76
+ `${label}: suite "${entry.suite}" has the case "${twice}" more than once, so its runs cannot be told from one case's repeats. A case is named by its id; the report needs each case once per repeat.`,
77
+ )
78
+ }
79
+ suites.push({
80
+ id: entry.suite,
81
+ cases: groupRuns(runs),
82
+ repeats: typeof report.repeats === 'number' ? report.repeats : 1,
83
+ facts: isRecord(report.disclosure) ? report.disclosure : undefined,
84
+ })
85
+ }
86
+ return { label, suites }
87
+ }
88
+
89
+ // ─── what differs in how two reports were produced ───────────────────────
90
+
91
+ export type DisclosureDimension = 'harness' | 'suites' | 'model' | 'route' | 'effort' | 'repeats'
92
+
93
+ const DIMENSION_ORDER: readonly DisclosureDimension[] = [
94
+ 'harness',
95
+ 'suites',
96
+ 'model',
97
+ 'route',
98
+ 'effort',
99
+ 'repeats',
100
+ ]
101
+
102
+ interface Fact {
103
+ readonly dimension: DisclosureDimension
104
+ readonly key: string
105
+ /** Null is unknown. */
106
+ readonly value: string | null
107
+ }
108
+
109
+ function known(value: unknown): string | null {
110
+ return typeof value === 'string' && value !== UNKNOWN && value.length > 0 ? value : null
111
+ }
112
+
113
+ function canonical(value: unknown): string | null {
114
+ if (value === undefined || value === null || value === UNKNOWN) return null
115
+ if (typeof value === 'string') return value
116
+ return JSON.stringify(value, (_key, v) =>
117
+ isRecord(v)
118
+ ? Object.fromEntries(Object.entries(v).sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0)))
119
+ : v,
120
+ )
121
+ }
122
+
123
+ function rolesFact(value: unknown): string | null {
124
+ if (!isRecord(value)) return null
125
+ const parts: string[] = []
126
+ for (const role of Object.keys(value).sort()) {
127
+ const who = value[role]
128
+ const provider = isRecord(who) ? known(who.provider) : null
129
+ const model = isRecord(who) ? known(who.model) : null
130
+ if (provider === null || model === null) return null
131
+ parts.push(`${role}=${provider}/${model}`)
132
+ }
133
+ return parts.length === 0 ? 'no model' : parts.join('; ')
134
+ }
135
+
136
+ function firstKnown(suites: readonly LoadedSuite[], field: string): string | null {
137
+ for (const suite of suites) {
138
+ const value = known(suite.facts?.[field])
139
+ if (value !== null) return value
140
+ }
141
+ return null
142
+ }
143
+
144
+ function factsOf(file: LoadedReportFile, suiteIds: readonly string[]): Fact[] {
145
+ const facts: Fact[] = [
146
+ {
147
+ dimension: 'harness',
148
+ key: 'harness version',
149
+ value: firstKnown(file.suites, 'harnessVersion'),
150
+ },
151
+ { dimension: 'harness', key: 'sdk version', value: firstKnown(file.suites, 'sdkVersion') },
152
+ { dimension: 'harness', key: 'commit', value: firstKnown(file.suites, 'commit') },
153
+ ]
154
+ for (const id of suiteIds) {
155
+ const suite = file.suites.find((s) => s.id === id)
156
+ if (!suite) continue
157
+ const f = suite.facts
158
+ facts.push(
159
+ { dimension: 'suites', key: `${id} version`, value: known(f?.suiteVersion) },
160
+ { dimension: 'model', key: `${id} mode`, value: known(f?.mode) },
161
+ { dimension: 'model', key: `${id} models`, value: rolesFact(f?.roles) },
162
+ { dimension: 'route', key: `${id} routing`, value: canonical(f?.routing) },
163
+ { dimension: 'effort', key: `${id} reasoning effort`, value: known(f?.reasoningEffort) },
164
+ // A file from before repeats existed ran every case once.
165
+ { dimension: 'repeats', key: `${id} repeats`, value: String(suite.repeats) },
166
+ )
167
+ }
168
+ return facts
169
+ }
170
+
171
+ export interface DisclosureDifference {
172
+ readonly dimension: DisclosureDimension
173
+ readonly key: string
174
+ readonly a: string
175
+ readonly b: string
176
+ }
177
+
178
+ export interface DisclosureUnknown {
179
+ readonly dimension: DisclosureDimension
180
+ readonly key: string
181
+ /** Which side does not say; the other side's value is given. */
182
+ readonly missingIn: 'a' | 'b'
183
+ readonly value: string
184
+ }
185
+
186
+ export interface DisclosureComparison {
187
+ readonly differences: readonly DisclosureDifference[]
188
+ /** Facts one side states and the other does not. */
189
+ readonly unknown: readonly DisclosureUnknown[]
190
+ /** Dimensions in which at least one stated fact differs, in a fixed order. */
191
+ readonly differingDimensions: readonly DisclosureDimension[]
192
+ /** Set when more than one dimension differs. */
193
+ readonly warning?: string
194
+ }
195
+
196
+ function compareDisclosure(
197
+ a: LoadedReportFile,
198
+ b: LoadedReportFile,
199
+ sharedSuites: readonly string[],
200
+ ): DisclosureComparison {
201
+ const left = new Map(factsOf(a, sharedSuites).map((f) => [f.key, f]))
202
+ const right = new Map(factsOf(b, sharedSuites).map((f) => [f.key, f]))
203
+ const differences: DisclosureDifference[] = []
204
+ const unknown: DisclosureUnknown[] = []
205
+ for (const [key, fa] of left) {
206
+ const fb = right.get(key) as Fact
207
+ if (fa.value !== null && fb.value !== null) {
208
+ if (fa.value !== fb.value) {
209
+ differences.push({ dimension: fa.dimension, key, a: fa.value, b: fb.value })
210
+ }
211
+ } else if (fa.value !== null || fb.value !== null) {
212
+ unknown.push({
213
+ dimension: fa.dimension,
214
+ key,
215
+ missingIn: fa.value === null ? 'a' : 'b',
216
+ value: (fa.value ?? fb.value) as string,
217
+ })
218
+ }
219
+ }
220
+ const differingDimensions = DIMENSION_ORDER.filter((d) =>
221
+ differences.some((x) => x.dimension === d),
222
+ )
223
+ return {
224
+ differences,
225
+ unknown,
226
+ differingDimensions,
227
+ ...(differingDimensions.length > 1
228
+ ? {
229
+ warning: `More than one thing differs between these two runs (${differingDimensions.join(', ')}), so a difference in the scores cannot be attributed to any one of them.`,
230
+ }
231
+ : {}),
232
+ }
233
+ }
234
+
235
+ // ─── the comparison ──────────────────────────────────────────────────────
236
+
237
+ export interface CaseRef {
238
+ readonly suite: string
239
+ readonly case: string
240
+ }
241
+
242
+ export interface SideNumbers {
243
+ /** Over the paired cases only. */
244
+ readonly mean: number | null
245
+ readonly passRate: number | null
246
+ }
247
+
248
+ export interface Flips {
249
+ /** Cases that passed in every run on side B and not on side A. */
250
+ readonly onlyBPasses: number
251
+ /** The reverse: passed in every run on side A and not on side B. */
252
+ readonly onlyAPasses: number
253
+ readonly bothPass: number
254
+ readonly bothFail: number
255
+ /** Exact two-sided sign test on the two flip counts; 1 with no flips. */
256
+ readonly pValue: number
257
+ readonly test: 'exact two-sided sign test on the cases that flipped'
258
+ }
259
+
260
+ export interface EvalComparison {
261
+ readonly seed: number
262
+ readonly a: { readonly label: string; readonly cases: number }
263
+ readonly b: { readonly label: string; readonly cases: number }
264
+ readonly onlyInA: readonly CaseRef[]
265
+ readonly onlyInB: readonly CaseRef[]
266
+ /** Present on both sides but with no judged run on one of them. */
267
+ readonly notJudged: readonly CaseRef[]
268
+ readonly paired: number
269
+ readonly sideA: SideNumbers
270
+ readonly sideB: SideNumbers
271
+ /** B minus A. */
272
+ readonly difference: DifferenceIntervals
273
+ readonly flips: Flips
274
+ readonly verdict: {
275
+ readonly claim: 'better' | 'worse' | 'none'
276
+ /** The one sentence. */
277
+ readonly sentence: string
278
+ /** Why, in plain words. */
279
+ readonly detail: string
280
+ }
281
+ readonly disclosure: DisclosureComparison
282
+ }
283
+
284
+ function sorted(refs: CaseRef[]): CaseRef[] {
285
+ return refs.sort((x, y) =>
286
+ x.suite === y.suite ? (x.case < y.case ? -1 : 1) : x.suite < y.suite ? -1 : 1,
287
+ )
288
+ }
289
+
290
+ function signed(value: number, digits = 3): string {
291
+ const text = value.toFixed(digits)
292
+ return value > 0 ? `+${text}` : text
293
+ }
294
+
295
+ export function compareEvalReports(
296
+ a: LoadedReportFile,
297
+ b: LoadedReportFile,
298
+ options: { seed?: number } = {},
299
+ ): EvalComparison {
300
+ const seed = validateSeed(options.seed)
301
+ const index = (file: LoadedReportFile) => {
302
+ const map = new Map<string, { ref: CaseRef; runs: CaseRuns }>()
303
+ for (const suite of file.suites) {
304
+ for (const c of suite.cases) {
305
+ map.set(`${suite.id}\u0000${c.name}`, { ref: { suite: suite.id, case: c.name }, runs: c })
306
+ }
307
+ }
308
+ return map
309
+ }
310
+ const left = index(a)
311
+ const right = index(b)
312
+
313
+ const onlyInA: CaseRef[] = []
314
+ const onlyInB: CaseRef[] = []
315
+ const notJudged: CaseRef[] = []
316
+ const pairs: PairedTally[] = []
317
+ let onlyBPasses = 0
318
+ let onlyAPasses = 0
319
+ let bothPass = 0
320
+ let bothFail = 0
321
+
322
+ for (const [key, l] of left) {
323
+ const r = right.get(key)
324
+ if (!r) {
325
+ onlyInA.push(l.ref)
326
+ continue
327
+ }
328
+ const ta = tallyOf(l.runs.runs)
329
+ const tb = tallyOf(r.runs.runs)
330
+ if (!ta || !tb) {
331
+ notJudged.push(l.ref)
332
+ continue
333
+ }
334
+ pairs.push({ a: ta, b: tb })
335
+ const passA = passedEveryRun(l.runs.runs)
336
+ const passB = passedEveryRun(r.runs.runs)
337
+ if (passA && passB) bothPass++
338
+ else if (!passA && !passB) bothFail++
339
+ else if (passB) onlyBPasses++
340
+ else onlyAPasses++
341
+ }
342
+ for (const [key, r] of right) if (!left.has(key)) onlyInB.push(r.ref)
343
+
344
+ const aSide: readonly CaseTally[] = pairs.map((p) => p.a)
345
+ const bSide: readonly CaseTally[] = pairs.map((p) => p.b)
346
+ const numbers = (tallies: readonly CaseTally[]): SideNumbers => {
347
+ if (tallies.length === 0) return { mean: null, passRate: null }
348
+ const [mean, passRate] = pooledMeanAndRate(tallies)
349
+ return { mean, passRate }
350
+ }
351
+
352
+ const difference = bootstrapPairedDifference(pairs, seed)
353
+ const flips: Flips = {
354
+ onlyBPasses,
355
+ onlyAPasses,
356
+ bothPass,
357
+ bothFail,
358
+ pValue: exactSignTestP(onlyBPasses, onlyAPasses),
359
+ test: 'exact two-sided sign test on the cases that flipped',
360
+ }
361
+ const verdict = decide(difference.mean, pairs.length, flips)
362
+ const sharedSuites = a.suites.map((s) => s.id).filter((id) => b.suites.some((s) => s.id === id))
363
+
364
+ return {
365
+ seed,
366
+ a: { label: a.label, cases: left.size },
367
+ b: { label: b.label, cases: right.size },
368
+ onlyInA: sorted(onlyInA),
369
+ onlyInB: sorted(onlyInB),
370
+ notJudged: sorted(notJudged),
371
+ paired: pairs.length,
372
+ sideA: numbers(aSide),
373
+ sideB: numbers(bSide),
374
+ difference,
375
+ flips,
376
+ verdict,
377
+ disclosure: compareDisclosure(a, b, sharedSuites),
378
+ }
379
+ }
380
+
381
+ /** The sign test must be at least this convincing before the sentence names a winner. */
382
+ const CLAIM_P = 0.05
383
+
384
+ /** Bounds are judged at the precision they are printed at. */
385
+ function atPrintedPrecision(value: number): number {
386
+ return Math.round(value * 1000) / 1000 || 0
387
+ }
388
+
389
+ function decide(mean: Interval, paired: number, flips: Flips): EvalComparison['verdict'] {
390
+ const none = 'no difference can be claimed at this size'
391
+ if (!mean.computed) {
392
+ return { claim: 'none', sentence: none, detail: `no interval: ${mean.reason}.` }
393
+ }
394
+ const range = `[${mean.low.toFixed(3)}, ${mean.high.toFixed(3)}]`
395
+ // A bound is zero when it prints as zero: sums that differ by rounding only
396
+ // (the same scores added in another order) must not read as a difference.
397
+ const low = atPrintedPrecision(mean.low)
398
+ const high = atPrintedPrecision(mean.high)
399
+ const excludesZero = (low > 0 || high < 0) && atPrintedPrecision(mean.estimate) !== 0
400
+ if (!excludesZero) {
401
+ return {
402
+ claim: 'none',
403
+ sentence: none,
404
+ detail: `the 95% interval ${range} for the difference in mean score over ${paired} paired cases includes zero.`,
405
+ }
406
+ }
407
+ const flipped = flips.onlyAPasses + flips.onlyBPasses
408
+ if (flipped > 0 && flips.pValue >= CLAIM_P) {
409
+ return {
410
+ claim: 'none',
411
+ sentence: none,
412
+ detail: `the 95% interval ${range} over ${paired} paired cases excludes zero, but the exact test on the ${flipped} case${flipped === 1 ? '' : 's'} that flipped gives p = ${flips.pValue.toFixed(4)}, not below ${CLAIM_P}; two checks that disagree are not a finding.`,
413
+ }
414
+ }
415
+ const better = low > 0
416
+ const size = Math.abs(mean.estimate).toFixed(3)
417
+ return {
418
+ claim: better ? 'better' : 'worse',
419
+ sentence: `B is ${better ? 'better' : 'worse'} than A by ${size} in mean score, 95% interval ${range}`,
420
+ detail: `the interval over ${paired} paired cases excludes zero${flipped > 0 ? ` and the exact test on the flips agrees (p = ${flips.pValue.toFixed(4)})` : ''}.`,
421
+ }
422
+ }
423
+
424
+ function listCases(title: string, refs: readonly CaseRef[]): string[] {
425
+ if (refs.length === 0) return [`${title}: none`]
426
+ const shown = refs.slice(0, 20).map((r) => ` ${r.suite} / ${r.case}`)
427
+ if (refs.length > shown.length) shown.push(` and ${refs.length - shown.length} more`)
428
+ return [`${title} (${refs.length}):`, ...shown]
429
+ }
430
+
431
+ function intervalText(interval: Interval, show: (n: number) => string): string {
432
+ if (!interval.computed) return `no interval: ${interval.reason}`
433
+ return `95% paired interval [${show(interval.low)}, ${show(interval.high)}]`
434
+ }
435
+
436
+ export function formatEvalComparison(c: EvalComparison): string {
437
+ const score = (n: number | null) => (n === null ? 'none' : n.toFixed(3))
438
+ const rate = (n: number | null) => (n === null ? 'none' : `${(n * 100).toFixed(1)}%`)
439
+ const points = (n: number) => `${signed(n * 100, 1)} points`
440
+ const lines = [
441
+ `A: ${c.a.label} (${c.a.cases} cases)`,
442
+ `B: ${c.b.label} (${c.b.cases} cases)`,
443
+ `Cases are paired by suite and id; ${c.paired} paired${c.notJudged.length > 0 ? `, ${c.notJudged.length} more present on both but not judged on one side` : ''}. Seed ${c.seed}.`,
444
+ '',
445
+ ...listCases('Only in A', c.onlyInA),
446
+ ...listCases('Only in B', c.onlyInB),
447
+ '',
448
+ `Mean score: A ${score(c.sideA.mean)}, B ${score(c.sideB.mean)}${c.difference.mean.estimate !== null ? `, difference ${signed(c.difference.mean.estimate)}` : ''}; ${intervalText(c.difference.mean, (n) => signed(n))}`,
449
+ `Pass rate: A ${rate(c.sideA.passRate)}, B ${rate(c.sideB.passRate)}${c.difference.passRate.estimate !== null ? `, difference ${points(c.difference.passRate.estimate)}` : ''}; ${intervalText(c.difference.passRate, (n) => points(n))}`,
450
+ `Flips: ${c.flips.onlyBPasses} case${c.flips.onlyBPasses === 1 ? '' : 's'} passed on B and not on A, ${c.flips.onlyAPasses} passed on A and not on B (${c.flips.bothPass} passed on both, ${c.flips.bothFail} failed on both). A case passes when every one of its runs passed. ${c.flips.test}: p = ${c.flips.pValue.toFixed(4)}`,
451
+ '',
452
+ `${c.verdict.sentence}.`,
453
+ ` (${c.verdict.detail})`,
454
+ '',
455
+ ]
456
+ const d = c.disclosure
457
+ if (d.differences.length === 0) {
458
+ lines.push('What the two reports say about how they were produced: no stated difference.')
459
+ } else {
460
+ lines.push('What differs in how the two reports were produced:')
461
+ for (const x of d.differences) lines.push(` ${x.key}: A ${x.a}, B ${x.b}`)
462
+ }
463
+ if (d.unknown.length > 0) {
464
+ lines.push('Stated on one side only, so it cannot be compared:')
465
+ for (const x of d.unknown) {
466
+ lines.push(
467
+ ` ${x.key}: ${x.missingIn === 'a' ? 'A does not say' : 'B does not say'}, the other says ${x.value}`,
468
+ )
469
+ }
470
+ }
471
+ if (d.warning) lines.push('', `Warning: ${d.warning}`)
472
+ return lines.join('\n')
473
+ }
@@ -0,0 +1,136 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
3
+ import type { EvalRunOptions } from './run-options.js'
4
+
5
+ /**
6
+ * What a report says about how it was produced.
7
+ *
8
+ * A score without its provenance cannot be compared with another: two runs
9
+ * three points apart may differ by a model, a route, a reasoning effort or a
10
+ * harness version, and the number alone does not say which. Every fact here
11
+ * is either known or the literal string `unknown`. Nothing is guessed, and a
12
+ * report written before this existed has none of it and is read as unknown.
13
+ */
14
+ export type RunMode =
15
+ /** The model is a script; nothing measures a vendor. */
16
+ | 'scripted'
17
+ /** A real provider answered. */
18
+ | 'real-model'
19
+ /** No model was involved at all. */
20
+ | 'no-model'
21
+ | 'unknown'
22
+
23
+ export interface RoleDisclosure {
24
+ readonly provider: string
25
+ readonly model: string
26
+ }
27
+
28
+ /** The routing configuration, `off` when routing was not on. */
29
+ export type RoutingDisclosure = 'off' | 'unknown' | Readonly<Record<string, unknown>>
30
+
31
+ /**
32
+ * What a suite declares about itself in its `runExperiment` config. Every
33
+ * field is optional; the ones left out are written `unknown`.
34
+ */
35
+ export interface SuiteFacts {
36
+ readonly mode?: RunMode
37
+ /** One entry per role that ran, such as `agent`, `judge`, `child`. */
38
+ readonly roles?: Readonly<Record<string, { provider?: string; model?: string }>>
39
+ readonly routing?: RoutingDisclosure
40
+ /** The reasoning effort the model ran at, or `not applicable`. */
41
+ readonly reasoningEffort?: string
42
+ /** Version of the suite. The runner fills it from the suite's package when it can. */
43
+ readonly suiteVersion?: string
44
+ }
45
+
46
+ export interface ReportDisclosure {
47
+ /** The program that ran the suites, for example the CLI's version. */
48
+ readonly harnessVersion: string
49
+ readonly sdkVersion: string
50
+ /** ISO time the experiment finished. */
51
+ readonly generatedAt: string
52
+ /** Cases in the suite. */
53
+ readonly cases: number
54
+ readonly repeats: number
55
+ readonly seed: number
56
+ readonly mode: RunMode
57
+ /** `unknown` when the suite declared no roles; an empty object when no model ran. */
58
+ readonly roles: Readonly<Record<string, RoleDisclosure>> | 'unknown'
59
+ readonly routing: RoutingDisclosure
60
+ readonly reasoningEffort: string
61
+ readonly suiteVersion: string
62
+ /** Source revision, when the runner could read one. */
63
+ readonly commit: string
64
+ }
65
+
66
+ export const UNKNOWN = 'unknown'
67
+
68
+ let cachedSdkVersion: string | undefined
69
+
70
+ /** The version of this SDK, read from its manifest; `unknown` when it cannot be read. */
71
+ export function sdkVersion(): string {
72
+ if (cachedSdkVersion !== undefined) return cachedSdkVersion
73
+ try {
74
+ // `src/eval/` and `dist/eval/` are both two levels below the package.
75
+ const path = fileURLToPath(new URL('../../package.json', import.meta.url))
76
+ const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown }
77
+ cachedSdkVersion =
78
+ typeof manifest.version === 'string' && manifest.version.length > 0
79
+ ? manifest.version
80
+ : UNKNOWN
81
+ } catch {
82
+ cachedSdkVersion = UNKNOWN
83
+ }
84
+ return cachedSdkVersion
85
+ }
86
+
87
+ function text(value: string | undefined): string {
88
+ return value !== undefined && value.length > 0 ? value : UNKNOWN
89
+ }
90
+
91
+ export function buildDisclosure(
92
+ facts: SuiteFacts | undefined,
93
+ counts: { cases: number; repeats: number; seed: number },
94
+ options: EvalRunOptions,
95
+ generatedAt: string,
96
+ ): ReportDisclosure {
97
+ let roles: ReportDisclosure['roles'] = UNKNOWN
98
+ if (facts?.roles !== undefined) {
99
+ const declared: Record<string, RoleDisclosure> = {}
100
+ for (const [role, who] of Object.entries(facts.roles)) {
101
+ declared[role] = { provider: text(who.provider), model: text(who.model) }
102
+ }
103
+ roles = declared
104
+ }
105
+ return {
106
+ harnessVersion: text(options.harnessVersion),
107
+ sdkVersion: sdkVersion(),
108
+ generatedAt,
109
+ cases: counts.cases,
110
+ repeats: counts.repeats,
111
+ seed: counts.seed,
112
+ mode: facts?.mode ?? UNKNOWN,
113
+ roles,
114
+ routing: facts?.routing ?? UNKNOWN,
115
+ reasoningEffort: text(facts?.reasoningEffort),
116
+ suiteVersion: text(facts?.suiteVersion ?? options.suiteVersion),
117
+ commit: text(options.commit),
118
+ }
119
+ }
120
+
121
+ /** One plain line for a terminal. */
122
+ export function describeDisclosure(d: ReportDisclosure): string {
123
+ const roles =
124
+ d.roles === UNKNOWN
125
+ ? 'models unknown'
126
+ : Object.keys(d.roles).length === 0
127
+ ? 'no model'
128
+ : Object.entries(d.roles)
129
+ .map(([role, who]) => `${role} ${who.provider}/${who.model}`)
130
+ .join(', ')
131
+ const routing =
132
+ d.routing === 'off' || d.routing === UNKNOWN
133
+ ? `routing ${d.routing}`
134
+ : `routing ${JSON.stringify(d.routing)}`
135
+ return `ran as ${d.mode}: ${roles}; ${routing}; effort ${d.reasoningEffort}; suite ${d.suiteVersion}`
136
+ }