@hackerrank/astra-cli 0.1.2 → 0.1.4
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 +53 -5
- package/package.json +7 -3
- package/src/bench.js +55 -16
- package/src/cli.js +81 -18
- package/src/project.js +177 -0
- package/src/report.html +122 -111
- package/src/report.js +35 -18
package/README.md
CHANGED
|
@@ -115,24 +115,69 @@ astra \
|
|
|
115
115
|
--repeat 3 \
|
|
116
116
|
--tar # write a bench/ tarball locally
|
|
117
117
|
|
|
118
|
-
# push results to S3 (tars bench/
|
|
118
|
+
# push results to S3 (tars bench/ and uploads it + report.html to the URI)
|
|
119
119
|
astra -m m1,m2 -p tasks/dummy-slugify --repeat 3 \
|
|
120
120
|
--push s3://hackerrank-astra-bench-results-dev/runs/2025-09-02/
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
Leaderboard columns:
|
|
123
|
+
Leaderboard columns: completed generation rate, average `steps` / `tokens`, and
|
|
124
124
|
`cost`. Cost is per-run summed; a model whose route reports no cost and has no
|
|
125
125
|
price entry shows **`n/a`** (never `$0`), and estimated costs (native routes) are
|
|
126
126
|
marked `~`. `--push` requires an `s3://` URI and the `aws` CLI on PATH; if the
|
|
127
127
|
upload fails the local tarball is kept.
|
|
128
128
|
|
|
129
|
+
Next to the tarball, `--push` also uploads the self-contained `report.html`
|
|
130
|
+
dashboard with `Content-Type: text/html` and prints a **7-day presigned
|
|
131
|
+
"view report" link** at the end of the run. The bucket is private (no public
|
|
132
|
+
read), so that presigned URL is the only way to open the dashboard — e.g.
|
|
133
|
+
clickable straight from a Jenkins console log.
|
|
134
|
+
|
|
135
|
+
### Project-configured bench runs
|
|
136
|
+
|
|
137
|
+
For repeatable benchmarks, keep the benchmark definition with the task and run
|
|
138
|
+
it through the existing bench persona:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
astra bench --project ./task
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`./task/.astra/project.toml` selects the public workspace, instruction,
|
|
145
|
+
models, limits, output root, and optional bench template/extensions. CLI flags
|
|
146
|
+
still override bench limits and output paths. Astra copies only the configured
|
|
147
|
+
workspace; container lifecycle, services, secrets, verification, and scoring
|
|
148
|
+
remain harness responsibilities. Run artifacts include `project.json` with the
|
|
149
|
+
project and input hashes. `completed` means the agent emitted its completion
|
|
150
|
+
sentinel—not that the candidate is correct; `model_error` and `request_error`
|
|
151
|
+
distinguish generation/provider failures from invalid invocation/configuration.
|
|
152
|
+
|
|
153
|
+
```toml
|
|
154
|
+
[project]
|
|
155
|
+
id = "peoplecore-scim"
|
|
156
|
+
version = 1
|
|
157
|
+
instruction = "instruction.md"
|
|
158
|
+
workspace = "public/codebase"
|
|
159
|
+
|
|
160
|
+
[bench]
|
|
161
|
+
models = ["gpt-5.6-sol"]
|
|
162
|
+
reasoning = ["medium"]
|
|
163
|
+
repeat = 3
|
|
164
|
+
steps = 60
|
|
165
|
+
wall_seconds = 3600
|
|
166
|
+
command_timeout_seconds = 120
|
|
167
|
+
|
|
168
|
+
[templates]
|
|
169
|
+
bench = ".astra/templates/bench.md"
|
|
170
|
+
[extensions]
|
|
171
|
+
directories = [".astra/extensions"]
|
|
172
|
+
```
|
|
173
|
+
|
|
129
174
|
### HTML report
|
|
130
175
|
|
|
131
176
|
Every matrix run (and every single `-p`/`-t`/`-f` bench run) rebuilds
|
|
132
177
|
`bench/summary.json` and a self-contained `bench/report.html` dashboard —
|
|
133
178
|
no server, no build step, no external JS: open it straight from `file://` or
|
|
134
179
|
from inside the `bench-*.tgz` tarball. It has a leaderboard, a model × task
|
|
135
|
-
|
|
180
|
+
completion-rate heatmap, cost/token charts, per-step trend lines, and an
|
|
136
181
|
expandable command timeline for every run.
|
|
137
182
|
|
|
138
183
|
Regenerate it on demand (e.g. after manually editing/pruning `bench/`) with:
|
|
@@ -144,7 +189,7 @@ astra --report --bench-root ./other-bench
|
|
|
144
189
|
|
|
145
190
|
`summary.json` follows the `astra-bench-1` schema: `kpis` (totals), a tidy
|
|
146
191
|
`leaderboard[]` (one row per model×reasoning), `matrix[]` (model×reasoning×task
|
|
147
|
-
|
|
192
|
+
completion telemetry), `runs[]` (every attempt, with a compact per-step `timeline`), and
|
|
148
193
|
`step_series[]` (token/cost distributions by step, for the trend charts). Each
|
|
149
194
|
run folder also gets a standalone `run.json` for drill-down without loading
|
|
150
195
|
the full `trajectory.json`.
|
|
@@ -172,10 +217,13 @@ credits — that's a quota issue, not a bug.
|
|
|
172
217
|
-t, --task <text> Task text -> autonomous mode
|
|
173
218
|
-f, --task-file <path> Read task text from a file -> autonomous mode
|
|
174
219
|
-p, --path <dir> Task directory -> isolated bench workspace
|
|
220
|
+
--project <path> Load .astra/project.toml for an explicit bench run
|
|
175
221
|
--repeat <n> Attempts per (model,reasoning) in a matrix (default: 1)
|
|
176
222
|
--bench-root <dir> Root folder for bench runs (default: ./bench)
|
|
177
223
|
--tar After a matrix, write a bench/ tarball locally
|
|
178
|
-
--push <s3-uri> After a matrix, tar bench/ and `aws s3 cp`
|
|
224
|
+
--push <s3-uri> After a matrix, tar bench/ and `aws s3 cp` it (plus
|
|
225
|
+
report.html) to the URI; prints presigned
|
|
226
|
+
download/view links
|
|
179
227
|
--report Rebuild bench/summary.json + bench/report.html from
|
|
180
228
|
whatever runs already exist on disk, then exit
|
|
181
229
|
-C, --cwd <path> Working directory for commands (default: cwd)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hackerrank/astra-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Minimal zero-dependency AI coding agent for the HackerRank AI Gateway.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
18
|
"start": "node src/cli.js",
|
|
19
|
-
"astra": "node src/cli.js"
|
|
19
|
+
"astra": "node src/cli.js",
|
|
20
|
+
"test": "node --test"
|
|
20
21
|
},
|
|
21
22
|
"publishConfig": {
|
|
22
23
|
"registry": "https://registry.npmjs.org/",
|
|
@@ -26,5 +27,8 @@
|
|
|
26
27
|
"type": "git",
|
|
27
28
|
"url": "git+ssh://git@github.com/interviewstreet/hrbench.git"
|
|
28
29
|
},
|
|
29
|
-
"license": "MIT"
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@iarna/toml": "2.2.5"
|
|
33
|
+
}
|
|
30
34
|
}
|
package/src/bench.js
CHANGED
|
@@ -30,7 +30,8 @@ export const METRIC_COLUMNS = [
|
|
|
30
30
|
"model",
|
|
31
31
|
"reasoning",
|
|
32
32
|
"exit_status",
|
|
33
|
-
"
|
|
33
|
+
"status",
|
|
34
|
+
"completed",
|
|
34
35
|
"steps",
|
|
35
36
|
"n_commands",
|
|
36
37
|
"n_failed_commands",
|
|
@@ -104,6 +105,7 @@ export function allocateRun({ model, reasoning, root } = {}) {
|
|
|
104
105
|
*/
|
|
105
106
|
export function seedWorkspace(workspace, { taskPath, taskFile, taskText } = {}) {
|
|
106
107
|
let text = taskText ?? "";
|
|
108
|
+
const hasExplicitTask = taskText !== undefined && taskText !== null;
|
|
107
109
|
let copyFrom = null;
|
|
108
110
|
let instructionName = null;
|
|
109
111
|
|
|
@@ -114,7 +116,7 @@ export function seedWorkspace(workspace, { taskPath, taskFile, taskText } = {})
|
|
|
114
116
|
copyFrom = resolved;
|
|
115
117
|
// Prefer a conventional instruction file for the task text.
|
|
116
118
|
for (const name of ["instruction.md", "INSTRUCTION.md", "task.md", "README.md"]) {
|
|
117
|
-
if (fs.existsSync(path.join(resolved, name))) {
|
|
119
|
+
if (!hasExplicitTask && fs.existsSync(path.join(resolved, name))) {
|
|
118
120
|
text = fs.readFileSync(path.join(resolved, name), "utf8");
|
|
119
121
|
break;
|
|
120
122
|
}
|
|
@@ -122,13 +124,13 @@ export function seedWorkspace(workspace, { taskPath, taskFile, taskText } = {})
|
|
|
122
124
|
} else {
|
|
123
125
|
copyFrom = path.dirname(resolved);
|
|
124
126
|
instructionName = path.basename(resolved);
|
|
125
|
-
text = fs.readFileSync(resolved, "utf8");
|
|
127
|
+
if (!hasExplicitTask) text = fs.readFileSync(resolved, "utf8");
|
|
126
128
|
}
|
|
127
129
|
} else if (taskFile) {
|
|
128
130
|
const resolved = path.resolve(taskFile);
|
|
129
131
|
copyFrom = path.dirname(resolved);
|
|
130
132
|
instructionName = path.basename(resolved);
|
|
131
|
-
text = fs.readFileSync(resolved, "utf8");
|
|
133
|
+
if (!hasExplicitTask) text = fs.readFileSync(resolved, "utf8");
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
if (copyFrom) copyDir(copyFrom, workspace);
|
|
@@ -162,13 +164,14 @@ function copyDir(from, to) {
|
|
|
162
164
|
/** Build a metrics row object from a finished agent + run info. */
|
|
163
165
|
export function collectMetrics({ agent, model, reasoning }) {
|
|
164
166
|
const info = agent.serialize().info;
|
|
165
|
-
const
|
|
167
|
+
const status = generationStatus(info.exit_status);
|
|
166
168
|
return {
|
|
167
169
|
timestamp: new Date().toISOString(),
|
|
168
170
|
model: model.model,
|
|
169
171
|
reasoning: reasoning || "none",
|
|
170
172
|
exit_status: info.exit_status || "",
|
|
171
|
-
|
|
173
|
+
status,
|
|
174
|
+
completed: status === "completed" ? 1 : 0,
|
|
172
175
|
steps: info.n_steps ?? 0,
|
|
173
176
|
n_commands: agent.nCommands ?? 0,
|
|
174
177
|
n_failed_commands: agent.nFailedCommands ?? 0,
|
|
@@ -188,6 +191,11 @@ export function collectMetrics({ agent, model, reasoning }) {
|
|
|
188
191
|
};
|
|
189
192
|
}
|
|
190
193
|
|
|
194
|
+
/** Classify autonomous generation without making a correctness claim. */
|
|
195
|
+
export function generationStatus(exitStatus) {
|
|
196
|
+
return exitStatus === "Submitted" ? "completed" : "model_error";
|
|
197
|
+
}
|
|
198
|
+
|
|
191
199
|
/** Serialize a metrics row to a single CSV line (columns in METRIC_COLUMNS order). */
|
|
192
200
|
export function metricsRow(m) {
|
|
193
201
|
return METRIC_COLUMNS.map((c) => csvCell(m[c])).join(",");
|
|
@@ -219,6 +227,14 @@ export function writeMetrics({ runDir, root, metrics }) {
|
|
|
219
227
|
return { runMetrics: path.join(runDir, "metrics.csv"), index: indexPath };
|
|
220
228
|
}
|
|
221
229
|
|
|
230
|
+
/** Persist the project-owned inputs that defined a bench attempt. */
|
|
231
|
+
export function writeProjectMetadata(runDir, metadata) {
|
|
232
|
+
if (!metadata) return null;
|
|
233
|
+
const file = path.join(runDir, "project.json");
|
|
234
|
+
fs.writeFileSync(file, JSON.stringify({ schema: "astra-project-1", ...metadata }, null, 2) + "\n");
|
|
235
|
+
return file;
|
|
236
|
+
}
|
|
237
|
+
|
|
222
238
|
function csvCell(v) {
|
|
223
239
|
const s = v == null ? "" : String(v);
|
|
224
240
|
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
@@ -264,6 +280,7 @@ export async function runCell({
|
|
|
264
280
|
task,
|
|
265
281
|
taskPath,
|
|
266
282
|
taskFile,
|
|
283
|
+
projectMetadata,
|
|
267
284
|
root,
|
|
268
285
|
steps = 40,
|
|
269
286
|
wall = 0,
|
|
@@ -278,6 +295,7 @@ export async function runCell({
|
|
|
278
295
|
const seeded = seedWorkspace(alloc.workspace, { taskPath, taskFile, taskText: task });
|
|
279
296
|
const taskText = seeded.taskText || task || "";
|
|
280
297
|
fs.writeFileSync(path.join(alloc.dir, "task.md"), taskText);
|
|
298
|
+
writeProjectMetadata(alloc.dir, projectMetadata);
|
|
281
299
|
|
|
282
300
|
const gw = new GatewayModel({
|
|
283
301
|
model,
|
|
@@ -340,7 +358,7 @@ export async function runMatrix({
|
|
|
340
358
|
|
|
341
359
|
/**
|
|
342
360
|
* Aggregate metrics rows into a per-(model,reasoning) leaderboard.
|
|
343
|
-
* `
|
|
361
|
+
* `completed` counts a completed generation. Cost is summed only over runs that reported a
|
|
344
362
|
* cost; a group with no cost data is reported as null (rendered "n/a"), never $0.
|
|
345
363
|
*/
|
|
346
364
|
export function aggregate(rows) {
|
|
@@ -352,7 +370,7 @@ export function aggregate(rows) {
|
|
|
352
370
|
model: r.model,
|
|
353
371
|
reasoning: r.reasoning || "none",
|
|
354
372
|
runs: 0,
|
|
355
|
-
|
|
373
|
+
completed: 0,
|
|
356
374
|
steps: 0,
|
|
357
375
|
totalTokens: 0,
|
|
358
376
|
costUsd: 0,
|
|
@@ -362,7 +380,7 @@ export function aggregate(rows) {
|
|
|
362
380
|
}
|
|
363
381
|
const g = groups.get(key);
|
|
364
382
|
g.runs++;
|
|
365
|
-
g.
|
|
383
|
+
g.completed += Number(r.completed) ? 1 : 0;
|
|
366
384
|
g.steps += Number(r.steps) || 0;
|
|
367
385
|
g.totalTokens += Number(r.total_tokens) || 0;
|
|
368
386
|
if (r.cost_source && r.cost_source !== "") {
|
|
@@ -375,8 +393,8 @@ export function aggregate(rows) {
|
|
|
375
393
|
model: g.model,
|
|
376
394
|
reasoning: g.reasoning,
|
|
377
395
|
runs: g.runs,
|
|
378
|
-
|
|
379
|
-
|
|
396
|
+
completed: g.completed,
|
|
397
|
+
completion_rate: g.runs ? g.completed / g.runs : 0,
|
|
380
398
|
avg_steps: g.runs ? g.steps / g.runs : 0,
|
|
381
399
|
avg_tokens: g.runs ? g.totalTokens / g.runs : 0,
|
|
382
400
|
cost_usd: g.costRuns ? g.costUsd : null,
|
|
@@ -455,17 +473,38 @@ function s3ConsoleUrl(s3Uri) {
|
|
|
455
473
|
return `https://s3.console.aws.amazon.com/s3/object/${bucket}?prefix=${encodeURIComponent(key)}`;
|
|
456
474
|
}
|
|
457
475
|
|
|
476
|
+
/**
|
|
477
|
+
* Generate a time-limited presigned URL for an S3 object via the AWS CLI
|
|
478
|
+
* (`aws s3 presign`). `expiresIn` defaults to 7 days (604800s). Returns null
|
|
479
|
+
* (rather than throwing) if the AWS CLI is missing or presigning fails, so
|
|
480
|
+
* a presign failure never blocks the run.
|
|
481
|
+
*/
|
|
482
|
+
export function presignS3Url(s3Uri, expiresIn = 604800) {
|
|
483
|
+
const res = spawnSync(
|
|
484
|
+
"aws",
|
|
485
|
+
["s3", "presign", s3Uri, "--expires-in", String(expiresIn)],
|
|
486
|
+
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
487
|
+
);
|
|
488
|
+
if (res.error && res.error.code === "ENOENT") return null;
|
|
489
|
+
if (res.status !== 0) return null;
|
|
490
|
+
return (res.stdout?.toString() || "").trim() || null;
|
|
491
|
+
}
|
|
492
|
+
|
|
458
493
|
/**
|
|
459
494
|
* Upload a file to S3 via the AWS CLI. Returns { uri, url } where `uri` is the
|
|
460
495
|
* final s3:// destination and `url` is a browsable https console link.
|
|
461
|
-
*
|
|
462
|
-
*
|
|
496
|
+
* Optionally sets the object's Content-Type (e.g. "text/html" so presigned
|
|
497
|
+
* URLs open in a browser instead of downloading). Fails gracefully (throws a
|
|
498
|
+
* descriptive error) when the AWS CLI is missing or the copy fails; callers
|
|
499
|
+
* should keep the local tarball on failure.
|
|
463
500
|
*/
|
|
464
|
-
export function pushToS3(file, s3Uri) {
|
|
501
|
+
export function pushToS3(file, s3Uri, { contentType } = {}) {
|
|
465
502
|
if (!/^s3:\/\//.test(s3Uri)) throw new Error(`--push destination must be an s3:// URI, got: ${s3Uri}`);
|
|
466
503
|
// If the URI ends with "/", upload into it under the file's basename.
|
|
467
504
|
const dest = s3Uri.endsWith("/") ? s3Uri + path.basename(file) : s3Uri;
|
|
468
|
-
const
|
|
505
|
+
const cmd = ["s3", "cp", file, dest];
|
|
506
|
+
if (contentType) cmd.push("--content-type", contentType);
|
|
507
|
+
const res = spawnSync("aws", cmd, {
|
|
469
508
|
stdio: ["ignore", "pipe", "pipe"],
|
|
470
509
|
});
|
|
471
510
|
if (res.error && res.error.code === "ENOENT") {
|
|
@@ -474,5 +513,5 @@ export function pushToS3(file, s3Uri) {
|
|
|
474
513
|
if (res.status !== 0) {
|
|
475
514
|
throw new Error(`aws s3 cp failed: ${res.stderr?.toString() || "unknown"}`);
|
|
476
515
|
}
|
|
477
|
-
return { uri: dest, url: s3ConsoleUrl(dest) };
|
|
516
|
+
return { uri: dest, url: s3ConsoleUrl(dest), presigned: presignS3Url(dest) };
|
|
478
517
|
}
|
package/src/cli.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* astra -m <model> -t "<task text>" bench run from task text
|
|
17
17
|
* astra -m <model> -f path/to/instruction.md
|
|
18
18
|
* astra -m <model> -p tasks/dummy-slugify bench run from a task directory
|
|
19
|
+
* astra bench --project ./task project-configured bench run
|
|
19
20
|
* astra --resume <session-id> resume a saved session
|
|
20
21
|
* astra --sessions list saved sessions
|
|
21
22
|
*
|
|
@@ -28,13 +29,15 @@
|
|
|
28
29
|
* comma-separated to sweep levels in bench mode).
|
|
29
30
|
* Sent to the model and recorded in the bench name.
|
|
30
31
|
* --repeat <n> Attempts per (model,reasoning) in a matrix (1)
|
|
31
|
-
* --push After a matrix, tar bench/ and upload
|
|
32
|
-
* s3://astra-bench-results/<timestamp
|
|
32
|
+
* --push After a matrix, tar bench/ and upload it (plus
|
|
33
|
+
* report.html) to s3://astra-bench-results/<timestamp>/;
|
|
34
|
+
* prints presigned download/view links
|
|
33
35
|
* --push-uri <s3-uri> Override the S3 push destination
|
|
34
36
|
* --tar After a matrix, write a bench/ tarball locally
|
|
35
37
|
* --report Rebuild bench/summary.json + bench/report.html
|
|
36
38
|
* from whatever runs already exist on disk, then
|
|
37
39
|
* exit (no model/API key needed).
|
|
40
|
+
* --project <path> Load .astra/project.toml and run its bench setup
|
|
38
41
|
* -t, --task <text> Task text -> bench mode (run to completion)
|
|
39
42
|
* -f, --task-file <path> Read task text from a file -> bench mode
|
|
40
43
|
* -p, --path <dir> Task directory (copied into an isolated bench
|
|
@@ -85,11 +88,13 @@ import {
|
|
|
85
88
|
deriveTitle,
|
|
86
89
|
} from "./session.js";
|
|
87
90
|
import { runRepl } from "./repl.js";
|
|
91
|
+
import { ProjectConfigError, loadProject } from "./project.js";
|
|
88
92
|
import {
|
|
89
93
|
allocateRun,
|
|
90
94
|
seedWorkspace,
|
|
91
95
|
collectMetrics,
|
|
92
96
|
writeMetrics,
|
|
97
|
+
writeProjectMetadata,
|
|
93
98
|
runMatrix,
|
|
94
99
|
aggregate,
|
|
95
100
|
packBench,
|
|
@@ -108,7 +113,7 @@ function reasoningKwargs(level) {
|
|
|
108
113
|
}
|
|
109
114
|
|
|
110
115
|
function parseArgs(argv) {
|
|
111
|
-
const args = { steps: 40, wall: 0, timeout: 60, quiet: false, "max-output": 16000 };
|
|
116
|
+
const args = { steps: 40, wall: 0, timeout: 60, quiet: false, "max-output": 16000, _provided: new Set() };
|
|
112
117
|
const alias = {
|
|
113
118
|
"-m": "model", "--model": "model",
|
|
114
119
|
"-t": "task", "--task": "task",
|
|
@@ -120,6 +125,7 @@ function parseArgs(argv) {
|
|
|
120
125
|
"--push-uri": "push-uri",
|
|
121
126
|
"--tar": "tar",
|
|
122
127
|
"--report": "report",
|
|
128
|
+
"--project": "project",
|
|
123
129
|
"--bench-root": "bench-root",
|
|
124
130
|
"-C": "cwd", "--cwd": "cwd",
|
|
125
131
|
"-o": "output", "--output": "output",
|
|
@@ -136,11 +142,17 @@ function parseArgs(argv) {
|
|
|
136
142
|
"-h": "help", "--help": "help",
|
|
137
143
|
};
|
|
138
144
|
const flags = new Set(["quiet", "yolo", "help", "sessions", "tar", "push", "report"]);
|
|
139
|
-
|
|
145
|
+
let start = 2;
|
|
146
|
+
if (argv[start] === "bench") {
|
|
147
|
+
args.bench = true;
|
|
148
|
+
start++;
|
|
149
|
+
}
|
|
150
|
+
for (let i = start; i < argv.length; i++) {
|
|
140
151
|
const key = alias[argv[i]];
|
|
141
152
|
if (!key) { console.error(`Unknown option: ${argv[i]}`); process.exit(2); }
|
|
142
153
|
if (flags.has(key)) { args[key] = true; continue; }
|
|
143
154
|
args[key] = argv[++i];
|
|
155
|
+
args._provided.add(key);
|
|
144
156
|
}
|
|
145
157
|
return args;
|
|
146
158
|
}
|
|
@@ -182,11 +194,28 @@ async function main() {
|
|
|
182
194
|
resumeDoc = loadSession(args.resume);
|
|
183
195
|
}
|
|
184
196
|
|
|
185
|
-
// A task (-t/-f)
|
|
197
|
+
// A task (-t/-f), task path (-p), or explicit bench project selects bench mode.
|
|
186
198
|
// Bench mode uses the autonomous engine; agent mode uses the interactive one.
|
|
187
199
|
let task = args.task;
|
|
188
200
|
if (args["task-file"]) task = fs.readFileSync(args["task-file"], "utf8");
|
|
189
|
-
|
|
201
|
+
let project = null;
|
|
202
|
+
if (args.project) {
|
|
203
|
+
try {
|
|
204
|
+
project = loadProject(args.project);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
const message = error instanceof ProjectConfigError ? error.message : String(error?.message || error);
|
|
207
|
+
console.error(`\x1b[31m[astra] request_error: ${message}\x1b[0m`);
|
|
208
|
+
process.exit(2);
|
|
209
|
+
}
|
|
210
|
+
task = project.taskText;
|
|
211
|
+
args.path = project.workspace;
|
|
212
|
+
if (!args._provided.has("bench-root")) args["bench-root"] = project.outputRoot;
|
|
213
|
+
if (!args._provided.has("steps")) args.steps = project.bench.steps;
|
|
214
|
+
if (!args._provided.has("wall")) args.wall = project.bench.wall;
|
|
215
|
+
if (!args._provided.has("timeout")) args.timeout = project.bench.timeout;
|
|
216
|
+
if (!args._provided.has("repeat")) args.repeat = project.bench.repeat;
|
|
217
|
+
}
|
|
218
|
+
const wantsBench = !!(task || args.path || args.bench);
|
|
190
219
|
const mode = wantsBench
|
|
191
220
|
? "autonomous"
|
|
192
221
|
: resumeDoc?.info?.mode === "autonomous"
|
|
@@ -235,6 +264,8 @@ async function main() {
|
|
|
235
264
|
let reasoning = args.reasoning || "";
|
|
236
265
|
|
|
237
266
|
if (mode === "autonomous") {
|
|
267
|
+
if (project && !modelId) modelId = project.bench.models.join(",");
|
|
268
|
+
if (project && !reasoning) reasoning = project.bench.reasoning.join(",");
|
|
238
269
|
if (!modelId) {
|
|
239
270
|
console.error("\x1b[31m[astra] --model is required for bench mode.\x1b[0m\n");
|
|
240
271
|
console.log(HELP);
|
|
@@ -253,7 +284,7 @@ async function main() {
|
|
|
253
284
|
const isMatrix = models.length > 1 || reasonings.length > 1 || repeat > 1;
|
|
254
285
|
|
|
255
286
|
if (isMatrix && !resumeDoc) {
|
|
256
|
-
await runBenchMatrix({ args, apiKey, models, reasonings, repeat, task, quiet: !!args.quiet });
|
|
287
|
+
await runBenchMatrix({ args, apiKey, models, reasonings, repeat, task, project, quiet: !!args.quiet });
|
|
257
288
|
return; // runBenchMatrix exits the process
|
|
258
289
|
}
|
|
259
290
|
} else {
|
|
@@ -309,6 +340,7 @@ async function main() {
|
|
|
309
340
|
task = seeded.taskText || task || "";
|
|
310
341
|
// Persist the task text alongside the run for reproducibility.
|
|
311
342
|
fs.writeFileSync(path.join(benchRun.dir, "task.md"), task || "");
|
|
343
|
+
writeProjectMetadata(benchRun.dir, project?.metadata);
|
|
312
344
|
}
|
|
313
345
|
|
|
314
346
|
const cmdCwd = benchRun ? benchRun.workspace : cwd;
|
|
@@ -365,6 +397,12 @@ async function main() {
|
|
|
365
397
|
const metrics = collectMetrics({ agent: benchAgent, model, reasoning });
|
|
366
398
|
const paths = writeMetrics({ runDir: alloc.dir, root: alloc.root, metrics });
|
|
367
399
|
log(`[astra] bench done: exit=${res.exit_status} · metrics → ${paths.runMetrics}`);
|
|
400
|
+
try {
|
|
401
|
+
const report = refreshReport(alloc.root);
|
|
402
|
+
log(`[astra] report → ${report.html}`);
|
|
403
|
+
} catch (err) {
|
|
404
|
+
log(`[astra] report generation skipped: ${err.message}`);
|
|
405
|
+
}
|
|
368
406
|
};
|
|
369
407
|
await runRepl(agent, { model, autoRun: !!args.yolo, fresh: !resumeDoc, reasoning, runBench });
|
|
370
408
|
process.exit(0);
|
|
@@ -465,6 +503,30 @@ function archiveAndPush(args, root, links) {
|
|
|
465
503
|
const pushed = pushToS3(tarball, dest);
|
|
466
504
|
links.push(["s3 uri", pushed.uri]);
|
|
467
505
|
links.push(["s3 url", pushed.url]);
|
|
506
|
+
if (pushed.presigned) {
|
|
507
|
+
links.push(["download (presigned, 7d)", pushed.presigned]);
|
|
508
|
+
}
|
|
509
|
+
// Also push the self-contained HTML dashboard next to the tarball and
|
|
510
|
+
// emit a presigned view link: the bucket is private (no public read),
|
|
511
|
+
// so the presigned URL is the only way to open the report — e.g. by
|
|
512
|
+
// clicking it straight from the Jenkins console output. Uploaded with
|
|
513
|
+
// Content-Type: text/html so browsers render it instead of downloading.
|
|
514
|
+
const reportHtml = path.join(benchRoot(root), "report.html");
|
|
515
|
+
if (fs.existsSync(reportHtml)) {
|
|
516
|
+
const htmlDest = dest.endsWith("/")
|
|
517
|
+
? dest + "report.html"
|
|
518
|
+
: dest.slice(0, dest.lastIndexOf("/") + 1) + "report.html";
|
|
519
|
+
try {
|
|
520
|
+
const html = pushToS3(reportHtml, htmlDest, { contentType: "text/html" });
|
|
521
|
+
if (html.presigned) {
|
|
522
|
+
links.push(["view report (presigned, 7d)", html.presigned]);
|
|
523
|
+
} else {
|
|
524
|
+
links.push(["report (s3 url)", html.url]);
|
|
525
|
+
}
|
|
526
|
+
} catch (err) {
|
|
527
|
+
console.error(`\x1b[31m[astra] S3 report push failed: ${err.message}\x1b[0m`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
468
530
|
} catch (err) {
|
|
469
531
|
console.error(`\x1b[31m[astra] S3 push failed: ${err.message}\x1b[0m`);
|
|
470
532
|
console.error(`\x1b[33m[astra] tarball kept locally: ${tarball}\x1b[0m`);
|
|
@@ -479,7 +541,7 @@ function archiveAndPush(args, root, links) {
|
|
|
479
541
|
* Run a multi-model / multi-reasoning / repeated bench matrix, print a
|
|
480
542
|
* leaderboard, optionally tar + upload the bench/ folder, then exit.
|
|
481
543
|
*/
|
|
482
|
-
async function runBenchMatrix({ args, apiKey, models, reasonings, repeat, task, quiet }) {
|
|
544
|
+
async function runBenchMatrix({ args, apiKey, models, reasonings, repeat, task, project, quiet }) {
|
|
483
545
|
const root = args["bench-root"];
|
|
484
546
|
const total = models.length * reasonings.length * repeat;
|
|
485
547
|
console.error(
|
|
@@ -497,6 +559,7 @@ async function runBenchMatrix({ args, apiKey, models, reasonings, repeat, task,
|
|
|
497
559
|
task,
|
|
498
560
|
taskPath: args.path,
|
|
499
561
|
taskFile: args["task-file"],
|
|
562
|
+
projectMetadata: project?.metadata,
|
|
500
563
|
root,
|
|
501
564
|
steps: Number(args.steps),
|
|
502
565
|
wall: Number(args.wall),
|
|
@@ -509,8 +572,8 @@ async function runBenchMatrix({ args, apiKey, models, reasonings, repeat, task,
|
|
|
509
572
|
}
|
|
510
573
|
},
|
|
511
574
|
onDone: (r) => {
|
|
512
|
-
const mark = r.
|
|
513
|
-
const status = r.error ? `
|
|
575
|
+
const mark = r.completed ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m";
|
|
576
|
+
const status = r.error ? `model_error: ${r.error.split("\n")[0]}` : r.status;
|
|
514
577
|
console.error(
|
|
515
578
|
` ${mark} ${String(r.model).padEnd(20)} ${String(r.reasoning).padEnd(8)} ` +
|
|
516
579
|
`${String(status).padEnd(16)} ${String(r.steps).padStart(3)} steps · ` +
|
|
@@ -539,8 +602,8 @@ async function runBenchMatrix({ args, apiKey, models, reasonings, repeat, task,
|
|
|
539
602
|
// Tar + optional S3 push of the whole bench/ folder.
|
|
540
603
|
archiveAndPush(args, root, links);
|
|
541
604
|
|
|
542
|
-
const
|
|
543
|
-
console.error(`\n\x1b[1m[astra] matrix done · ${
|
|
605
|
+
const completed = rows.filter((r) => r.completed).length;
|
|
606
|
+
console.error(`\n\x1b[1m[astra] matrix done · ${completed}/${rows.length} completed\x1b[0m`);
|
|
544
607
|
|
|
545
608
|
// Print the important URLs / paths, aligned, at the very end.
|
|
546
609
|
const w = Math.max(...links.map(([k]) => k.length));
|
|
@@ -549,23 +612,23 @@ async function runBenchMatrix({ args, apiKey, models, reasonings, repeat, task,
|
|
|
549
612
|
console.error(`\x1b[1m ${k.padEnd(w)}\x1b[0m ${v}`);
|
|
550
613
|
}
|
|
551
614
|
|
|
552
|
-
process.exit(
|
|
615
|
+
process.exit(completed === rows.length ? 0 : 1);
|
|
553
616
|
}
|
|
554
617
|
|
|
555
618
|
/** Print the per-(model,reasoning) leaderboard table. */
|
|
556
619
|
function printLeaderboard(board) {
|
|
557
|
-
board.sort((a, b) => b.
|
|
620
|
+
board.sort((a, b) => b.completion_rate - a.completion_rate || a.avg_steps - b.avg_steps);
|
|
558
621
|
console.error(
|
|
559
|
-
`\n\x1b[1m${"model".padEnd(20)} ${"reason".padEnd(8)} ${"
|
|
622
|
+
`\n\x1b[1m${"model".padEnd(20)} ${"reason".padEnd(8)} ${"complete".padEnd(8)} ` +
|
|
560
623
|
`${"steps".padStart(6)} ${"tokens".padStart(9)} ${"cost".padStart(9)} source\x1b[0m`
|
|
561
624
|
);
|
|
562
625
|
for (const g of board) {
|
|
563
|
-
const
|
|
564
|
-
const rate = `${Math.round(g.
|
|
626
|
+
const completed = `${g.completed}/${g.runs}`;
|
|
627
|
+
const rate = `${Math.round(g.completion_rate * 100)}%`;
|
|
565
628
|
const cost = g.cost_usd == null ? "n/a" : fmtUsd(g.cost_usd);
|
|
566
629
|
console.error(
|
|
567
630
|
`${String(g.model).padEnd(20)} ${String(g.reasoning).padEnd(8)} ` +
|
|
568
|
-
`${(
|
|
631
|
+
`${(completed + " " + rate).padEnd(8)} ${g.avg_steps.toFixed(1).padStart(6)} ` +
|
|
569
632
|
`${fmt(Math.round(g.avg_tokens)).padStart(9)} ${cost.padStart(9)} ${g.cost_source}`
|
|
570
633
|
);
|
|
571
634
|
}
|
package/src/project.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import toml from "@iarna/toml";
|
|
5
|
+
|
|
6
|
+
export class ProjectConfigError extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "ProjectConfigError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_BENCH = {
|
|
14
|
+
models: [],
|
|
15
|
+
reasoning: [""],
|
|
16
|
+
repeat: 1,
|
|
17
|
+
steps: 40,
|
|
18
|
+
wall_seconds: 0,
|
|
19
|
+
command_timeout_seconds: 60,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function requireObject(value, name) {
|
|
23
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
24
|
+
throw new ProjectConfigError(`${name} must be a TOML table`);
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function requireString(value, name) {
|
|
30
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
31
|
+
throw new ProjectConfigError(`${name} must be a non-empty string`);
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function positiveInteger(value, name, fallback, { allowZero = false } = {}) {
|
|
37
|
+
if (value === undefined) return fallback;
|
|
38
|
+
if (!Number.isInteger(value) || value < (allowZero ? 0 : 1)) {
|
|
39
|
+
throw new ProjectConfigError(`${name} must be ${allowZero ? "a non-negative" : "a positive"} integer`);
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function stringArray(value, name, fallback) {
|
|
45
|
+
if (value === undefined) return fallback;
|
|
46
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item.trim())) {
|
|
47
|
+
throw new ProjectConfigError(`${name} must be an array of non-empty strings`);
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolveInside(root, relative, name) {
|
|
53
|
+
const candidate = path.resolve(root, requireString(relative, name));
|
|
54
|
+
if (candidate !== root && !candidate.startsWith(root + path.sep)) {
|
|
55
|
+
throw new ProjectConfigError(`${name} must stay inside the project directory`);
|
|
56
|
+
}
|
|
57
|
+
return candidate;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readRegularFile(file, name, { optional = false } = {}) {
|
|
61
|
+
if (!fs.existsSync(file)) {
|
|
62
|
+
if (optional) return null;
|
|
63
|
+
throw new ProjectConfigError(`${name} does not exist: ${file}`);
|
|
64
|
+
}
|
|
65
|
+
const stat = fs.lstatSync(file);
|
|
66
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
67
|
+
throw new ProjectConfigError(`${name} must be a regular file: ${file}`);
|
|
68
|
+
}
|
|
69
|
+
return fs.readFileSync(file, "utf8");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sha256(value) {
|
|
73
|
+
return createHash("sha256").update(value).digest("hex");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function extensionFiles(root, directories) {
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
const extensions = [];
|
|
79
|
+
for (const directory of directories) {
|
|
80
|
+
const resolved = resolveInside(root, directory, "extensions.directories entry");
|
|
81
|
+
if (!fs.existsSync(resolved)) {
|
|
82
|
+
throw new ProjectConfigError(`extension directory does not exist: ${resolved}`);
|
|
83
|
+
}
|
|
84
|
+
const stat = fs.lstatSync(resolved);
|
|
85
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
86
|
+
throw new ProjectConfigError(`extension directory must be a regular directory: ${resolved}`);
|
|
87
|
+
}
|
|
88
|
+
for (const entry of fs.readdirSync(resolved, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
89
|
+
if (!entry.isFile() || entry.isSymbolicLink()) continue;
|
|
90
|
+
const file = path.join(resolved, entry.name);
|
|
91
|
+
if (seen.has(file)) throw new ProjectConfigError(`duplicate extension path: ${file}`);
|
|
92
|
+
seen.add(file);
|
|
93
|
+
const content = readRegularFile(file, "extension");
|
|
94
|
+
extensions.push({ path: file, content, sha256: sha256(content) });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return extensions;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function loadProject(projectPath) {
|
|
101
|
+
const root = path.resolve(requireString(projectPath, "project path"));
|
|
102
|
+
if (!fs.existsSync(root) || !fs.lstatSync(root).isDirectory()) {
|
|
103
|
+
throw new ProjectConfigError(`project directory does not exist: ${root}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const configPath = path.join(root, ".astra", "project.toml");
|
|
107
|
+
const configText = readRegularFile(configPath, "project configuration");
|
|
108
|
+
let config;
|
|
109
|
+
try {
|
|
110
|
+
config = toml.parse(configText);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
throw new ProjectConfigError(`invalid project TOML: ${error.message}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const project = requireObject(config.project, "project");
|
|
116
|
+
const bench = requireObject(config.bench ?? {}, "bench");
|
|
117
|
+
const output = requireObject(config.output ?? {}, "output");
|
|
118
|
+
const templates = requireObject(config.templates ?? {}, "templates");
|
|
119
|
+
const extensionConfig = requireObject(config.extensions ?? {}, "extensions");
|
|
120
|
+
const provenance = requireObject(config.provenance ?? {}, "provenance");
|
|
121
|
+
const id = requireString(project.id, "project.id");
|
|
122
|
+
const version = positiveInteger(project.version, "project.version", undefined);
|
|
123
|
+
|
|
124
|
+
const instructionPath = resolveInside(root, project.instruction, "project.instruction");
|
|
125
|
+
const workspace = resolveInside(root, project.workspace, "project.workspace");
|
|
126
|
+
const instruction = readRegularFile(instructionPath, "instruction");
|
|
127
|
+
if (!instruction.trim()) throw new ProjectConfigError("instruction must not be empty");
|
|
128
|
+
if (!fs.existsSync(workspace) || !fs.lstatSync(workspace).isDirectory() || fs.lstatSync(workspace).isSymbolicLink()) {
|
|
129
|
+
throw new ProjectConfigError(`project.workspace must be a regular directory: ${workspace}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const templatePath = templates.bench === undefined ? null : resolveInside(root, templates.bench, "templates.bench");
|
|
133
|
+
const template = templatePath === null ? null : readRegularFile(templatePath, "bench template");
|
|
134
|
+
const extensions = extensionFiles(root, stringArray(extensionConfig.directories, "extensions.directories", []));
|
|
135
|
+
const taskText = [instruction, template, ...extensions.map((extension) => extension.content)]
|
|
136
|
+
.filter(Boolean)
|
|
137
|
+
.join("\n\n");
|
|
138
|
+
|
|
139
|
+
const baselineSha256 = provenance.baseline_sha256;
|
|
140
|
+
if (baselineSha256 !== undefined && (typeof baselineSha256 !== "string" || !/^[a-f0-9]{64}$/.test(baselineSha256))) {
|
|
141
|
+
throw new ProjectConfigError("provenance.baseline_sha256 must be a lowercase SHA-256 hash");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
root,
|
|
146
|
+
id,
|
|
147
|
+
version,
|
|
148
|
+
instructionPath,
|
|
149
|
+
instruction,
|
|
150
|
+
instructionSha256: sha256(instruction),
|
|
151
|
+
workspace,
|
|
152
|
+
outputRoot: resolveInside(root, output.root ?? "bench", "output.root"),
|
|
153
|
+
template: template === null ? null : { path: templatePath, content: template, sha256: sha256(template) },
|
|
154
|
+
extensions,
|
|
155
|
+
taskText,
|
|
156
|
+
provenance: { baselineSha256: baselineSha256 ?? null },
|
|
157
|
+
metadata: {
|
|
158
|
+
id,
|
|
159
|
+
version,
|
|
160
|
+
instruction_sha256: sha256(instruction),
|
|
161
|
+
baseline_sha256: baselineSha256 ?? null,
|
|
162
|
+
template_sha256: template === null ? null : sha256(template),
|
|
163
|
+
extension_sha256: extensions.map((extension) => ({
|
|
164
|
+
path: path.relative(root, extension.path),
|
|
165
|
+
sha256: extension.sha256,
|
|
166
|
+
})),
|
|
167
|
+
},
|
|
168
|
+
bench: {
|
|
169
|
+
models: stringArray(bench.models, "bench.models", DEFAULT_BENCH.models),
|
|
170
|
+
reasoning: stringArray(bench.reasoning, "bench.reasoning", DEFAULT_BENCH.reasoning),
|
|
171
|
+
repeat: positiveInteger(bench.repeat, "bench.repeat", DEFAULT_BENCH.repeat),
|
|
172
|
+
steps: positiveInteger(bench.steps, "bench.steps", DEFAULT_BENCH.steps),
|
|
173
|
+
wall: positiveInteger(bench.wall_seconds, "bench.wall_seconds", DEFAULT_BENCH.wall_seconds, { allowZero: true }),
|
|
174
|
+
timeout: positiveInteger(bench.command_timeout_seconds, "bench.command_timeout_seconds", DEFAULT_BENCH.command_timeout_seconds),
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
package/src/report.html
CHANGED
|
@@ -4,41 +4,15 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>astra bench report</title>
|
|
7
|
+
<link rel="stylesheet" href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700&display=swap" />
|
|
7
8
|
<style>
|
|
8
|
-
:root {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
--text: #e6e8ee;
|
|
14
|
-
--muted: #8b93a7;
|
|
15
|
-
--accent: #5b9dff;
|
|
16
|
-
--good: #3ecf8e;
|
|
17
|
-
--bad: #ef5a6f;
|
|
18
|
-
--warn: #e8b339;
|
|
19
|
-
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
20
|
-
}
|
|
21
|
-
* { box-sizing: border-box; }
|
|
22
|
-
body { margin: 0; background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.45; }
|
|
23
|
-
a { color: var(--accent); }
|
|
24
|
-
.wrap { max-width: 1180px; margin: 0 auto; padding: 28px 20px 64px; }
|
|
25
|
-
header.top { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 4px; flex-wrap: wrap; gap: 8px; }
|
|
26
|
-
header.top h1 { font-size: 20px; margin: 0; font-weight: 650; letter-spacing: .2px; }
|
|
27
|
-
header.top .meta { color: var(--muted); font-size: 12.5px; }
|
|
28
|
-
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin: 20px 0 28px; }
|
|
29
|
-
.kpi { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; }
|
|
30
|
-
.kpi .v { font-size: 22px; font-weight: 700; }
|
|
31
|
-
.kpi .l { color: var(--muted); font-size: 11.5px; text-transform: uppercase; letter-spacing: .06em; margin-top: 2px; }
|
|
32
|
-
section { margin: 34px 0; }
|
|
33
|
-
section > h2 { font-size: 15px; margin: 0 0 12px; font-weight: 650; color: var(--text); display: flex; align-items: center; gap: 8px; }
|
|
34
|
-
section > h2 .sub { color: var(--muted); font-weight: 400; font-size: 12px; }
|
|
35
|
-
.grid2 { display: grid; grid-template-columns: 1.1fr 1fr; gap: 16px; }
|
|
36
|
-
.grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
|
37
|
-
@media (max-width: 900px) { .grid2, .grid3 { grid-template-columns: 1fr; } }
|
|
38
|
-
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; }
|
|
39
|
-
.panel h3 { margin: 0 0 10px; font-size: 12.5px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .05em; }
|
|
9
|
+
:root { --ink:#11131a; --muted:#69707d; --wash:#f5f6f8; --line:#e4e7eb; --lime:#a7ff63; --blue:#5285f7; --purple:#9160d8; --green:#39ae76; --orange:#f3a021; --red:#d85668; font-family:Satoshi,"Avenir Next",Arial,sans-serif; }
|
|
10
|
+
* { box-sizing:border-box; } body { margin:0; background:#fff; color:var(--ink); font-size:14px; line-height:1.45; } .wrap { max-width:1240px; margin:auto; padding:28px 32px 68px; }
|
|
11
|
+
header.top { display:flex; justify-content:space-between; gap:20px; align-items:flex-start; padding-bottom:28px; border-bottom:1px solid var(--line); } header.top h1 { font-size:32px; letter-spacing:-.045em; line-height:1; margin:8px 0 0; font-weight:700; } .eyebrow { display:inline-block; padding:5px 14px; border-radius:99px; background:var(--lime); font-size:12px; font-weight:700; } header.top .meta { color:var(--muted); font-size:12px; text-align:right; }
|
|
12
|
+
.kpis { display:grid; grid-template-columns:repeat(4,1fr); margin:24px 0 52px; } .kpi { min-height:96px; padding:8px 18px; border-left:1px solid var(--line); } .kpi:first-child { border:0; padding-left:0; } .kpi .v { font-size:28px; letter-spacing:-.045em; font-weight:700; } .kpi .l { color:var(--muted); font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; margin-top:8px; }
|
|
13
|
+
section { margin:56px 0; } section > h2 { font-size:20px; letter-spacing:-.035em; margin:0 0 22px; } section > h2 .sub { color:var(--muted); font-size:13px; font-weight:400; letter-spacing:0; } .grid2 { display:grid; grid-template-columns:1.1fr 1fr; gap:44px; } .panel { padding:0; } .panel h3 { color:var(--muted); letter-spacing:.07em; text-transform:uppercase; font-size:12px; margin:0 0 16px; }
|
|
40
14
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
41
|
-
th, td { text-align: left; padding:
|
|
15
|
+
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
|
42
16
|
th { color: var(--muted); font-weight: 600; font-size: 11.5px; text-transform: uppercase; letter-spacing: .03em; cursor: pointer; user-select: none; }
|
|
43
17
|
th:hover { color: var(--text); }
|
|
44
18
|
th .arrow { opacity: .5; font-size: 10px; margin-left: 3px; }
|
|
@@ -52,7 +26,7 @@
|
|
|
52
26
|
.muted { color: var(--muted); }
|
|
53
27
|
.bar-row { display: flex; align-items: center; gap: 8px; margin: 6px 0; }
|
|
54
28
|
.bar-row .lbl { width: 150px; flex: 0 0 150px; font-size: 12px; overflow: hidden; text-overflow: ellipsis; }
|
|
55
|
-
.bar-track { flex: 1; height:
|
|
29
|
+
.bar-track { flex: 1; height: 9px; background: var(--wash); border-radius: 99px; overflow: hidden; display: flex; }
|
|
56
30
|
.bar-seg { height: 100%; }
|
|
57
31
|
.bar-val { width: 64px; flex: 0 0 64px; text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
58
32
|
.legend { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 10px; font-size: 11.5px; color: var(--muted); }
|
|
@@ -61,7 +35,7 @@
|
|
|
61
35
|
.heat-cell { display: inline-block; min-width: 44px; padding: 3px 6px; border-radius: 5px; font-size: 12px; }
|
|
62
36
|
details.run-detail { margin: 0; }
|
|
63
37
|
details.run-detail > summary { display: none; }
|
|
64
|
-
.timeline { margin: 0 0 18px;
|
|
38
|
+
.timeline { margin: 0 0 18px; background:var(--wash); padding:0 14px; overflow: hidden; }
|
|
65
39
|
.timeline .step { padding: 10px 14px; border-bottom: 1px solid var(--border); }
|
|
66
40
|
.timeline .step:last-child { border-bottom: none; }
|
|
67
41
|
.timeline .step .head { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; margin-bottom: 4px; }
|
|
@@ -71,35 +45,30 @@
|
|
|
71
45
|
.timeline .step pre { background: var(--panel-2); border-radius: 6px; padding: 8px 10px; margin: 4px 0; font-size: 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
|
|
72
46
|
.rc0 { color: var(--good); } .rcN { color: var(--bad); }
|
|
73
47
|
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; align-items: center; }
|
|
74
|
-
.toolbar select, .toolbar input { background: var(--
|
|
48
|
+
.toolbar select, .toolbar input { background: var(--wash); border:0; color:var(--ink); border-radius:4px; padding:7px 9px; font-size:12px; } .model-filter { align-items:end; } .model-filter > span { color:var(--muted); font-size:11px; } .model-menu { position:relative; } .model-menu summary { cursor:pointer; list-style:none; min-width:220px; padding:9px 32px 9px 11px; background:var(--wash); border-radius:5px; font-size:12px; position:relative; } .model-menu summary::-webkit-details-marker { display:none; } .model-menu summary::after { content:"⌄"; position:absolute; right:11px; color:var(--muted); } .model-options { position:absolute; z-index:2; top:calc(100% + 6px); left:0; width:260px; max-height:240px; overflow:auto; padding:6px; background:#fff; border:1px solid var(--line); box-shadow:0 10px 24px rgba(17,19,26,.1); } .model-option { display:flex; gap:9px; align-items:center; padding:8px; font-size:12px; cursor:pointer; } .model-option:hover { background:var(--wash); } .model-option input { accent-color:var(--blue); }
|
|
75
49
|
.toolbar label { color: var(--muted); font-size: 12px; }
|
|
76
50
|
footer { color: var(--muted); font-size: 11.5px; margin-top: 40px; text-align: center; }
|
|
77
51
|
svg text { fill: var(--muted); font-size: 10.5px; }
|
|
78
|
-
.axis line, .axis path { stroke: var(--
|
|
52
|
+
.axis line, .axis path { stroke: var(--line); }
|
|
79
53
|
.n-a { color: var(--muted); }
|
|
80
54
|
</style>
|
|
81
55
|
</head>
|
|
82
56
|
<body>
|
|
83
57
|
<div class="wrap">
|
|
84
58
|
<header class="top">
|
|
85
|
-
<h1>
|
|
86
|
-
<div class="meta" id="generated"></div>
|
|
59
|
+
<div><h1>Benchmark report</h1><div class="meta" id="generated"></div></div>
|
|
87
60
|
</header>
|
|
88
61
|
|
|
89
62
|
<div class="kpis" id="kpis"></div>
|
|
90
63
|
|
|
91
64
|
<section>
|
|
92
|
-
<h2>
|
|
93
|
-
<div class="
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
<h3>Outcomes</h3>
|
|
100
|
-
<div id="chart-outcomes"></div>
|
|
101
|
-
<div class="legend" id="legend-outcomes"></div>
|
|
102
|
-
</div>
|
|
65
|
+
<h2>Performance <span class="sub">run volume and reliability by model</span></h2>
|
|
66
|
+
<div class="toolbar model-filter">
|
|
67
|
+
<span>Models</span>
|
|
68
|
+
<details class="model-menu">
|
|
69
|
+
<summary id="model-filter-label">All models</summary>
|
|
70
|
+
<div id="leaderboard-model-options" class="model-options"></div>
|
|
71
|
+
</details>
|
|
103
72
|
</div>
|
|
104
73
|
<div class="panel" style="margin-top:16px;">
|
|
105
74
|
<table id="tbl-leaderboard"></table>
|
|
@@ -107,22 +76,29 @@
|
|
|
107
76
|
</section>
|
|
108
77
|
|
|
109
78
|
<section>
|
|
110
|
-
<h2>
|
|
79
|
+
<h2>Efficiency <span class="sub">spend and token composition</span></h2>
|
|
111
80
|
<div class="grid2">
|
|
112
81
|
<div class="panel">
|
|
113
|
-
<h3>Cost vs.
|
|
82
|
+
<h3>Cost vs. average duration</h3>
|
|
114
83
|
<div id="chart-scatter"></div>
|
|
115
84
|
</div>
|
|
116
85
|
<div class="panel">
|
|
117
|
-
<h3>
|
|
118
|
-
<div id="chart-
|
|
119
|
-
<div class="legend" id="legend-tokens"></div>
|
|
86
|
+
<h3>Cost vs. average steps</h3>
|
|
87
|
+
<div id="chart-scatter-steps"></div>
|
|
120
88
|
</div>
|
|
121
89
|
</div>
|
|
122
90
|
</section>
|
|
123
91
|
|
|
92
|
+
<section>
|
|
93
|
+
<h2>Token mix <span class="sub">prompt, completion, reasoning, and cached tokens</span></h2>
|
|
94
|
+
<div class="panel">
|
|
95
|
+
<div id="chart-tokens"></div>
|
|
96
|
+
<div class="legend" id="legend-tokens"></div>
|
|
97
|
+
</div>
|
|
98
|
+
</section>
|
|
99
|
+
|
|
124
100
|
<section id="section-series">
|
|
125
|
-
<h2>
|
|
101
|
+
<h2>Trajectory <span class="sub">where context and cost accumulate</span></h2>
|
|
126
102
|
<div class="grid2">
|
|
127
103
|
<div class="panel">
|
|
128
104
|
<h3>Prompt tokens (context) vs. step</h3>
|
|
@@ -136,31 +112,6 @@
|
|
|
136
112
|
<div class="legend" id="legend-series"></div>
|
|
137
113
|
</section>
|
|
138
114
|
|
|
139
|
-
<section id="section-matrix">
|
|
140
|
-
<h2>Model × task <span class="sub">pass@k</span></h2>
|
|
141
|
-
<div class="panel">
|
|
142
|
-
<table id="tbl-matrix" class="heat-table"></table>
|
|
143
|
-
</div>
|
|
144
|
-
</section>
|
|
145
|
-
|
|
146
|
-
<section>
|
|
147
|
-
<h2>Runs <span class="sub">every attempt · click a row to expand the command timeline</span></h2>
|
|
148
|
-
<div class="toolbar">
|
|
149
|
-
<label>Model
|
|
150
|
-
<select id="filter-model"><option value="">all</option></select>
|
|
151
|
-
</label>
|
|
152
|
-
<label>Task
|
|
153
|
-
<select id="filter-task"><option value="">all</option></select>
|
|
154
|
-
</label>
|
|
155
|
-
<label>Outcome
|
|
156
|
-
<select id="filter-outcome"><option value="">all</option></select>
|
|
157
|
-
</label>
|
|
158
|
-
</div>
|
|
159
|
-
<div class="panel">
|
|
160
|
-
<table id="tbl-runs"></table>
|
|
161
|
-
</div>
|
|
162
|
-
</section>
|
|
163
|
-
|
|
164
115
|
<footer>generated by <span class="mono">astra bench --report</span> · schema <span id="schema"></span></footer>
|
|
165
116
|
</div>
|
|
166
117
|
|
|
@@ -169,8 +120,19 @@
|
|
|
169
120
|
</script>
|
|
170
121
|
<script>
|
|
171
122
|
(function () {
|
|
172
|
-
|
|
173
|
-
|
|
123
|
+
function loadReportData() {
|
|
124
|
+
try {
|
|
125
|
+
var request = new XMLHttpRequest();
|
|
126
|
+
request.open("GET", "summary.json", false);
|
|
127
|
+
request.send(null);
|
|
128
|
+
if ((request.status >= 200 && request.status < 300) || request.status === 0) return JSON.parse(request.responseText);
|
|
129
|
+
} catch (_) {
|
|
130
|
+
// Direct file access can block JSON reads; use the embedded snapshot then.
|
|
131
|
+
}
|
|
132
|
+
return window.__ASTRA_EMBEDDED_DATA__ || { kpis: {}, leaderboard: [], matrix: [], runs: [], step_series: [] };
|
|
133
|
+
}
|
|
134
|
+
var DATA = loadReportData();
|
|
135
|
+
var PALETTE = ["#5285f7", "#9160d8", "#39ae76", "#f3a021", "#d85668", "#37a8ad"];
|
|
174
136
|
var colorCache = {};
|
|
175
137
|
function colorFor(key) {
|
|
176
138
|
if (!colorCache[key]) {
|
|
@@ -209,21 +171,19 @@
|
|
|
209
171
|
}
|
|
210
172
|
|
|
211
173
|
// ---------------------------------------------------------------- header
|
|
212
|
-
document.getElementById("generated").textContent =
|
|
174
|
+
document.getElementById("generated").textContent = DATA.generated_at
|
|
175
|
+
? "Generated " + new Date(DATA.generated_at).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" })
|
|
176
|
+
: "";
|
|
213
177
|
document.getElementById("schema").textContent = DATA.schema || "";
|
|
214
178
|
|
|
215
179
|
// ---------------------------------------------------------------- KPIs
|
|
216
180
|
(function renderKpis() {
|
|
217
181
|
var k = DATA.kpis || {};
|
|
218
182
|
var cards = [
|
|
219
|
-
["
|
|
220
|
-
["
|
|
221
|
-
["Tasks", fmt(k.tasks)],
|
|
222
|
-
["Solved", fmt(k.solved) + " / " + fmt(k.runs)],
|
|
223
|
-
["Solve rate", pct(k.solved_rate)],
|
|
224
|
-
["Total tokens", fmt(k.tokens)],
|
|
183
|
+
["Models Benchmarked", fmt(k.models)],
|
|
184
|
+
["Total duration", fmt(k.elapsed_seconds) + "s"],
|
|
225
185
|
["Total cost", fmtUsd(k.cost_usd) + (k.cost_source === "estimated" ? "~" : "")],
|
|
226
|
-
["
|
|
186
|
+
["Tokens used", fmt(k.tokens)],
|
|
227
187
|
];
|
|
228
188
|
var host = document.getElementById("kpis");
|
|
229
189
|
cards.forEach(function (c) {
|
|
@@ -266,20 +226,22 @@
|
|
|
266
226
|
draw();
|
|
267
227
|
}
|
|
268
228
|
|
|
269
|
-
// ---------------------------------------------------------------- leaderboard chart:
|
|
229
|
+
// ---------------------------------------------------------------- leaderboard chart: run volume bars
|
|
270
230
|
(function () {
|
|
271
231
|
var host = document.getElementById("chart-solve");
|
|
232
|
+
if (!host) return;
|
|
233
|
+
var maxRuns = Math.max(1, Math.max.apply(null, (DATA.leaderboard || []).map(function (g) { return g.runs || 0; })));
|
|
272
234
|
(DATA.leaderboard || []).forEach(function (g) {
|
|
273
235
|
var row = el("div", { class: "bar-row" });
|
|
274
236
|
row.appendChild(el("div", { class: "lbl mono" }, esc(g.slug)));
|
|
275
237
|
var track = el("div", { class: "bar-track" });
|
|
276
238
|
var seg = el("div", {
|
|
277
239
|
class: "bar-seg",
|
|
278
|
-
style: "width:" + Math.round((g.
|
|
240
|
+
style: "width:" + Math.round((g.runs || 0) / maxRuns * 100) + "%;background:" + colorFor(g.slug),
|
|
279
241
|
});
|
|
280
242
|
track.appendChild(seg);
|
|
281
243
|
row.appendChild(track);
|
|
282
|
-
row.appendChild(el("div", { class: "bar-val" },
|
|
244
|
+
row.appendChild(el("div", { class: "bar-val" }, fmt(g.runs) + " runs"));
|
|
283
245
|
host.appendChild(row);
|
|
284
246
|
});
|
|
285
247
|
})();
|
|
@@ -288,6 +250,7 @@
|
|
|
288
250
|
(function () {
|
|
289
251
|
var host = document.getElementById("chart-outcomes");
|
|
290
252
|
var legend = document.getElementById("legend-outcomes");
|
|
253
|
+
if (!host || !legend) return;
|
|
291
254
|
var outcomeColors = { Submitted: "#3ecf8e", LimitsExceeded: "#e8b339", TimeExceeded: "#e8b339", RepeatedFormatError: "#ef5a6f", ContextWindowExceeded: "#ef5a6f", Error: "#ef5a6f", Unknown: "#8b93a7" };
|
|
292
255
|
function colorForOutcome(k) { return outcomeColors[k] || "#8b93a7"; }
|
|
293
256
|
var allOutcomes = {};
|
|
@@ -313,18 +276,31 @@
|
|
|
313
276
|
// ---------------------------------------------------------------- leaderboard table
|
|
314
277
|
(function () {
|
|
315
278
|
var container = document.getElementById("tbl-leaderboard");
|
|
279
|
+
var options = document.getElementById("leaderboard-model-options");
|
|
280
|
+
var filterLabel = document.getElementById("model-filter-label");
|
|
316
281
|
var columns = [
|
|
317
282
|
{ key: "model", label: "Model", render: function (r) { return esc(r.model); } },
|
|
318
283
|
{ key: "reasoning", label: "Reasoning", render: function (r) { return esc(r.reasoning); } },
|
|
319
284
|
{ key: "runs", label: "Runs", render: function (r) { return fmt(r.runs); } },
|
|
320
|
-
{ key: "
|
|
321
|
-
{ key: "
|
|
322
|
-
{ key: "
|
|
323
|
-
{ key: "avg_tokens", label: "Avg tokens", render: function (r) { return fmt(Math.round(r.avg_tokens)); } },
|
|
285
|
+
{ key: "sum_steps", label: "Total steps", render: function (r) { return fmt(r.sum_steps); } },
|
|
286
|
+
{ key: "sum_elapsed_seconds", label: "Time taken", render: function (r) { return fmt(r.sum_elapsed_seconds) + "s"; } },
|
|
287
|
+
{ key: "sum_tokens", label: "Tokens", render: function (r) { return fmt(r.sum_tokens); } },
|
|
324
288
|
{ key: "cost_usd", label: "Cost", render: function (r) { return fmtUsd(r.cost_usd) + (r.cost_source === "estimated" ? "~" : ""); } },
|
|
325
|
-
{ key: "avg_n_failed_commands", label: "Fail/run", render: function (r) { return fmt1(r.avg_n_failed_commands); } },
|
|
326
289
|
];
|
|
327
|
-
|
|
290
|
+
(DATA.leaderboard || []).forEach(function (r) {
|
|
291
|
+
var option = el("label", { class: "model-option" });
|
|
292
|
+
option.appendChild(el("input", { type: "checkbox", value: r.slug }));
|
|
293
|
+
option.appendChild(document.createTextNode(r.model + " · " + r.reasoning));
|
|
294
|
+
options.appendChild(option);
|
|
295
|
+
});
|
|
296
|
+
function draw() {
|
|
297
|
+
var selected = Array.prototype.slice.call(options.querySelectorAll("input:checked")).map(function (o) { return o.value; });
|
|
298
|
+
var rows = selected.length ? (DATA.leaderboard || []).filter(function (r) { return selected.indexOf(r.slug) !== -1; }) : DATA.leaderboard || [];
|
|
299
|
+
filterLabel.textContent = selected.length ? selected.length + " model" + (selected.length === 1 ? "" : "s") + " selected" : "All models";
|
|
300
|
+
sortableTable(container, columns, rows, { defaultKey: "sum_tokens" });
|
|
301
|
+
}
|
|
302
|
+
options.addEventListener("change", draw);
|
|
303
|
+
draw();
|
|
328
304
|
})();
|
|
329
305
|
|
|
330
306
|
// ---------------------------------------------------------------- token mix stacked bar
|
|
@@ -354,29 +330,30 @@
|
|
|
354
330
|
});
|
|
355
331
|
})();
|
|
356
332
|
|
|
357
|
-
// ---------------------------------------------------------------- scatter: cost vs
|
|
333
|
+
// ---------------------------------------------------------------- scatter: cost vs duration
|
|
358
334
|
(function () {
|
|
359
335
|
var host = document.getElementById("chart-scatter");
|
|
360
336
|
var W = host.clientWidth || 460, H = 260, pad = { l: 42, r: 16, t: 14, b: 30 };
|
|
361
337
|
var svg = svgEl("svg", { width: "100%", height: H, viewBox: "0 0 " + W + " " + H });
|
|
362
338
|
var data = (DATA.leaderboard || []).filter(function (g) { return g.cost_usd != null; });
|
|
363
339
|
var maxCost = Math.max(0.001, Math.max.apply(null, data.map(function (g) { return g.cost_usd; }).concat([0.001])));
|
|
340
|
+
var maxDuration = Math.max(1, Math.max.apply(null, data.map(function (g) { return g.avg_elapsed_seconds || 0; }).concat([1])));
|
|
364
341
|
function x(v) { return pad.l + (v / maxCost) * (W - pad.l - pad.r); }
|
|
365
|
-
function y(v) { return H - pad.b - v * (H - pad.t - pad.b); }
|
|
342
|
+
function y(v) { return H - pad.b - (v / maxDuration) * (H - pad.t - pad.b); }
|
|
366
343
|
// axes
|
|
367
344
|
svg.appendChild(svgEl("line", { x1: pad.l, x2: W - pad.r, y1: H - pad.b, y2: H - pad.b, stroke: "#262b36" }));
|
|
368
345
|
svg.appendChild(svgEl("line", { x1: pad.l, x2: pad.l, y1: pad.t, y2: H - pad.b, stroke: "#262b36" }));
|
|
369
346
|
[0, 0.25, 0.5, 0.75, 1].forEach(function (t) {
|
|
370
|
-
var ty = y(t);
|
|
347
|
+
var ty = y(maxDuration * t);
|
|
371
348
|
var lab = svgEl("text", { x: pad.l - 8, y: ty + 3, "text-anchor": "end" });
|
|
372
|
-
lab.textContent = Math.round(
|
|
349
|
+
lab.textContent = Math.round(maxDuration * t) + "s";
|
|
373
350
|
svg.appendChild(lab);
|
|
374
351
|
});
|
|
375
352
|
var lab2 = svgEl("text", { x: W - pad.r, y: H - pad.b + 20, "text-anchor": "end" });
|
|
376
353
|
lab2.textContent = "cost \u2192 (max " + fmtUsd(maxCost) + ")";
|
|
377
354
|
svg.appendChild(lab2);
|
|
378
355
|
data.forEach(function (g) {
|
|
379
|
-
var cx = x(g.cost_usd), cy = y(g.
|
|
356
|
+
var cx = x(g.cost_usd), cy = y(g.avg_elapsed_seconds || 0);
|
|
380
357
|
var r = 6 + Math.min(10, (g.avg_tokens || 0) / 4000);
|
|
381
358
|
var c = svgEl("circle", { cx: cx, cy: cy, r: r, fill: colorFor(g.slug), "fill-opacity": 0.85 });
|
|
382
359
|
svg.appendChild(c);
|
|
@@ -387,6 +364,38 @@
|
|
|
387
364
|
host.appendChild(svg);
|
|
388
365
|
})();
|
|
389
366
|
|
|
367
|
+
// ---------------------------------------------------------------- scatter: cost vs average steps
|
|
368
|
+
(function () {
|
|
369
|
+
var host = document.getElementById("chart-scatter-steps");
|
|
370
|
+
var W = host.clientWidth || 460, H = 260, pad = { l: 42, r: 16, t: 14, b: 30 };
|
|
371
|
+
var svg = svgEl("svg", { width: "100%", height: H, viewBox: "0 0 " + W + " " + H });
|
|
372
|
+
var data = (DATA.leaderboard || []).filter(function (g) { return g.cost_usd != null; });
|
|
373
|
+
var maxCost = Math.max(0.001, Math.max.apply(null, data.map(function (g) { return g.cost_usd; }).concat([0.001])));
|
|
374
|
+
var maxSteps = Math.max(1, Math.max.apply(null, data.map(function (g) { return g.avg_steps || 0; }).concat([1])));
|
|
375
|
+
function x(v) { return pad.l + (v / maxCost) * (W - pad.l - pad.r); }
|
|
376
|
+
function y(v) { return H - pad.b - (v / maxSteps) * (H - pad.t - pad.b); }
|
|
377
|
+
svg.appendChild(svgEl("line", { x1: pad.l, x2: W - pad.r, y1: H - pad.b, y2: H - pad.b, stroke: "#e4e7eb" }));
|
|
378
|
+
svg.appendChild(svgEl("line", { x1: pad.l, x2: pad.l, y1: pad.t, y2: H - pad.b, stroke: "#e4e7eb" }));
|
|
379
|
+
[0, 0.25, 0.5, 0.75, 1].forEach(function (t) {
|
|
380
|
+
var ty = y(maxSteps * t);
|
|
381
|
+
var lab = svgEl("text", { x: pad.l - 8, y: ty + 3, "text-anchor": "end" });
|
|
382
|
+
lab.textContent = fmt1(maxSteps * t);
|
|
383
|
+
svg.appendChild(lab);
|
|
384
|
+
});
|
|
385
|
+
var lab2 = svgEl("text", { x: W - pad.r, y: H - pad.b + 20, "text-anchor": "end" });
|
|
386
|
+
lab2.textContent = "cost \u2192 (max " + fmtUsd(maxCost) + ")";
|
|
387
|
+
svg.appendChild(lab2);
|
|
388
|
+
data.forEach(function (g) {
|
|
389
|
+
var cx = x(g.cost_usd), cy = y(g.avg_steps || 0);
|
|
390
|
+
var r = 6 + Math.min(10, (g.avg_tokens || 0) / 4000);
|
|
391
|
+
svg.appendChild(svgEl("circle", { cx: cx, cy: cy, r: r, fill: colorFor(g.slug), "fill-opacity": 0.85 }));
|
|
392
|
+
var label = svgEl("text", { x: cx + r + 4, y: cy + 3 });
|
|
393
|
+
label.textContent = g.slug;
|
|
394
|
+
svg.appendChild(label);
|
|
395
|
+
});
|
|
396
|
+
host.appendChild(svg);
|
|
397
|
+
})();
|
|
398
|
+
|
|
390
399
|
// ---------------------------------------------------------------- per-step line charts
|
|
391
400
|
function lineChart(host, series, valueFn, opts) {
|
|
392
401
|
opts = opts || {};
|
|
@@ -444,6 +453,7 @@
|
|
|
444
453
|
(function () {
|
|
445
454
|
var matrix = DATA.matrix || [];
|
|
446
455
|
var container = document.getElementById("tbl-matrix");
|
|
456
|
+
if (!container) return;
|
|
447
457
|
if (matrix.length === 0) {
|
|
448
458
|
document.getElementById("section-matrix").style.display = "none";
|
|
449
459
|
return;
|
|
@@ -467,7 +477,7 @@
|
|
|
467
477
|
var cells = tasks.map(function (t) {
|
|
468
478
|
var m = cell[slug + "\u0000" + t];
|
|
469
479
|
if (!m) return "<td>—</td>";
|
|
470
|
-
return '<td><span class="heat-cell" style="background:' + heatColor(m.
|
|
480
|
+
return '<td><span class="heat-cell" style="background:' + heatColor(m.completion_rate) + '">' + m.completed + "/" + m.k + "<br>" + pct(m.completion_rate) + "</span></td>";
|
|
471
481
|
}).join("");
|
|
472
482
|
return "<tr><td class=\"mono\">" + esc(slug) + "</td>" + cells + "</tr>";
|
|
473
483
|
}).join("") + "</tbody>";
|
|
@@ -481,6 +491,7 @@
|
|
|
481
491
|
var fModel = document.getElementById("filter-model");
|
|
482
492
|
var fTask = document.getElementById("filter-task");
|
|
483
493
|
var fOutcome = document.getElementById("filter-outcome");
|
|
494
|
+
if (!container || !fModel || !fTask || !fOutcome) return;
|
|
484
495
|
|
|
485
496
|
function uniq(fn) {
|
|
486
497
|
var seen = {}, out = [];
|
|
@@ -489,15 +500,15 @@
|
|
|
489
500
|
}
|
|
490
501
|
uniq(function (r) { return r.slug; }).forEach(function (v) { fModel.appendChild(el("option", { value: v }, esc(v))); });
|
|
491
502
|
uniq(function (r) { return r.task_id; }).forEach(function (v) { fTask.appendChild(el("option", { value: v }, esc(v))); });
|
|
492
|
-
uniq(function (r) { return r.
|
|
503
|
+
uniq(function (r) { return r.status; }).forEach(function (v) { fOutcome.appendChild(el("option", { value: v }, esc(v))); });
|
|
493
504
|
|
|
494
505
|
var columns = [
|
|
495
506
|
{ key: "slug", label: "Model", render: function (r) { return '<span class="mono">' + esc(r.slug) + "</span>"; } },
|
|
496
507
|
{ key: "task_id", label: "Task", render: function (r) { return esc(r.task_id); } },
|
|
497
508
|
{ key: "run_id", label: "Run", render: function (r) { return esc(r.run_id); } },
|
|
498
|
-
{ key: "
|
|
499
|
-
var cls = r.
|
|
500
|
-
return '<span class="pill ' + cls + '">' + esc(r.
|
|
509
|
+
{ key: "status", label: "Status", render: function (r) {
|
|
510
|
+
var cls = r.completed ? "ok" : (r.status === "request_error" ? "bad" : "warn");
|
|
511
|
+
return '<span class="pill ' + cls + '">' + esc(r.status || "Unknown") + "</span>";
|
|
501
512
|
} },
|
|
502
513
|
{ key: "steps", label: "Steps", render: function (r) { return fmt(r.steps); } },
|
|
503
514
|
{ key: "elapsed_seconds", label: "Time", render: function (r) { return fmt(r.elapsed_seconds) + "s"; } },
|
|
@@ -530,7 +541,7 @@
|
|
|
530
541
|
var filtered = runs.filter(function (r) {
|
|
531
542
|
if (fModel.value && r.slug !== fModel.value) return false;
|
|
532
543
|
if (fTask.value && r.task_id !== fTask.value) return false;
|
|
533
|
-
if (fOutcome.value && r.
|
|
544
|
+
if (fOutcome.value && r.status !== fOutcome.value) return false;
|
|
534
545
|
return true;
|
|
535
546
|
});
|
|
536
547
|
sortableTable(container, columns, filtered, {
|
package/src/report.js
CHANGED
|
@@ -91,17 +91,19 @@ export function buildSummary(runs) {
|
|
|
91
91
|
const k = r.exit_status || "Unknown";
|
|
92
92
|
outcomes[k] = (outcomes[k] || 0) + 1;
|
|
93
93
|
}
|
|
94
|
-
const
|
|
94
|
+
const completed = rs.filter((r) => r.completed).length;
|
|
95
95
|
return {
|
|
96
96
|
model: rs[0].model,
|
|
97
97
|
reasoning: rs[0].reasoning,
|
|
98
98
|
slug,
|
|
99
99
|
runs: rs.length,
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
completed,
|
|
101
|
+
completion_rate: rs.length ? completed / rs.length : 0,
|
|
102
102
|
outcomes,
|
|
103
103
|
avg_steps: avg(rs, (r) => r.steps),
|
|
104
|
+
sum_steps: sum(rs, (r) => r.steps),
|
|
104
105
|
avg_elapsed_seconds: avg(rs, (r) => r.elapsed_seconds),
|
|
106
|
+
sum_elapsed_seconds: sum(rs, (r) => r.elapsed_seconds),
|
|
105
107
|
avg_tokens: avg(rs, (r) => r.tokens.total),
|
|
106
108
|
sum_tokens: sum(rs, (r) => r.tokens.total),
|
|
107
109
|
tokens: {
|
|
@@ -118,10 +120,10 @@ export function buildSummary(runs) {
|
|
|
118
120
|
avg_n_retries: avg(rs, (r) => r.n_retries),
|
|
119
121
|
};
|
|
120
122
|
});
|
|
121
|
-
leaderboard.sort((a, b) => b.
|
|
123
|
+
leaderboard.sort((a, b) => b.completion_rate - a.completion_rate || a.avg_steps - b.avg_steps);
|
|
122
124
|
|
|
123
125
|
const matrix = groupBy(runs, (r) => `${r.slug}\u0000${r.task_id}`).map(([, rs]) => {
|
|
124
|
-
const
|
|
126
|
+
const completed = rs.filter((r) => r.completed).length;
|
|
125
127
|
const costRuns = rs.filter((r) => r.cost_source);
|
|
126
128
|
return {
|
|
127
129
|
model: rs[0].model,
|
|
@@ -130,8 +132,8 @@ export function buildSummary(runs) {
|
|
|
130
132
|
task_id: rs[0].task_id,
|
|
131
133
|
task_title: rs[0].task_title,
|
|
132
134
|
k: rs.length,
|
|
133
|
-
|
|
134
|
-
|
|
135
|
+
completed,
|
|
136
|
+
completion_rate: rs.length ? completed / rs.length : 0,
|
|
135
137
|
avg_steps: avg(rs, (r) => r.steps),
|
|
136
138
|
avg_tokens: avg(rs, (r) => r.tokens.total),
|
|
137
139
|
avg_elapsed_seconds: avg(rs, (r) => r.elapsed_seconds),
|
|
@@ -190,7 +192,7 @@ export function buildSummary(runs) {
|
|
|
190
192
|
.sort((a, b) => a.slug.localeCompare(b.slug) || a.step - b.step);
|
|
191
193
|
|
|
192
194
|
const costRuns = runs.filter((r) => r.cost_source);
|
|
193
|
-
const
|
|
195
|
+
const completed = runs.filter((r) => r.completed).length;
|
|
194
196
|
const sources = new Set(costRuns.map((r) => r.cost_source));
|
|
195
197
|
|
|
196
198
|
return {
|
|
@@ -200,8 +202,8 @@ export function buildSummary(runs) {
|
|
|
200
202
|
models: new Set(runs.map((r) => r.slug)).size,
|
|
201
203
|
tasks: new Set(runs.map((r) => r.task_id)).size,
|
|
202
204
|
runs: runs.length,
|
|
203
|
-
|
|
204
|
-
|
|
205
|
+
completed,
|
|
206
|
+
completion_rate: runs.length ? completed / runs.length : 0,
|
|
205
207
|
cost_usd: costRuns.length ? round(sum(costRuns, (r) => r.cost_usd), 6) : null,
|
|
206
208
|
cost_source: sources.size === 0 ? "unknown" : sources.size === 1 ? [...sources][0] : "mixed",
|
|
207
209
|
tokens: sum(runs, (r) => r.tokens.total),
|
|
@@ -221,7 +223,7 @@ export function renderReportHtml(summary) {
|
|
|
221
223
|
if (!tpl.includes("/*__ASTRA_DATA__*/")) {
|
|
222
224
|
throw new Error("src/report.html is missing the /*__ASTRA_DATA__*/ injection marker");
|
|
223
225
|
}
|
|
224
|
-
return tpl.replace("/*__ASTRA_DATA__*/", `window.
|
|
226
|
+
return tpl.replace("/*__ASTRA_DATA__*/", `window.__ASTRA_EMBEDDED_DATA__ = ${data};`);
|
|
225
227
|
}
|
|
226
228
|
|
|
227
229
|
function toRunDoc(run) {
|
|
@@ -232,6 +234,7 @@ function readRunDir({ rootDir, slug, runId, dir }) {
|
|
|
232
234
|
const trajPath = path.join(dir, "trajectory.json");
|
|
233
235
|
const metricsPath = path.join(dir, "metrics.csv");
|
|
234
236
|
const taskPath = path.join(dir, "task.md");
|
|
237
|
+
const projectPath = path.join(dir, "project.json");
|
|
235
238
|
if (!fs.existsSync(trajPath) && !fs.existsSync(metricsPath)) return null;
|
|
236
239
|
|
|
237
240
|
let traj = null;
|
|
@@ -243,6 +246,7 @@ function readRunDir({ rootDir, slug, runId, dir }) {
|
|
|
243
246
|
}
|
|
244
247
|
}
|
|
245
248
|
const metricsRow = fs.existsSync(metricsPath) ? parseCsv(fs.readFileSync(metricsPath, "utf8"))[0] : null;
|
|
249
|
+
const project = readJson(projectPath);
|
|
246
250
|
const taskMd = fs.existsSync(taskPath) ? fs.readFileSync(taskPath, "utf8") : traj?.info?.task || "";
|
|
247
251
|
const parsed = parseSlug(slug);
|
|
248
252
|
const model = metricsRow?.model || traj?.info?.model || parsed.model;
|
|
@@ -259,11 +263,11 @@ function readRunDir({ rootDir, slug, runId, dir }) {
|
|
|
259
263
|
last_context: num(metricsRow?.last_context_tokens ?? traj?.info?.tokens?.last_context),
|
|
260
264
|
};
|
|
261
265
|
const costSource = metricsRow?.cost_source || traj?.info?.cost?.source || "";
|
|
262
|
-
const
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
?
|
|
266
|
-
:
|
|
266
|
+
const legacyResolved = metricsRow?.resolved;
|
|
267
|
+
const status = metricsRow?.status ||
|
|
268
|
+
((legacyResolved == null ? traj?.info?.exit_status === "Submitted" : legacyResolved === true || legacyResolved === "1" || legacyResolved === 1)
|
|
269
|
+
? "completed"
|
|
270
|
+
: "model_error");
|
|
267
271
|
|
|
268
272
|
return {
|
|
269
273
|
id: `${slug}/${runId}`,
|
|
@@ -271,12 +275,16 @@ function readRunDir({ rootDir, slug, runId, dir }) {
|
|
|
271
275
|
model,
|
|
272
276
|
reasoning,
|
|
273
277
|
slug,
|
|
274
|
-
task_id: inferTaskId({ taskMd }),
|
|
278
|
+
task_id: typeof project?.id === "string" && project.id ? project.id : inferTaskId({ taskMd }),
|
|
275
279
|
task_title: inferTaskTitle(taskMd),
|
|
280
|
+
project: project && typeof project.id === "string"
|
|
281
|
+
? { id: project.id, version: project.version }
|
|
282
|
+
: null,
|
|
276
283
|
run: runNum,
|
|
277
284
|
run_id: runId,
|
|
278
285
|
exit_status: metricsRow?.exit_status || traj?.info?.exit_status || "",
|
|
279
|
-
|
|
286
|
+
status,
|
|
287
|
+
completed: status === "completed",
|
|
280
288
|
error: null,
|
|
281
289
|
steps: num(metricsRow?.steps ?? traj?.info?.n_steps),
|
|
282
290
|
n_calls: num(metricsRow?.n_calls ?? traj?.info?.n_calls),
|
|
@@ -299,6 +307,15 @@ function readRunDir({ rootDir, slug, runId, dir }) {
|
|
|
299
307
|
};
|
|
300
308
|
}
|
|
301
309
|
|
|
310
|
+
function readJson(file) {
|
|
311
|
+
if (!fs.existsSync(file)) return null;
|
|
312
|
+
try {
|
|
313
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
302
319
|
export function extractTimeline(doc) {
|
|
303
320
|
const messages = doc?.messages || [];
|
|
304
321
|
const steps = [];
|