static-fire 0.44.0 → 0.54.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -44,8 +44,8 @@ export function trialDetail(store: Store, trialId: string): TrialDetail | null {
44
44
  };
45
45
  }
46
46
 
47
- /** The trial's output file as the drawer shows it: an image (by the output path's extension) or text. */
48
- export type TrialOutputFile = { image: Uint8Array<ArrayBuffer>; mediaType: string } | { text: string };
47
+ /** The trial's output file as the drawer shows it: an image (by the output path's extension) or text, with the output path it was read from. */
48
+ export type TrialOutputFile = ({ image: Uint8Array<ArrayBuffer>; mediaType: string } | { text: string }) & { path: string };
49
49
 
50
50
  /**
51
51
  * The trial's output file: the task's `output` path, as recorded with the task version the trial ran on, inside
@@ -69,5 +69,5 @@ export function readTrialOutputFile(store: Store, dbPath: string, trialId: strin
69
69
  const rel = relative(realpathSync(workspace), real);
70
70
  if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return { missing: `trial ${trialId}: its output path ${output} leaves the workspace` };
71
71
  const mediaType = IMAGE_MEDIA[extname(output).toLowerCase()];
72
- try { return mediaType ? { image: readFileSync(real), mediaType } : { text: readFileSync(real, "utf8") }; } catch { return none; } // a folder, or unreadable
72
+ try { return mediaType ? { image: readFileSync(real), mediaType, path: output } : { text: readFileSync(real, "utf8"), path: output }; } catch { return none; } // a folder, or unreadable
73
73
  }
package/src/core/types.ts CHANGED
@@ -184,7 +184,7 @@ export interface ConfigSummary {
184
184
  profile?: string;
185
185
  tasks: Record<string, TaskSummary>;
186
186
  suites: Record<string, Stat>; // Suite Index per suite
187
- index: Stat; // Workflow Index
187
+ index: Stat; // Agent Performance Index
188
188
  costUsd: number | null; // mean cost per task over all tasks
189
189
  durationMs: number | null; // mean time per task
190
190
  }
@@ -4,12 +4,20 @@
4
4
  // the route and the dashboard's loading code pick it up. This file is the one home of the config facet list.
5
5
  import type { ViewFilters } from "./view";
6
6
 
7
- /** What the chart is about: the Workflow Index over every suite, one suite, or one task. */
8
- export type Scope = { kind: "index" } | { kind: "suite"; id: string } | { kind: "task"; id: string };
7
+ /**
8
+ * What the chart is about: the Agent Performance Index over the suites in view (the picked suites, else the default
9
+ * set), or one task.
10
+ */
11
+ export type Scope = { kind: "index" } | { kind: "task"; id: string };
9
12
 
10
13
  /** The view a page asks for. Every field is optional; an empty object is the default view. */
