lastlight-evals 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/dist/assets/index-B4TAaIUR.css +1 -0
- package/dashboard/dist/assets/index-LFdFc6Z9.js +109 -0
- package/dashboard/dist/index.html +2 -2
- package/datasets/aacr-bench/README.md +146 -0
- package/datasets/pr-review/README.md +41 -0
- package/datasets/pr-review/anchors.json +3709 -0
- package/dist/add-case.js.map +1 -1
- package/dist/arm.js +14 -5
- package/dist/arm.js.map +1 -1
- package/dist/bootstrap.js +14 -0
- package/dist/bootstrap.js.map +1 -1
- package/dist/clean.js.map +1 -1
- package/dist/config.js +59 -20
- package/dist/config.js.map +1 -1
- package/dist/fake-github.js +72 -0
- package/dist/fake-github.js.map +1 -1
- package/dist/grade.js +308 -64
- package/dist/grade.js.map +1 -1
- package/dist/init.js.map +1 -1
- package/dist/judge.js +19 -0
- package/dist/judge.js.map +1 -1
- package/dist/mechanism.test.js +303 -13
- package/dist/mechanism.test.js.map +1 -1
- package/dist/metrics.js +88 -9
- package/dist/metrics.js.map +1 -1
- package/dist/paths.js +135 -1
- package/dist/paths.js.map +1 -1
- package/dist/phase-models.js +56 -0
- package/dist/phase-models.js.map +1 -0
- package/dist/pool.js +32 -0
- package/dist/pool.js.map +1 -0
- package/dist/pool.test.js +180 -0
- package/dist/pool.test.js.map +1 -0
- package/dist/pr-context.js +77 -6
- package/dist/pr-context.js.map +1 -1
- package/dist/repeats.test.js +317 -0
- package/dist/repeats.test.js.map +1 -0
- package/dist/report.js +99 -5
- package/dist/report.js.map +1 -1
- package/dist/review-metrics.js +571 -0
- package/dist/review-metrics.js.map +1 -0
- package/dist/review-metrics.test.js +634 -0
- package/dist/review-metrics.test.js.map +1 -0
- package/dist/review-pipeline-stats.js +455 -0
- package/dist/review-pipeline-stats.js.map +1 -0
- package/dist/review-pipeline-stats.test.js +413 -0
- package/dist/review-pipeline-stats.test.js.map +1 -0
- package/dist/run-instance.js +176 -45
- package/dist/run-instance.js.map +1 -1
- package/dist/run.js +637 -122
- package/dist/run.js.map +1 -1
- package/dist/schema.js +22 -1
- package/dist/schema.js.map +1 -1
- package/package.json +9 -8
- package/dashboard/dist/assets/index-B1jNgf2P.js +0 -313
- package/dashboard/dist/assets/index-Sic0jI7y.css +0 -1
package/dist/run.js
CHANGED
|
@@ -26,10 +26,13 @@ import chalk from "chalk";
|
|
|
26
26
|
import { loadDotEnv, hasProviderKey, evalModels, compareModels, modelLabels, resolveModel, setModelsPath } from "./env.js";
|
|
27
27
|
import { runInstance, applyEvalEnv, slug } from "./run-instance.js";
|
|
28
28
|
import { modelsArm, configArm, releaseOverlayGuard } from "./arm.js";
|
|
29
|
+
import { mapPool } from "./pool.js";
|
|
29
30
|
import { summarize, writeArtifacts, writeScorecard, aggregateTrials, loadMartianSidecar, computeMartianRanking, } from "./report.js";
|
|
30
|
-
import { bootstrapAssets } from "./bootstrap.js";
|
|
31
|
+
import { bootstrapAssets, describeCore } from "./bootstrap.js";
|
|
31
32
|
import { discoverTiers, loadInstances, workflowFor } from "./discovery.js";
|
|
32
|
-
import { builtinDatasetsRoot, tierResultsDir, makeRunId, gitShortSha, resultsRoot, dashboardDistRoot } from "./paths.js";
|
|
33
|
+
import { builtinDatasetsRoot, tierResultsDir, makeRunId, assignRunIds, gitShortSha, resultsRoot, dashboardDistRoot, packageRoot, harnessVersion, resolveFactsBin, factsToolchainStamp, } from "./paths.js";
|
|
34
|
+
import { defaultJudgeModel } from "./judge.js";
|
|
35
|
+
import { defaultBeta } from "./grade.js";
|
|
33
36
|
import { startServer } from "./serve.js";
|
|
34
37
|
import { runInit } from "./init.js";
|
|
35
38
|
import { runAddCase } from "./add-case.js";
|
|
@@ -186,7 +189,7 @@ function strFlagAll(name) {
|
|
|
186
189
|
return out;
|
|
187
190
|
}
|
|
188
191
|
/** CLI flags that take a following value (so it isn't read as a tier name). */
|
|
189
|
-
const VALUE_FLAGS = new Set(["--runs", "--model", "--models", "--mode", "--overlay", "--datasets", "--models-file", "--instance", "--limit", "--f-beta", "--sandbox"]);
|
|
192
|
+
const VALUE_FLAGS = new Set(["--runs", "--repeats", "--model", "--models", "--mode", "--overlay", "--datasets", "--models-file", "--instance", "--limit", "--f-beta", "--sandbox", "--concurrency", "--repeat-concurrency"]);
|
|
190
193
|
async function runEval() {
|
|
191
194
|
loadDotEnv();
|
|
192
195
|
p.intro(chalk.bold(`Last Light ${chalk.yellow("·")} eval`));
|
|
@@ -254,6 +257,13 @@ async function runEval() {
|
|
|
254
257
|
if (overlayDir && overlayDir === autoOverlay)
|
|
255
258
|
p.log.info(`overlay → ${chalk.cyan("./instance")} ${chalk.dim("(auto-detected)")}`);
|
|
256
259
|
const { builtInRoot } = bootstrapAssets({ overlayDir });
|
|
260
|
+
// Say WHICH core is about to run, at the top, every time. A published core and
|
|
261
|
+
// a working tree can report the same version, so an arm measuring unreleased
|
|
262
|
+
// engine work would otherwise look identical to one that never loaded it —
|
|
263
|
+
// and every gate in the deterministic-pr-levers plan is a delta against a
|
|
264
|
+
// stored baseline, where that reads as a real negative result.
|
|
265
|
+
const core = describeCore(builtInRoot);
|
|
266
|
+
p.log.info(`core → ${chalk.cyan(core.version)} ${core.published ? chalk.yellow("(published package)") : chalk.green("(working tree)")} ${chalk.dim(core.root)}`);
|
|
257
267
|
// A user/overlay can ship its own model registry too: explicit --models-file
|
|
258
268
|
// wins, else an overlay's `evals/models.json` if present, else the built-in.
|
|
259
269
|
const overlayModels = overlayDir ? join(overlayDir, "evals", "models.json") : undefined;
|
|
@@ -276,7 +286,25 @@ async function runEval() {
|
|
|
276
286
|
p.outro(chalk.red("aborted"));
|
|
277
287
|
return 1;
|
|
278
288
|
}
|
|
279
|
-
|
|
289
|
+
// `--repeats N` — run the WHOLE arm N times, as N sibling runs, so a result can
|
|
290
|
+
// be reported as a band rather than a point. Three identical runs of one arm
|
|
291
|
+
// measured micro-recall 0.320 / 0.080 / 0.200 (union 0.440, intersection 0.040
|
|
292
|
+
// — 1 of 25 gold findings found by all three), and `diff-runs` returned KEEP on
|
|
293
|
+
// one and REVERT on the other two FROM ONE CONFIGURATION. A single arm is not
|
|
294
|
+
// a measurement of that arm.
|
|
295
|
+
//
|
|
296
|
+
// Emphatically NOT `--runs N`, which repeats each CASE and folds the trials
|
|
297
|
+
// into one worst-case result with mean metrics (`aggregateTrials`): that
|
|
298
|
+
// destroys the per-trial hit vectors, so it can never produce a band. The two
|
|
299
|
+
// compose (`--repeats 3 --runs 2` = 3 bands of 2-trial cases), they do not
|
|
300
|
+
// substitute.
|
|
301
|
+
const repeats = intFlag("repeats", 1);
|
|
302
|
+
// A repeat loop must never leave a dashboard server behind: `run` holds one open
|
|
303
|
+
// FOREVER at the end (see the server-hold block), and on 2026-08-23 seven leaked
|
|
304
|
+
// servers had accumulated overnight from ordinary single runs. N of them would be
|
|
305
|
+
// worse, and the band is read afterwards anyway — so `--repeats` implies
|
|
306
|
+
// `--no-open` and prints the standalone `serve` command once instead.
|
|
307
|
+
const noOpen = process.argv.includes("--no-open") || !!process.env.CI || repeats > 1;
|
|
280
308
|
const runs = intFlag("runs", 1);
|
|
281
309
|
// Positional tier names — skip flags AND the values that follow value-flags.
|
|
282
310
|
const argv = process.argv.slice(2);
|
|
@@ -367,7 +395,11 @@ async function runEval() {
|
|
|
367
395
|
: compare
|
|
368
396
|
? compareModels().map((m) => ({ id: m.id, family: m.envKey ?? m.provider ?? "default" }))
|
|
369
397
|
: evalModels().map((id) => ({ id, family: "default" }));
|
|
370
|
-
|
|
398
|
+
// The overlay goes in even though the model is forced: it also carries the
|
|
399
|
+
// arm's `review:` policy (`review.analysis.enabled` — the review evidence
|
|
400
|
+
// pipeline switch), which is a deployment fact a `--model` flag says nothing
|
|
401
|
+
// about. Same overlay `bootstrapAssets` already wired above.
|
|
402
|
+
arms = entries.map((e) => modelsArm(e.id, e.family, overlayDir));
|
|
371
403
|
}
|
|
372
404
|
if (!arms.length) {
|
|
373
405
|
p.log.error("No comparison models available — set provider keys (OPENAI_API_KEY / ANTHROPIC_API_KEY /\n" +
|
|
@@ -410,6 +442,17 @@ async function runEval() {
|
|
|
410
442
|
// run-instance's injection step. ON by default; `--no-inject-context` (or
|
|
411
443
|
// `EVAL_INJECT_CONTEXT=0`) forces a clean control run for an A/B.
|
|
412
444
|
const injectContext = !process.argv.includes("--no-inject-context") && process.env.EVAL_INJECT_CONTEXT !== "0";
|
|
445
|
+
// Keep each trial's workspace instead of deleting it (`runInstance`'s
|
|
446
|
+
// `finally`). The evidence pipeline's deliverable is files —
|
|
447
|
+
// `.lastlight/pr-review/{facts.json,obligations/,hypotheses/,probes/}` — and
|
|
448
|
+
// without this flag the only way to read one was to catch a live run
|
|
449
|
+
// mid-flight, which is not an instrument. OFF by default: a kept workspace is
|
|
450
|
+
// a full checkout, plus `node_modules` once `prepare` installs.
|
|
451
|
+
//
|
|
452
|
+
// `--repeats` forces it on: when the repeats disagree, the question is always
|
|
453
|
+
// *which* evidence each one produced, and a previous repeat was already lost
|
|
454
|
+
// for want of the flag. A band you cannot open is a band you cannot explain.
|
|
455
|
+
const keepWorkspace = process.argv.includes("--keep-workspace") || repeats > 1;
|
|
413
456
|
// Execution sandbox backend (or EVAL_SANDBOX). Default `none` (in-process, no
|
|
414
457
|
// QEMU dependency — the fast/CI path). `gondolin` isolates the agent's tools
|
|
415
458
|
// in a QEMU micro-VM so it can't read host gold data, while keeping the fake
|
|
@@ -434,6 +477,68 @@ async function runEval() {
|
|
|
434
477
|
return 1;
|
|
435
478
|
}
|
|
436
479
|
}
|
|
480
|
+
// `--concurrency N`: how many CASES of ONE arm run at once. Default 1, so an
|
|
481
|
+
// invocation without the flag takes the exact serial path it always did.
|
|
482
|
+
//
|
|
483
|
+
// Within-arm serialism was never a correctness constraint — it is the
|
|
484
|
+
// rate-limit choice recorded above `byFamily` below. Everything that makes
|
|
485
|
+
// in-process concurrency safe is already true per case: a fresh `mkdtemp`
|
|
486
|
+
// stateDir, its own fake-GitHub port, a threaded per-run `cwd` with no
|
|
487
|
+
// `process.chdir`, and the eval env hoisted once for the whole batch. What is
|
|
488
|
+
// NOT safe is two ARMS at once — see the overlay note where the pool is driven.
|
|
489
|
+
const concurrencyRaw = strFlag("concurrency");
|
|
490
|
+
let concurrency = concurrencyRaw !== undefined ? Number(concurrencyRaw) : 1;
|
|
491
|
+
if (!Number.isFinite(concurrency) || !Number.isInteger(concurrency) || concurrency < 1) {
|
|
492
|
+
p.log.error(`--concurrency must be a positive integer, got "${concurrencyRaw}".`);
|
|
493
|
+
return 1;
|
|
494
|
+
}
|
|
495
|
+
// gondolin boots a QEMU micro-VM per case. Clamp rather than error: the flag is
|
|
496
|
+
// about throughput and the right answer on this backend is "one", not "refuse".
|
|
497
|
+
if (sandbox === "gondolin" && concurrency > 1) {
|
|
498
|
+
p.log.warn(`--concurrency ${concurrency} ignored: --sandbox gondolin runs a QEMU micro-VM per case. Using 1.`);
|
|
499
|
+
concurrency = 1;
|
|
500
|
+
}
|
|
501
|
+
// `--repeat-concurrency N`: how many of the `--repeats` REPEATS run at once.
|
|
502
|
+
// Default 1 = today's strictly-sequential repeat loop, untouched. With N>1 the
|
|
503
|
+
// repeats of the arm overlap — safe because every repeat runs the SAME arm(s)
|
|
504
|
+
// over ONE overlay, so the process-global asset-root guard (ADR 0001) is taken
|
|
505
|
+
// once for the whole batch, exactly as `--concurrency` holds it per arm.
|
|
506
|
+
//
|
|
507
|
+
// What overlap costs is the LATENCY instrument: concurrent repeats contend for
|
|
508
|
+
// CPU/network, so per-phase `durationMs`/`agentMs` on such runs are
|
|
509
|
+
// contaminated. That trade is accepted and made legible — each repeat's
|
|
510
|
+
// `meta.repeat.concurrency` records it so latency reads can be discounted.
|
|
511
|
+
// Verdicts, cost, and recall/precision are unaffected.
|
|
512
|
+
//
|
|
513
|
+
// Composes with `--concurrency` (case-level): total in-flight cases =
|
|
514
|
+
// repeatConcurrency × concurrency. No cap — size the product against the
|
|
515
|
+
// provider's rate limit.
|
|
516
|
+
const repeatConcurrencyRaw = strFlag("repeat-concurrency");
|
|
517
|
+
let repeatConcurrency = repeatConcurrencyRaw !== undefined ? Number(repeatConcurrencyRaw) : 1;
|
|
518
|
+
if (!Number.isFinite(repeatConcurrency) || !Number.isInteger(repeatConcurrency) || repeatConcurrency < 1) {
|
|
519
|
+
p.log.error(`--repeat-concurrency must be a positive integer, got "${repeatConcurrencyRaw}".`);
|
|
520
|
+
return 1;
|
|
521
|
+
}
|
|
522
|
+
// Same clamp-not-refuse policy as --concurrency: one QEMU micro-VM at a time.
|
|
523
|
+
if (sandbox === "gondolin" && repeatConcurrency > 1) {
|
|
524
|
+
p.log.warn(`--repeat-concurrency ${repeatConcurrency} ignored: --sandbox gondolin runs a QEMU micro-VM per case. Using 1.`);
|
|
525
|
+
repeatConcurrency = 1;
|
|
526
|
+
}
|
|
527
|
+
// A multi-overlay `config` run switches the process-global asset root between
|
|
528
|
+
// arms, so its repeats cannot overlap: two repeats in different arms would be
|
|
529
|
+
// two overlays at once (the guard would throw — ADR 0001). One overlay per
|
|
530
|
+
// batch is the invariant the concurrent branch is built on.
|
|
531
|
+
if (runType === "config" && arms.length > 1 && repeatConcurrency > 1) {
|
|
532
|
+
p.log.warn(`--repeat-concurrency ${repeatConcurrency} ignored: a multi-overlay config run switches the ` +
|
|
533
|
+
`process-global asset root between arms (ADR 0001), so repeats must stay sequential. Using 1.`);
|
|
534
|
+
repeatConcurrency = 1;
|
|
535
|
+
}
|
|
536
|
+
if (repeats === 1 && repeatConcurrency > 1) {
|
|
537
|
+
p.log.warn(`--repeat-concurrency ${repeatConcurrency} has nothing to overlap without --repeats N>1 — ignored.`);
|
|
538
|
+
repeatConcurrency = 1;
|
|
539
|
+
}
|
|
540
|
+
/** True when the repeat loop actually overlaps (the concurrent branch below). */
|
|
541
|
+
const repeatsConcurrent = repeats > 1 && repeatConcurrency > 1;
|
|
437
542
|
// Instances per tier, resolved ONCE — the case set is identical across arms
|
|
438
543
|
// (arms vary only the model selection / assets, never the cases).
|
|
439
544
|
const tierInstances = new Map();
|
|
@@ -582,8 +687,33 @@ async function runEval() {
|
|
|
582
687
|
const tierKeyFor = (tier) => `${tier}${runType === "config" ? "-config" : compare ? "-compare" : ""}`;
|
|
583
688
|
// One shared runId, checked free in the first tier's dir (collisions in the
|
|
584
689
|
// same second across runs are what the suffix guards — rare, one dir suffices).
|
|
585
|
-
|
|
586
|
-
const
|
|
690
|
+
//
|
|
691
|
+
// `let`, not `const`, and reassigned once per `--repeats` iteration. Every
|
|
692
|
+
// closure below that names a run directory — `resultsDirFor`, `baseMetaFor`,
|
|
693
|
+
// the session paths built from them — reads `runId` at CALL time, so a single
|
|
694
|
+
// reassignment repoints all of them with no code movement. Repeats are SIBLING
|
|
695
|
+
// dirs (`<tierKey>/<runId>/`, a fresh id each time): `indexTier` and `clean`
|
|
696
|
+
// both walk exactly two levels, so a nested `<runId>/rep-2/` would be invisible
|
|
697
|
+
// to the dashboard AND uncleanable. `makeRunId`'s existing `-2`/`-3` suffix
|
|
698
|
+
// already handles two repeats finishing inside the same second.
|
|
699
|
+
//
|
|
700
|
+
// CONCURRENT repeats (`--repeat-concurrency N>1`) can't assign lazily — N
|
|
701
|
+
// launches inside one second would all resolve the same id, because the ids
|
|
702
|
+
// exist nowhere on disk yet for `makeRunId` to dedupe against. So the whole
|
|
703
|
+
// band's ids are PRE-ASSIGNED here (`assignRunIds` bumps the second per id,
|
|
704
|
+
// keeping every id `<timestamp>-<sha>`-shaped with a batch-unique stamp) and
|
|
705
|
+
// each repeat's state carries its own. The sequential path keeps lazy
|
|
706
|
+
// assignment, one fresh id per repeat, exactly as before.
|
|
707
|
+
const preassignedRunIds = repeatsConcurrent
|
|
708
|
+
? assignRunIds(repeats, new Date(), gitSha, tierResultsDir(tierKeyFor(tiers[0])))
|
|
709
|
+
: undefined;
|
|
710
|
+
let runId = preassignedRunIds?.[0] ?? makeRunId(new Date(), gitSha, tierResultsDir(tierKeyFor(tiers[0])));
|
|
711
|
+
/** The band's id = the FIRST repeat's runId (see `RepeatRef`) — pre-assigned
|
|
712
|
+
* before any repeat launches, so the group label never depends on run order. */
|
|
713
|
+
const repeatGroup = runId;
|
|
714
|
+
/** 1-based position in the band; bumped by the (sequential) repeat loop. */
|
|
715
|
+
let repeatIndex = 1;
|
|
716
|
+
const resultsDirFor = (tier, rid = runId) => join(tierResultsDir(tierKeyFor(tier)), rid);
|
|
587
717
|
// Each case's session logs live under `sessions/<id>__<model>/trial-<N>/`
|
|
588
718
|
// (relative to the tier run dir) — the model is in the name since several
|
|
589
719
|
// models share a run dir; per-trial keeps every `--runs N` trial. `full.jsonl`
|
|
@@ -595,14 +725,77 @@ async function runEval() {
|
|
|
595
725
|
// identity, labels, and live state straight off disk). `live`/`progress`/
|
|
596
726
|
// `pending`/`generatedAt` are layered on per write; `tiers` is the single tier.
|
|
597
727
|
const armLabels = arms.map((a) => labels[a.label] ?? a.label);
|
|
598
|
-
|
|
599
|
-
|
|
728
|
+
// The invocation, recorded beside the numbers it produced (see `RunProvenance`).
|
|
729
|
+
// Resolved ONCE — none of it varies per repeat, and the facts-toolchain probe
|
|
730
|
+
// spawns a process. `judgeModel`/`factsBin` are what this run WOULD use, which
|
|
731
|
+
// is the point: an arm reporting `coverage: "none"` is explained by a null
|
|
732
|
+
// `factsBin`, and a scorecard whose judge silently changed between two runs is
|
|
733
|
+
// otherwise indistinguishable from one where the models changed.
|
|
734
|
+
const factsBin = resolveFactsBin();
|
|
735
|
+
// A pr-review tier with no facts binary runs a DIFFERENT pipeline than the
|
|
736
|
+
// one the scorecard claims: the survey seed, the adjudicate conservation gate
|
|
737
|
+
// and the reconcile floor all shell out to `lastlight-facts` and exit 127
|
|
738
|
+
// without it — measured 2026-08-25, every adjudication ran to max_iterations
|
|
739
|
+
// and shipped unrepaired findings while the run reported all phases green.
|
|
740
|
+
// Warn loudly rather than refuse: an analysis-DISABLED pr-review arm (the
|
|
741
|
+
// shipped baseline) never touches the binary and is fine without it.
|
|
742
|
+
if (!factsBin && tiers.some((t) => tierKeyFor(t).includes("pr-review"))) {
|
|
743
|
+
p.log.warn("lastlight-facts is UNRESOLVABLE (factsBin: null). Any analysis-enabled pr-review arm will run with " +
|
|
744
|
+
"its conservation gate and reconcile floor dead (exit 127) — adjudication loops to max_iterations and " +
|
|
745
|
+
"findings ship unrepaired. Set LASTLIGHT_FACTS_BIN or build packages/code-facts before trusting this run.");
|
|
746
|
+
}
|
|
747
|
+
let judgeModel;
|
|
748
|
+
try {
|
|
749
|
+
judgeModel = defaultJudgeModel();
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
judgeModel = undefined; // no provider key resolves one; already fatal above for models
|
|
753
|
+
}
|
|
754
|
+
const provenance = {
|
|
755
|
+
overlay: overlayDir,
|
|
756
|
+
overlays: overlays.length ? overlays : undefined,
|
|
757
|
+
datasets: userDatasetsDir,
|
|
758
|
+
sandbox,
|
|
759
|
+
fBeta: fBeta ?? defaultBeta(),
|
|
760
|
+
judgeWithDiff: judge.withDiff,
|
|
761
|
+
injectContext,
|
|
762
|
+
keepWorkspace,
|
|
763
|
+
instances: instanceFilter.length ? instanceFilter : undefined,
|
|
764
|
+
limit,
|
|
765
|
+
repeats: repeats > 1 ? repeats : undefined,
|
|
766
|
+
judgeModel,
|
|
767
|
+
factsBin,
|
|
768
|
+
toolchain: factsToolchainStamp(factsBin),
|
|
769
|
+
harness: { version: harnessVersion(), root: packageRoot() },
|
|
770
|
+
argv: process.argv.slice(2),
|
|
771
|
+
};
|
|
772
|
+
// `rid`/`idx` default to the sequential loop's shared `runId`/`repeatIndex`;
|
|
773
|
+
// a CONCURRENT repeat passes its own (two repeats in flight cannot share one).
|
|
774
|
+
const baseMetaFor = (tier, rid = runId, idx = repeatIndex) => ({
|
|
775
|
+
// Flat on `meta`, exactly like `gitSha`/`concurrency`/`core`.
|
|
776
|
+
...provenance,
|
|
777
|
+
runId: rid,
|
|
600
778
|
runType,
|
|
601
779
|
tiers: [tier],
|
|
602
780
|
models: armLabels,
|
|
603
781
|
runs,
|
|
782
|
+
concurrency,
|
|
783
|
+
// A band of one is not a band — an ordinary run's meta stays exactly as it
|
|
784
|
+
// was, and `undefined` reads as "not repeated" rather than "repeated once".
|
|
785
|
+
// `repeat.concurrency` is stamped only when the repeats actually overlapped
|
|
786
|
+
// (`--repeat-concurrency N>1`) — the flag that contaminates per-phase
|
|
787
|
+
// latency, recorded so `durationMs`/`agentMs` reads can be discounted.
|
|
788
|
+
repeat: repeats > 1
|
|
789
|
+
? {
|
|
790
|
+
group: repeatGroup,
|
|
791
|
+
index: idx,
|
|
792
|
+
of: repeats,
|
|
793
|
+
...(repeatsConcurrent ? { concurrency: repeatConcurrency } : {}),
|
|
794
|
+
}
|
|
795
|
+
: undefined,
|
|
604
796
|
gitSha,
|
|
605
797
|
labels,
|
|
798
|
+
core: describeCore(builtInRoot),
|
|
606
799
|
});
|
|
607
800
|
// pr-review: load each tier's Martian leaderboard sidecar ONCE. Lets every
|
|
608
801
|
// scorecard carry "where would we rank" over the PRs it covered (recomputed per
|
|
@@ -631,22 +824,32 @@ async function runEval() {
|
|
|
631
824
|
`${chalk.bold("tiers")} ${tiers.join(", ")}\n` +
|
|
632
825
|
`${chalk.bold("cases")} ${work.length}${runs > 1
|
|
633
826
|
? chalk.dim(` × ${runs} trials = ${work.length * runs} runs · worst-case verdict, mean cost`)
|
|
827
|
+
: ""}${repeats > 1
|
|
828
|
+
? `\n${chalk.bold("repeats")} ${repeats}${chalk.dim(` × the whole arm, ${repeatsConcurrent
|
|
829
|
+
? `up to ${repeatConcurrency} at once (per-phase latency contends — stamped meta.repeat.concurrency)`
|
|
830
|
+
: "sequentially"} · ${repeats} sibling runs, read as a band (implies --keep-workspace, --no-open)`)}`
|
|
634
831
|
: ""}`, "plan");
|
|
635
832
|
// `total` counts individual trials so live progress advances per model call.
|
|
636
833
|
const total = work.length * runs;
|
|
637
|
-
// Seed an empty live scorecard per tier so the dashboard has something to poll
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
834
|
+
// Seed an empty live scorecard per tier so the dashboard has something to poll.
|
|
835
|
+
// Called once per repeat, AFTER `runId` has been repointed, so each sibling run
|
|
836
|
+
// dir exists from the moment its repeat starts rather than only when it ends.
|
|
837
|
+
const seedScorecards = (rid = runId, idx = repeatIndex) => {
|
|
838
|
+
for (const tier of tiers) {
|
|
839
|
+
writeScorecard(resultsDirFor(tier, rid), withMeta(summarize([]), {
|
|
840
|
+
...baseMetaFor(tier, rid, idx),
|
|
841
|
+
generatedAt: new Date().toISOString(),
|
|
842
|
+
live: true,
|
|
843
|
+
pid: process.pid,
|
|
844
|
+
heartbeat: new Date().toISOString(),
|
|
845
|
+
progress: `0/${total}`,
|
|
846
|
+
}));
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
// Repeat 1's seed lands BEFORE the server starts (the SPA must have something
|
|
850
|
+
// to poll the instant it opens deep-linked at this run) and before the possibly
|
|
851
|
+
// slow prefetch.
|
|
852
|
+
seedScorecards();
|
|
650
853
|
let server;
|
|
651
854
|
if (!noOpen) {
|
|
652
855
|
try {
|
|
@@ -669,23 +872,38 @@ async function runEval() {
|
|
|
669
872
|
p.outro(chalk.red("aborted"));
|
|
670
873
|
return 1;
|
|
671
874
|
}
|
|
875
|
+
// The per-repeat accumulators. Declared ONCE, outside the repeat loop, because
|
|
876
|
+
// `refresh`/`runItem` close over them — the loop RESETS them in place rather
|
|
877
|
+
// than rebinding, so every closure keeps pointing at the live objects.
|
|
672
878
|
const all = [];
|
|
673
879
|
let harnessErrors = 0;
|
|
674
880
|
let completed = 0;
|
|
881
|
+
/** Harness errors across ALL repeats — what the exit code is read from. A band
|
|
882
|
+
* whose first repeat blew up and whose last was clean is not a clean run. */
|
|
883
|
+
let totalHarnessErrors = 0;
|
|
884
|
+
/** Trials + cases summed over every repeat, for the closing line. */
|
|
885
|
+
let totalCompleted = 0;
|
|
886
|
+
let totalCases = 0;
|
|
675
887
|
// Track in-flight cases so the live report can show running / queued rows.
|
|
676
888
|
const caseKey = (tier, model, id) => `${tier}|${model}|${id}`;
|
|
677
889
|
const running = new Set();
|
|
678
890
|
// Current trial number per running case, so the live "follow" link points at
|
|
679
891
|
// the right `trial-<N>/full.jsonl` (matters only for `--runs N>1`).
|
|
680
892
|
const trialOf = new Map();
|
|
893
|
+
/** The concurrent repeats currently in flight — what the heartbeat re-stamps. */
|
|
894
|
+
const activeStates = new Set();
|
|
681
895
|
// writeScorecard/summarize/all.push run synchronously to completion inside one
|
|
682
896
|
// event-loop turn, so even with concurrent families they never interleave; the
|
|
683
897
|
// temp-file+rename keeps a polling dashboard from reading a half-written file.
|
|
684
|
-
const refresh = () => {
|
|
685
|
-
|
|
898
|
+
const refresh = (st) => {
|
|
899
|
+
// A concurrent repeat's own accumulators, else the sequential shared ones.
|
|
900
|
+
const results = st?.all ?? all;
|
|
901
|
+
const inFlight = st?.running ?? running;
|
|
902
|
+
const trialNo = st?.trialOf ?? trialOf;
|
|
903
|
+
const done = new Set(results.map((r) => caseKey(r.tier ?? "", r.model, r.instance_id)));
|
|
686
904
|
const now = new Date().toISOString();
|
|
687
905
|
for (const tier of tiers) {
|
|
688
|
-
const tierResults =
|
|
906
|
+
const tierResults = results.filter((r) => (r.tier ?? "") === tier);
|
|
689
907
|
const pending = work
|
|
690
908
|
.filter((w) => w.tierName === tier)
|
|
691
909
|
.map((w) => ({ w, k: caseKey(w.tierName, w.arm.label, w.inst.instance_id) }))
|
|
@@ -694,18 +912,18 @@ async function runEval() {
|
|
|
694
912
|
tier: w.tierName,
|
|
695
913
|
model: w.arm.label,
|
|
696
914
|
instance_id: w.inst.instance_id,
|
|
697
|
-
status:
|
|
915
|
+
status: inFlight.has(k) ? "running" : "pending",
|
|
698
916
|
// Only a running case has a (live-updating) transcript to follow —
|
|
699
917
|
// point at the current trial's consolidated `full.jsonl`.
|
|
700
|
-
sessionLog:
|
|
701
|
-
? `${trialRelFor(w.inst.instance_id, w.arm.label,
|
|
918
|
+
sessionLog: inFlight.has(k)
|
|
919
|
+
? `${trialRelFor(w.inst.instance_id, w.arm.label, trialNo.get(k) ?? 1)}/full.jsonl`
|
|
702
920
|
: undefined,
|
|
703
921
|
}));
|
|
704
922
|
// Per-tier progress (cases), not the global trial count — each tier's
|
|
705
923
|
// scorecard stands alone, so "0/5" across both tiers was misleading.
|
|
706
924
|
const tierCases = work.filter((w) => w.tierName === tier).length;
|
|
707
|
-
writeScorecard(resultsDirFor(tier), withMeta(summarize(tierResults), {
|
|
708
|
-
...baseMetaFor(tier),
|
|
925
|
+
writeScorecard(resultsDirFor(tier, st?.runId), withMeta(summarize(tierResults), {
|
|
926
|
+
...baseMetaFor(tier, st?.runId, st?.index),
|
|
709
927
|
generatedAt: now,
|
|
710
928
|
live: true,
|
|
711
929
|
// Liveness signals: a `live` run whose heartbeat goes stale (writer
|
|
@@ -719,13 +937,23 @@ async function runEval() {
|
|
|
719
937
|
}));
|
|
720
938
|
}
|
|
721
939
|
};
|
|
940
|
+
// NOT reset between repeats: its only consumer is the end-of-run summary, and
|
|
941
|
+
// the whole reason `--repeats` forces `--keep-workspace` is that you need to
|
|
942
|
+
// open EVERY repeat's evidence when they disagree. A per-repeat reset would
|
|
943
|
+
// print only the last one's paths and silently orphan the rest.
|
|
944
|
+
const keptWorkspaces = [];
|
|
722
945
|
// Run one case `runs` times and fold the trials into a single result
|
|
723
|
-
// (worst-case verdict, mean metrics). `onTrial` ticks per model call.
|
|
724
|
-
|
|
946
|
+
// (worst-case verdict, mean metrics). `onTrial` ticks per model call. `st` is
|
|
947
|
+
// a concurrent repeat's own state (`--repeat-concurrency N>1`); absent, the
|
|
948
|
+
// sequential shared accumulators are used, unchanged.
|
|
949
|
+
const runItem = async (w, onTrial, st) => {
|
|
725
950
|
const k = caseKey(w.tierName, w.arm.label, w.inst.instance_id);
|
|
726
951
|
const trials = [];
|
|
952
|
+
// Collected per TRIAL, not off the aggregate: `aggregateTrials` carries
|
|
953
|
+
// trial 1's fields through, so with `--runs 3` the other two workspaces
|
|
954
|
+
// would still be on disk and named nowhere.
|
|
727
955
|
for (let t = 1; t <= runs; t++) {
|
|
728
|
-
trialOf.set(k, t); // so the live "follow" link targets this trial
|
|
956
|
+
(st?.trialOf ?? trialOf).set(k, t); // so the live "follow" link targets this trial
|
|
729
957
|
const trialRel = trialRelFor(w.inst.instance_id, w.arm.label, t);
|
|
730
958
|
const r = await runInstance(w.inst, {
|
|
731
959
|
// The arm carries all model selection (forced model / merged config) +
|
|
@@ -740,15 +968,21 @@ async function runEval() {
|
|
|
740
968
|
defaultWorkflow: w.defaultWorkflow,
|
|
741
969
|
manageEnv: false,
|
|
742
970
|
// Per-trial dir: `full.jsonl` (consolidated, live) + `NN-<phase>.jsonl`.
|
|
743
|
-
sessionTrialDir: join(resultsDirFor(w.tierName), trialRel),
|
|
971
|
+
sessionTrialDir: join(resultsDirFor(w.tierName, st?.runId), trialRel),
|
|
744
972
|
sessionTrialRel: trialRel,
|
|
745
973
|
trial: t,
|
|
746
974
|
judge,
|
|
747
975
|
sandbox,
|
|
976
|
+
keepWorkspace,
|
|
748
977
|
});
|
|
749
978
|
r.tier = w.tierName;
|
|
979
|
+
if (r.workspaceDir)
|
|
980
|
+
keptWorkspaces.push({ id: w.inst.instance_id, trial: t, repeat: st?.index ?? repeatIndex, path: r.workspaceDir });
|
|
750
981
|
trials.push(r);
|
|
751
|
-
|
|
982
|
+
if (st)
|
|
983
|
+
st.completed++;
|
|
984
|
+
else
|
|
985
|
+
completed++;
|
|
752
986
|
onTrial();
|
|
753
987
|
}
|
|
754
988
|
const agg = aggregateTrials(trials);
|
|
@@ -763,112 +997,359 @@ async function runEval() {
|
|
|
763
997
|
// Heartbeat: re-stamp the live scorecard every 20s so a long-running single
|
|
764
998
|
// phase keeps its `heartbeat` fresh; if the process dies the stamp goes stale
|
|
765
999
|
// and the index reclassifies the run as interrupted. `unref` so the timer
|
|
766
|
-
// never keeps the process alive on its own.
|
|
767
|
-
|
|
1000
|
+
// never keeps the process alive on its own. With concurrent repeats each
|
|
1001
|
+
// in-flight repeat's OWN scorecard is re-stamped (never the shared one — its
|
|
1002
|
+
// accumulators are unused there, and a shared refresh would overwrite repeat
|
|
1003
|
+
// 1's card with an empty live one).
|
|
1004
|
+
const heartbeat = setInterval(() => {
|
|
1005
|
+
if (repeatsConcurrent)
|
|
1006
|
+
for (const st of activeStates)
|
|
1007
|
+
refresh(st);
|
|
1008
|
+
else
|
|
1009
|
+
refresh();
|
|
1010
|
+
}, 20_000);
|
|
768
1011
|
heartbeat.unref?.();
|
|
1012
|
+
/** Every repeat's per-tier artifact dirs, for the closing summary. */
|
|
1013
|
+
const repeatDirs = [];
|
|
769
1014
|
try {
|
|
770
|
-
if (
|
|
771
|
-
//
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
1015
|
+
if (repeatsConcurrent) {
|
|
1016
|
+
// ── `--repeat-concurrency N>1`: up to N whole-arm repeats in flight ──
|
|
1017
|
+
//
|
|
1018
|
+
// Everything the repeats share is BATCH-scoped, the same shape as the
|
|
1019
|
+
// case-concurrent branch below, widened to span the repeats:
|
|
1020
|
+
//
|
|
1021
|
+
// - ONE overlay for the whole batch. Every repeat runs the same arm
|
|
1022
|
+
// list, so the process-global asset root (ADR 0001) is activated once
|
|
1023
|
+
// here and released once in the finally — the guard window covers the
|
|
1024
|
+
// entire batch. (Multi-overlay config runs clamped the flag to 1 up
|
|
1025
|
+
// top: they have no single overlay for the window to hold.
|
|
1026
|
+
// `activate()` is a no-op for models arms.)
|
|
1027
|
+
// - Console is silenced ONCE (the per-run `quiet()` swap is not
|
|
1028
|
+
// concurrency-safe) and one spinner reports every repeat — the same
|
|
1029
|
+
// trade the parallel branches below make.
|
|
1030
|
+
// - RunIds were pre-assigned (`preassignedRunIds`), so simultaneous
|
|
1031
|
+
// launches cannot collide on the second; each repeat's `RepeatState`
|
|
1032
|
+
// carries its own id, index, and accumulators.
|
|
1033
|
+
//
|
|
1034
|
+
// Within a repeat, cases run through the same arms-outer work list with
|
|
1035
|
+
// `--concurrency` bounding cases in flight — so the total is
|
|
1036
|
+
// repeatConcurrency × concurrency. In models mode several arms' cases
|
|
1037
|
+
// interleave here (no overlay is at stake, only the provider rate limit);
|
|
1038
|
+
// the per-family grouping of the parallel branch is deliberately not
|
|
1039
|
+
// reproduced — repeat-level parallelism is the throughput lever.
|
|
1040
|
+
const states = preassignedRunIds.map((rid, i) => ({
|
|
1041
|
+
runId: rid,
|
|
1042
|
+
index: i + 1,
|
|
1043
|
+
all: [],
|
|
1044
|
+
harnessErrors: 0,
|
|
1045
|
+
completed: 0,
|
|
1046
|
+
running: new Set(),
|
|
1047
|
+
trialOf: new Map(),
|
|
1048
|
+
}));
|
|
1049
|
+
p.log.step(`${chalk.bold(`repeats ×${repeats}, up to ${repeatConcurrency} at once`)}\n` +
|
|
1050
|
+
chalk.dim(states.map((st) => ` ${st.index}/${repeats} ${st.runId}`).join("\n")));
|
|
1051
|
+
for (const arm of arms)
|
|
1052
|
+
arm.activate();
|
|
1053
|
+
const grandTotal = total * repeats;
|
|
1054
|
+
const grand = () => states.reduce((n, st) => n + st.completed, 0);
|
|
782
1055
|
const s = makeSpinner();
|
|
1056
|
+
const status = () => `${chalk.dim(`${grand()}/${grandTotal}`)} ${states
|
|
1057
|
+
.map((st) => {
|
|
1058
|
+
const seg = `r${st.index} ${st.completed}/${total}`;
|
|
1059
|
+
return st.completed === total ? chalk.green(seg) : seg;
|
|
1060
|
+
})
|
|
1061
|
+
.join(chalk.dim(" · "))}`;
|
|
783
1062
|
s.start(status());
|
|
784
1063
|
const restoreConsole = silenceConsole();
|
|
785
1064
|
const verdicts = [];
|
|
786
1065
|
try {
|
|
787
|
-
await
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
1066
|
+
await mapPool(states, repeatConcurrency, async (st) => {
|
|
1067
|
+
// Repeat 1's live scorecard was seeded before the server started /
|
|
1068
|
+
// the prefetch ran, like any run's; a later repeat seeds its sibling
|
|
1069
|
+
// dir the moment it LAUNCHES here, so the dashboard sees it live.
|
|
1070
|
+
if (st.index > 1)
|
|
1071
|
+
seedScorecards(st.runId, st.index);
|
|
1072
|
+
activeStates.add(st);
|
|
1073
|
+
try {
|
|
1074
|
+
await mapPool(work, concurrency, async (w) => {
|
|
1075
|
+
const k = caseKey(w.tierName, w.arm.label, w.inst.instance_id);
|
|
1076
|
+
st.running.add(k);
|
|
1077
|
+
refresh(st);
|
|
1078
|
+
const result = await runItem(w, () => {
|
|
1079
|
+
s.message(status());
|
|
1080
|
+
refresh(st);
|
|
1081
|
+
}, st);
|
|
1082
|
+
st.running.delete(k);
|
|
1083
|
+
st.all.push(result);
|
|
1084
|
+
if (result.error)
|
|
1085
|
+
st.harnessErrors++;
|
|
1086
|
+
const mark = result.error ? chalk.red("✗") : result.blocked ? chalk.yellow("■") : chalk.green("✓");
|
|
1087
|
+
verdicts.push(`${mark} ${chalk.dim(`r${st.index}/${repeats}`)} ${verdictLine(w.tierName, w.inst, result)}`);
|
|
794
1088
|
s.message(status());
|
|
795
|
-
refresh();
|
|
1089
|
+
refresh(st);
|
|
796
1090
|
});
|
|
797
|
-
running.delete(k);
|
|
798
|
-
all.push(result);
|
|
799
|
-
if (result.error)
|
|
800
|
-
harnessErrors++;
|
|
801
|
-
const mark = result.error ? chalk.red("✗") : result.blocked ? chalk.yellow("■") : chalk.green("✓");
|
|
802
|
-
verdicts.push(`${mark} ${chalk.dim(familyLabel(f))} ${verdictLine(w.tierName, w.inst, result)}`);
|
|
803
|
-
refresh();
|
|
804
1091
|
}
|
|
805
|
-
|
|
1092
|
+
finally {
|
|
1093
|
+
activeStates.delete(st);
|
|
1094
|
+
}
|
|
1095
|
+
// Finalize THIS repeat as it lands — its siblings may still be running.
|
|
1096
|
+
const generatedAt = new Date().toISOString();
|
|
1097
|
+
for (const tier of tiers) {
|
|
1098
|
+
const tierResults = st.all.filter((r) => (r.tier ?? "") === tier);
|
|
1099
|
+
writeArtifacts(resultsDirFor(tier, st.runId), withMeta(summarize(tierResults), {
|
|
1100
|
+
...baseMetaFor(tier, st.runId, st.index),
|
|
1101
|
+
generatedAt,
|
|
1102
|
+
live: false,
|
|
1103
|
+
martian: martianFor(tier, tierResults),
|
|
1104
|
+
}));
|
|
1105
|
+
repeatDirs.push(resultsDirFor(tier, st.runId));
|
|
1106
|
+
}
|
|
1107
|
+
totalHarnessErrors += st.harnessErrors;
|
|
1108
|
+
totalCompleted += st.completed;
|
|
1109
|
+
totalCases += st.all.length;
|
|
1110
|
+
});
|
|
806
1111
|
}
|
|
807
1112
|
finally {
|
|
808
1113
|
restoreConsole();
|
|
1114
|
+
// The batch-wide overlay window closes exactly once, whatever happened.
|
|
1115
|
+
releaseOverlayGuard();
|
|
809
1116
|
}
|
|
810
|
-
s.stop(`${chalk.dim(`${
|
|
1117
|
+
s.stop(`${chalk.dim(`${grand()}/${grandTotal}`)} ${chalk.green("done")}`);
|
|
811
1118
|
p.log.message(verdicts.join("\n"));
|
|
812
1119
|
}
|
|
813
|
-
else
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
1120
|
+
else
|
|
1121
|
+
// `--repeats N` (sequential, the default): the WHOLE arm, N times, one
|
|
1122
|
+
// after another. Overlapping is opt-in (`--repeat-concurrency`, the branch
|
|
1123
|
+
// above) because concurrent repeats contend for CPU/network and the same
|
|
1124
|
+
// provider rate limit — which contaminates the per-phase latency this
|
|
1125
|
+
// sequential path keeps clean.
|
|
1126
|
+
for (repeatIndex = 1; repeatIndex <= repeats; repeatIndex++) {
|
|
1127
|
+
if (repeatIndex > 1) {
|
|
1128
|
+
// A fresh SIBLING run id. Every closure that names a run dir reads
|
|
1129
|
+
// `runId` at call time, so this one assignment repoints all of them.
|
|
1130
|
+
// `makeRunId`'s `-2`/`-3` suffix covers two repeats landing in the same
|
|
1131
|
+
// second (a `--limit 1` smoke run can).
|
|
1132
|
+
runId = makeRunId(new Date(), gitSha, tierResultsDir(tierKeyFor(tiers[0])));
|
|
1133
|
+
// Reset the accumulators IN PLACE — `refresh`/`runItem` closed over these
|
|
1134
|
+
// objects, so rebinding them would leave the closures on the old ones.
|
|
1135
|
+
// `keptWorkspaces` is deliberately NOT reset (see its declaration).
|
|
1136
|
+
all.length = 0;
|
|
1137
|
+
harnessErrors = 0;
|
|
1138
|
+
completed = 0;
|
|
1139
|
+
running.clear();
|
|
1140
|
+
trialOf.clear();
|
|
1141
|
+
seedScorecards();
|
|
1142
|
+
p.log.step(`${chalk.bold(`repeat ${repeatIndex}/${repeats}`)} ${chalk.dim(runId)}`);
|
|
1143
|
+
}
|
|
1144
|
+
try {
|
|
1145
|
+
if (parallel) {
|
|
1146
|
+
// Per-family progress for the aggregate spinner line.
|
|
1147
|
+
const fam = new Map();
|
|
1148
|
+
for (const [f, items] of byFamily)
|
|
1149
|
+
fam.set(f, { done: 0, total: items.length * runs });
|
|
1150
|
+
const status = () => {
|
|
1151
|
+
const segs = [...fam].map(([f, c]) => {
|
|
1152
|
+
const done = c.done === c.total ? chalk.green(`${c.done}/${c.total}`) : `${c.done}/${c.total}`;
|
|
1153
|
+
return `${familyLabel(f)} ${done}`;
|
|
1154
|
+
});
|
|
1155
|
+
return `${chalk.dim(`${completed}/${total}`)} ${segs.join(chalk.dim(" · "))}`;
|
|
1156
|
+
};
|
|
1157
|
+
const s = makeSpinner();
|
|
1158
|
+
s.start(status());
|
|
1159
|
+
const restoreConsole = silenceConsole();
|
|
1160
|
+
const verdicts = [];
|
|
1161
|
+
try {
|
|
1162
|
+
await Promise.all([...byFamily].map(async ([f, items]) => {
|
|
1163
|
+
// Within a family, `--concurrency` bounds how many of its cases are
|
|
1164
|
+
// in flight. `models` arms never switch overlays (`activate()` is a
|
|
1165
|
+
// no-op for them), which is why this branch needs no arm grouping —
|
|
1166
|
+
// see the `config`/overlay note in the serial branch below.
|
|
1167
|
+
await mapPool(items, concurrency, async (w) => {
|
|
1168
|
+
const k = caseKey(w.tierName, w.arm.label, w.inst.instance_id);
|
|
1169
|
+
running.add(k);
|
|
1170
|
+
refresh();
|
|
1171
|
+
const result = await runItem(w, () => {
|
|
1172
|
+
fam.get(f).done++;
|
|
1173
|
+
s.message(status());
|
|
1174
|
+
refresh();
|
|
1175
|
+
});
|
|
1176
|
+
running.delete(k);
|
|
1177
|
+
all.push(result);
|
|
1178
|
+
if (result.error)
|
|
1179
|
+
harnessErrors++;
|
|
1180
|
+
const mark = result.error ? chalk.red("✗") : result.blocked ? chalk.yellow("■") : chalk.green("✓");
|
|
1181
|
+
verdicts.push(`${mark} ${chalk.dim(familyLabel(f))} ${verdictLine(w.tierName, w.inst, result)}`);
|
|
1182
|
+
refresh();
|
|
1183
|
+
});
|
|
1184
|
+
}));
|
|
1185
|
+
}
|
|
1186
|
+
finally {
|
|
1187
|
+
restoreConsole();
|
|
1188
|
+
}
|
|
1189
|
+
s.stop(`${chalk.dim(`${completed}/${total}`)} ${chalk.green("done")}`);
|
|
1190
|
+
p.log.message(verdicts.join("\n"));
|
|
1191
|
+
}
|
|
1192
|
+
else if (concurrency > 1) {
|
|
1193
|
+
// Concurrent WITHIN one arm, strictly serial ACROSS arms.
|
|
1194
|
+
//
|
|
1195
|
+
// The asset root core resolves workflows/skills/prompts from is a module
|
|
1196
|
+
// GLOBAL (ADR 0001), so two overlays can never be live at once — which is
|
|
1197
|
+
// what `activateOverlay`/`releaseOverlayGuard` enforce by throwing. Running
|
|
1198
|
+
// arms concurrently would therefore either throw or, worse, measure one
|
|
1199
|
+
// arm's cases against another arm's prompts. So the pool is opened and
|
|
1200
|
+
// closed inside each arm's overlay window, never across it.
|
|
1201
|
+
//
|
|
1202
|
+
// Cases within one arm have nothing global between them: per-run `mkdtemp`
|
|
1203
|
+
// stateDir, per-run fake-GitHub port, a threaded `cwd` (never
|
|
1204
|
+
// `process.chdir`), and the static-token env installed once for the batch.
|
|
1205
|
+
const groups = [];
|
|
1206
|
+
for (const w of work) {
|
|
1207
|
+
const tail = groups[groups.length - 1];
|
|
1208
|
+
if (tail && tail.arm === w.arm)
|
|
1209
|
+
tail.items.push(w);
|
|
1210
|
+
else
|
|
1211
|
+
groups.push({ arm: w.arm, items: [w] });
|
|
1212
|
+
}
|
|
1213
|
+
const s = makeSpinner();
|
|
1214
|
+
const status = () => `${chalk.dim(`${completed}/${total}`)} ${chalk.dim(`×${concurrency}`)}`;
|
|
1215
|
+
s.start(status());
|
|
1216
|
+
// The per-case `quiet()` swap saves/restores `console` and is NOT
|
|
1217
|
+
// re-entrant, so concurrency drops console for the batch instead — the
|
|
1218
|
+
// same trade the family-parallel branch makes.
|
|
1219
|
+
const restoreConsole = silenceConsole();
|
|
1220
|
+
const verdicts = [];
|
|
1221
|
+
try {
|
|
1222
|
+
for (const g of groups) {
|
|
1223
|
+
g.arm.activate();
|
|
1224
|
+
try {
|
|
1225
|
+
await mapPool(g.items, concurrency, async (w) => {
|
|
1226
|
+
// The invariant this whole branch is built on, asserted rather
|
|
1227
|
+
// than assumed: one overlay is live and it is this arm's.
|
|
1228
|
+
if (w.arm !== g.arm)
|
|
1229
|
+
throw new Error(`concurrency crossed an arm boundary: ${w.arm.label} in ${g.arm.label}`);
|
|
1230
|
+
const k = caseKey(w.tierName, w.arm.label, w.inst.instance_id);
|
|
1231
|
+
running.add(k);
|
|
1232
|
+
refresh();
|
|
1233
|
+
const result = await runItem(w, () => {
|
|
1234
|
+
s.message(status());
|
|
1235
|
+
refresh();
|
|
1236
|
+
});
|
|
1237
|
+
running.delete(k);
|
|
1238
|
+
all.push(result);
|
|
1239
|
+
if (result.error)
|
|
1240
|
+
harnessErrors++;
|
|
1241
|
+
const mark = result.error ? chalk.red("✗") : result.blocked ? chalk.yellow("■") : chalk.green("✓");
|
|
1242
|
+
verdicts.push(`${mark} ${chalk.dim(labels[w.arm.label] ?? w.arm.label)} ${verdictLine(w.tierName, w.inst, result)}`);
|
|
1243
|
+
s.message(status());
|
|
1244
|
+
refresh();
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
finally {
|
|
1248
|
+
// Release before the next arm activates, or the guard reads the
|
|
1249
|
+
// switch as a second concurrent overlay and throws.
|
|
1250
|
+
releaseOverlayGuard();
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
finally {
|
|
1255
|
+
restoreConsole();
|
|
1256
|
+
}
|
|
1257
|
+
s.stop(`${chalk.dim(`${completed}/${total}`)} ${chalk.green("done")}`);
|
|
1258
|
+
p.log.message(verdicts.join("\n"));
|
|
1259
|
+
}
|
|
1260
|
+
else {
|
|
1261
|
+
// Serial: one spinner per case (updates per trial) + a verdict line. On
|
|
1262
|
+
// each arm change `arm.activate()` repoints the (process-global) asset root
|
|
1263
|
+
// to that arm's overlay (a no-op for models arms); work is arms-outer, so
|
|
1264
|
+
// it fires at most once per arm. `releaseOverlayGuard()` lets the next arm
|
|
1265
|
+
// switch overlays — without it the guard treats the switch as a concurrent
|
|
1266
|
+
// overlay and throws (ADR 0001).
|
|
1267
|
+
let currentArm;
|
|
1268
|
+
for (let i = 0; i < work.length; i++) {
|
|
1269
|
+
const w = work[i];
|
|
1270
|
+
if (w.arm !== currentArm) {
|
|
1271
|
+
if (currentArm)
|
|
1272
|
+
releaseOverlayGuard();
|
|
1273
|
+
w.arm.activate();
|
|
1274
|
+
currentArm = w.arm;
|
|
1275
|
+
}
|
|
1276
|
+
const s = makeSpinner();
|
|
1277
|
+
const head = `${chalk.dim(`[${i + 1}/${work.length}]`)} ${chalk.cyan(w.tierName)}/${w.inst.instance_id} ${chalk.dim(labels[w.arm.label] ?? w.arm.label)}`;
|
|
1278
|
+
s.start(head);
|
|
1279
|
+
const k = caseKey(w.tierName, w.arm.label, w.inst.instance_id);
|
|
1280
|
+
running.add(k);
|
|
1281
|
+
refresh();
|
|
1282
|
+
let t = 0;
|
|
1283
|
+
const { value: result, logs } = await quiet(() => runItem(w, () => {
|
|
1284
|
+
t++;
|
|
1285
|
+
if (runs > 1)
|
|
1286
|
+
s.message(`${head} ${chalk.dim(`trial ${t}/${runs}`)}`);
|
|
1287
|
+
refresh();
|
|
1288
|
+
}));
|
|
1289
|
+
running.delete(k);
|
|
1290
|
+
all.push(result);
|
|
1291
|
+
if (result.error)
|
|
1292
|
+
harnessErrors++;
|
|
1293
|
+
const mark = result.error ? chalk.red("✗") : result.blocked ? chalk.yellow("■") : chalk.green("✓");
|
|
1294
|
+
s.stop(`${chalk.dim(`[${i + 1}/${work.length}]`)} ${mark} ${verdictLine(w.tierName, w.inst, result)}`);
|
|
1295
|
+
if (result.error) {
|
|
1296
|
+
p.log.error(chalk.dim(result.error));
|
|
1297
|
+
const tail = logs.split("\n").filter(Boolean).slice(-12).join("\n");
|
|
1298
|
+
if (tail)
|
|
1299
|
+
p.log.message(chalk.dim(tail));
|
|
1300
|
+
}
|
|
1301
|
+
refresh();
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
finally {
|
|
1306
|
+
// Release the overlay guard at the END OF EVERY REPEAT, not just on an
|
|
1307
|
+
// arm change. The serial branch above declares `currentArm` inside itself
|
|
1308
|
+
// and only releases when the arm CHANGES (never after the last one), so
|
|
1309
|
+
// repeat 2 would call `w.arm.activate()` while repeat 1's overlay is
|
|
1310
|
+
// still marked active and `activateOverlay` would THROW (arm.ts). The
|
|
1311
|
+
// concurrent branch already releases per arm in its own `finally`, and a
|
|
1312
|
+
// second release is a harmless no-op — the guard is two booleans.
|
|
1313
|
+
releaseOverlayGuard();
|
|
828
1314
|
}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
if (runs > 1)
|
|
839
|
-
s.message(`${head} ${chalk.dim(`trial ${t}/${runs}`)}`);
|
|
840
|
-
refresh();
|
|
841
|
-
}));
|
|
842
|
-
running.delete(k);
|
|
843
|
-
all.push(result);
|
|
844
|
-
if (result.error)
|
|
845
|
-
harnessErrors++;
|
|
846
|
-
const mark = result.error ? chalk.red("✗") : result.blocked ? chalk.yellow("■") : chalk.green("✓");
|
|
847
|
-
s.stop(`${chalk.dim(`[${i + 1}/${work.length}]`)} ${mark} ${verdictLine(w.tierName, w.inst, result)}`);
|
|
848
|
-
if (result.error) {
|
|
849
|
-
p.log.error(chalk.dim(result.error));
|
|
850
|
-
const tail = logs.split("\n").filter(Boolean).slice(-12).join("\n");
|
|
851
|
-
if (tail)
|
|
852
|
-
p.log.message(chalk.dim(tail));
|
|
1315
|
+
// Final, static scorecard + machine artifacts FOR THIS REPEAT. The
|
|
1316
|
+
// run-level metadata is persisted into scorecard.json so the dashboard can
|
|
1317
|
+
// label, order, and (no longer) live-poll the run without re-deriving from
|
|
1318
|
+
// the current config.
|
|
1319
|
+
const generatedAt = new Date().toISOString();
|
|
1320
|
+
for (const tier of tiers) {
|
|
1321
|
+
const tierResults = all.filter((r) => (r.tier ?? "") === tier);
|
|
1322
|
+
writeArtifacts(resultsDirFor(tier), withMeta(summarize(tierResults), { ...baseMetaFor(tier), generatedAt, live: false, martian: martianFor(tier, tierResults) }));
|
|
1323
|
+
repeatDirs.push(resultsDirFor(tier));
|
|
853
1324
|
}
|
|
854
|
-
|
|
1325
|
+
totalHarnessErrors += harnessErrors;
|
|
1326
|
+
totalCompleted += completed;
|
|
1327
|
+
totalCases += all.length;
|
|
855
1328
|
}
|
|
856
|
-
}
|
|
857
1329
|
}
|
|
858
1330
|
finally {
|
|
859
1331
|
clearInterval(heartbeat);
|
|
860
1332
|
restoreEvalEnv();
|
|
861
1333
|
}
|
|
862
|
-
|
|
863
|
-
//
|
|
864
|
-
//
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
1334
|
+
p.log.success(`Artifacts → ${chalk.cyan(repeatDirs.join("\n "))}\n /{scorecard.json,predictions.jsonl,sessions/}`);
|
|
1335
|
+
// `--keep-workspace` is only useful if you can find what it kept. The paths
|
|
1336
|
+
// are on every result too (`workspaceDir`); this is the line that stops a
|
|
1337
|
+
// kept batch from being N anonymous temp dirs, and the reminder that nothing
|
|
1338
|
+
// else will ever delete them.
|
|
1339
|
+
if (keptWorkspaces.length) {
|
|
1340
|
+
p.log.warn(`Kept ${keptWorkspaces.length} workspace${keptWorkspaces.length === 1 ? "" : "s"} (--keep-workspace) — nothing else will remove them:\n` +
|
|
1341
|
+
keptWorkspaces
|
|
1342
|
+
.map((w) => ` ${w.id}${repeats > 1 ? ` (repeat ${w.repeat})` : ""}${runs > 1 ? ` (trial ${w.trial})` : ""} · ${chalk.cyan(w.path)}`)
|
|
1343
|
+
.join("\n"));
|
|
1344
|
+
}
|
|
1345
|
+
// `--repeats` suppressed the auto-served dashboard (see the flag's note), so
|
|
1346
|
+
// say — once, at the end — how to bring one up over the whole band instead of
|
|
1347
|
+
// leaving N servers running for the length of the run.
|
|
1348
|
+
if (repeats > 1) {
|
|
1349
|
+
p.log.info(`${repeats} repeats written as sibling runs (band ${chalk.cyan(repeatGroup)}). Browse the band:\n` +
|
|
1350
|
+
` ${chalk.cyan(`cd apps/evals && LASTLIGHT_EVALS_OUT=${resultsRoot()} npx tsx src/run.ts serve --port 4400`)}`);
|
|
869
1351
|
}
|
|
870
|
-
|
|
871
|
-
const ran = runs > 1 ? `${completed} runs (${all.length} cases × ${runs})` : `${all.length} runs`;
|
|
1352
|
+
const ran = runs > 1 ? `${totalCompleted} runs (${totalCases} cases × ${runs})` : `${totalCases} runs`;
|
|
872
1353
|
// Keep the dashboard server alive so the just-finished run stays viewable —
|
|
873
1354
|
// until the user stops it (Ctrl-C, or `kill`/stop for a detached run). The
|
|
874
1355
|
// dashboard is opt-in already (`--no-open`, or CI, ⇒ no server was started), so
|
|
@@ -879,21 +1360,22 @@ async function runEval() {
|
|
|
879
1360
|
if (server) {
|
|
880
1361
|
const runUrl = `${server.url}/#/${encodeURIComponent(tierKeyFor(tiers[0]))}/${encodeURIComponent(runId)}`;
|
|
881
1362
|
p.log.success(`Dashboard → ${chalk.cyan(runUrl)} ${chalk.dim("(serving · Ctrl-C or kill to stop)")}`);
|
|
882
|
-
const tail =
|
|
1363
|
+
const tail = totalHarnessErrors > 0 ? chalk.yellow(`done — ${ran}, ${totalHarnessErrors} harness error${totalHarnessErrors === 1 ? "" : "s"} (see above)`) : chalk.green(`done — ${ran}`);
|
|
883
1364
|
p.outro(tail);
|
|
884
1365
|
await waitForSigint();
|
|
885
1366
|
await server.close();
|
|
886
1367
|
}
|
|
887
1368
|
else {
|
|
888
|
-
if (
|
|
889
|
-
p.outro(chalk.yellow(`done — ${ran}, ${
|
|
1369
|
+
if (totalHarnessErrors > 0) {
|
|
1370
|
+
p.outro(chalk.yellow(`done — ${ran}, ${totalHarnessErrors} harness error${totalHarnessErrors === 1 ? "" : "s"} (see above)`));
|
|
890
1371
|
}
|
|
891
1372
|
else {
|
|
892
1373
|
p.outro(chalk.green(`done — ${ran}`));
|
|
893
1374
|
}
|
|
894
1375
|
}
|
|
895
|
-
// Non-zero ONLY on harness failure — model quality is the measurement.
|
|
896
|
-
|
|
1376
|
+
// Non-zero ONLY on harness failure — model quality is the measurement. Summed
|
|
1377
|
+
// over every repeat: a band whose middle repeat blew up did not run clean.
|
|
1378
|
+
return totalHarnessErrors > 0 ? 1 : 0;
|
|
897
1379
|
}
|
|
898
1380
|
/** Resolve on the first SIGINT (Ctrl-C) so `run`/`serve` can keep a server up
|
|
899
1381
|
* for browsing and shut it down cleanly when the user is done. */
|
|
@@ -969,7 +1451,31 @@ Run options:
|
|
|
969
1451
|
--compare Cross-vendor set (only models whose provider key is present)
|
|
970
1452
|
--instance <id[,id]> Only run these instance_id(s) (or set EVAL_INSTANCE). Exact match.
|
|
971
1453
|
--limit <n> Run only the first n instances per tier (after --instance).
|
|
972
|
-
--runs <n> Repeat each
|
|
1454
|
+
--runs <n> Repeat each CASE n× (worst-case verdict, mean metrics).
|
|
1455
|
+
Folds the trials into one result — no per-trial detail.
|
|
1456
|
+
--repeats <n> Repeat the whole ARM n×, sequentially, as n SIBLING runs,
|
|
1457
|
+
so a result reads as a band instead of a point. Three
|
|
1458
|
+
identical arms measured 0.320/0.080/0.200 micro-recall, so
|
|
1459
|
+
one arm is not a measurement of that arm. Each repeat is a
|
|
1460
|
+
normal run (its own runId + scorecard) tagged with
|
|
1461
|
+
meta.repeat={group,index,of}; scripts/band.ts and the
|
|
1462
|
+
dashboard roll them up into a band (mean/min/max + union
|
|
1463
|
+
and intersection recall). Implies --keep-workspace (you will
|
|
1464
|
+
want each repeat's evidence) and --no-open (a run holds a
|
|
1465
|
+
dashboard server open forever; n of them leak). Or
|
|
1466
|
+
EVAL_REPEATS.
|
|
1467
|
+
--repeat-concurrency <n> Run up to n of the --repeats repeats at once
|
|
1468
|
+
(default 1 = sequential, the exact path above). RunIds are
|
|
1469
|
+
pre-assigned so simultaneous launches never collide; each
|
|
1470
|
+
repeat stays a normal sibling run. Repeats share ONE
|
|
1471
|
+
overlay, so multi-overlay --mode config runs clamp to 1
|
|
1472
|
+
(as does --sandbox gondolin). Composes with --concurrency:
|
|
1473
|
+
total in-flight cases = repeat-concurrency × concurrency
|
|
1474
|
+
(no cap — size the product against your rate limit).
|
|
1475
|
+
CAVEAT: concurrent repeats contend for CPU/network, so
|
|
1476
|
+
per-phase durationMs/agentMs are contaminated; each
|
|
1477
|
+
repeat's meta.repeat.concurrency records n so latency
|
|
1478
|
+
reads can be discounted. Verdicts/cost/recall unaffected.
|
|
973
1479
|
--f-beta <n> pr-review F-beta β (default 1 = F1; 0.5 = precision 2×). Or EVAL_F_BETA.
|
|
974
1480
|
--judge-with-diff pr-review: feed the PR diff to the judge (higher fidelity,
|
|
975
1481
|
off by default — Martian's offline judge is diff-blind)
|
|
@@ -981,6 +1487,15 @@ Run options:
|
|
|
981
1487
|
in-process (fast, CI). gondolin isolates the agent's tools
|
|
982
1488
|
in a QEMU micro-VM so it can't read host gold data (needs
|
|
983
1489
|
QEMU natively — brew install qemu). Or EVAL_SANDBOX.
|
|
1490
|
+
--keep-workspace Don't delete each trial's workspace. Keeps the evidence
|
|
1491
|
+
pipeline's artifacts (.lastlight/pr-review/facts.json,
|
|
1492
|
+
obligations/, hypotheses/, probes/) readable after the run;
|
|
1493
|
+
the path lands on each result as workspaceDir. Costs disk.
|
|
1494
|
+
--concurrency <n> Run n cases of the SAME arm at once (default 1 = serial).
|
|
1495
|
+
Arms always stay serial (one overlay at a time, ADR 0001);
|
|
1496
|
+
--runs trials within a case stay serial too. Bounded by the
|
|
1497
|
+
provider's rate limit, not by correctness. Forced to 1 on
|
|
1498
|
+
--sandbox gondolin (a QEMU micro-VM per case).
|
|
984
1499
|
--serial Force serial execution across provider families
|
|
985
1500
|
--datasets <dir> Extra datasets root to discover tiers from
|
|
986
1501
|
--models-file <f> Use an explicit models.json
|