@autor3search/javascript 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +500 -0
- package/bin/autor3search-javascript.js +4 -0
- package/package.json +50 -0
- package/src/adapters/bench/index.js +31 -0
- package/src/adapters/bench/vitest.js +128 -0
- package/src/adapters/driver-child.js +72 -0
- package/src/adapters/driver-hooks.js +18 -0
- package/src/adapters/driver.js +192 -0
- package/src/adapters/gates/index.js +64 -0
- package/src/adapters/gates/lint.js +46 -0
- package/src/adapters/gates/test.js +27 -0
- package/src/adapters/gates/typecheck.js +98 -0
- package/src/adapters/gates/util.js +45 -0
- package/src/adapters/vitest-shim.js +51 -0
- package/src/bench/parse.js +120 -0
- package/src/bench/set.js +101 -0
- package/src/bench/stats.js +443 -0
- package/src/cli/cmd-baseline.js +136 -0
- package/src/cli/cmd-doctor.js +38 -0
- package/src/cli/cmd-eval.js +231 -0
- package/src/cli/cmd-init.js +136 -0
- package/src/cli/cmd-profile.js +38 -0
- package/src/cli/cmd-report.js +96 -0
- package/src/cli/cmd-status.js +68 -0
- package/src/cli/cmd-stop.js +98 -0
- package/src/cli/cmd-version.js +31 -0
- package/src/cli/context.js +98 -0
- package/src/cli/main.js +62 -0
- package/src/config.js +209 -0
- package/src/discover.js +239 -0
- package/src/doctor.js +282 -0
- package/src/duration.js +75 -0
- package/src/freeze.js +234 -0
- package/src/gitx.js +115 -0
- package/src/measure.js +127 -0
- package/src/pipeline.js +391 -0
- package/src/profile.js +100 -0
- package/src/results.js +124 -0
- package/src/runner.js +198 -0
- package/src/scope.js +92 -0
- package/src/state/index.js +312 -0
- package/src/state/lock.js +189 -0
- package/src/state/stop.js +56 -0
- package/src/verdict.js +214 -0
- package/templates/program.md +264 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The statistics behind every verdict. The estimators follow the method
|
|
3
|
+
* described by golang.org/x/perf/benchmath, implemented here directly; that
|
|
4
|
+
* package is cited for the method, not depended on.
|
|
5
|
+
*
|
|
6
|
+
* The estimator is deliberately distribution-free ("assume nothing"):
|
|
7
|
+
* benchmark timings are not normal, they are right-skewed with occasional
|
|
8
|
+
* large outliers from GC pauses and scheduler preemption, and a mean with a
|
|
9
|
+
* t-interval would be pulled around by exactly those outliers.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Confidence level for the reported median interval. */
|
|
13
|
+
export const CONFIDENCE = 0.95
|
|
14
|
+
|
|
15
|
+
/** Rejection threshold for the significance test. */
|
|
16
|
+
export const ALPHA = 0.05
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The median. Uses the average of the two middle values at even length,
|
|
20
|
+
* which is the R-7 quantile at 0.5 and matches benchmath.
|
|
21
|
+
*
|
|
22
|
+
* @param {number[]} values
|
|
23
|
+
* @returns {number}
|
|
24
|
+
*/
|
|
25
|
+
export function median(values) {
|
|
26
|
+
const v = [...values].sort((a, b) => a - b)
|
|
27
|
+
const mid = v.length >> 1
|
|
28
|
+
return v.length % 2 === 1 ? v[mid] : (v[mid - 1] + v[mid]) / 2
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* C(n, k), multiplying and dividing in step so the intermediate value stays
|
|
33
|
+
* near the result rather than overflowing through a factorial.
|
|
34
|
+
*
|
|
35
|
+
* @param {number} n
|
|
36
|
+
* @param {number} k
|
|
37
|
+
* @returns {number}
|
|
38
|
+
*/
|
|
39
|
+
export function binom(n, k) {
|
|
40
|
+
if (k < 0 || k > n) return 0
|
|
41
|
+
const kk = Math.min(k, n - k)
|
|
42
|
+
let c = 1
|
|
43
|
+
for (let i = 0; i < kk; i++) c = (c * (n - i)) / (i + 1)
|
|
44
|
+
return c
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Summarises one sample: the median, and a distribution-free confidence
|
|
49
|
+
* interval built from order statistics.
|
|
50
|
+
*
|
|
51
|
+
* The interval [x(k), x(n-1-k)] has coverage 1 - 2*P(Bin(n, 1/2) <= k). The
|
|
52
|
+
* widest available interval — the whole range, at k = 0 — has coverage
|
|
53
|
+
* 1 - 2/2^n, which only reaches 95% once n >= 6. Below that NO interval
|
|
54
|
+
* achieves the requested confidence, so rather than quietly reporting the
|
|
55
|
+
* range as though it did, the bounds are reported as infinite and a warning
|
|
56
|
+
* says why. benchmath raises the same warning, and its documentation says it
|
|
57
|
+
* should be shown to the user, so it is carried out of here rather than
|
|
58
|
+
* dropped.
|
|
59
|
+
*
|
|
60
|
+
* @param {number[]} values
|
|
61
|
+
* @param {number} [confidence]
|
|
62
|
+
* @returns {{center: number, lo: number, hi: number, warnings: string[]}}
|
|
63
|
+
*/
|
|
64
|
+
export function summary(values, confidence = CONFIDENCE) {
|
|
65
|
+
const n = values.length
|
|
66
|
+
if (n === 0) throw new Error('summary needs at least one observation')
|
|
67
|
+
|
|
68
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
69
|
+
const center = median(sorted)
|
|
70
|
+
const warnings = []
|
|
71
|
+
|
|
72
|
+
// Largest k whose interval still covers at least `confidence`.
|
|
73
|
+
//
|
|
74
|
+
// The cumulative binomial is accumulated in LOG SPACE, carrying log C(n,k)
|
|
75
|
+
// incrementally. The direct form — binom(n, k) / 2 ** n — overflows to
|
|
76
|
+
// Infinity at n = 1024 and then silently yields 0 and NaN, so the loop exits
|
|
77
|
+
// on a comparison against NaN and returns a k that is neither correct nor
|
|
78
|
+
// conservative, with no warning. Carrying the log also makes this O(n)
|
|
79
|
+
// rather than O(n^2), since binom is no longer recomputed per iteration.
|
|
80
|
+
const logThreshold = Math.log((1 - confidence) / 2)
|
|
81
|
+
let logCoefficient = 0 // log C(n, 0)
|
|
82
|
+
let logCumulative = logCoefficient - n * Math.LN2 // log P(Bin(n, 1/2) <= 0)
|
|
83
|
+
let best = logCumulative <= logThreshold ? 0 : -1
|
|
84
|
+
for (let k = 1; k <= (n - 1) >> 1; k++) {
|
|
85
|
+
logCoefficient += Math.log(n - k + 1) - Math.log(k)
|
|
86
|
+
logCumulative = logAddExp(logCumulative, logCoefficient - n * Math.LN2)
|
|
87
|
+
if (logCumulative <= logThreshold) best = k
|
|
88
|
+
else break
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (best < 0) {
|
|
92
|
+
warnings.push(
|
|
93
|
+
`confidence interval requires at least 6 observations at ${(confidence * 100).toFixed(0)}% ` +
|
|
94
|
+
`confidence; got ${n}, so the interval around the reported median is unbounded — raise count`,
|
|
95
|
+
)
|
|
96
|
+
return { center, lo: -Infinity, hi: Infinity, warnings }
|
|
97
|
+
}
|
|
98
|
+
return { center, lo: sorted[best], hi: sorted[n - 1 - best], warnings }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* log(exp(a) + exp(b)), computed so the larger term never leaves log space.
|
|
103
|
+
* Summing the probabilities directly would underflow to zero for the tiny
|
|
104
|
+
* per-term values that arise at large n.
|
|
105
|
+
*/
|
|
106
|
+
function logAddExp(a, b) {
|
|
107
|
+
const max = Math.max(a, b)
|
|
108
|
+
if (max === -Infinity) return max
|
|
109
|
+
return max + Math.log1p(Math.exp(Math.min(a, b) - max))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The smallest two-sided p-value the Mann-Whitney U test can return for
|
|
114
|
+
* samples of size n1 and n2: 2 / C(n1+n2, n1).
|
|
115
|
+
*
|
|
116
|
+
* The two samples can be maximally separated and the test still only reaches
|
|
117
|
+
* this, because it is the fraction of orderings at least as extreme as the
|
|
118
|
+
* observed one. It reproduces benchmath's generated table exactly (0.3333 at
|
|
119
|
+
* n=2, 0.1000 at 3, 0.02857 at 4, 0.00794 at 5) without duplicating it.
|
|
120
|
+
*
|
|
121
|
+
* @param {number} n1
|
|
122
|
+
* @param {number} n2
|
|
123
|
+
* @returns {number}
|
|
124
|
+
*/
|
|
125
|
+
export function minAchievableP(n1, n2) {
|
|
126
|
+
if (n1 < 1 || n2 < 1) return 1
|
|
127
|
+
const p = 2 / binom(n1 + n2, n1)
|
|
128
|
+
return p > 1 ? 1 : p
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The smallest number of rounds per side at which the U test can produce a
|
|
133
|
+
* p-value below alpha, or 0 when no practical count does. The search stops at
|
|
134
|
+
* 50, far past any sensible benchmark budget.
|
|
135
|
+
*
|
|
136
|
+
* @param {number} alpha
|
|
137
|
+
* @returns {number}
|
|
138
|
+
*/
|
|
139
|
+
export function countForAlpha(alpha) {
|
|
140
|
+
for (let n = 2; n <= 50; n++) {
|
|
141
|
+
if (minAchievableP(n, n) < alpha) return n
|
|
142
|
+
}
|
|
143
|
+
return 0
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Largest sample size for which the exact test is enumerated. Above this the
|
|
148
|
+
* normal approximation is used. The default count of 10 rounds per side sits
|
|
149
|
+
* exactly at this limit, so a default run gets the exact test.
|
|
150
|
+
*/
|
|
151
|
+
export const EXACT_LIMIT = 10
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Two-sided Mann-Whitney U test (a rank-sum test).
|
|
155
|
+
*
|
|
156
|
+
* Chosen over a t-test because it assumes nothing about the shape of the
|
|
157
|
+
* distribution. Benchmark timings are right-skewed with occasional large
|
|
158
|
+
* outliers from GC pauses and scheduler preemption; a t-test's normality
|
|
159
|
+
* assumption is not merely unmet, it is unmet in the direction that
|
|
160
|
+
* manufactures false significance.
|
|
161
|
+
*
|
|
162
|
+
* @param {number[]} a
|
|
163
|
+
* @param {number[]} b
|
|
164
|
+
* @returns {{p: number, n1: number, n2: number, exact: boolean, warnings: string[]}}
|
|
165
|
+
*/
|
|
166
|
+
export function mannWhitneyU(a, b) {
|
|
167
|
+
const n1 = a.length
|
|
168
|
+
const n2 = b.length
|
|
169
|
+
if (n1 < 2 || n2 < 2) {
|
|
170
|
+
throw new Error(`Mann-Whitney needs at least 2 observations per side, got ${n1}/${n2}`)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const warnings = []
|
|
174
|
+
const { rankSumA, tieGroups, allTied } = rank(a, b)
|
|
175
|
+
|
|
176
|
+
if (allTied) {
|
|
177
|
+
warnings.push(
|
|
178
|
+
'the two samples are indistinguishable — every observation is identical, so no test can separate them',
|
|
179
|
+
)
|
|
180
|
+
return { p: 1, n1, n2, exact: false, warnings }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// U1 is the count of (a, b) pairs in which a wins, derived from the rank sum.
|
|
184
|
+
const u1 = rankSumA - (n1 * (n1 + 1)) / 2
|
|
185
|
+
const u2 = n1 * n2 - u1
|
|
186
|
+
const u = Math.min(u1, u2)
|
|
187
|
+
|
|
188
|
+
const exact = n1 <= EXACT_LIMIT && n2 <= EXACT_LIMIT && tieGroups.length === 0
|
|
189
|
+
const p = exact ? exactP(n1, n2, u) : normalP(n1, n2, u, tieGroups)
|
|
190
|
+
|
|
191
|
+
// The floor below which this pair of sample sizes cannot reach, however far
|
|
192
|
+
// apart the samples are. Surfaced here so a caller can tell "no difference"
|
|
193
|
+
// from "this experiment was never able to show one".
|
|
194
|
+
if (minAchievableP(n1, n2) >= ALPHA) {
|
|
195
|
+
warnings.push(
|
|
196
|
+
`with ${n1}/${n2} observations the test cannot produce a p-value below ` +
|
|
197
|
+
`${minAchievableP(n1, n2).toFixed(5)}, so it can never reach alpha=${ALPHA} however large the ` +
|
|
198
|
+
`difference is — raise count to at least ${countForAlpha(ALPHA)}`,
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
return { p: Math.min(1, p), n1, n2, exact, warnings }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Assigns midranks across the pooled samples and returns the rank sum of a,
|
|
206
|
+
* along with the sizes of every tie group (used by the tie correction).
|
|
207
|
+
*/
|
|
208
|
+
function rank(a, b) {
|
|
209
|
+
const pooled = [
|
|
210
|
+
...a.map((value) => ({ value, fromA: true })),
|
|
211
|
+
...b.map((value) => ({ value, fromA: false })),
|
|
212
|
+
].sort((x, y) => x.value - y.value)
|
|
213
|
+
|
|
214
|
+
let rankSumA = 0
|
|
215
|
+
const tieGroups = []
|
|
216
|
+
for (let i = 0; i < pooled.length; ) {
|
|
217
|
+
let j = i
|
|
218
|
+
while (j + 1 < pooled.length && pooled[j + 1].value === pooled[i].value) j++
|
|
219
|
+
const size = j - i + 1
|
|
220
|
+
// Midrank: the average of the 1-based ranks this tie group spans.
|
|
221
|
+
const midrank = (i + 1 + (j + 1)) / 2
|
|
222
|
+
for (let k = i; k <= j; k++) {
|
|
223
|
+
if (pooled[k].fromA) rankSumA += midrank
|
|
224
|
+
}
|
|
225
|
+
if (size > 1) tieGroups.push(size)
|
|
226
|
+
i = j + 1
|
|
227
|
+
}
|
|
228
|
+
return { rankSumA, tieGroups, allTied: tieGroups.length === 1 && tieGroups[0] === pooled.length }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Exact two-sided p by enumerating the null distribution of U.
|
|
233
|
+
*
|
|
234
|
+
* counts[u] is the number of ways to arrange n1 items among n1+n2 positions
|
|
235
|
+
* that produce statistic u, from the recurrence
|
|
236
|
+
* N(n1, n2, u) = N(n1-1, n2, u - n2) + N(n1, n2-1, u)
|
|
237
|
+
* evaluated as a rolling table over u. At n1 = n2 = 10 this is 100 * 101
|
|
238
|
+
* table updates, which is instant.
|
|
239
|
+
*/
|
|
240
|
+
function exactP(n1, n2, u) {
|
|
241
|
+
const maxU = n1 * n2
|
|
242
|
+
|
|
243
|
+
// f[j][x] = the number of arrangements of i A's and j B's whose statistic is
|
|
244
|
+
// x, rolled forward over i. The recurrence is
|
|
245
|
+
//
|
|
246
|
+
// f(i, j, x) = f(i-1, j, x-j) + f(i, j-1, x)
|
|
247
|
+
//
|
|
248
|
+
// Place an A last and it sits after all j B's, contributing j to the
|
|
249
|
+
// statistic; place a B last and it contributes nothing.
|
|
250
|
+
//
|
|
251
|
+
// The tempting shortcut — letting each of the n1 A's independently take any
|
|
252
|
+
// value in 0..n2 — is WRONG: it counts ordered compositions, (n2+1)^n1
|
|
253
|
+
// rather than C(n1+n2, n1) arrangements (25,937,424,601 instead of 184,756
|
|
254
|
+
// at n1=n2=10). It agrees with this one only at u=0, where a single
|
|
255
|
+
// arrangement is possible either way, so a test that checks only the
|
|
256
|
+
// p-value floor cannot tell the two apart while every intermediate p-value
|
|
257
|
+
// is wrong.
|
|
258
|
+
let f = Array.from({ length: n2 + 1 }, () => new Float64Array(maxU + 1))
|
|
259
|
+
for (let j = 0; j <= n2; j++) f[j][0] = 1 // i=0: all B's, statistic 0
|
|
260
|
+
for (let i = 1; i <= n1; i++) {
|
|
261
|
+
const next = Array.from({ length: n2 + 1 }, () => new Float64Array(maxU + 1))
|
|
262
|
+
for (let j = 0; j <= n2; j++) {
|
|
263
|
+
for (let x = 0; x <= maxU; x++) {
|
|
264
|
+
let v = x - j >= 0 ? f[j][x - j] : 0 // f(i-1, j, x-j)
|
|
265
|
+
if (j > 0) v += next[j - 1][x] // f(i, j-1, x)
|
|
266
|
+
next[j][x] = v
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
f = next
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const total = binom(n1 + n2, n1)
|
|
273
|
+
let cumulative = 0
|
|
274
|
+
for (let x = 0; x <= u; x++) cumulative += f[n2][x]
|
|
275
|
+
return Math.min(1, (2 * cumulative) / total)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Normal approximation with the standard tie correction. Used above the exact
|
|
280
|
+
* limit and whenever the pooled samples contain ties.
|
|
281
|
+
*/
|
|
282
|
+
function normalP(n1, n2, u, tieGroups) {
|
|
283
|
+
const n = n1 + n2
|
|
284
|
+
let tieTerm = 0
|
|
285
|
+
for (const t of tieGroups) tieTerm += t ** 3 - t
|
|
286
|
+
const variance = ((n1 * n2) / 12) * (n + 1 - tieTerm / (n * (n - 1)))
|
|
287
|
+
if (variance <= 0) return 1
|
|
288
|
+
const z = (u - (n1 * n2) / 2) / Math.sqrt(variance)
|
|
289
|
+
return Math.min(1, 2 * normalCdf(-Math.abs(z)))
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Standard normal CDF, via the complementary error function. */
|
|
293
|
+
function normalCdf(z) {
|
|
294
|
+
return 0.5 * erfc(-z / Math.SQRT2)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Complementary error function. Numerical Recipes' Chebyshev approximation,
|
|
299
|
+
* accurate to about 1.2e-7 relative — far tighter than any p-value here is
|
|
300
|
+
* interpreted to.
|
|
301
|
+
*/
|
|
302
|
+
function erfc(x) {
|
|
303
|
+
const z = Math.abs(x)
|
|
304
|
+
const t = 2 / (2 + z)
|
|
305
|
+
const ty = 4 * t - 2
|
|
306
|
+
const coefficients = [
|
|
307
|
+
-1.3026537197817094, 6.4196979235649026e-1, 1.9476473204185836e-2, -9.561514786808631e-3,
|
|
308
|
+
-9.46595344482036e-4, 3.66839497852761e-4, 4.2523324806907e-5, -2.0278578112534e-5,
|
|
309
|
+
-1.624290004647e-6, 1.303655835580e-6, 1.5626441722e-8, -8.5238095915e-8, 6.529054439e-9,
|
|
310
|
+
5.059343495e-9, -9.91364156e-10, -2.27365122e-10, 9.6467911e-11, 2.394038e-12,
|
|
311
|
+
-6.886027e-12, 8.94487e-13, 3.13092e-13, -1.12708e-13, 3.81e-16, 7.106e-15,
|
|
312
|
+
]
|
|
313
|
+
let d = 0
|
|
314
|
+
let dd = 0
|
|
315
|
+
for (let j = coefficients.length - 1; j > 0; j--) {
|
|
316
|
+
const tmp = d
|
|
317
|
+
d = ty * d - dd + coefficients[j]
|
|
318
|
+
dd = tmp
|
|
319
|
+
}
|
|
320
|
+
const result = t * Math.exp(-z * z + 0.5 * (coefficients[0] + ty * d) - dd)
|
|
321
|
+
return x >= 0 ? result : 2 - result
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* @typedef {object} Delta
|
|
326
|
+
* @property {string} name
|
|
327
|
+
* @property {string} unit
|
|
328
|
+
* @property {number} baseCenter median of the baseline observations
|
|
329
|
+
* @property {number} candCenter median of the candidate observations
|
|
330
|
+
* @property {number} ratio candCenter / baseCenter; below 1 is faster
|
|
331
|
+
* @property {number} pctChange (ratio - 1) * 100
|
|
332
|
+
* @property {number} p Mann-Whitney two-sided p-value
|
|
333
|
+
* @property {number} alpha rejection threshold, uncorrected
|
|
334
|
+
* @property {boolean} significant p < alpha, with NO multiple-comparison correction
|
|
335
|
+
* @property {number} nBase
|
|
336
|
+
* @property {number} nCand
|
|
337
|
+
* @property {string[]} warnings
|
|
338
|
+
*/
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Compares one benchmark's unit across two measurement sets.
|
|
342
|
+
*
|
|
343
|
+
* `significant` here always means "significant at the raw, uncorrected
|
|
344
|
+
* alpha". That is the honest statistic a human or an agent should read. A
|
|
345
|
+
* Bonferroni correction that gates a keep/reject decision is applied
|
|
346
|
+
* elsewhere, on top of this — it is a decision threshold, not a
|
|
347
|
+
* redefinition of the word.
|
|
348
|
+
*
|
|
349
|
+
* @param {import('./set.js').BenchSet} base
|
|
350
|
+
* @param {import('./set.js').BenchSet} cand
|
|
351
|
+
* @param {string} name
|
|
352
|
+
* @param {string} unit
|
|
353
|
+
* @returns {Delta}
|
|
354
|
+
*/
|
|
355
|
+
export function compare(base, cand, name, unit) {
|
|
356
|
+
const bv = base.values(name, unit)
|
|
357
|
+
if (!bv) throw new Error(`baseline has no ${unit} for ${name}`)
|
|
358
|
+
const cv = cand.values(name, unit)
|
|
359
|
+
if (!cv) throw new Error(`candidate has no ${unit} for ${name}`)
|
|
360
|
+
if (bv.length < 2 || cv.length < 2) {
|
|
361
|
+
throw new Error(`${name}: need at least 2 observations per side, got ${bv.length}/${cv.length}`)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const bSum = summary(bv)
|
|
365
|
+
const cSum = summary(cv)
|
|
366
|
+
if (bSum.center === 0) {
|
|
367
|
+
throw new Error(`${name}: baseline ${unit} median is zero, cannot form a ratio`)
|
|
368
|
+
}
|
|
369
|
+
const test = mannWhitneyU(bv, cv)
|
|
370
|
+
const ratio = cSum.center / bSum.center
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
name,
|
|
374
|
+
unit,
|
|
375
|
+
baseCenter: bSum.center,
|
|
376
|
+
candCenter: cSum.center,
|
|
377
|
+
ratio,
|
|
378
|
+
pctChange: (ratio - 1) * 100,
|
|
379
|
+
p: test.p,
|
|
380
|
+
alpha: ALPHA,
|
|
381
|
+
significant: test.p < ALPHA,
|
|
382
|
+
nBase: test.n1,
|
|
383
|
+
nCand: test.n2,
|
|
384
|
+
// Deduplicated: the two summaries warn about the same sample size in the
|
|
385
|
+
// same words, and printing that twice per benchmark buries the distinct
|
|
386
|
+
// warnings among duplicates.
|
|
387
|
+
warnings: [...new Set([...bSum.warnings, ...cSum.warnings, ...test.warnings])],
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Compares every benchmark present in both sets, sorted by name.
|
|
393
|
+
*
|
|
394
|
+
* Strict in one direction: a benchmark measured at baseline but missing from
|
|
395
|
+
* the candidate is an error, not a skip. A benchmark that disappears cannot
|
|
396
|
+
* be checked for regressions — which is exactly how an agent would hide one.
|
|
397
|
+
* A benchmark the candidate added is ignored rather than an error, because a
|
|
398
|
+
* new benchmark cannot have regressed against a baseline that never ran it.
|
|
399
|
+
*
|
|
400
|
+
* @param {import('./set.js').BenchSet} base
|
|
401
|
+
* @param {import('./set.js').BenchSet} cand
|
|
402
|
+
* @param {string} unit
|
|
403
|
+
* @returns {Delta[]}
|
|
404
|
+
*/
|
|
405
|
+
export function compareAll(base, cand, unit) {
|
|
406
|
+
const out = []
|
|
407
|
+
const missing = []
|
|
408
|
+
for (const name of base.names()) {
|
|
409
|
+
if (!base.has(name, unit)) continue
|
|
410
|
+
if (!cand.has(name, unit)) {
|
|
411
|
+
missing.push(name)
|
|
412
|
+
continue
|
|
413
|
+
}
|
|
414
|
+
out.push(compare(base, cand, name, unit))
|
|
415
|
+
}
|
|
416
|
+
if (missing.length > 0) {
|
|
417
|
+
throw new Error(
|
|
418
|
+
`benchmark(s) measured at baseline but missing from the candidate: ${missing.sort().join(', ')} — ` +
|
|
419
|
+
`a benchmark that disappears cannot be checked for regressions`,
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
if (out.length === 0) throw new Error('no benchmark appears in both baseline and candidate')
|
|
423
|
+
return out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* The geometric mean of the deltas' ratios — the single score the agent
|
|
428
|
+
* optimizes. Below 1 is an overall speedup.
|
|
429
|
+
*
|
|
430
|
+
* Computed in log space so a long list of small ratios cannot underflow.
|
|
431
|
+
*
|
|
432
|
+
* @param {Delta[]} deltas
|
|
433
|
+
* @returns {number}
|
|
434
|
+
*/
|
|
435
|
+
export function geoMean(deltas) {
|
|
436
|
+
if (deltas.length === 0) throw new Error('geoMean of an empty delta set')
|
|
437
|
+
let sum = 0
|
|
438
|
+
for (const d of deltas) {
|
|
439
|
+
if (!(d.ratio > 0)) throw new Error(`${d.name}: non-positive ratio ${d.ratio}`)
|
|
440
|
+
sum += Math.log(d.ratio)
|
|
441
|
+
}
|
|
442
|
+
return Math.exp(sum / deltas.length)
|
|
443
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates the run branch, freezes the tests and benchmarks, and pins the
|
|
3
|
+
* commit this run measures against.
|
|
4
|
+
*
|
|
5
|
+
* Everything it writes lands OUTSIDE the repository, under the run's state
|
|
6
|
+
* directory — see src/state/index.js for why.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from 'node:crypto'
|
|
9
|
+
import { readFile, rm } from 'node:fs/promises'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { parseArgs } from 'node:util'
|
|
12
|
+
import { CONFIG_PATH } from '../config.js'
|
|
13
|
+
import { baseNames, benchmarks, frozenFiles } from '../discover.js'
|
|
14
|
+
import * as freeze from '../freeze.js'
|
|
15
|
+
import * as gitx from '../gitx.js'
|
|
16
|
+
import { RESULTS_PATH, loadRows } from '../results.js'
|
|
17
|
+
import {
|
|
18
|
+
BASELINE_FILE,
|
|
19
|
+
BRANCH_PREFIX,
|
|
20
|
+
WORKTREE_NAME,
|
|
21
|
+
benchPattern,
|
|
22
|
+
ensureSecureDir,
|
|
23
|
+
linkNodeModules,
|
|
24
|
+
saveBaseline,
|
|
25
|
+
stateDir,
|
|
26
|
+
validTag,
|
|
27
|
+
} from '../state/index.js'
|
|
28
|
+
import { expandSingleDashFlags, loadRepoConfig, resolveRepo } from './context.js'
|
|
29
|
+
|
|
30
|
+
export async function runBaseline(args, io) {
|
|
31
|
+
const { values } = parseArgs({
|
|
32
|
+
args: expandSingleDashFlags(args, ['tag']),
|
|
33
|
+
options: {
|
|
34
|
+
C: { type: 'string', default: '.' },
|
|
35
|
+
tag: { type: 'string', default: defaultTag() },
|
|
36
|
+
force: { type: 'boolean', default: false },
|
|
37
|
+
},
|
|
38
|
+
allowPositionals: false,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const tag = values.tag
|
|
42
|
+
validTag(tag) // before the tag ever reaches a filesystem path
|
|
43
|
+
const root = await resolveRepo(values.C)
|
|
44
|
+
const cfg = await loadRepoConfig(root)
|
|
45
|
+
|
|
46
|
+
if (!(await gitx.isClean(root))) {
|
|
47
|
+
io.err.write(
|
|
48
|
+
'the working tree has uncommitted changes.\n\n' +
|
|
49
|
+
'baseline refuses a dirty tree because a baseline pinned against what is on disk, rather than\n' +
|
|
50
|
+
'what is in git, would not be reproducible — the pinned worktree could never be recreated.\n' +
|
|
51
|
+
'Commit or stash first.\n',
|
|
52
|
+
)
|
|
53
|
+
return 2
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const rows = await loadRows(join(root, RESULTS_PATH))
|
|
57
|
+
if (rows.length > 0 && !values.force) {
|
|
58
|
+
io.err.write(
|
|
59
|
+
`${RESULTS_PATH} already holds ${rows.length} experiment row(s) from an earlier run. Starting a new\n` +
|
|
60
|
+
`baseline over them would mix two runs' results in one log. Move it aside, or pass --force to\n` +
|
|
61
|
+
`start anyway.\n`,
|
|
62
|
+
)
|
|
63
|
+
return 2
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const branch = `${BRANCH_PREFIX}${tag}`
|
|
67
|
+
if (await gitx.branchExists(root, branch)) {
|
|
68
|
+
io.err.write(
|
|
69
|
+
`branch ${branch} already exists — that tag has been used, and a run's state directory is keyed\n` +
|
|
70
|
+
`by tag, so reusing it would silently mix two runs' frozen copies and baseline records. Pick\n` +
|
|
71
|
+
`another -tag.\n`,
|
|
72
|
+
)
|
|
73
|
+
return 2
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const found = await benchmarks(root)
|
|
77
|
+
if (found.length === 0) {
|
|
78
|
+
io.err.write(`no benchmarks found in ${root}: run 'autor3search-javascript init' and read what it says\n`)
|
|
79
|
+
return 2
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const originalBranch = await gitx.currentBranch(root)
|
|
83
|
+
await gitx.createBranch(root, branch)
|
|
84
|
+
|
|
85
|
+
// From here on, any failure must undo the branch, or a retry under the same
|
|
86
|
+
// tag is permanently blocked by a branch nothing finished creating.
|
|
87
|
+
try {
|
|
88
|
+
const dir = await ensureSecureDir(await stateDir(root, tag))
|
|
89
|
+
|
|
90
|
+
const declared = cfg.benchmarks.length > 0 ? cfg.benchmarks : baseNames(found)
|
|
91
|
+
const toFreeze = await frozenFiles(root, cfg.unfreeze)
|
|
92
|
+
const manifest = await freeze.snapshot(root, join(dir, freeze.STORE_DIR), toFreeze)
|
|
93
|
+
await freeze.saveManifest(join(dir, freeze.MANIFEST_PATH), manifest)
|
|
94
|
+
|
|
95
|
+
const commit = await gitx.headCommit(root)
|
|
96
|
+
const worktree = join(dir, WORKTREE_NAME)
|
|
97
|
+
await rm(worktree, { recursive: true, force: true })
|
|
98
|
+
await gitx.addWorktree(root, worktree, commit)
|
|
99
|
+
// A worktree checks out only tracked files, and node_modules is normally
|
|
100
|
+
// gitignored — link it in so the pinned baseline side can resolve the
|
|
101
|
+
// bench runner at all. See linkNodeModules for why this lives here.
|
|
102
|
+
await linkNodeModules(root, worktree)
|
|
103
|
+
|
|
104
|
+
await saveBaseline(join(dir, BASELINE_FILE), {
|
|
105
|
+
tag,
|
|
106
|
+
branch,
|
|
107
|
+
commit,
|
|
108
|
+
measureCommit: commit,
|
|
109
|
+
createdAt: new Date().toISOString(),
|
|
110
|
+
benchmarks: declared,
|
|
111
|
+
pattern: benchPattern(declared),
|
|
112
|
+
configSha256: createHash('sha256').update(await readFile(join(root, CONFIG_PATH))).digest('hex'),
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
io.out.write(`run branch ${branch} (checked out)\n`)
|
|
116
|
+
io.out.write(`baseline ${commit}\n`)
|
|
117
|
+
io.out.write(`benchmarks ${declared.join(', ')}\n`)
|
|
118
|
+
io.out.write(`frozen ${toFreeze.length} test and bench file(s)\n`)
|
|
119
|
+
io.out.write(`worktree ${worktree}\n`)
|
|
120
|
+
io.out.write(`\nstart the agent: point it at program.md\n`)
|
|
121
|
+
io.out.write(`to stop the run: autor3search-javascript stop\n`)
|
|
122
|
+
return 0
|
|
123
|
+
} catch (err) {
|
|
124
|
+
await gitx.checkout(root, originalBranch).catch(() => {})
|
|
125
|
+
await gitx.deleteBranch(root, branch).catch(() => {})
|
|
126
|
+
io.err.write(`baseline failed, run branch ${branch} removed: ${err.message}\n`)
|
|
127
|
+
return 2
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Today as a short slug, e.g. "sep7" — the shape the README's examples use. */
|
|
132
|
+
function defaultTag() {
|
|
133
|
+
const now = new Date()
|
|
134
|
+
const month = now.toLocaleString('en-US', { month: 'short' }).toLowerCase()
|
|
135
|
+
return `${month}${now.getDate()}`
|
|
136
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Prints the machine-fitness report. Always exits 0 — it informs, never blocks. */
|
|
2
|
+
import { parseArgs } from 'node:util'
|
|
3
|
+
import { SEVERITY, check } from '../doctor.js'
|
|
4
|
+
|
|
5
|
+
const LABEL = { [SEVERITY.OK]: 'ok ', [SEVERITY.WARN]: 'warn', [SEVERITY.FAIL]: 'FAIL', [SEVERITY.NA]: 'n/a ' }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {string[]} args
|
|
9
|
+
* @param {{out: {write(s: string): void}, err: {write(s: string): void}}} io
|
|
10
|
+
* @returns {Promise<number>}
|
|
11
|
+
*/
|
|
12
|
+
export async function runDoctor(args, io) {
|
|
13
|
+
const { values } = parseArgs({
|
|
14
|
+
args,
|
|
15
|
+
options: { C: { type: 'string', default: '.' } },
|
|
16
|
+
allowPositionals: false,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const findings = await check(values.C)
|
|
20
|
+
for (const f of findings) io.out.write(`${LABEL[f.severity]} ${f.name.padEnd(14)} ${f.detail}\n`)
|
|
21
|
+
|
|
22
|
+
const failures = findings.filter((f) => f.severity === SEVERITY.FAIL).length
|
|
23
|
+
const warnings = findings.filter((f) => f.severity === SEVERITY.WARN).length
|
|
24
|
+
io.out.write('\n')
|
|
25
|
+
if (failures > 0) {
|
|
26
|
+
io.out.write(`${failures} problem(s) will stop a run from working at all — fix those first.\n`)
|
|
27
|
+
} else if (warnings > 0) {
|
|
28
|
+
io.out.write(
|
|
29
|
+
`${warnings} warning(s): this machine can measure, but expect noisier numbers. Raise min_effect_pct\n` +
|
|
30
|
+
`if experiments look erratic — it costs you only wins smaller than the noise you cannot measure anyway.\n`,
|
|
31
|
+
)
|
|
32
|
+
} else {
|
|
33
|
+
io.out.write('this machine looks fit to measure.\n')
|
|
34
|
+
}
|
|
35
|
+
// Deliberately 0 regardless: whether to accept a noisy machine is the
|
|
36
|
+
// human's decision, not the harness's.
|
|
37
|
+
return 0
|
|
38
|
+
}
|