@qvac/harness 1.0.0-rc.2 → 1.0.0-rc.3
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/README.md +12 -12
- package/bench/README.md +94 -0
- package/bench/bfcl/checker.ts +313 -0
- package/bench/bfcl/data.ts +93 -0
- package/bench/bfcl/observe.ts +93 -0
- package/bench/bfcl/run.ts +213 -0
- package/bench/bfcl/tools.ts +72 -0
- package/bench/bridge/bridge.ts +392 -0
- package/bench/bridge/openai.ts +150 -0
- package/bench/bridge/python/qvac_bench/__init__.py +3 -0
- package/bench/bridge/python/qvac_bench/airline.py +42 -0
- package/bench/bridge/python/qvac_bench/approval.py +26 -0
- package/bench/bridge/python/qvac_bench/bfcl_multi_turn.py +152 -0
- package/bench/bridge/python/qvac_bench/client.py +88 -0
- package/bench/bridge/python/qvac_bench/harbor_agent.py +85 -0
- package/bench/bridge/python/qvac_bench/session.py +107 -0
- package/bench/bridge/python/qvac_bench/speed.py +30 -0
- package/bench/bridge/python/qvac_bench/tau2_agent.py +106 -0
- package/bench/bridge/python/qvac_bench/tools.py +11 -0
- package/bench/bridge/python/run_bfcl_multi_turn.py +95 -0
- package/bench/bridge/python/run_tau2.py +150 -0
- package/bench/bridge/python/smoke.py +47 -0
- package/bench/bridge/python/test_airline.py +28 -0
- package/bench/bridge/python/test_approval.py +57 -0
- package/bench/bridge/python/test_bfcl_multi_turn.py +185 -0
- package/bench/bridge/python/test_harbor_agent.py +107 -0
- package/bench/bridge/serve.ts +52 -0
- package/bench/bridge/transcript.ts +52 -0
- package/bench/bun-tsconfig.json +15 -0
- package/bench/cpu-main.mjs +216 -0
- package/bench/cpu.ts +457 -0
- package/bench/evidence/2026-09-28-review15.md +225 -0
- package/bench/evidence/redact-bun1.3.14.json +92 -0
- package/bench/evidence/redact-node24.11.json +92 -0
- package/bench/graph-metrics.ts +43 -0
- package/bench/harness.ts +73 -0
- package/bench/issue129-matrix.mjs +722 -0
- package/bench/redact-adversarial.ts +256 -0
- package/bench/repeat-cpu.mjs +281 -0
- package/bench/report.ts +1075 -0
- package/bench/skill-eval/harness.ts +30 -0
- package/bench/skill-eval/laws.ts +37 -0
- package/bench/skill-eval/mermaid/client.ts +132 -0
- package/bench/skill-eval/mermaid/validate.mjs +115 -0
- package/bench/skill-eval/pack.ts +61 -0
- package/bench/skill-eval/packs/diagrams/cases.ts +281 -0
- package/bench/skill-eval/packs/diagrams/index.ts +50 -0
- package/bench/skill-eval/packs/diagrams/score.ts +74 -0
- package/bench/skill-eval/packs/index.ts +5 -0
- package/bench/skill-eval/packs/weather/cases.ts +279 -0
- package/bench/skill-eval/packs/weather/fixtures/wttr.json +566 -0
- package/bench/skill-eval/packs/weather/fixtures.ts +84 -0
- package/bench/skill-eval/packs/weather/index.ts +55 -0
- package/bench/skill-eval/packs/weather/score.ts +92 -0
- package/bench/skill-eval/report.ts +194 -0
- package/bench/skill-eval/run.ts +316 -0
- package/bench/speed.ts +80 -0
- package/package.json +4 -13
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import process from 'process'
|
|
2
|
+
import { CompletionEngine } from '../lib/capabilities/completion-engine/index.ts'
|
|
3
|
+
import type { CompletionSdk, ToolDefinition } from '../lib/contract/index.ts'
|
|
4
|
+
import { isRecord } from '../lib/guards.ts'
|
|
5
|
+
import { RunLog, type EngineBoot, type EngineStep, type RunRecord } from '../lib/run-log/index.ts'
|
|
6
|
+
|
|
7
|
+
const SIZES = [100_000, 500_000, 1_000_000]
|
|
8
|
+
const PATTERNS = {
|
|
9
|
+
prose: 'A pagination cursor remains ordinary free text. ',
|
|
10
|
+
jwtPrefix: 'eyJ-',
|
|
11
|
+
markup: '<parameter=',
|
|
12
|
+
punctuation: 'the hyphen-token\\path stays byte-for-byte plain text - '
|
|
13
|
+
} as const
|
|
14
|
+
const SECRET = 'credential-value-must-not-be-persisted'
|
|
15
|
+
const SAMPLES = 3
|
|
16
|
+
const WARMUPS = 1
|
|
17
|
+
const encoder = new TextEncoder()
|
|
18
|
+
|
|
19
|
+
function runtimeInfo() {
|
|
20
|
+
const bun = Reflect.get(globalThis, 'Bun')
|
|
21
|
+
if (bun && typeof bun === 'object' && 'version' in bun && typeof bun.version === 'string') {
|
|
22
|
+
return { runtime: 'bun', version: bun.version }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return { runtime: 'node', version: process.version }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stressText(pattern: string, bytes: number): string {
|
|
29
|
+
return pattern.repeat(Math.ceil(bytes / pattern.length)).slice(0, bytes)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function hasPair(value: unknown, key: string, expected: unknown): boolean {
|
|
33
|
+
if (Array.isArray(value)) {
|
|
34
|
+
return value.some((entry) => hasPair(entry, key, expected))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!isRecord(value)) return false
|
|
38
|
+
|
|
39
|
+
return Object.entries(value).some(
|
|
40
|
+
([childKey, child]) =>
|
|
41
|
+
(childKey === key && (child === expected || (isRecord(child) && child.$ === expected))) ||
|
|
42
|
+
hasPair(child, key, expected)
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sdkFor(payload: string): CompletionSdk {
|
|
47
|
+
let round = 0
|
|
48
|
+
return {
|
|
49
|
+
completion: ({ tools }) => {
|
|
50
|
+
const toolCalls =
|
|
51
|
+
round === 0 && tools
|
|
52
|
+
? [
|
|
53
|
+
{
|
|
54
|
+
id: 'sensitive-call',
|
|
55
|
+
name: 'credential_tool',
|
|
56
|
+
arguments: { credential: SECRET, password: 739184, token: 1024, note: payload }
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
: []
|
|
60
|
+
const contentText = round++ === 0 ? '' : 'done'
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
tokenStream: (async function* () {
|
|
64
|
+
if (contentText) {
|
|
65
|
+
yield contentText
|
|
66
|
+
}
|
|
67
|
+
})(),
|
|
68
|
+
final: Promise.resolve({ contentText, toolCalls, raw: { fullText: contentText } })
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function createTool(execute: (args: Record<string, unknown>) => void): ToolDefinition {
|
|
75
|
+
return {
|
|
76
|
+
name: 'credential_tool',
|
|
77
|
+
description: 'Use the credential without displaying it',
|
|
78
|
+
parameters: {
|
|
79
|
+
type: 'object',
|
|
80
|
+
properties: {
|
|
81
|
+
credential: { type: 'string', sensitive: true },
|
|
82
|
+
password: { type: 'number' },
|
|
83
|
+
token: { type: 'number' },
|
|
84
|
+
note: { type: 'string' }
|
|
85
|
+
},
|
|
86
|
+
required: ['credential', 'note']
|
|
87
|
+
},
|
|
88
|
+
toolset: null,
|
|
89
|
+
autoApprove: true,
|
|
90
|
+
effect: () => 'read',
|
|
91
|
+
execute: (args) => {
|
|
92
|
+
execute(args)
|
|
93
|
+
return Promise.resolve({ content: payloadForResult(args) })
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function payloadForResult(args: Record<string, unknown>): string {
|
|
99
|
+
return typeof args.note === 'string' ? args.note : ''
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface Observation {
|
|
103
|
+
records: RunRecord[]
|
|
104
|
+
boot: EngineBoot
|
|
105
|
+
executedSecret: boolean
|
|
106
|
+
executedPassword: boolean
|
|
107
|
+
executedToken: boolean
|
|
108
|
+
sawRedactedCredential: boolean
|
|
109
|
+
sawRedactedPassword: boolean
|
|
110
|
+
sawTypedToken: boolean
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function createObservation() {
|
|
114
|
+
const state: Observation = {
|
|
115
|
+
records: [],
|
|
116
|
+
boot: RunLog.createEngineBoot(),
|
|
117
|
+
executedSecret: false,
|
|
118
|
+
executedPassword: false,
|
|
119
|
+
executedToken: false,
|
|
120
|
+
sawRedactedCredential: false,
|
|
121
|
+
sawRedactedPassword: false,
|
|
122
|
+
sawTypedToken: false
|
|
123
|
+
}
|
|
124
|
+
const tool = createTool((args) => {
|
|
125
|
+
state.executedSecret = args.credential === SECRET
|
|
126
|
+
state.executedPassword = args.password === 739184
|
|
127
|
+
state.executedToken = args.token === 1024
|
|
128
|
+
})
|
|
129
|
+
const onStep = (step: EngineStep) => observeStep(state, step)
|
|
130
|
+
|
|
131
|
+
return { state, tool, onStep }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function observeStep(state: Observation, step: EngineStep): Promise<void> {
|
|
135
|
+
const next = RunLog.applyEngineStep(state.boot, step)
|
|
136
|
+
if (step.expectedLength !== state.records.length) {
|
|
137
|
+
throw new Error('noncontiguous host step')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const record of step.records) {
|
|
141
|
+
if (hasPair(record, 'credential', SECRET)) {
|
|
142
|
+
throw new Error('A schema-marked credential reached typed durable fields')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (hasPair(record, 'password', 739184)) {
|
|
146
|
+
throw new Error('A numeric password reached typed durable fields')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
state.sawRedactedCredential ||= hasPair(record, 'credential', '[redacted]')
|
|
150
|
+
state.sawRedactedPassword ||= hasPair(record, 'password', '[redacted]')
|
|
151
|
+
state.sawTypedToken ||= hasPair(record, 'token', 1024)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
state.records.push(...step.records)
|
|
155
|
+
state.boot = next
|
|
156
|
+
|
|
157
|
+
return Promise.resolve()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function assertObservation(state: Observation, label: string, payload: string): void {
|
|
161
|
+
if (!state.executedSecret) {
|
|
162
|
+
throw new Error('The tool did not receive its unredacted credential')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!state.executedPassword || !state.executedToken) {
|
|
166
|
+
throw new Error('Numeric tool arguments changed at runtime')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const metadata = state.records.find((record) => record.type === 'metadata')
|
|
170
|
+
if (!metadata || !isRecord(metadata.request)) {
|
|
171
|
+
throw new Error('Missing request metadata')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const messages = metadata.request.messages
|
|
175
|
+
if (!Array.isArray(messages) || !isRecord(messages[0]) || messages[0].content !== payload) {
|
|
176
|
+
throw new Error(`Free text changed for ${label}/${payload.length}`)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!state.sawRedactedCredential) {
|
|
180
|
+
throw new Error('The schema-marked tool argument was not redacted')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!state.sawRedactedPassword) {
|
|
184
|
+
throw new Error('A numeric password was not redacted')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!state.sawTypedToken) {
|
|
188
|
+
throw new Error('An ambiguous token count was over-redacted')
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function runOne(label: string, payload: string, sample: number): Promise<number> {
|
|
193
|
+
const { state, tool, onStep } = createObservation()
|
|
194
|
+
const started = process.cpuUsage()
|
|
195
|
+
for await (const _event of CompletionEngine.run(
|
|
196
|
+
sdkFor(payload),
|
|
197
|
+
{
|
|
198
|
+
runId: `redact-${label}-${payload.length}-${sample}`,
|
|
199
|
+
modelId: 'benchmark-model',
|
|
200
|
+
messages: [{ role: 'user', content: payload }],
|
|
201
|
+
tools: [tool]
|
|
202
|
+
},
|
|
203
|
+
{ onStep, requestDigestKey: new Uint8Array(32).fill(7) }
|
|
204
|
+
)) {
|
|
205
|
+
// Consume the real completion iterator; the durable callback is the production redaction path.
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const usage = process.cpuUsage(started)
|
|
209
|
+
assertObservation(state, label, payload)
|
|
210
|
+
|
|
211
|
+
return (usage.user + usage.system) / 1000
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function median(values: number[]): number {
|
|
215
|
+
const ordered = [...values].sort((left, right) => left - right)
|
|
216
|
+
return ordered[Math.floor(ordered.length / 2)]
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function main(): Promise<void> {
|
|
220
|
+
const results: Array<Record<string, number | string>> = []
|
|
221
|
+
for (const [label, pattern] of Object.entries(PATTERNS)) {
|
|
222
|
+
for (const size of SIZES) {
|
|
223
|
+
const payload = stressText(pattern, size)
|
|
224
|
+
for (let sample = 0; sample < WARMUPS; sample++) {
|
|
225
|
+
await runOne(label, payload, -sample - 1)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const times: number[] = []
|
|
229
|
+
for (let sample = 0; sample < SAMPLES; sample++) {
|
|
230
|
+
times.push(await runOne(label, payload, sample))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
results.push({
|
|
234
|
+
pattern: label,
|
|
235
|
+
bytes: encoder.encode(payload).byteLength,
|
|
236
|
+
samples: SAMPLES,
|
|
237
|
+
medianCpuMs: median(times),
|
|
238
|
+
maxCpuMs: Math.max(...times)
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
console.log(
|
|
244
|
+
JSON.stringify({
|
|
245
|
+
...runtimeInfo(),
|
|
246
|
+
adapter: 'completion() production redactor + schema registration + awaited host onStep',
|
|
247
|
+
warmups: WARMUPS,
|
|
248
|
+
results
|
|
249
|
+
})
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
main().catch((error) => {
|
|
254
|
+
console.error(error)
|
|
255
|
+
process.exitCode = 1
|
|
256
|
+
})
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
6
|
+
const engineBench = path.join(root, 'bench/cpu.ts')
|
|
7
|
+
const mainBench = path.join(root, 'bench/cpu-main.mjs')
|
|
8
|
+
const bunConfig = path.join(root, 'bench/bun-tsconfig.json')
|
|
9
|
+
const options = parseOptions(process.argv.slice(2))
|
|
10
|
+
const bench = options.driver === 'main' ? mainBench : engineBench
|
|
11
|
+
|
|
12
|
+
const command =
|
|
13
|
+
options.runtime === 'bun'
|
|
14
|
+
? { executable: 'bun', prefix: [`--tsconfig-override=${bunConfig}`] }
|
|
15
|
+
: { executable: process.execPath, prefix: ['--experimental-strip-types'] }
|
|
16
|
+
|
|
17
|
+
const samples = []
|
|
18
|
+
for (let i = 0; i < options.processes; i++) {
|
|
19
|
+
const args = [
|
|
20
|
+
...command.prefix,
|
|
21
|
+
bench,
|
|
22
|
+
String(options.runs),
|
|
23
|
+
`--spacing=${options.spacing}`,
|
|
24
|
+
`--workload=${options.workload}`,
|
|
25
|
+
'--json'
|
|
26
|
+
]
|
|
27
|
+
if (options.driver === 'engine') {
|
|
28
|
+
args.splice(args.length - 1, 0, `--mode=${options.mode}`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const child = spawnSync(command.executable, args, {
|
|
32
|
+
cwd: root,
|
|
33
|
+
encoding: 'utf8'
|
|
34
|
+
})
|
|
35
|
+
if (child.error) {
|
|
36
|
+
throw child.error
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (child.status !== 0) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`${command.executable} exited with ${child.status}:\n${child.stderr || child.stdout}`
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const lines = child.stdout.trim().split('\n')
|
|
46
|
+
const result = JSON.parse(lines.at(-1))
|
|
47
|
+
validateChildReport(result, i + 1)
|
|
48
|
+
samples.push({ process: i + 1, ...result })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
validateProcessReports(samples)
|
|
52
|
+
|
|
53
|
+
const keys = new Map()
|
|
54
|
+
for (const sample of samples) {
|
|
55
|
+
for (const result of sample.results) {
|
|
56
|
+
const key = [result.workload, result.mode, result.spacing ?? ''].join(':')
|
|
57
|
+
if (!keys.has(key)) {
|
|
58
|
+
keys.set(key, [])
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
keys.get(key).push(result)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const medians = [...keys].map(([key, results]) => {
|
|
66
|
+
const first = results[0]
|
|
67
|
+
const aggregate = { workload: first.workload, mode: first.mode, spacing: first.spacing }
|
|
68
|
+
|
|
69
|
+
const cpuSamples = results.flatMap((result) => result.cpuSamplesMs)
|
|
70
|
+
aggregate.rawSampleCount = cpuSamples.length
|
|
71
|
+
aggregate.cpuMs = median(cpuSamples)
|
|
72
|
+
aggregate.processMedianMs = median(results.map((result) => result.cpuMs))
|
|
73
|
+
if (typeof first.usPerToken === 'number' && first.cpuMs > 0) {
|
|
74
|
+
aggregate.usPerToken = aggregate.cpuMs * (first.usPerToken / first.cpuMs)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const metric of ['logBytes', 'records', 'contentEvents']) {
|
|
78
|
+
const values = results.map((result) => result[metric]).filter(Number.isFinite)
|
|
79
|
+
if (values.length) {
|
|
80
|
+
aggregate[metric] = median(values)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { key, ...aggregate }
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
console.log(
|
|
88
|
+
JSON.stringify({
|
|
89
|
+
runtime: samples[0].runtime,
|
|
90
|
+
version: samples[0].version,
|
|
91
|
+
driver: options.driver,
|
|
92
|
+
revision: options.revision,
|
|
93
|
+
processes: options.processes,
|
|
94
|
+
runsPerProcess: options.runs,
|
|
95
|
+
reduction: 'pooled median of all raw cpuSamplesMs; process medians are reported separately',
|
|
96
|
+
workload: options.workload,
|
|
97
|
+
samples,
|
|
98
|
+
medians
|
|
99
|
+
})
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
function validateOptions(options) {
|
|
103
|
+
if (!['node', 'bun'].includes(options.runtime)) {
|
|
104
|
+
throw new Error('runtime must be node or bun')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!['engine', 'main'].includes(options.driver)) {
|
|
108
|
+
throw new Error('driver must be engine or main')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!['tools', 'stream', 'all'].includes(options.workload)) {
|
|
112
|
+
throw new Error('workload must be tools, stream or all')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!['immediate', 'timeout', 'all'].includes(options.spacing)) {
|
|
116
|
+
throw new Error('spacing must be immediate, timeout or all')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!['plain', 'bench', 'production', 'all'].includes(options.mode)) {
|
|
120
|
+
throw new Error('mode must be plain, bench, production or all')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const key of ['processes', 'runs']) {
|
|
124
|
+
if (!Number.isInteger(options[key]) || options[key] < 1) {
|
|
125
|
+
throw new Error(`${key} must be a positive integer`)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return options
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function expectedResultKeys() {
|
|
133
|
+
const workloads = options.workload === 'all' ? ['tools', 'stream'] : [options.workload]
|
|
134
|
+
let modes
|
|
135
|
+
if (options.driver === 'main') {
|
|
136
|
+
modes = ['main']
|
|
137
|
+
} else if (options.mode === 'all') {
|
|
138
|
+
modes = ['plain', 'bench', 'production']
|
|
139
|
+
} else {
|
|
140
|
+
modes = [options.mode]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const spacings = options.spacing === 'all' ? ['immediate', 'timeout'] : [options.spacing]
|
|
144
|
+
const expected = []
|
|
145
|
+
|
|
146
|
+
for (const mode of modes) {
|
|
147
|
+
for (const workload of workloads) {
|
|
148
|
+
const workloadSpacings = workload === 'tools' ? [''] : spacings
|
|
149
|
+
for (const spacing of workloadSpacings) {
|
|
150
|
+
expected.push([workload, mode, spacing].join(':'))
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return expected.sort()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function resultKey(result) {
|
|
159
|
+
return [result.workload, result.mode, result.spacing ?? ''].join(':')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function validateChildReport(report, processNumber) {
|
|
163
|
+
if (!report || report.runtime !== options.runtime || typeof report.version !== 'string') {
|
|
164
|
+
throw new Error(`process ${processNumber} reported an unexpected runtime identity`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (report.runs !== options.runs || !Array.isArray(report.results)) {
|
|
168
|
+
throw new Error(`process ${processNumber} reported an unexpected run count or results list`)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const expectedKeys = expectedResultKeys()
|
|
172
|
+
const actualKeys = report.results.map(resultKey)
|
|
173
|
+
if (
|
|
174
|
+
actualKeys.length !== expectedKeys.length ||
|
|
175
|
+
new Set(actualKeys).size !== actualKeys.length ||
|
|
176
|
+
actualKeys.sort().join('\n') !== expectedKeys.join('\n')
|
|
177
|
+
) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`process ${processNumber} returned an incomplete or inconsistent workload matrix`
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const result of report.results) {
|
|
184
|
+
if (
|
|
185
|
+
result.runs !== options.runs ||
|
|
186
|
+
!Array.isArray(result.cpuSamplesMs) ||
|
|
187
|
+
result.cpuSamplesMs.length !== options.runs ||
|
|
188
|
+
!result.cpuSamplesMs.every((sample) => Number.isFinite(sample) && sample >= 0)
|
|
189
|
+
) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`process ${processNumber} result ${resultKey(result)} did not preserve ${options.runs} finite raw CPU samples`
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (result.cpuMs !== median(result.cpuSamplesMs)) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`process ${processNumber} result ${resultKey(result)} has an invalid process median`
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function validateProcessReports(samples) {
|
|
204
|
+
const expectedKeys = expectedResultKeys()
|
|
205
|
+
const referenceVersion = samples[0].version
|
|
206
|
+
|
|
207
|
+
for (const sample of samples) {
|
|
208
|
+
if (sample.version !== referenceVersion) {
|
|
209
|
+
throw new Error('benchmark processes reported different runtime versions')
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const keys = sample.results.map(resultKey).sort()
|
|
213
|
+
if (keys.join('\n') !== expectedKeys.join('\n')) {
|
|
214
|
+
throw new Error('benchmark processes returned different workload matrices')
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function applyOption(options, arg) {
|
|
220
|
+
const [key, value] = arg.split('=', 2)
|
|
221
|
+
const values = {
|
|
222
|
+
'--runtime': () => {
|
|
223
|
+
options.runtime = value
|
|
224
|
+
},
|
|
225
|
+
'--driver': () => {
|
|
226
|
+
options.driver = value
|
|
227
|
+
},
|
|
228
|
+
'--processes': () => {
|
|
229
|
+
options.processes = Number(value)
|
|
230
|
+
},
|
|
231
|
+
'--runs': () => {
|
|
232
|
+
options.runs = Number(value)
|
|
233
|
+
},
|
|
234
|
+
'--mode': () => {
|
|
235
|
+
options.mode = value
|
|
236
|
+
},
|
|
237
|
+
'--spacing': () => {
|
|
238
|
+
options.spacing = value
|
|
239
|
+
},
|
|
240
|
+
'--workload': () => {
|
|
241
|
+
options.workload = value
|
|
242
|
+
},
|
|
243
|
+
'--revision': () => {
|
|
244
|
+
options.revision = value
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const setValue = values[key]
|
|
248
|
+
if (!setValue) {
|
|
249
|
+
throw new Error(`Unknown option: ${arg}`)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
setValue()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function parseOptions(args) {
|
|
256
|
+
const options = {
|
|
257
|
+
runtime: 'node',
|
|
258
|
+
driver: 'engine',
|
|
259
|
+
processes: 3,
|
|
260
|
+
runs: 3,
|
|
261
|
+
mode: 'all',
|
|
262
|
+
spacing: 'all',
|
|
263
|
+
workload: 'all',
|
|
264
|
+
revision: ''
|
|
265
|
+
}
|
|
266
|
+
for (const arg of args) {
|
|
267
|
+
applyOption(options, arg)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return validateOptions(options)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function median(values) {
|
|
274
|
+
if (values.length === 0) {
|
|
275
|
+
throw new Error('Cannot calculate a median without samples')
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
279
|
+
|
|
280
|
+
return sorted[Math.floor(sorted.length / 2)]
|
|
281
|
+
}
|