testronaut 1.11.0 → 1.12.1
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 +226 -0
- package/TODO.md +5 -0
- package/bin/cli.js +232 -5
- package/bin/terminalUi.js +9 -1
- package/core/agent.js +7 -2
- package/core/browserEvidence.js +184 -0
- package/core/jevRouting.js +77 -0
- package/core/jevShadow.js +325 -0
- package/core/jevSuiteSelection.js +39 -0
- package/core/jevUsage.js +62 -0
- package/core/missionDiscovery.js +56 -6
- package/core/optimization.js +98 -0
- package/core/turnLoop.js +232 -11
- package/experiments/CROSS_PROVIDER.md +19 -0
- package/experiments/README.md +62 -0
- package/experiments/buildExperimentPublicationPackage.js +219 -0
- package/experiments/buildOptimizationDataPackage.js +390 -0
- package/experiments/crossProviderBenchmark.js +227 -0
- package/experiments/optimizationBenchmark.js +339 -0
- package/package.json +5 -1
- package/tests/binTests/cli.helpers.test.js +16 -0
- package/tests/binTests/cli.integration.test.js +18 -1
- package/tests/binTests/terminalUi.test.js +2 -1
- package/tests/coreTests/agent.test.js +2 -0
- package/tests/coreTests/browserEvidence.test.js +70 -0
- package/tests/coreTests/jevRouting.test.js +57 -0
- package/tests/coreTests/jevShadow.test.js +158 -0
- package/tests/coreTests/jevSuiteSelection.test.js +24 -0
- package/tests/coreTests/jevUsage.test.js +58 -0
- package/tests/coreTests/missionDiscovery.test.js +44 -0
- package/tests/coreTests/optimization.test.js +80 -0
- package/tests/coreTests/turnLoop.test.js +351 -1
- package/tests/experimentTests/buildOptimizationDataPackage.test.js +25 -0
- package/tests/experimentTests/crossProviderBenchmark.test.js +37 -0
- package/tests/experimentTests/optimizationBenchmark.test.js +49 -0
- package/tests/toolsTests/generateHtmlReport.test.js +100 -0
- package/tools/generateHtmlReport.js +67 -10
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { BENCHMARK_TARGETS } from './optimizationBenchmark.js';
|
|
9
|
+
|
|
10
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const DEFAULT_EXPERIMENT_ID = 'optimization-pilot-v1';
|
|
12
|
+
const T95_DF4 = 2.776;
|
|
13
|
+
const EXPECTED_RECORDS = {
|
|
14
|
+
openai: {
|
|
15
|
+
'missions/login.mission.js': 1,
|
|
16
|
+
'missions/removeTask.mission.js': 2,
|
|
17
|
+
'missions/fileTransfer.mission.js': 3,
|
|
18
|
+
},
|
|
19
|
+
gemini: {
|
|
20
|
+
'missions/profileNavigation.mission.js': 2,
|
|
21
|
+
'missions/projectsDashboard.mission.js': 2,
|
|
22
|
+
'missions/projectWorkspaceNavigation.mission.js': 2,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
const CONTROL_CONDITION = {
|
|
26
|
+
openai: 'l1_terra_control',
|
|
27
|
+
gemini: 'l1_gemini38_control',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function mean(values) {
|
|
31
|
+
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sampleSd(values) {
|
|
35
|
+
if (values.length < 2) return 0;
|
|
36
|
+
const average = mean(values);
|
|
37
|
+
return Math.sqrt(values.reduce((sum, value) => sum + (value - average) ** 2, 0) / (values.length - 1));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function round(value, digits = 2) {
|
|
41
|
+
return Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function csvEscape(value) {
|
|
45
|
+
const text = typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value ?? '');
|
|
46
|
+
return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function writeCsv(file, columns, rows) {
|
|
50
|
+
fs.writeFileSync(file, `${columns.join(',')}\n${rows.map(row => columns.map(column => csvEscape(row[column])).join(',')).join('\n')}\n`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readReports(targetName, experimentId) {
|
|
54
|
+
const target = BENCHMARK_TARGETS[targetName];
|
|
55
|
+
const reportDir = path.join(target.repo, 'missions/mission_reports');
|
|
56
|
+
return fs.readdirSync(reportDir)
|
|
57
|
+
.filter(file => /^run_\d+\.json$/.test(file))
|
|
58
|
+
.map(file => ({ file, path: path.join(reportDir, file) }))
|
|
59
|
+
.map(entry => ({ ...entry, report: JSON.parse(fs.readFileSync(entry.path, 'utf8')) }))
|
|
60
|
+
.filter(entry => entry.report.experiment?.id === experimentId)
|
|
61
|
+
.map(entry => ({ ...entry, target: targetName }));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function stepMetrics(steps, report) {
|
|
65
|
+
const models = {};
|
|
66
|
+
for (const step of steps) {
|
|
67
|
+
const model = step.model || report.llm?.model || 'unknown';
|
|
68
|
+
models[model] ||= { turns: 0, tokens: 0, inputTokens: 0, outputTokens: 0 };
|
|
69
|
+
models[model].turns += 1;
|
|
70
|
+
models[model].tokens += Number(step.tokensUsed || 0);
|
|
71
|
+
models[model].inputTokens += Number(step.inputTokens || 0);
|
|
72
|
+
models[model].outputTokens += Number(step.outputTokens || 0);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
turns: steps.length,
|
|
76
|
+
tokens: steps.reduce((sum, step) => sum + Number(step.tokensUsed || 0), 0),
|
|
77
|
+
inputTokens: steps.reduce((sum, step) => sum + Number(step.inputTokens || 0), 0),
|
|
78
|
+
outputTokens: steps.reduce((sum, step) => sum + Number(step.outputTokens || 0), 0),
|
|
79
|
+
routedTurns: steps.filter(step => step.modelRouting?.applied).length,
|
|
80
|
+
forcedPrimaryRetries: steps.filter(step => step.modelRouting?.forcedPrimaryRetry).length,
|
|
81
|
+
requestFallbacks: steps.filter(step => step.modelRouting?.fallback).length,
|
|
82
|
+
models,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizeRun(entry) {
|
|
87
|
+
const { report, target } = entry;
|
|
88
|
+
const expected = EXPECTED_RECORDS[target];
|
|
89
|
+
const steps = report.missions.flatMap(mission => mission.steps || []);
|
|
90
|
+
const step = stepMetrics(steps, report);
|
|
91
|
+
const missionOutcomes = Object.entries(expected).map(([file, expectedRecords]) => {
|
|
92
|
+
const records = report.missions.filter(mission => mission.file === file);
|
|
93
|
+
const recordSteps = records.flatMap(record => record.steps || []);
|
|
94
|
+
return {
|
|
95
|
+
experimentId: report.experiment.id,
|
|
96
|
+
target,
|
|
97
|
+
condition: report.experiment.condition,
|
|
98
|
+
block: report.experiment.block,
|
|
99
|
+
runId: report.runId,
|
|
100
|
+
file,
|
|
101
|
+
expectedRecords,
|
|
102
|
+
actualRecords: records.length,
|
|
103
|
+
passed: records.length === expectedRecords && records.every(record => record.status === 'passed'),
|
|
104
|
+
phasePassed: records.filter(record => record.status === 'passed').length,
|
|
105
|
+
phaseFailed: records.filter(record => record.status === 'failed').length,
|
|
106
|
+
turns: recordSteps.length,
|
|
107
|
+
tokens: recordSteps.reduce((sum, item) => sum + Number(item.tokensUsed || 0), 0),
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
const condition = BENCHMARK_TARGETS[target].conditions.find(item => item.id === report.experiment.condition);
|
|
111
|
+
return {
|
|
112
|
+
experimentId: report.experiment.id,
|
|
113
|
+
target,
|
|
114
|
+
condition: report.experiment.condition,
|
|
115
|
+
level: condition?.level,
|
|
116
|
+
block: report.experiment.block,
|
|
117
|
+
runId: report.runId,
|
|
118
|
+
provider: report.llm?.provider,
|
|
119
|
+
primaryModel: condition?.primary || report.llm?.model,
|
|
120
|
+
secondaryModel: condition?.secondary || '',
|
|
121
|
+
objective: report.optimization?.objective || condition?.objective,
|
|
122
|
+
mode: report.optimization?.mode || condition?.mode,
|
|
123
|
+
phasePassed: Number(report.summary?.passed || 0),
|
|
124
|
+
phaseFailed: Number(report.summary?.failed || 0),
|
|
125
|
+
phaseTotal: Number(report.summary?.totalMissions || 0),
|
|
126
|
+
missionFilesPassed: missionOutcomes.filter(outcome => outcome.passed).length,
|
|
127
|
+
missionFilesTotal: missionOutcomes.length,
|
|
128
|
+
durationSeconds: (new Date(report.endTime) - new Date(report.startTime)) / 1000,
|
|
129
|
+
...step,
|
|
130
|
+
jevInputTokens: Number(report.jevShadow?.inputTokens || 0),
|
|
131
|
+
jevOutputTokens: Number(report.jevShadow?.outputTokens || 0),
|
|
132
|
+
jevEvaluations: Number(report.jevShadow?.evaluations || 0),
|
|
133
|
+
completionGates: Number(report.jevShadow?.triggeredCompletions || 0),
|
|
134
|
+
reportPath: entry.path,
|
|
135
|
+
reportFile: entry.file,
|
|
136
|
+
missionOutcomes,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function pairedDelta(conditionRuns, controlRuns, metric) {
|
|
141
|
+
const controlByBlock = Object.fromEntries(controlRuns.map(run => [run.block, run]));
|
|
142
|
+
const values = conditionRuns.map(run => {
|
|
143
|
+
const control = controlByBlock[run.block];
|
|
144
|
+
return control?.[metric] ? (run[metric] - control[metric]) / control[metric] * 100 : 0;
|
|
145
|
+
});
|
|
146
|
+
const average = mean(values);
|
|
147
|
+
const halfWidth = T95_DF4 * sampleSd(values) / Math.sqrt(values.length);
|
|
148
|
+
return { average, low: average - halfWidth, high: average + halfWidth };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function summarizeConditions(runs) {
|
|
152
|
+
const grouped = runs.reduce((groups, run) => {
|
|
153
|
+
(groups[`${run.target}:${run.condition}`] ||= []).push(run);
|
|
154
|
+
return groups;
|
|
155
|
+
}, {});
|
|
156
|
+
const controls = Object.fromEntries(Object.entries(CONTROL_CONDITION).map(([target, condition]) => [
|
|
157
|
+
target,
|
|
158
|
+
runs.filter(run => run.target === target && run.condition === condition),
|
|
159
|
+
]));
|
|
160
|
+
return Object.values(grouped).map(conditionRuns => {
|
|
161
|
+
conditionRuns.sort((a, b) => a.block - b.block);
|
|
162
|
+
const first = conditionRuns[0];
|
|
163
|
+
const tokenDelta = pairedDelta(conditionRuns, controls[first.target], 'tokens');
|
|
164
|
+
const turnDelta = pairedDelta(conditionRuns, controls[first.target], 'turns');
|
|
165
|
+
const durationDelta = pairedDelta(conditionRuns, controls[first.target], 'durationSeconds');
|
|
166
|
+
return {
|
|
167
|
+
target: first.target,
|
|
168
|
+
condition: first.condition,
|
|
169
|
+
level: first.level,
|
|
170
|
+
primaryModel: first.primaryModel,
|
|
171
|
+
secondaryModel: first.secondaryModel,
|
|
172
|
+
objective: first.objective,
|
|
173
|
+
mode: first.mode,
|
|
174
|
+
n: conditionRuns.length,
|
|
175
|
+
missionFileAttempts: conditionRuns.reduce((sum, run) => sum + run.missionFilesTotal, 0),
|
|
176
|
+
missionFilePassed: conditionRuns.reduce((sum, run) => sum + run.missionFilesPassed, 0),
|
|
177
|
+
missionFilePassRate: conditionRuns.reduce((sum, run) => sum + run.missionFilesPassed, 0)
|
|
178
|
+
/ conditionRuns.reduce((sum, run) => sum + run.missionFilesTotal, 0),
|
|
179
|
+
phasePassed: conditionRuns.reduce((sum, run) => sum + run.phasePassed, 0),
|
|
180
|
+
phaseTotal: conditionRuns.reduce((sum, run) => sum + run.phaseTotal, 0),
|
|
181
|
+
phasePassRate: conditionRuns.reduce((sum, run) => sum + run.phasePassed, 0)
|
|
182
|
+
/ conditionRuns.reduce((sum, run) => sum + run.phaseTotal, 0),
|
|
183
|
+
meanTurns: mean(conditionRuns.map(run => run.turns)),
|
|
184
|
+
sdTurns: sampleSd(conditionRuns.map(run => run.turns)),
|
|
185
|
+
turnDeltaPct: turnDelta.average,
|
|
186
|
+
turnDeltaLow95: turnDelta.low,
|
|
187
|
+
turnDeltaHigh95: turnDelta.high,
|
|
188
|
+
meanTokens: mean(conditionRuns.map(run => run.tokens)),
|
|
189
|
+
sdTokens: sampleSd(conditionRuns.map(run => run.tokens)),
|
|
190
|
+
tokenDeltaPct: tokenDelta.average,
|
|
191
|
+
tokenDeltaLow95: tokenDelta.low,
|
|
192
|
+
tokenDeltaHigh95: tokenDelta.high,
|
|
193
|
+
meanDurationSeconds: mean(conditionRuns.map(run => run.durationSeconds)),
|
|
194
|
+
sdDurationSeconds: sampleSd(conditionRuns.map(run => run.durationSeconds)),
|
|
195
|
+
durationDeltaPct: durationDelta.average,
|
|
196
|
+
durationDeltaLow95: durationDelta.low,
|
|
197
|
+
durationDeltaHigh95: durationDelta.high,
|
|
198
|
+
meanRoutedTurns: mean(conditionRuns.map(run => run.routedTurns)),
|
|
199
|
+
meanForcedPrimaryRetries: mean(conditionRuns.map(run => run.forcedPrimaryRetries)),
|
|
200
|
+
meanCompletionGates: mean(conditionRuns.map(run => run.completionGates)),
|
|
201
|
+
meanJevTokens: mean(conditionRuns.map(run => run.jevInputTokens + run.jevOutputTokens)),
|
|
202
|
+
};
|
|
203
|
+
}).sort((a, b) => a.target.localeCompare(b.target) || a.level - b.level || a.condition.localeCompare(b.condition));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function markdownTable(rows) {
|
|
207
|
+
const header = '| Condition | Strict pass | Mean tokens | Δ tokens vs control (paired 95% CI) | Mean turns | Mean duration | Routes/run | Gates/run |';
|
|
208
|
+
const divider = '|---|---:|---:|---:|---:|---:|---:|---:|';
|
|
209
|
+
const body = rows.map(row => [
|
|
210
|
+
`| ${row.condition}`,
|
|
211
|
+
`${row.missionFilePassed}/${row.missionFileAttempts} (${(row.missionFilePassRate * 100).toFixed(0)}%)`,
|
|
212
|
+
Math.round(row.meanTokens).toLocaleString('en-US'),
|
|
213
|
+
`${row.tokenDeltaPct >= 0 ? '+' : ''}${row.tokenDeltaPct.toFixed(1)}% [${row.tokenDeltaLow95.toFixed(1)}, ${row.tokenDeltaHigh95.toFixed(1)}]`,
|
|
214
|
+
row.meanTurns.toFixed(1),
|
|
215
|
+
`${row.meanDurationSeconds.toFixed(0)}s`,
|
|
216
|
+
row.meanRoutedTurns.toFixed(1),
|
|
217
|
+
row.meanCompletionGates.toFixed(1),
|
|
218
|
+
].join(' | ') + ' |').join('\n');
|
|
219
|
+
return `${header}\n${divider}\n${body}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function buildReport(experimentId, runs, summaries) {
|
|
223
|
+
const openai = summaries.filter(row => row.target === 'openai');
|
|
224
|
+
const gemini = summaries.filter(row => row.target === 'gemini');
|
|
225
|
+
const periods = runs.map(run => JSON.parse(fs.readFileSync(run.reportPath, 'utf8')));
|
|
226
|
+
const starts = periods.map(report => new Date(report.startTime)).filter(date => !Number.isNaN(date.valueOf()));
|
|
227
|
+
const ends = periods.map(report => new Date(report.endTime)).filter(date => !Number.isNaN(date.valueOf()));
|
|
228
|
+
return `# Testronaut Jev optimization pilot report
|
|
229
|
+
|
|
230
|
+
Experiment: \`${experimentId}\`
|
|
231
|
+
Generated: ${new Date().toISOString()}
|
|
232
|
+
Benchmark period: ${new Date(Math.min(...starts)).toISOString()} to ${new Date(Math.max(...ends)).toISOString()}
|
|
233
|
+
|
|
234
|
+
## Executive summary
|
|
235
|
+
|
|
236
|
+
This blocked pilot completed **${runs.length} runs** across two repositories, seven conditions per provider, five repetitions per condition, and **${runs.length * 3} mission-file attempts**. Each routed condition used at most two execution models. Jev remained the guardrail/router and was not counted as an execution model.
|
|
237
|
+
|
|
238
|
+
The clearest OpenAI result was guarded GPT-5.5: **15/15 strict mission-file passes** and **${Math.abs(openai.find(row => row.condition === 'l2_gpt55_guarded').tokenDeltaPct).toFixed(1)}% fewer model tokens** than the Terra control. Its paired 95% interval excluded zero, but it was materially slower. OpenAI routing coverage was too low—less than one routed turn per run—to distinguish secondary-model quality confidently.
|
|
239
|
+
|
|
240
|
+
Gemini 3.8 Flash was already perfectly reliable on this workload. Guarded Gemini 3.5 Flash-Lite also achieved **15/15 strict passes** with a nominal **${Math.abs(gemini.find(row => row.condition === 'l2_gemini35lite_guarded').tokenDeltaPct).toFixed(1)}% token reduction**, but the paired interval included zero and duration increased. Gemini 3.8 → 3.7 was the strongest conservative routing policy: 15/15 strict passes, modest nominal token reduction, and nearly unchanged duration. Routing 3.8 → 3.5 Flash-Lite was less reliable and caused primary-model retries.
|
|
241
|
+
|
|
242
|
+
## Methodology
|
|
243
|
+
|
|
244
|
+
- Five randomized blocks per condition (\`n=5\`).
|
|
245
|
+
- OpenAI repository: login, self-cleaning task creation/removal, and file transfer.
|
|
246
|
+
- Gemini repository: profile navigation, projects dashboard, and project workspace navigation.
|
|
247
|
+
- Level 1: single-model control without Jev.
|
|
248
|
+
- Level 2: single model with live Jev guardrails and completion gating.
|
|
249
|
+
- Level 3: current-generation/recent two-model routing with guardrails.
|
|
250
|
+
- Level 4: current primary routed to older same-provider generations with guardrails.
|
|
251
|
+
- Routing probability threshold: 0.40.
|
|
252
|
+
- Strict mission-file success requires every expected phase for that file to exist and pass. This avoids treating a failed prerequisite that suppresses later phases as a smaller denominator.
|
|
253
|
+
- Token, turn, and duration deltas are paired by randomized block against the provider's level-1 control. Intervals use a two-sided 95% Student-t interval with four degrees of freedom.
|
|
254
|
+
|
|
255
|
+
## OpenAI results
|
|
256
|
+
|
|
257
|
+
${markdownTable(openai)}
|
|
258
|
+
|
|
259
|
+
### OpenAI interpretation
|
|
260
|
+
|
|
261
|
+
- **Guarded GPT-5.5 was the pilot winner** for reliability and model-token volume: all 15 mission-file attempts passed.
|
|
262
|
+
- GPT-5.5 used fewer model tokens and turns than Terra control, but mean duration increased substantially; it is a quality/cost candidate rather than a speed candidate.
|
|
263
|
+
- Guarded Terra improved nominal tokens and strict completion, showing that completion gating contributed independently of model switching.
|
|
264
|
+
- Terra → Luna was the best OpenAI routing condition by strict pass rate, but it routed only 0.4 turns per run and produced almost no model-token reduction.
|
|
265
|
+
- Terra → GPT-5.4 and Terra → GPT-5.5 also routed fewer than one turn per run. Their secondary-model effects remain underpowered.
|
|
266
|
+
- Guarded Luna showed high variance and incomplete mission-file execution in two blocks.
|
|
267
|
+
|
|
268
|
+
## Gemini results
|
|
269
|
+
|
|
270
|
+
${markdownTable(gemini)}
|
|
271
|
+
|
|
272
|
+
### Gemini interpretation
|
|
273
|
+
|
|
274
|
+
- The Gemini 3.8 control achieved 15/15 strict passes, leaving no reliability headroom.
|
|
275
|
+
- Guarded Gemini 3.5 Flash-Lite retained 15/15 passes and had the largest nominal reduction among reliable Gemini conditions, at the cost of higher duration.
|
|
276
|
+
- Gemini 3.8 → 3.7 retained perfect reliability and near-control duration; it is the strongest conservative Gemini routing candidate.
|
|
277
|
+
- Gemini 3.8 → 3.6 retained reliability but offered little token or latency benefit.
|
|
278
|
+
- Direct Gemini 3.6 and Gemini 3.8 → 3.5 Flash-Lite each fell to 11/15 strict passes. The latter also generated frequent primary-model retries.
|
|
279
|
+
- No Gemini token delta had a paired 95% interval excluding zero at \`n=5\`; larger samples are required.
|
|
280
|
+
|
|
281
|
+
## Cross-provider conclusions
|
|
282
|
+
|
|
283
|
+
1. Keep the execution-model limit at two per run: one primary and one secondary. This preserved attribution and made fallback behavior auditable.
|
|
284
|
+
2. Guardrails and completion gating produced more consistent savings than routing because routing coverage remained low.
|
|
285
|
+
3. Model choice mattered more than routing sophistication on the OpenAI workload: guarded GPT-5.5 outperformed Terra-based policies.
|
|
286
|
+
4. For Gemini, use guarded 3.5 Flash-Lite when cost is dominant and its slower runtime is acceptable; use 3.8 → 3.7 when preserving current-model reliability and latency is more important.
|
|
287
|
+
5. Treat this as a pilot, not a universal model ranking. The mission subsets are intentionally small, provider pricing was not embedded, and application/model behavior may drift.
|
|
288
|
+
|
|
289
|
+
## Limitations and next steps
|
|
290
|
+
|
|
291
|
+
- Five repetitions provide useful variance estimates but limited statistical power.
|
|
292
|
+
- Results apply to these six mission files and their test environments.
|
|
293
|
+
- The package reports raw model-token volume, not price-weighted spend; pricing metadata should be versioned before asserting monetary savings.
|
|
294
|
+
- Routed OpenAI conditions need either a larger sample or a routing policy that yields more eligible turns.
|
|
295
|
+
- Repeat the winning and control conditions at a powered sample size derived from these observed variances.
|
|
296
|
+
- Evaluate cross-provider routing first at mission boundaries to avoid transcript/tool-history incompatibilities.
|
|
297
|
+
|
|
298
|
+
## Data package
|
|
299
|
+
|
|
300
|
+
The accompanying package includes normalized run-, mission-, and condition-level CSV files; all 70 raw JSON reports; the frozen benchmark matrix; source state/CSV files where available; and SHA-256 checksums. Screenshots, HTML reports, environment files, credentials, and verbose logs are intentionally excluded.
|
|
301
|
+
`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function sha256(file) {
|
|
305
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function walkFiles(root) {
|
|
309
|
+
return fs.readdirSync(root, { withFileTypes: true }).flatMap(entry => {
|
|
310
|
+
const full = path.join(root, entry.name);
|
|
311
|
+
return entry.isDirectory() ? walkFiles(full) : [full];
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function buildOptimizationDataPackage({ experimentId = DEFAULT_EXPERIMENT_ID } = {}) {
|
|
316
|
+
const root = path.resolve(HERE, 'results', experimentId);
|
|
317
|
+
const packageDir = path.join(root, 'data-package');
|
|
318
|
+
fs.rmSync(packageDir, { recursive: true, force: true });
|
|
319
|
+
fs.mkdirSync(path.join(packageDir, 'data/raw-reports/openai'), { recursive: true });
|
|
320
|
+
fs.mkdirSync(path.join(packageDir, 'data/raw-reports/gemini'), { recursive: true });
|
|
321
|
+
fs.mkdirSync(path.join(packageDir, 'data/source-state'), { recursive: true });
|
|
322
|
+
|
|
323
|
+
const entries = ['openai', 'gemini'].flatMap(target => readReports(target, experimentId));
|
|
324
|
+
if (entries.length !== 70) throw new Error(`Expected 70 reports for ${experimentId}; found ${entries.length}.`);
|
|
325
|
+
const runs = entries.map(normalizeRun).sort((a, b) => a.target.localeCompare(b.target) || a.block - b.block || a.condition.localeCompare(b.condition));
|
|
326
|
+
const summaries = summarizeConditions(runs);
|
|
327
|
+
const missionRows = runs.flatMap(run => run.missionOutcomes);
|
|
328
|
+
|
|
329
|
+
const runColumns = [
|
|
330
|
+
'experimentId', 'target', 'condition', 'level', 'block', 'runId', 'provider', 'primaryModel',
|
|
331
|
+
'secondaryModel', 'objective', 'mode', 'missionFilesPassed', 'missionFilesTotal', 'phasePassed',
|
|
332
|
+
'phaseFailed', 'phaseTotal', 'turns', 'tokens', 'inputTokens', 'outputTokens', 'durationSeconds',
|
|
333
|
+
'routedTurns', 'forcedPrimaryRetries', 'requestFallbacks', 'completionGates', 'jevEvaluations',
|
|
334
|
+
'jevInputTokens', 'jevOutputTokens', 'models', 'reportFile',
|
|
335
|
+
];
|
|
336
|
+
const conditionColumns = Object.keys(summaries[0]);
|
|
337
|
+
const missionColumns = Object.keys(missionRows[0]);
|
|
338
|
+
writeCsv(path.join(packageDir, 'data/runs.csv'), runColumns, runs);
|
|
339
|
+
writeCsv(path.join(packageDir, 'data/conditions.csv'), conditionColumns, summaries.map(row => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, typeof value === 'number' ? round(value, 6) : value]))));
|
|
340
|
+
writeCsv(path.join(packageDir, 'data/missions.csv'), missionColumns, missionRows);
|
|
341
|
+
|
|
342
|
+
for (const entry of entries) {
|
|
343
|
+
fs.copyFileSync(entry.path, path.join(packageDir, `data/raw-reports/${entry.target}/${entry.file}`));
|
|
344
|
+
}
|
|
345
|
+
for (const name of ['state-openai.json', 'state-gemini.json', 'runs-openai.csv', 'runs-gemini.csv']) {
|
|
346
|
+
const source = path.join(root, name);
|
|
347
|
+
if (fs.existsSync(source)) fs.copyFileSync(source, path.join(packageDir, `data/source-state/${name}`));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const matrix = Object.fromEntries(Object.entries(BENCHMARK_TARGETS).map(([target, value]) => [target, {
|
|
351
|
+
provider: value.provider,
|
|
352
|
+
repoName: path.basename(value.repo),
|
|
353
|
+
missions: value.missions,
|
|
354
|
+
conditions: value.conditions,
|
|
355
|
+
}]));
|
|
356
|
+
fs.writeFileSync(path.join(packageDir, 'data/matrix.json'), `${JSON.stringify(matrix, null, 2)}\n`);
|
|
357
|
+
const report = buildReport(experimentId, runs, summaries);
|
|
358
|
+
fs.writeFileSync(path.join(packageDir, 'REPORT.md'), report);
|
|
359
|
+
fs.writeFileSync(path.join(root, `${experimentId}-report.md`), report);
|
|
360
|
+
fs.writeFileSync(path.join(packageDir, 'README.md'), `# ${experimentId} data package\n\nStart with [REPORT.md](REPORT.md). Normalized datasets are under \`data/\`; raw Testronaut JSON reports are under \`data/raw-reports/\`. See \`manifest.json\` and \`SHA256SUMS\` for provenance and integrity. Screenshots, HTML, credentials, environment files, and verbose logs are excluded.\n`);
|
|
361
|
+
|
|
362
|
+
const dataFiles = walkFiles(packageDir).filter(file => !['manifest.json', 'SHA256SUMS'].includes(path.basename(file)));
|
|
363
|
+
const manifest = {
|
|
364
|
+
schemaVersion: 1,
|
|
365
|
+
experimentId,
|
|
366
|
+
generatedAt: new Date().toISOString(),
|
|
367
|
+
counts: { reports: entries.length, runs: runs.length, conditions: summaries.length, missionFileAttempts: missionRows.length },
|
|
368
|
+
exclusions: ['screenshots', 'HTML reports', 'environment files', 'credentials', 'verbose CLI logs'],
|
|
369
|
+
files: dataFiles.map(file => ({ path: path.relative(packageDir, file), bytes: fs.statSync(file).size, sha256: sha256(file) })),
|
|
370
|
+
};
|
|
371
|
+
fs.writeFileSync(path.join(packageDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
372
|
+
const checksumFiles = walkFiles(packageDir).filter(file => path.basename(file) !== 'SHA256SUMS');
|
|
373
|
+
fs.writeFileSync(path.join(packageDir, 'SHA256SUMS'), checksumFiles.map(file => `${sha256(file)} ${path.relative(packageDir, file)}`).sort().join('\n') + '\n');
|
|
374
|
+
|
|
375
|
+
const archive = path.join(root, `${experimentId}-data-package.tar.gz`);
|
|
376
|
+
fs.rmSync(archive, { force: true });
|
|
377
|
+
const tar = spawnSync('tar', ['-czf', archive, '-C', root, 'data-package'], { encoding: 'utf8' });
|
|
378
|
+
if (tar.status !== 0) throw new Error(`Could not create archive: ${tar.stderr || tar.stdout}`);
|
|
379
|
+
fs.writeFileSync(`${archive}.sha256`, `${sha256(archive)} ${path.basename(archive)}\n`);
|
|
380
|
+
return { root, packageDir, archive, manifest };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
384
|
+
const experimentId = process.argv.find(arg => arg.startsWith('--experiment-id='))?.split('=')[1] || DEFAULT_EXPERIMENT_ID;
|
|
385
|
+
const result = buildOptimizationDataPackage({ experimentId });
|
|
386
|
+
console.log(`Report and data package generated at ${result.packageDir}`);
|
|
387
|
+
console.log(`Archive: ${result.archive}`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export const __dataPackageInternals = { mean, sampleSd, normalizeRun, summarizeConditions, pairedDelta };
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import dotenv from 'dotenv';
|
|
8
|
+
import { evaluateJevShadow, resolveJevShadowConfig } from '../core/jevShadow.js';
|
|
9
|
+
import { __benchmarkInternals } from './optimizationBenchmark.js';
|
|
10
|
+
|
|
11
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const DEFAULT_CLI = path.resolve(HERE, '../bin/cli.js');
|
|
13
|
+
const DEFAULT_REPO = '/home/shane/workspace/testronaut-examples';
|
|
14
|
+
const MISSIONS = [
|
|
15
|
+
'missions/login.mission.js',
|
|
16
|
+
'missions/removeTask.mission.js',
|
|
17
|
+
'missions/fileTransfer.mission.js',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const MODELS = {
|
|
21
|
+
gpt55: { provider: 'openai', model: 'gpt-5.5', profile: 'Strongest OpenAI reliability and token result in the prior pilot; slower than Terra.' },
|
|
22
|
+
terra: { provider: 'openai', model: 'gpt-5.6-terra', profile: 'Current-generation balanced OpenAI reference.' },
|
|
23
|
+
gemini38: { provider: 'gemini', model: 'gemini-3.8-flash', profile: 'Reliable current-generation Gemini reference.' },
|
|
24
|
+
gemini35lite: { provider: 'gemini', model: 'gemini-3.5-flash-lite', profile: 'Low-cost Gemini candidate; prior guarded runs were reliable but slower.' },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const CROSS_PROVIDER_CONDITIONS = [
|
|
28
|
+
{ id: 'c1_gpt55', candidates: [MODELS.gpt55] },
|
|
29
|
+
{ id: 'c2_terra', candidates: [MODELS.terra] },
|
|
30
|
+
{ id: 'c3_gemini38', candidates: [MODELS.gemini38] },
|
|
31
|
+
{ id: 'c4_gemini35lite', candidates: [MODELS.gemini35lite] },
|
|
32
|
+
{ id: 'x1_gpt55_gemini38', candidates: [MODELS.gpt55, MODELS.gemini38] },
|
|
33
|
+
{ id: 'x2_gpt55_gemini35lite', candidates: [MODELS.gpt55, MODELS.gemini35lite] },
|
|
34
|
+
{ id: 'x3_terra_gemini38', candidates: [MODELS.terra, MODELS.gemini38] },
|
|
35
|
+
{ id: 'x4_terra_gemini35lite', candidates: [MODELS.terra, MODELS.gemini35lite] },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export function buildCrossProviderSchedule({ repetitions = 5, seed = 'cross-provider-pilot-v1', repo = DEFAULT_REPO } = {}) {
|
|
39
|
+
const schedule = [];
|
|
40
|
+
for (let block = 1; block <= repetitions; block += 1) {
|
|
41
|
+
const conditions = __benchmarkInternals.shuffled(CROSS_PROVIDER_CONDITIONS, `${seed}:conditions:${block}`);
|
|
42
|
+
for (const condition of conditions) {
|
|
43
|
+
const missions = __benchmarkInternals.shuffled(MISSIONS, `${seed}:${condition.id}:${block}`);
|
|
44
|
+
for (const mission of missions) {
|
|
45
|
+
const unit = path.basename(mission, '.mission.js');
|
|
46
|
+
schedule.push({
|
|
47
|
+
key: `b${block}:${condition.id}:${unit}`,
|
|
48
|
+
block,
|
|
49
|
+
unit,
|
|
50
|
+
mission,
|
|
51
|
+
repo,
|
|
52
|
+
...condition,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return schedule;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function interpretSelection(evaluation, candidates, threshold = 0.55) {
|
|
61
|
+
const fallback = candidates[0];
|
|
62
|
+
if (candidates.length === 1) return { selected: fallback, selectedBy: 'fixed', score: 1, evaluation: null };
|
|
63
|
+
const answer = evaluation?.answers?.executionModel;
|
|
64
|
+
const index = String(answer?.choice || '').match(/^model_(\d+)$/)?.[1];
|
|
65
|
+
const candidate = index ? candidates[Number(index) - 1] : null;
|
|
66
|
+
const score = candidate ? Number(answer?.probabilities?.[`model_${index}`]) : Number.NaN;
|
|
67
|
+
if (evaluation?.status === 'ok' && candidate && Number.isFinite(score) && score >= threshold) {
|
|
68
|
+
return { selected: candidate, selectedBy: 'jev', score, evaluation };
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
selected: fallback,
|
|
72
|
+
selectedBy: 'fallback',
|
|
73
|
+
score: Number.isFinite(score) ? score : null,
|
|
74
|
+
reason: evaluation?.status === 'ok' ? 'low_or_invalid_probability' : evaluation?.reason || evaluation?.error || 'no_evaluation',
|
|
75
|
+
evaluation,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function selectExecutionModel(run, { env = process.env, threshold = 0.55, fetchImpl } = {}) {
|
|
80
|
+
if (run.candidates.length === 1) return interpretSelection(null, run.candidates, threshold);
|
|
81
|
+
const missionText = fs.readFileSync(path.join(run.repo, run.mission), 'utf8').slice(0, 12000);
|
|
82
|
+
const criteria = Object.fromEntries(run.candidates.map((candidate, index) => [
|
|
83
|
+
`model_${index + 1}`,
|
|
84
|
+
`${candidate.provider}/${candidate.model}: ${candidate.profile}`,
|
|
85
|
+
]));
|
|
86
|
+
const evaluation = await evaluateJevShadow({
|
|
87
|
+
task: 'Choose one execution model before a browser-testing mission starts.',
|
|
88
|
+
mission: missionText,
|
|
89
|
+
constraints: ['One model owns the entire mission transcript.', 'Do not assume cross-provider failover.', 'Prefer lower expected spend when reliability is comparable.'],
|
|
90
|
+
}, {
|
|
91
|
+
config: { ...resolveJevShadowConfig(env), enabled: true, strategiesEnabled: false },
|
|
92
|
+
questions: {
|
|
93
|
+
executionModel: {
|
|
94
|
+
type: 'choice',
|
|
95
|
+
instructions: 'Choose the least expensive candidate likely to complete this mission reliably.',
|
|
96
|
+
criteria,
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
...(fetchImpl ? { fetchImpl } : {}),
|
|
100
|
+
});
|
|
101
|
+
return interpretSelection(evaluation, run.candidates, threshold);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function commandForCrossProviderRun(run, selection, cliPath = DEFAULT_CLI) {
|
|
105
|
+
return {
|
|
106
|
+
command: process.execPath,
|
|
107
|
+
args: [cliPath, `--provider=${selection.selected.provider}`, `--model=${selection.selected.model}`, '--optimize=tokens', '--optimization-mode=live', run.mission],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseArgs(argv) {
|
|
112
|
+
const read = (name, fallback) => argv.find(arg => arg.startsWith(`--${name}=`))?.slice(name.length + 3) ?? fallback;
|
|
113
|
+
return {
|
|
114
|
+
dryRun: argv.includes('--dry-run'),
|
|
115
|
+
repetitions: Math.max(1, Number.parseInt(read('repetitions', '5'), 10) || 5),
|
|
116
|
+
seed: read('seed', 'cross-provider-pilot-v1'),
|
|
117
|
+
experimentId: read('experiment-id', 'cross-provider-pilot-v1'),
|
|
118
|
+
repo: path.resolve(read('repo', DEFAULT_REPO)),
|
|
119
|
+
cliPath: path.resolve(read('cli', DEFAULT_CLI)),
|
|
120
|
+
threshold: Math.min(1, Math.max(0, Number(read('selection-threshold', '0.55')) || 0.55)),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function writeJson(file, value) {
|
|
125
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
126
|
+
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function reportFiles(repo) {
|
|
130
|
+
const directory = path.join(repo, 'missions/mission_reports');
|
|
131
|
+
if (!fs.existsSync(directory)) return new Set();
|
|
132
|
+
return new Set(fs.readdirSync(directory).filter(file => /^run_\d+\.json$/.test(file)));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function findNewReport(repo, before) {
|
|
136
|
+
const directory = path.join(repo, 'missions/mission_reports');
|
|
137
|
+
if (!fs.existsSync(directory)) return null;
|
|
138
|
+
const file = fs.readdirSync(directory)
|
|
139
|
+
.filter(name => /^run_\d+\.json$/.test(name) && !before.has(name))
|
|
140
|
+
.map(name => ({ name, time: fs.statSync(path.join(directory, name)).mtimeMs }))
|
|
141
|
+
.sort((a, b) => b.time - a.time)[0]?.name;
|
|
142
|
+
return file ? path.join(directory, file) : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function summarize(reportPath) {
|
|
146
|
+
if (!reportPath) return null;
|
|
147
|
+
try {
|
|
148
|
+
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
|
149
|
+
const steps = report.missions?.flatMap(mission => mission.steps || []) || [];
|
|
150
|
+
return {
|
|
151
|
+
runId: report.runId,
|
|
152
|
+
passed: report.summary?.passed || 0,
|
|
153
|
+
failed: report.summary?.failed || 0,
|
|
154
|
+
turns: steps.length,
|
|
155
|
+
tokens: steps.reduce((sum, step) => sum + Number(step.tokensUsed || 0), 0),
|
|
156
|
+
jevInputTokens: Number(report.jevShadow?.inputTokens || 0),
|
|
157
|
+
jevOutputTokens: Number(report.jevShadow?.outputTokens || 0),
|
|
158
|
+
};
|
|
159
|
+
} catch { return null; }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function writeCsv(file, state) {
|
|
163
|
+
const columns = ['key', 'block', 'condition', 'mission', 'provider', 'model', 'selectedBy', 'score', 'status', 'passed', 'failed', 'turns', 'tokens', 'jevInputTokens', 'jevOutputTokens'];
|
|
164
|
+
const quote = value => /[",\n]/.test(String(value ?? '')) ? `"${String(value ?? '').replaceAll('"', '""')}"` : String(value ?? '');
|
|
165
|
+
const rows = Object.values(state.runs).map(run => [run.key, run.block, run.id, run.mission, run.selection?.selected?.provider, run.selection?.selected?.model, run.selection?.selectedBy, run.selection?.score, run.status, run.metrics?.passed, run.metrics?.failed, run.metrics?.turns, run.metrics?.tokens, run.metrics?.jevInputTokens, run.metrics?.jevOutputTokens].map(quote).join(','));
|
|
166
|
+
fs.writeFileSync(file, `${columns.join(',')}\n${rows.join('\n')}\n`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function runCrossProviderBenchmark(options) {
|
|
170
|
+
dotenv.config({ path: path.join(options.repo, '.env'), override: false, quiet: true });
|
|
171
|
+
const schedule = buildCrossProviderSchedule(options);
|
|
172
|
+
if (options.dryRun) return { schedule, completed: 0, skipped: 0 };
|
|
173
|
+
const root = path.resolve(HERE, 'results', options.experimentId);
|
|
174
|
+
const stateFile = path.join(root, 'state.json');
|
|
175
|
+
let state;
|
|
176
|
+
try { state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); } catch { state = { experimentId: options.experimentId, seed: options.seed, repetitions: options.repetitions, createdAt: new Date().toISOString(), runs: {} }; }
|
|
177
|
+
let completed = 0;
|
|
178
|
+
let skipped = 0;
|
|
179
|
+
for (const run of schedule) {
|
|
180
|
+
if (state.runs[run.key]?.status === 'completed') { skipped += 1; continue; }
|
|
181
|
+
let selection = state.runs[run.key]?.selection;
|
|
182
|
+
if (!selection) {
|
|
183
|
+
selection = await selectExecutionModel(run, { threshold: options.threshold });
|
|
184
|
+
state.runs[run.key] = { ...run, status: 'selected', selection };
|
|
185
|
+
writeJson(stateFile, state);
|
|
186
|
+
}
|
|
187
|
+
const invocation = commandForCrossProviderRun(run, selection, options.cliPath);
|
|
188
|
+
console.log(`\n[${run.key}] ${selection.selected.provider}/${selection.selected.model} (${selection.selectedBy})`);
|
|
189
|
+
const before = reportFiles(run.repo);
|
|
190
|
+
const logFile = path.join(root, 'logs', `${run.key.replaceAll(':', '_')}.log`);
|
|
191
|
+
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
|
192
|
+
const env = { ...process.env,
|
|
193
|
+
TESTRONAUT_EXPERIMENT_ID: options.experimentId,
|
|
194
|
+
TESTRONAUT_EXPERIMENT_CONDITION: run.id,
|
|
195
|
+
TESTRONAUT_EXPERIMENT_BLOCK: String(run.block),
|
|
196
|
+
TESTRONAUT_EXPERIMENT_UNIT: run.unit,
|
|
197
|
+
TESTRONAUT_EXPERIMENT_SELECTED_BY: selection.selectedBy,
|
|
198
|
+
TESTRONAUT_EXPERIMENT_SELECTION_SCORE: selection.score == null ? '' : String(selection.score),
|
|
199
|
+
};
|
|
200
|
+
delete env.TESTRONAUT_JEV_FAST_MODEL;
|
|
201
|
+
const startedAt = new Date().toISOString();
|
|
202
|
+
const result = spawnSync(invocation.command, invocation.args, { cwd: run.repo, env, encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'], maxBuffer: 50 * 1024 * 1024 });
|
|
203
|
+
fs.writeFileSync(logFile, `${result.stdout || ''}${result.stderr || ''}`);
|
|
204
|
+
const reportPath = findNewReport(run.repo, before);
|
|
205
|
+
const status = reportPath ? 'completed' : 'no_report';
|
|
206
|
+
state.runs[run.key] = { ...run, status, selection, exitCode: result.status, signal: result.signal, startedAt, endedAt: new Date().toISOString(), reportPath, logFile, metrics: summarize(reportPath) };
|
|
207
|
+
writeJson(stateFile, state);
|
|
208
|
+
writeCsv(path.join(root, 'runs.csv'), state);
|
|
209
|
+
console.log(`[${run.key}] ${status}; exit=${result.status}; report=${reportPath || 'none'}`);
|
|
210
|
+
if (status === 'completed') completed += 1;
|
|
211
|
+
}
|
|
212
|
+
return { schedule, completed, skipped, stateFile };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function printSchedule(schedule) {
|
|
216
|
+
console.log(`Cross-provider benchmark schedule: ${schedule.length} isolated mission executions`);
|
|
217
|
+
for (const run of schedule) console.log(`${run.key} | ${run.candidates.map(item => `${item.provider}/${item.model}`).join(' vs ')} | ${run.mission}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
221
|
+
const options = parseArgs(process.argv.slice(2));
|
|
222
|
+
const result = await runCrossProviderBenchmark(options);
|
|
223
|
+
if (options.dryRun) printSchedule(result.schedule);
|
|
224
|
+
else console.log(`Benchmark progress saved to ${result.stateFile}. Completed ${result.completed}; resumed/skipped ${result.skipped}.`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export const __crossProviderInternals = { parseArgs };
|