11
14
  export interface ViewQuery {
12
15
  scope?: Scope;
16
+ /**
17
+ * The picked suites of the index, as the repeated `suite` parameter (`?suite=short&suite=writing`). None picked:
18
+ * the view chooses the default set. Only with the index scope.
19
+ */
20
+ suites?: string[];
13
21
  judge?: string;
14
22
  grading?: string;
15
23
  /**
@@ -24,14 +32,14 @@ export interface ViewQuery {
24
32
  until?: string;
25
33
  }
26
34
 
27
- /** Scope in a URL: absent or `index`, `suite:<id>`, `task:<id>`. */
28
- export const scopeParam = (s: Scope): string => (s.kind === "index" ? "index" : `${s.kind}:${s.id}`);
35
+ /** Scope in a URL: absent or `index`, `task:<id>`. A link from before #80 can say `suite:<id>`: that is `suite=<id>`. */
36
+ export const scopeParam = (s: Scope): string => (s.kind === "index" ? "index" : `task:${s.id}`);
29
37
 
30
- function parseScope(v: string): Scope {
31
- if (v === "index") return { kind: "index" };
38
+ function parseScope(v: string): { scope: Scope; suite?: string } {
39
+ if (v === "index") return { scope: { kind: "index" } };
32
40
  const m = /^(suite|task):(.+)$/.exec(v);
33
41
  if (!m) throw new Error(`scope ${v} is not index, suite:<id> or task:<id>`);
34
- return { kind: m[1] as "suite" | "task", id: m[2]! };
42
+ return m[1] === "task" ? { scope: { kind: "task", id: m[2]! } } : { scope: { kind: "index" }, suite: m[2]! };
35
43
  }
36
44
 
37
45
  /** Parameters that take one value each. A view has one judge, one grading version and one date range. */
@@ -41,8 +49,8 @@ const SINGLE: Single[] = ["judge", "grading", "since", "until"];
41
49
  export const CONFIG_FACETS = ["harness", "model", "effort", "profile"] as const;
42
50
  export type ConfigFacet = (typeof CONFIG_FACETS)[number];
43
51
  /** Every parameter, in the order a URL the page writes lists them. */
44
- const PARAMS: readonly ("scope" | Single | ConfigFacet)[] = ["scope", "judge", "grading", "harness", "model", "effort", "profile", "since", "until"];
45
- const KNOWN = new Set<string>(["scope", ...SINGLE, ...CONFIG_FACETS]);
52
+ const PARAMS: readonly ("scope" | "suite" | Single | ConfigFacet)[] = ["scope", "suite", "judge", "grading", "harness", "model", "effort", "profile", "since", "until"];
53
+ const KNOWN = new Set<string>(["scope", "suite", ...SINGLE, ...CONFIG_FACETS]);
46
54
  const isConfig = (k: string): k is ConfigFacet => (CONFIG_FACETS as readonly string[]).includes(k);
47
55
 
48
56
  /** The values in one stable order, each once, with empty ones dropped: a set of picks has one URL. */
@@ -56,7 +64,15 @@ export function parseViewQuery(params: URLSearchParams): ViewQuery {
56
64
  if (repeated.length) throw new Error(`view parameter(s) ${repeated.join(", ")} can take only one value (repeat only ${CONFIG_FACETS.join(", ")})`);
57
65
  const q: ViewQuery = {};
58
66
  const scope = params.get("scope");
59
- if (scope) q.scope = parseScope(scope);
67
+ const suites = params.getAll("suite");
68
+ if (scope) {
69
+ const parsed = parseScope(scope);
70
+ q.scope = parsed.scope;
71
+ if (parsed.suite) suites.push(parsed.suite);
72
+ }
73
+ const picked = valueSet(suites);
74
+ if (picked.length && q.scope?.kind === "task") throw new Error(`suite cannot be picked with scope ${scope}: a task's view has no suites`);
75
+ if (picked.length) q.suites = picked;
60
76
  for (const k of SINGLE) { const v = params.get(k); if (v) q[k] = v; }
61
77
  for (const k of CONFIG_FACETS) { const v = valueSet(params.getAll(k)); if (v.length) q[k] = v; }
62
78
  return q;
@@ -66,19 +82,20 @@ export function parseViewQuery(params: URLSearchParams): ViewQuery {
66
82
  export function viewQueryParams(q: ViewQuery): URLSearchParams {
67
83
  const p = new URLSearchParams();
68
84
  if (q.scope && q.scope.kind !== "index") p.set("scope", scopeParam(q.scope));
85
+ else for (const v of valueSet(q.suites ?? [])) p.append("suite", v);
69
86
  for (const k of PARAMS) {
70
- if (k === "scope") continue;
87
+ if (k === "scope" || k === "suite") continue;
71
88
  if (isConfig(k)) for (const v of valueSet(q[k] ?? [])) p.append(k, v);
72
89
  else if (q[k]) p.set(k, q[k]!);
73
90
  }
74
91
  return p;
75
92
  }
76
93
 
77
- /** The view function's filters for `q`: a suite scope is that suite, a task scope that task. */
94
+ /** The view function's filters for `q`: the picked suites of the index, or a task scope's task. */
78
95
  export function toViewFilters(q: ViewQuery): ViewFilters {
79
96
  const f: ViewFilters = {};
80
- if (q.scope?.kind === "suite") f.suites = [q.scope.id];
81
97
  if (q.scope?.kind === "task") f.tasks = [q.scope.id];
98
+ else if (q.suites?.length) f.suites = valueSet(q.suites);
82
99
  for (const k of SINGLE) if (q[k]) f[k] = q[k];
83
100
  for (const k of CONFIG_FACETS) { const v = valueSet(q[k] ?? []); if (v.length) f[k] = v; }
84
101
  return f;
package/src/core/view.ts CHANGED
@@ -11,6 +11,8 @@ import type { NamedGradingSetup, VersionName } from "./grading-versions";
11
11
  import { gradingHash as currentGradingHash, taskHash as currentTaskHash } from "./identity";
12
12
  import type { CriterionContent, GradingContent } from "./identity";
13
13
  import { summarize } from "./score";
14
+ import { defaultSuites } from "./coverage";
15
+ import type { Coverage } from "./coverage";
14
16
  import { CONFIG_FACETS, valueSet } from "./view-query";
15
17
  import type { ConfigFacet } from "./view-query";
16
18
  import { loadJudges, loadSuites, loadTask } from "./authored";
@@ -21,9 +23,12 @@ import type { AgentConfig, ConfigSummary, Criterion, Experiment, Grades, JudgeCo
21
23
 
22
24
  /** What the caller asks for. Every field is optional; an empty object is the default view. */
23
25
  export interface ViewFilters {
24
- /** Suite ids. Their tasks are in scope and the Workflow Index is the mean over these suites. Default: every suite. */
26
+ /**
27
+ * Suite ids. Their tasks are in scope and the Agent Performance Index is the mean over these suites. Default: the
28
+ * default set of the data in view (coverage.ts defaultSuites, DEC-019).
29
+ */
25
30
  suites?: string[];
26
- /** Task ids. When set, only these tasks are in scope and no suite is (so no Suite or Workflow Index). Wins over `suites`. */
31
+ /** Task ids. When set, only these tasks are in scope and no suite is (so no Suite or Agent Performance Index). Wins over `suites`. */
27
32
  tasks?: string[];
28
33
  /** Judge id from judges.yaml. Default: the first one (the default judge). */
29
34
  judge?: string;
@@ -185,6 +190,8 @@ export interface View {
185
190
  /** The values of each config facet applied, sorted (absent when not set). */
186
191
  harness?: string[]; model?: string[]; effort?: string[]; profile?: string[];
187
192
  };
193
+ /** Which suites the index covers and why each other suite is left out (DEC-020). Null for a view of tasks. */
194
+ coverage: Coverage | null;
188
195
  /** Task id → the task in scope. */
189
196
  tasks: Record<string, ViewTask>;
190
197
  /** The trials in the view, by task, config key, then id. */
@@ -198,63 +205,66 @@ export interface View {
198
205
 
199
206
  /** Selects and summarises the comparable trials for `filters`. Throws on an unknown id or an unusable filter. */
200
207
  export function buildView(store: Store, filters: ViewFilters = {}, catalog: Catalog = projectCatalog()): View {
201
- const { suites, taskIds } = scope(filters, catalog);
208
+ const { suites: candidates, taskIds: allTaskIds, picked: suitesPicked } = scope(filters, catalog);
202
209
  const since = bound(filters.since, "since"), until = bound(filters.until, "until");
203
210
  const picked = filters.grading ? pickGrading(store, filters.grading) : null;
204
211
  // A view shows one judge: a picked grading setup's judge, for every task in scope.
205
212
  const judge = picked ? setupJudge(picked, filters.judge, catalog.judges) : pickJudge(filters.judge, catalog.judges);
206
213
  const pickedTasks = new Set(picked ? store.gradingSetupTaskIds(picked.hash) : []);
207
- if (picked && !taskIds.some((t) => pickedTasks.has(t))) {
208
- throw new Error(`grading version ${picked.hash} is for task ${[...pickedTasks].join(", ")}, which is not in scope (${taskIds.join(", ")})`);
214
+ if (picked && !allTaskIds.some((t) => pickedTasks.has(t))) {
215
+ throw new Error(`grading version ${picked.hash} is for task ${[...pickedTasks].join(", ")}, which is not in scope (${allTaskIds.join(", ")})`);
216
+ }
217
+
218
+ // 1. Gather each task that can be in view (every suite's, when the suites are not picked).
219
+ const gathered = new Map<string, Gathered>();
220
+ for (const taskId of allTaskIds) gathered.set(taskId, gatherTask(store, catalog.loadTask(taskId), judge, picked, pickedTasks, filters, since, until));
221
+
222
+ // 2. The suites in view: the picked ones, else the default set (DEC-019). A view of tasks has no suites.
223
+ let suites = candidates;
224
+ let coverage: Coverage | null = null;
225
+ if (!filters.tasks?.length) {
226
+ const completedBy = new Map(catalog.suites.map((s) => [s.id, completers(s, gathered)]));
227
+ if (!suitesPicked) {
228
+ const ids = new Set(defaultSuites(candidates.map((s) => s.id), completedBy));
229
+ suites = candidates.filter((s) => ids.has(s.id));
230
+ // The default set is the one rule, with no exception for a picked grading version: a view that must show its
231
+ // task picks that task's suite (the dashboard puts the suite pick in the address when it sets the grading pick).
232
+ if (picked && !suites.some((s) => s.tasks.some((t) => pickedTasks.has(t)))) {
233
+ const of = candidates.filter((s) => s.tasks.some((t) => pickedTasks.has(t))).map((s) => s.id);
234
+ throw new Error(`grading version ${picked.hash} is for task ${[...pickedTasks].join(", ")}, which is not in the default set of suites (${suites.map((s) => s.id).join(", ")}); pick its suite (${of.join(", ")}) or its task to see it`);
235
+ }
236
+ }
237
+ const inView = new Set(suites.map((s) => s.id));
238
+ const ref = (s: Suite) => ({ id: s.id, title: s.title });
239
+ const both = (ids: Set<string>[]) => ids.length ? [...ids[0]!].filter((c) => ids.every((x) => x.has(c))).length : 0;
240
+ coverage = {
241
+ picked: suitesPicked,
242
+ covered: suites.map(ref),
243
+ leftOut: catalog.suites.filter((s) => !inView.has(s.id)).map((s) => {
244
+ if (suitesPicked) return { ...ref(s), completed: null, reason: "not-picked" as const };
245
+ const completed = completedBy.get(s.id)!.size;
246
+ return { ...ref(s), completed, reason: completed ? "too-few" as const : "none-completed" as const };
247
+ }),
248
+ complete: both(suites.map((s) => completedBy.get(s.id)!)),
249
+ };
209
250
  }
251
+ const taskIds = filters.tasks?.length ? allTaskIds : [...new Set(suites.flatMap((s) => s.tasks))];
210
252
 
253
+ // 3. The view of those tasks.
211
254
  const tasks: Record<string, ViewTask> = {};
212
255
  const inView: ViewTrial[] = [];
213
256
  const records = new Map<string, TrialRecord>(); // trial id → its row, for the config fields
214
257
  const left = { otherGrading: new Counter(), otherTask: new Counter(), ungraded: new Counter() };
215
- const otherVersions = new Map<string, { version: OtherTaskVersion; rows: TrialRecord[] }>(); // with its trials, to count per grading version
258
+ const versions: OtherTaskVersion[] = [];
216
259
  const gradable: string[] = [];
217
-
218
260
  for (const taskId of taskIds) {
219
- const task = catalog.loadTask(taskId);
220
- const nowTask = currentTaskHash(task), nowGrading = currentGradingHash(task, judge);
221
- const names = versionNames(store, taskId);
222
- const tHash = pickedTasks.has(taskId) ? picked!.taskHash : nowTask;
223
- const gHash = pickedTasks.has(taskId) ? picked!.hash : nowGrading;
224
- const stored = store.getTaskVersion(tHash);
225
- const label = stored ? stored.label : task.version ?? null; // a stored task version keeps its label, even none
226
- tasks[taskId] = {
227
- taskHash: tHash, gradingHash: gHash, gradingName: names.get(gHash)?.name ?? null, // null: today's grading version is not stored yet
228
- label,
229
- current: tHash === nowTask && gHash === nowGrading,
230
- def: gHash === nowGrading ? snapshotOf(task) : taskDefinition(task, picked!, label),
231
- };
232
-
233
- const rows = store.listTrials({ taskId }).filter((r) => ENDED.has(r.status) && inRange(r.startedAt, since, until) && matchesConfig(r, filters));
234
- const same: TrialRecord[] = [];
235
- for (const r of rows) {
236
- if (r.taskHash === tHash) { same.push(r); continue; }
237
- left.otherTask.add(r.configKey);
238
- const key = `${taskId}\0${r.taskHash}`; // two tasks can share an assignment (task hash): each keeps its own entry
239
- const v = otherVersions.get(key) ?? otherVersions.set(key, { version: otherVersion(store, taskId, r.taskHash, names), rows: [] }).get(key)!;
240
- v.version.trials++;
241
- v.rows.push(r);
242
- }
243
- for (const r of latestHumanOnly(same)) {
244
- records.set(r.id, r);
245
- if (r.status !== "done") { inView.push(viewTrial(r, null, [])); continue; } // failed, or copied the held-out original
246
- const g = store.currentGrading(r.id, gHash);
247
- if (g) { inView.push(viewTrial(r, g, store.listVerdicts(g.id))); continue; }
248
- const other = store.listGradings(r.id).some((x) => x.status !== "grading");
249
- (other ? left.otherGrading : left.ungraded).add(r.configKey);
250
- gradable.push(r.id);
251
- }
252
- }
253
-
254
- for (const { version: v, rows } of otherVersions.values()) {
255
- const shown = latestHumanOnly(rows).filter((r) => r.status === "done");
256
- for (const g of v.gradingSetups) g.trials = shown.filter((r) => store.currentGrading(r.id, g.hash)?.status === "done").length;
257
- v.link = linkTarget(v.gradingSetups, judge);
261
+ const g = gathered.get(taskId)!;
262
+ tasks[taskId] = g.task;
263
+ inView.push(...g.trials);
264
+ for (const r of g.records) records.set(r.id, r);
265
+ for (const k of ["otherGrading", "otherTask", "ungraded"] as const) for (const key of g.left[k]) left[k].add(key);
266
+ versions.push(...g.versions);
267
+ gradable.push(...g.gradable);
258
268
  }
259
269
 
260
270
  inView.sort((a, b) => cmp(a.taskId, b.taskId) || cmp(a.configKey, b.configKey) || cmp(a.id, b.id));
@@ -263,30 +273,98 @@ export function buildView(store: Store, filters: ViewFilters = {}, catalog: Cata
263
273
  suiteIds: suites.map((s) => s.id), taskIds, judge, grading: picked?.hash ?? null, since, until,
264
274
  ...Object.fromEntries(CONFIG_FACETS.map((k) => [k, valueSet(filters[k] ?? [])] as const).filter(([, v]) => v.length)),
265
275
  },
276
+ coverage,
266
277
  tasks,
267
278
  trials: inView,
268
279
  summary: summarizeTrials(inView, records, tasks, suites),
269
280
  leftOut: {
270
281
  otherGrading: left.otherGrading.count(),
271
- otherTask: { ...left.otherTask.count(), versions: [...otherVersions.values()].map((x) => x.version) },
282
+ otherTask: { ...left.otherTask.count(), versions },
272
283
  ungraded: left.ungraded.count(),
273
284
  gradable: gradable.sort(),
274
285
  },
275
286
  };
276
287
  }
277
288
 
289
+ /** One task's part of a view: its definition in view, its trials in view, and its left-out trials by config key. */
290
+ interface Gathered {
291
+ task: ViewTask;
292
+ trials: ViewTrial[];
293
+ records: TrialRecord[];
294
+ left: { otherGrading: string[]; otherTask: string[]; ungraded: string[] };
295
+ versions: OtherTaskVersion[];
296
+ gradable: string[];
297
+ }
298
+
299
+ function gatherTask(store: Store, task: Task, judge: JudgeConfig, picked: GradingSetupRecord | null, pickedTasks: Set<string>,
300
+ filters: ViewFilters, since: string | null, until: string | null): Gathered {
301
+ const taskId = task.id;
302
+ const nowTask = currentTaskHash(task), nowGrading = currentGradingHash(task, judge);
303
+ const names = versionNames(store, taskId);
304
+ const tHash = pickedTasks.has(taskId) ? picked!.taskHash : nowTask;
305
+ const gHash = pickedTasks.has(taskId) ? picked!.hash : nowGrading;
306
+ const stored = store.getTaskVersion(tHash);
307
+ const label = stored ? stored.label : task.version ?? null; // a stored task version keeps its label, even none
308
+ const out: Gathered = {
309
+ task: {
310
+ taskHash: tHash, gradingHash: gHash, gradingName: names.get(gHash)?.name ?? null, // null: today's grading version is not stored yet
311
+ label,
312
+ current: tHash === nowTask && gHash === nowGrading,
313
+ def: gHash === nowGrading ? snapshotOf(task) : taskDefinition(task, picked!, label),
314
+ },
315
+ trials: [], records: [], left: { otherGrading: [], otherTask: [], ungraded: [] }, versions: [], gradable: [],
316
+ };
317
+
318
+ const rows = store.listTrials({ taskId }).filter((r) => ENDED.has(r.status) && inRange(r.startedAt, since, until) && matchesConfig(r, filters));
319
+ const same: TrialRecord[] = [];
320
+ const otherVersions = new Map<string, { version: OtherTaskVersion; rows: TrialRecord[] }>(); // with its trials, to count per grading version
321
+ for (const r of rows) {
322
+ if (r.taskHash === tHash) { same.push(r); continue; }
323
+ out.left.otherTask.push(r.configKey);
324
+ const v = otherVersions.get(r.taskHash) ?? otherVersions.set(r.taskHash, { version: otherVersion(store, taskId, r.taskHash, names), rows: [] }).get(r.taskHash)!;
325
+ v.version.trials++;
326
+ v.rows.push(r);
327
+ }
328
+ for (const r of latestHumanOnly(same)) {
329
+ out.records.push(r);
330
+ if (r.status !== "done") { out.trials.push(viewTrial(r, null, [])); continue; } // failed, or copied the held-out original
331
+ const g = store.currentGrading(r.id, gHash);
332
+ if (g) { out.trials.push(viewTrial(r, g, store.listVerdicts(g.id))); continue; }
333
+ const other = store.listGradings(r.id).some((x) => x.status !== "grading");
334
+ (other ? out.left.otherGrading : out.left.ungraded).push(r.configKey);
335
+ out.gradable.push(r.id);
336
+ }
337
+
338
+ for (const { version: v, rows } of otherVersions.values()) {
339
+ const shown = latestHumanOnly(rows).filter((r) => r.status === "done");
340
+ for (const g of v.gradingSetups) g.trials = shown.filter((r) => store.currentGrading(r.id, g.hash)?.status === "done").length;
341
+ v.link = linkTarget(v.gradingSetups, judge);
342
+ out.versions.push(v);
343
+ }
344
+ return out;
345
+ }
346
+
347
+ /** The configs (config keys) that have a valid score on every task of `suite` in what was gathered. */
348
+ function completers(suite: Suite, gathered: Map<string, Gathered>): Set<string> {
349
+ const per = suite.tasks.map((t) => new Set((gathered.get(t)?.trials ?? []).filter((x) => x.status === "done" && x.grades).map((x) => x.configKey)));
350
+ if (!per.length) return new Set();
351
+ return new Set([...per[0]!].filter((c) => per.every((p) => p.has(c))));
352
+ }
353
+
278
354
  // ---- Selection ---------------------------------------------------------------------------------
279
355
 
280
356
  const ENDED = new Set(["done", "failed", "invalid"]);
281
357
  const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
282
358
 
283
- function scope(f: ViewFilters, catalog: Catalog): { suites: Suite[]; taskIds: string[] } {
284
- if (f.tasks?.length) return { suites: [], taskIds: [...new Set(f.tasks)] };
359
+ function scope(f: ViewFilters, catalog: Catalog): { suites: Suite[]; taskIds: string[]; picked: boolean } {
360
+ if (f.tasks?.length) return { suites: [], taskIds: [...new Set(f.tasks)], picked: false };
285
361
  const byId = new Map(catalog.suites.map((s) => [s.id, s]));
286
- const suites = f.suites?.length
287
- ? [...new Set(f.suites)].map((id) => byId.get(id) ?? fail(`no suite ${id} (suites: ${[...byId.keys()].join(", ") || "none"})`))
288
- : catalog.suites;
289
- return { suites, taskIds: [...new Set(suites.flatMap((s) => s.tasks))] };
362
+ const picked = !!f.suites?.length;
363
+ // Picked suites are in catalog order, so a set of picks has one view whatever order it was given in.
364
+ const ids = new Set(f.suites ?? []);
365
+ for (const id of ids) if (!byId.has(id)) fail(`no suite ${id} (suites: ${[...byId.keys()].join(", ") || "none"})`);
366
+ const suites = picked ? catalog.suites.filter((s) => ids.has(s.id)) : catalog.suites;
367
+ return { suites, taskIds: [...new Set(suites.flatMap((s) => s.tasks))], picked };
290
368
  }
291
369
 
292
370
  function pickJudge(id: string | undefined, judges: JudgeConfig[]): JudgeConfig {