@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,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs one experiment and returns a verdict.
|
|
3
|
+
*
|
|
4
|
+
* With --json this prints ONE JSON object to stdout and nothing else. That is
|
|
5
|
+
* a contract program.md's loop depends on: the agent parses stdout directly,
|
|
6
|
+
* so a stray line of progress output would break every run. The subprocess
|
|
7
|
+
* transcript goes to run.log instead, opened by this command itself so the
|
|
8
|
+
* agent never has to redirect stdout (doing so would open a second
|
|
9
|
+
* descriptor on the same path and clobber whichever writes second).
|
|
10
|
+
*/
|
|
11
|
+
import { createWriteStream } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { parseArgs } from 'node:util'
|
|
14
|
+
import { headCommit } from '../gitx.js'
|
|
15
|
+
import { RUN_LOG_NAME, evalOnce } from '../pipeline.js'
|
|
16
|
+
import { RESULTS_PATH, appendRow, loadRows } from '../results.js'
|
|
17
|
+
import { BASELINE_FILE, WORKTREE_NAME, loadBaseline } from '../state/index.js'
|
|
18
|
+
import { claimEval } from '../state/lock.js'
|
|
19
|
+
import { stopRequested } from '../state/stop.js'
|
|
20
|
+
import { REASON, STATUS, exitCode } from '../verdict.js'
|
|
21
|
+
import { expandSingleDashFlags, loadRepoConfig, resolveRun } from './context.js'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string[]} args
|
|
25
|
+
* @param {{out: {write(s: string): void}, err: {write(s: string): void}}} io
|
|
26
|
+
* @returns {Promise<number>}
|
|
27
|
+
*/
|
|
28
|
+
export async function runEval(args, io) {
|
|
29
|
+
const { values } = parseArgs({
|
|
30
|
+
args: expandSingleDashFlags(args, ['desc']),
|
|
31
|
+
options: {
|
|
32
|
+
C: { type: 'string', default: '.' },
|
|
33
|
+
json: { type: 'boolean', default: false },
|
|
34
|
+
desc: { type: 'string', default: '' },
|
|
35
|
+
'no-log': { type: 'boolean', default: false },
|
|
36
|
+
},
|
|
37
|
+
allowPositionals: false,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// --json's whole contract is that stdout carries nothing but the verdict.
|
|
41
|
+
// --no-log sends the transcript to stdout instead of run.log. Combining
|
|
42
|
+
// them would interleave subprocess chatter with the JSON object, breaking
|
|
43
|
+
// the one contract this command exists to keep — refuse before anything
|
|
44
|
+
// is claimed or touched.
|
|
45
|
+
if (values.json && values['no-log']) {
|
|
46
|
+
io.err.write('--json and --no-log cannot be combined: --json requires stdout to carry only the verdict\n')
|
|
47
|
+
return 2
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const run = await resolveRun(values.C)
|
|
51
|
+
const cfg = await loadRepoConfig(run.root)
|
|
52
|
+
const baseline = await loadBaseline(join(run.stateDir, BASELINE_FILE))
|
|
53
|
+
|
|
54
|
+
// An interrupt must not leave Vitest workers running: the runner kills its
|
|
55
|
+
// process groups, and this abort signal is what tells evalOnce's measure
|
|
56
|
+
// phase to do it.
|
|
57
|
+
//
|
|
58
|
+
// Installed BEFORE the claim, and that order is load-bearing. claimEval
|
|
59
|
+
// creates the lock DIRECTORY first and writes its pid and heartbeat files
|
|
60
|
+
// afterwards, so a handler installed after it would leave a window in which
|
|
61
|
+
// the run already owns on-disk state while SIGINT still has Node's default
|
|
62
|
+
// action: the process dies outright, the `finally` below never runs, and the
|
|
63
|
+
// claim sits stranded until it goes stale ~30s later, refusing every eval in
|
|
64
|
+
// between. That window is also observable from outside — evalRunning reports
|
|
65
|
+
// `running` as soon as the directory exists — so anything waiting for the run
|
|
66
|
+
// to start could deliver the signal squarely into it.
|
|
67
|
+
//
|
|
68
|
+
// An interrupt arriving before there is anything to abort is harmless: the
|
|
69
|
+
// controller simply starts out aborted, and evalOnce stops at its first
|
|
70
|
+
// checkpoint without measuring anything.
|
|
71
|
+
const controller = new AbortController()
|
|
72
|
+
const onSignal = () => controller.abort()
|
|
73
|
+
process.on('SIGINT', onSignal)
|
|
74
|
+
process.on('SIGTERM', onSignal)
|
|
75
|
+
const offSignals = () => {
|
|
76
|
+
process.off('SIGINT', onSignal)
|
|
77
|
+
process.off('SIGTERM', onSignal)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let claim
|
|
81
|
+
try {
|
|
82
|
+
claim = await claimEval(run.stateDir)
|
|
83
|
+
} catch (err) {
|
|
84
|
+
// dispatch() also runs in-process under test, so a leaked listener here
|
|
85
|
+
// would accumulate across runs rather than dying with the process.
|
|
86
|
+
offSignals()
|
|
87
|
+
io.err.write(`${err.message}\n`)
|
|
88
|
+
return 2
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The transcript goes to run.log by default; --no-log streams it to stdout
|
|
92
|
+
// for a human watching interactively instead (never combined with --json,
|
|
93
|
+
// refused above).
|
|
94
|
+
const logStream = values['no-log'] ? null : createWriteStream(join(run.root, RUN_LOG_NAME), { flags: 'a' })
|
|
95
|
+
// A stream error (e.g. the directory vanishing mid-run) must not crash the
|
|
96
|
+
// process via an unhandled 'error' event; it surfaces as a missing
|
|
97
|
+
// transcript instead, which run.log's absence already makes visible.
|
|
98
|
+
logStream?.on('error', () => {})
|
|
99
|
+
const log = values['no-log'] ? io.out : logStream
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const { result, measurements } = await evalOnce({
|
|
103
|
+
root: run.root,
|
|
104
|
+
stateDir: run.stateDir,
|
|
105
|
+
cfg,
|
|
106
|
+
baseline,
|
|
107
|
+
log,
|
|
108
|
+
signal: controller.signal,
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const experiment = (await loadRows(join(run.root, RESULTS_PATH))).length + 1
|
|
112
|
+
await appendRow(join(run.root, RESULTS_PATH), {
|
|
113
|
+
commit: await headCommit(run.root).catch(() => 'unknown'),
|
|
114
|
+
score: result.score,
|
|
115
|
+
bestBenchDelta: bestDelta(measurements?.time),
|
|
116
|
+
bytesDelta: bestDelta(measurements?.bytes),
|
|
117
|
+
status: result.status.toLowerCase(),
|
|
118
|
+
description: values.desc,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const stopped = await stopRequested(run.stateDir)
|
|
122
|
+
const context = buildContext(run, baseline, experiment)
|
|
123
|
+
|
|
124
|
+
if (values.json) writeJson(io, result, measurements, stopped, context)
|
|
125
|
+
else writeHuman(io, result, measurements, stopped, context)
|
|
126
|
+
return exitCode(result)
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (controller.signal.aborted) {
|
|
129
|
+
// ABORTED is not a verdict: nothing was measured, so NO results.tsv row
|
|
130
|
+
// is written. The agent treats it as it would a FAIL.
|
|
131
|
+
const aborted = {
|
|
132
|
+
status: STATUS.ABORTED,
|
|
133
|
+
reason: REASON.STOP_FORCED,
|
|
134
|
+
score: 0,
|
|
135
|
+
message: 'the experiment was interrupted before it could be measured',
|
|
136
|
+
regressions: [],
|
|
137
|
+
warnings: [],
|
|
138
|
+
}
|
|
139
|
+
// Still report whatever is actually pending — an interrupt is not
|
|
140
|
+
// itself evidence that a graceful stop was also requested.
|
|
141
|
+
const stopped = await stopRequested(run.stateDir).catch(() => false)
|
|
142
|
+
const context = buildContext(run, baseline)
|
|
143
|
+
if (values.json) writeJson(io, aborted, null, stopped, context)
|
|
144
|
+
else {
|
|
145
|
+
io.err.write(`ABORTED: ${aborted.message}\n`)
|
|
146
|
+
if (stopped) io.err.write('STOP REQUESTED: end the loop after applying this verdict\n')
|
|
147
|
+
}
|
|
148
|
+
return exitCode(aborted)
|
|
149
|
+
}
|
|
150
|
+
throw err
|
|
151
|
+
} finally {
|
|
152
|
+
offSignals()
|
|
153
|
+
await closeLog(logStream)
|
|
154
|
+
await claim.release()
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The run context reported alongside a verdict; experiment is omitted when none was recorded. */
|
|
159
|
+
function buildContext(run, baseline, experiment) {
|
|
160
|
+
return {
|
|
161
|
+
tag: run.tag,
|
|
162
|
+
branch: run.branch,
|
|
163
|
+
baseline_commit: baseline.commit,
|
|
164
|
+
measure_commit: baseline.measureCommit,
|
|
165
|
+
worktree: join(run.stateDir, WORKTREE_NAME),
|
|
166
|
+
...(experiment !== undefined ? { experiment } : {}),
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Flushes and closes the transcript stream, if one was opened. */
|
|
171
|
+
async function closeLog(stream) {
|
|
172
|
+
if (!stream) return
|
|
173
|
+
await new Promise((resolve) => stream.end(resolve))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function writeJson(io, result, measurements, stopped, run) {
|
|
177
|
+
io.out.write(
|
|
178
|
+
`${JSON.stringify({
|
|
179
|
+
status: result.status,
|
|
180
|
+
reason: result.reason,
|
|
181
|
+
score: result.score,
|
|
182
|
+
message: result.message,
|
|
183
|
+
regressions: result.regressions ?? [],
|
|
184
|
+
warnings: result.warnings ?? [],
|
|
185
|
+
deltas: (measurements?.time ?? []).map(publicDelta),
|
|
186
|
+
bytes: (measurements?.bytes ?? []).map(publicDelta),
|
|
187
|
+
stop_requested: stopped,
|
|
188
|
+
run,
|
|
189
|
+
})}\n`,
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function writeHuman(io, result, measurements, stopped, run) {
|
|
194
|
+
for (const delta of measurements?.time ?? []) {
|
|
195
|
+
io.out.write(
|
|
196
|
+
`${delta.name} ${(delta.baseCenter * 1e9).toFixed(0)} -> ${(delta.candCenter * 1e9).toFixed(0)} ns/op ` +
|
|
197
|
+
`${delta.pctChange >= 0 ? '+' : ''}${delta.pctChange.toFixed(2)}% p=${delta.p.toFixed(5)}` +
|
|
198
|
+
`${delta.significant ? '' : ' (not significant)'}\n`,
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
for (const delta of measurements?.bytes ?? []) {
|
|
202
|
+
io.out.write(
|
|
203
|
+
`${delta.name} ${delta.baseCenter.toFixed(0)} -> ${delta.candCenter.toFixed(0)} bytes/op ` +
|
|
204
|
+
`${delta.pctChange >= 0 ? '+' : ''}${delta.pctChange.toFixed(2)}% (approximate hint, never scored)\n`,
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
// Warnings go ABOVE the verdict: they qualify how far it can be read, and a
|
|
208
|
+
// reader who stops at the verdict line must not miss them.
|
|
209
|
+
for (const warning of result.warnings ?? []) io.out.write(`WARNING: ${warning}\n`)
|
|
210
|
+
io.out.write(`VERDICT: ${result.status} (${result.reason}) — ${result.message}\n`)
|
|
211
|
+
if (stopped) io.out.write('STOP REQUESTED: end the loop after applying this verdict\n')
|
|
212
|
+
if (run?.experiment) io.out.write(`experiment ${run.experiment} on ${run.branch}\n`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The delta fields the agent and a human need, without internal detail. */
|
|
216
|
+
const publicDelta = (d) => ({
|
|
217
|
+
name: d.name,
|
|
218
|
+
unit: d.unit,
|
|
219
|
+
base: d.baseCenter,
|
|
220
|
+
candidate: d.candCenter,
|
|
221
|
+
ratio: d.ratio,
|
|
222
|
+
pct_change: d.pctChange,
|
|
223
|
+
p: d.p,
|
|
224
|
+
significant: d.significant,
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
/** The largest single-benchmark improvement, in percent; 0 when unmeasured. */
|
|
228
|
+
function bestDelta(deltas) {
|
|
229
|
+
if (!deltas || deltas.length === 0) return 0
|
|
230
|
+
return Math.min(...deltas.map((d) => d.pctChange))
|
|
231
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scans the repository, discovers benchmarks, and writes the run
|
|
3
|
+
* configuration and the agent's instruction set.
|
|
4
|
+
*
|
|
5
|
+
* It writes files and commits NOTHING. `baseline` refuses an uncommitted
|
|
6
|
+
* tree, so the human commits what init produced — deliberately, because a
|
|
7
|
+
* baseline pinned against what is on disk rather than what is in git would
|
|
8
|
+
* not be reproducible.
|
|
9
|
+
*/
|
|
10
|
+
import { copyFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
|
11
|
+
import { dirname, join } from 'node:path'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
import { parseArgs } from 'node:util'
|
|
14
|
+
import { CONFIG_PATH, defaultConfig } from '../config.js'
|
|
15
|
+
import { baseNames, benchmarks } from '../discover.js'
|
|
16
|
+
import { resolveRepo } from './context.js'
|
|
17
|
+
|
|
18
|
+
const TEMPLATE = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates', 'program.md')
|
|
19
|
+
|
|
20
|
+
/** Entries init adds to .gitignore, in order. */
|
|
21
|
+
const IGNORE_ENTRIES = ['results.tsv', 'run.log', '.autor3search/*', '!.autor3search/config.yaml']
|
|
22
|
+
|
|
23
|
+
export async function runInit(args, io) {
|
|
24
|
+
const { values } = parseArgs({
|
|
25
|
+
args,
|
|
26
|
+
options: { C: { type: 'string', default: '.' }, force: { type: 'boolean', default: false } },
|
|
27
|
+
allowPositionals: false,
|
|
28
|
+
})
|
|
29
|
+
const root = await resolveRepo(values.C)
|
|
30
|
+
|
|
31
|
+
const found = await benchmarks(root)
|
|
32
|
+
if (found.length === 0) {
|
|
33
|
+
// Refusing is deliberate. The verdict is entirely a function of the
|
|
34
|
+
// declared benchmarks' timings, so with none there is no signal to gate
|
|
35
|
+
// on — every candidate would be accepted or rejected for no reason.
|
|
36
|
+
io.err.write(
|
|
37
|
+
`no benchmarks found in ${root}.\n\n` +
|
|
38
|
+
`autor3search-javascript optimizes what it can measure, and refuses to guess. Write at least one\n` +
|
|
39
|
+
`Vitest benchmark covering the code you want made faster, in a *.bench.js file:\n\n` +
|
|
40
|
+
` import { bench } from 'vitest'\n` +
|
|
41
|
+
` import { thing } from './thing.js'\n\n` +
|
|
42
|
+
` bench('thing', () => { thing() })\n\n` +
|
|
43
|
+
`Benchmark the path that actually dominates your workload — one that exercises a cold path or a\n` +
|
|
44
|
+
`trivial helper produces numbers that are entirely real and entirely useless. Then run init again.\n`,
|
|
45
|
+
)
|
|
46
|
+
return 2
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const configPath = join(root, CONFIG_PATH)
|
|
50
|
+
const exists = await stat(configPath).then(
|
|
51
|
+
() => true,
|
|
52
|
+
() => false,
|
|
53
|
+
)
|
|
54
|
+
if (exists && !values.force) {
|
|
55
|
+
io.err.write(`${CONFIG_PATH} already exists; pass --force to overwrite it\n`)
|
|
56
|
+
return 2
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await mkdir(dirname(configPath), { recursive: true })
|
|
60
|
+
await writeFile(configPath, renderConfig(baseNames(found)))
|
|
61
|
+
await copyFile(TEMPLATE, join(root, 'program.md'))
|
|
62
|
+
await addIgnoreEntries(join(root, '.gitignore'))
|
|
63
|
+
|
|
64
|
+
io.out.write(`discovered ${found.length} benchmark(s):\n`)
|
|
65
|
+
for (const b of found) io.out.write(` ${b.path.padEnd(40)} ${b.file}\n`)
|
|
66
|
+
io.out.write(`\nwrote ${CONFIG_PATH}, program.md and .gitignore entries.\n`)
|
|
67
|
+
io.out.write(`next: git add -A && git commit -m "autor3search-javascript init"\n`)
|
|
68
|
+
io.out.write(`then: autor3search-javascript doctor && autor3search-javascript baseline -tag <tag>\n`)
|
|
69
|
+
return 0
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Renders the config as commented YAML rather than serialising the defaults
|
|
74
|
+
* object: the comments are the documentation a human reads when deciding
|
|
75
|
+
* what to change, and they are the reason this file lives in the repository
|
|
76
|
+
* at all.
|
|
77
|
+
*/
|
|
78
|
+
function renderConfig(names) {
|
|
79
|
+
const d = defaultConfig()
|
|
80
|
+
return `# autor3search-javascript run configuration.
|
|
81
|
+
#
|
|
82
|
+
# This file is HUMAN-OWNED and version-controlled. Its hash is recorded at
|
|
83
|
+
# baseline time, so any change during a run fails the run rather than
|
|
84
|
+
# silently moving the goalposts.
|
|
85
|
+
|
|
86
|
+
# The declared benchmark set. An empty list means every discovered benchmark.
|
|
87
|
+
benchmarks:
|
|
88
|
+
${names.map((n) => ` - ${JSON.stringify(n)}`).join('\n')}
|
|
89
|
+
|
|
90
|
+
# Glob patterns the agent may edit. Everything else is rejected before an
|
|
91
|
+
# experiment is even measured.
|
|
92
|
+
scope:
|
|
93
|
+
- "**"
|
|
94
|
+
|
|
95
|
+
# Measured rounds per side. Below 4 the significance test can never report
|
|
96
|
+
# p < 0.05 however large the improvement, so every experiment would discard.
|
|
97
|
+
# Capped at 1000: timeout bounds each measurement process, not the run, so a
|
|
98
|
+
# stray zero here does not fail — it runs for days.
|
|
99
|
+
count: ${d.count}
|
|
100
|
+
|
|
101
|
+
# The largest tolerated significant regression, in percent.
|
|
102
|
+
max_regress_pct: ${d.maxRegressPct}
|
|
103
|
+
|
|
104
|
+
# The smallest geomean improvement a KEEP will accept, in percent.
|
|
105
|
+
min_effect_pct: ${d.minEffectPct}
|
|
106
|
+
|
|
107
|
+
# Bound on each subprocess phase.
|
|
108
|
+
timeout: ${d.timeout}
|
|
109
|
+
|
|
110
|
+
# Test or bench files deliberately exempt from freezing.
|
|
111
|
+
unfreeze: []
|
|
112
|
+
|
|
113
|
+
# The bench adapter. Only "vitest" is registered in this version.
|
|
114
|
+
runner: ${d.runner}
|
|
115
|
+
|
|
116
|
+
# Sample approximate bytes/op alongside the timings. Never scored.
|
|
117
|
+
heap_hint: ${d.heapHint}
|
|
118
|
+
|
|
119
|
+
# auto runs each gate when the repository is set up for it, on requires it,
|
|
120
|
+
# off never runs it.
|
|
121
|
+
gates:
|
|
122
|
+
typecheck: ${d.gates.typecheck}
|
|
123
|
+
lint: ${d.gates.lint}
|
|
124
|
+
test: ${d.gates.test}
|
|
125
|
+
`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Appends any missing ignore entries, leaving existing content untouched. */
|
|
129
|
+
async function addIgnoreEntries(path) {
|
|
130
|
+
const existing = await readFile(path, 'utf8').catch(() => '')
|
|
131
|
+
const lines = new Set(existing.split('\n').map((l) => l.trim()))
|
|
132
|
+
const missing = IGNORE_ENTRIES.filter((entry) => !lines.has(entry))
|
|
133
|
+
if (missing.length === 0) return
|
|
134
|
+
const prefix = existing === '' || existing.endsWith('\n') ? '' : '\n'
|
|
135
|
+
await writeFile(path, `${existing}${prefix}\n# autor3search-javascript\n${missing.join('\n')}\n`)
|
|
136
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Profiles the declared benchmarks and prints where the time actually goes. */
|
|
2
|
+
import { parseArgs } from 'node:util'
|
|
3
|
+
import { profile } from '../profile.js'
|
|
4
|
+
import { loadRepoConfig, resolveRepo } from './context.js'
|
|
5
|
+
|
|
6
|
+
export async function runProfile(args, io) {
|
|
7
|
+
const { values } = parseArgs({
|
|
8
|
+
args,
|
|
9
|
+
options: { C: { type: 'string', default: '.' }, top: { type: 'string', default: '15' } },
|
|
10
|
+
allowPositionals: false,
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const root = await resolveRepo(values.C)
|
|
14
|
+
const cfg = await loadRepoConfig(root)
|
|
15
|
+
const reports = await profile(root, cfg, { top: Number(values.top) })
|
|
16
|
+
|
|
17
|
+
for (const report of reports) {
|
|
18
|
+
io.out.write(`\n${report.file}\n`)
|
|
19
|
+
if (report.top.length === 0) {
|
|
20
|
+
io.out.write(' (no samples attributable to benchmarked code)\n')
|
|
21
|
+
continue
|
|
22
|
+
}
|
|
23
|
+
io.out.write(`${'self ms'.padStart(9)} ${'%'.padStart(6)} function\n`)
|
|
24
|
+
for (const entry of report.top) {
|
|
25
|
+
io.out.write(
|
|
26
|
+
`${entry.selfMs.toFixed(1).padStart(9)} ${entry.pct.toFixed(1).padStart(6)} ` +
|
|
27
|
+
`${entry.name}${entry.file ? ` (${entry.file})` : ''}\n`,
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
io.out.write(`\n cpu ${report.cpuProfile}\n`)
|
|
31
|
+
io.out.write(` heap ${report.heapProfile}\n`)
|
|
32
|
+
}
|
|
33
|
+
io.out.write(
|
|
34
|
+
'\nopen a .cpuprofile or .heapprofile in Chrome DevTools (Performance > Load profile) or at\n' +
|
|
35
|
+
'https://speedscope.app for a flame graph.\n',
|
|
36
|
+
)
|
|
37
|
+
return 0
|
|
38
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Summarizes results.tsv: what ran, what was kept, and how much faster.
|
|
3
|
+
*
|
|
4
|
+
* Cumulative speedup is the PRODUCT of every kept score, never the latest
|
|
5
|
+
* score and never an average. The harness re-points its measurement baseline
|
|
6
|
+
* at the commit just kept after every KEEP, so each kept `score` measures
|
|
7
|
+
* only that experiment's own incremental contribution — "did this change
|
|
8
|
+
* help, compared to the last thing we kept" — never "is the tree better than
|
|
9
|
+
* when the run started". Successive improvements compound the way
|
|
10
|
+
* percentage changes do, so multiplying is the only way to recover total
|
|
11
|
+
* progress; averaging or taking the last score would both understate a good
|
|
12
|
+
* night, in different ways.
|
|
13
|
+
*
|
|
14
|
+
* A DISCARD's score is real (it was measured) but was rejected, so it is
|
|
15
|
+
* excluded from the product — folding it in would invent progress that was
|
|
16
|
+
* explicitly not banked.
|
|
17
|
+
*/
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import { parseArgs } from 'node:util'
|
|
20
|
+
import { RESULTS_PATH, loadRows } from '../results.js'
|
|
21
|
+
import { resolveRepo } from './context.js'
|
|
22
|
+
|
|
23
|
+
/** How many of the largest wins to list. */
|
|
24
|
+
const TOP_N = 5
|
|
25
|
+
|
|
26
|
+
/** Every status appendRow may write, in the order counted and printed. */
|
|
27
|
+
const STATUSES = ['keep', 'discard', 'fail', 'crash']
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string[]} args
|
|
31
|
+
* @param {{out: {write(s: string): void}, err: {write(s: string): void}}} io
|
|
32
|
+
* @returns {Promise<number>}
|
|
33
|
+
*/
|
|
34
|
+
export async function runReport(args, io) {
|
|
35
|
+
const { values } = parseArgs({
|
|
36
|
+
args,
|
|
37
|
+
options: { C: { type: 'string', default: '.' } },
|
|
38
|
+
allowPositionals: false,
|
|
39
|
+
})
|
|
40
|
+
const root = await resolveRepo(values.C)
|
|
41
|
+
const rows = await loadRows(join(root, RESULTS_PATH))
|
|
42
|
+
|
|
43
|
+
if (rows.length === 0) {
|
|
44
|
+
io.out.write(`no experiments recorded in ${RESULTS_PATH} yet — nothing to report\n`)
|
|
45
|
+
return 0
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const counts = Object.fromEntries(STATUSES.map((s) => [s, 0]))
|
|
49
|
+
let other = 0
|
|
50
|
+
for (const row of rows) {
|
|
51
|
+
if (Object.hasOwn(counts, row.status)) counts[row.status]++
|
|
52
|
+
else other++
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
io.out.write('experiments\n')
|
|
56
|
+
for (const status of STATUSES) io.out.write(` ${status.padEnd(10)} ${counts[status]}\n`)
|
|
57
|
+
if (other > 0) io.out.write(` ${'other'.padEnd(10)} ${other} (unrecognised status)\n`)
|
|
58
|
+
io.out.write(` ${'total'.padEnd(10)} ${rows.length}\n\n`)
|
|
59
|
+
|
|
60
|
+
const kept = rows.filter((r) => r.status === 'keep')
|
|
61
|
+
if (kept.length === 0) {
|
|
62
|
+
io.out.write('nothing was kept — no measured improvement cleared the bar.\n')
|
|
63
|
+
return 0
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The PRODUCT of every kept score — see file header for why.
|
|
67
|
+
const cumulative = kept.reduce((acc, r) => acc * r.score, 1)
|
|
68
|
+
io.out.write(`cumulative speedup ${cumulative.toFixed(4)} (${formatMultiplier(cumulative)}, `)
|
|
69
|
+
io.out.write(`${formatPercent(cumulative)})\n`)
|
|
70
|
+
io.out.write(` the product of all ${kept.length} kept score(s); discards do not contribute\n\n`)
|
|
71
|
+
|
|
72
|
+
io.out.write('largest individual wins\n')
|
|
73
|
+
for (const row of [...kept].sort((a, b) => a.score - b.score).slice(0, TOP_N)) {
|
|
74
|
+
io.out.write(
|
|
75
|
+
` ${row.commit.padEnd(9)} ${row.score.toFixed(4)} ` +
|
|
76
|
+
`${row.bestBenchDelta >= 0 ? '+' : ''}${row.bestBenchDelta.toFixed(1)}% ${row.description}\n`,
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
return 0
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The cumulative ratio as a human-readable "Nx faster". A ratio of exactly
|
|
84
|
+
* zero (a kept experiment measured at effectively zero cost) has no finite
|
|
85
|
+
* reciprocal — report that plainly instead of printing "Infinityx".
|
|
86
|
+
*/
|
|
87
|
+
function formatMultiplier(cumulative) {
|
|
88
|
+
if (cumulative <= 0) return 'immeasurably faster (measured cost of zero)'
|
|
89
|
+
return `${(1 / cumulative).toFixed(2)}x faster`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The cumulative ratio as a signed percent change, e.g. "-75.00%". */
|
|
93
|
+
function formatPercent(cumulative) {
|
|
94
|
+
const pct = (cumulative - 1) * 100
|
|
95
|
+
return `${pct >= 0 ? '+' : ''}${pct.toFixed(2)}%`
|
|
96
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prints where a run is. READ-ONLY: checking on a run must never change it,
|
|
3
|
+
* which is why nothing here writes, claims, or clears anything.
|
|
4
|
+
*/
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { parseArgs } from 'node:util'
|
|
7
|
+
import * as gitx from '../gitx.js'
|
|
8
|
+
import { RESULTS_PATH, loadRows } from '../results.js'
|
|
9
|
+
import { BASELINE_FILE, WORKTREE_NAME, loadBaseline } from '../state/index.js'
|
|
10
|
+
import { evalRunning } from '../state/lock.js'
|
|
11
|
+
import { stopRequested } from '../state/stop.js'
|
|
12
|
+
import { expandSingleDashFlags, resolveRun } from './context.js'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string[]} args
|
|
16
|
+
* @param {{out: {write(s: string): void}, err: {write(s: string): void}}} io
|
|
17
|
+
* @returns {Promise<number>}
|
|
18
|
+
*/
|
|
19
|
+
export async function runStatus(args, io) {
|
|
20
|
+
const { values } = parseArgs({
|
|
21
|
+
args: expandSingleDashFlags(args, ['tag']),
|
|
22
|
+
options: { C: { type: 'string', default: '.' }, tag: { type: 'string' } },
|
|
23
|
+
allowPositionals: false,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const run = await resolveRun(values.C, values.tag)
|
|
27
|
+
const baseline = await loadBaseline(join(run.stateDir, BASELINE_FILE))
|
|
28
|
+
const current = await gitx.currentBranch(run.root)
|
|
29
|
+
const rows = await loadRows(join(run.root, RESULTS_PATH))
|
|
30
|
+
const counts = { keep: 0, discard: 0, fail: 0, crash: 0 }
|
|
31
|
+
for (const row of rows) if (row.status in counts) counts[row.status]++
|
|
32
|
+
|
|
33
|
+
// A corrupt pid file is an error `evalRunning` deliberately throws rather
|
|
34
|
+
// than guessing at — but a human reaching for `status` to see the REST of
|
|
35
|
+
// the run must never be stopped cold by that. Report the eval line as
|
|
36
|
+
// unreadable and keep going with everything else.
|
|
37
|
+
let evalLine
|
|
38
|
+
try {
|
|
39
|
+
const { pid, running } = await evalRunning(run.stateDir)
|
|
40
|
+
evalLine = running ? `running (pid ${pid}) — an experiment is being measured` : 'idle'
|
|
41
|
+
} catch (err) {
|
|
42
|
+
evalLine = `unknown (${err.message})`
|
|
43
|
+
}
|
|
44
|
+
const stopped = await stopRequested(run.stateDir)
|
|
45
|
+
|
|
46
|
+
const field = (name, value) => io.out.write(`${name.padEnd(14)} ${value}\n`)
|
|
47
|
+
field('run tag', run.tag)
|
|
48
|
+
field('branch', `${run.branch} ${current === run.branch ? '(checked out)' : `(not checked out; on ${current})`}`)
|
|
49
|
+
field('baseline', `${baseline.commit} (run started here)`)
|
|
50
|
+
field(
|
|
51
|
+
'measuring vs',
|
|
52
|
+
baseline.measureCommit === baseline.commit
|
|
53
|
+
? `${baseline.measureCommit} (unchanged — nothing kept yet)`
|
|
54
|
+
: `${baseline.measureCommit} (advanced past the baseline by earlier KEEPs)`,
|
|
55
|
+
)
|
|
56
|
+
field('worktree', join(run.stateDir, WORKTREE_NAME))
|
|
57
|
+
field(
|
|
58
|
+
'experiments',
|
|
59
|
+
`${rows.length} run (${counts.keep} keep, ${counts.discard} discard, ${counts.fail} fail, ` +
|
|
60
|
+
`${counts.crash} crash) — next is #${rows.length + 1}`,
|
|
61
|
+
)
|
|
62
|
+
field('eval', evalLine)
|
|
63
|
+
field('stop', stopped ? 'requested — the agent will end the run after the current experiment' : 'not requested')
|
|
64
|
+
|
|
65
|
+
io.out.write('\nto stop after the current experiment: autor3search-javascript stop\n')
|
|
66
|
+
io.out.write('to stop now, abandoning it: autor3search-javascript stop --force\n')
|
|
67
|
+
return 0
|
|
68
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asks the agent to end the run.
|
|
3
|
+
*
|
|
4
|
+
* Plain `stop` is the one to use: it writes a request `eval` reports back, so
|
|
5
|
+
* the experiment under way finishes and is scored, its verdict is applied,
|
|
6
|
+
* and only then does the loop exit. Nothing is thrown away.
|
|
7
|
+
*
|
|
8
|
+
* `--force` is for when a long benchmark cannot be waited out. It writes the
|
|
9
|
+
* same request, then signals the running eval to abandon the experiment. It
|
|
10
|
+
* REPORTS what state that leaves the repository in; it does not drop anything
|
|
11
|
+
* for you, because deciding what to do with a half-finished experiment is the
|
|
12
|
+
* human's call.
|
|
13
|
+
*/
|
|
14
|
+
import { parseArgs } from 'node:util'
|
|
15
|
+
import * as gitx from '../gitx.js'
|
|
16
|
+
import { evalRunning } from '../state/lock.js'
|
|
17
|
+
import { clearStop, requestStop } from '../state/stop.js'
|
|
18
|
+
import { expandSingleDashFlags, resolveRun } from './context.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string[]} args
|
|
22
|
+
* @param {{out: {write(s: string): void}, err: {write(s: string): void}}} io
|
|
23
|
+
* @returns {Promise<number>}
|
|
24
|
+
*/
|
|
25
|
+
export async function runStop(args, io) {
|
|
26
|
+
const { values } = parseArgs({
|
|
27
|
+
args: expandSingleDashFlags(args, ['tag']),
|
|
28
|
+
options: {
|
|
29
|
+
C: { type: 'string', default: '.' },
|
|
30
|
+
tag: { type: 'string' },
|
|
31
|
+
clear: { type: 'boolean', default: false },
|
|
32
|
+
force: { type: 'boolean', default: false },
|
|
33
|
+
},
|
|
34
|
+
allowPositionals: false,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
if (values.clear && values.force) {
|
|
38
|
+
io.err.write('--clear and --force ask for opposite things; pass one or the other\n')
|
|
39
|
+
return 2
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const run = await resolveRun(values.C, values.tag)
|
|
43
|
+
|
|
44
|
+
if (values.clear) {
|
|
45
|
+
await clearStop(run.stateDir)
|
|
46
|
+
io.out.write(`stop request for ${run.tag} cancelled — the loop may continue\n`)
|
|
47
|
+
return 0
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await requestStop(run.stateDir)
|
|
51
|
+
io.out.write(`stop requested for ${run.tag}: the agent will end the run after the current experiment\n`)
|
|
52
|
+
if (!values.force) {
|
|
53
|
+
io.out.write('the experiment under way will still be measured, scored and applied\n')
|
|
54
|
+
io.out.write(`to cancel: autor3search-javascript stop -tag ${run.tag} --clear\n`)
|
|
55
|
+
return 0
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A corrupt pid file must not crash `stop --force` — it is refused by
|
|
59
|
+
// evalRunning rather than guessed at, but the request above already stands
|
|
60
|
+
// regardless, so fall back to "nothing known to signal" and keep going.
|
|
61
|
+
let pid = null
|
|
62
|
+
let running = false
|
|
63
|
+
try {
|
|
64
|
+
;({ pid, running } = await evalRunning(run.stateDir))
|
|
65
|
+
} catch (err) {
|
|
66
|
+
io.err.write(`could not read the eval lock (${err.message}); assuming there is nothing to signal\n`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (running) {
|
|
70
|
+
// SIGTERM, not SIGKILL: eval installs a handler that tears down its child
|
|
71
|
+
// process groups. Killing it outright would leave Vitest workers running,
|
|
72
|
+
// burning CPU and corrupting every later measurement on this machine.
|
|
73
|
+
try {
|
|
74
|
+
process.kill(pid, 'SIGTERM')
|
|
75
|
+
io.out.write(`signalled eval (pid ${pid}) to abandon the current experiment\n`)
|
|
76
|
+
} catch (err) {
|
|
77
|
+
io.err.write(`could not signal eval (pid ${pid}): ${err.message}\n`)
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
io.out.write('no eval is running, so there was nothing to interrupt — the request stands\n')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Report, do not act — whether or not anything was actually interrupted,
|
|
84
|
+
// this is what the repository looks like right now. The commit an
|
|
85
|
+
// abandoned experiment made, if any, is still on the branch and nothing
|
|
86
|
+
// scored it; dropping it is the human's call, not this command's.
|
|
87
|
+
const branch = await gitx.currentBranch(run.root).catch(() => 'unknown')
|
|
88
|
+
const head = await gitx.headCommit(run.root).catch(() => 'unknown')
|
|
89
|
+
const subject = await gitx.headSubject(run.root).catch(() => '')
|
|
90
|
+
io.out.write('\nwhat this leaves you with:\n')
|
|
91
|
+
io.out.write(` branch ${branch}\n`)
|
|
92
|
+
io.out.write(` HEAD ${head} ${subject}\n`)
|
|
93
|
+
io.out.write(' no results.tsv row is written for an experiment interrupted this way\n')
|
|
94
|
+
io.out.write('\nHEAD may be the commit for an experiment nothing scored. If it is, drop it yourself:\n')
|
|
95
|
+
io.out.write(' git reset --hard HEAD~1\n')
|
|
96
|
+
io.out.write('Every commit kept before it is untouched.\n')
|
|
97
|
+
return 0
|
|
98
|
+
}
|