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.
- package/README.md +40 -34
- package/package.json +2 -1
- package/src/adapters/index.ts +3 -3
- package/src/api.ts +38 -6
- package/src/cli.ts +106 -20
- package/src/core/batch.ts +16 -1
- package/src/core/commands.ts +7 -6
- package/src/core/compare.ts +4 -1
- package/src/core/coverage.ts +84 -0
- package/src/core/db.ts +36 -0
- package/src/core/facets.ts +23 -8
- package/src/core/init.ts +99 -9
- package/src/core/plan.ts +84 -0
- package/src/core/project.ts +95 -9
- package/src/core/regrade.ts +10 -1
- package/src/core/score.ts +1 -1
- package/src/core/task-page.ts +54 -0
- package/src/core/trial-detail.ts +3 -3
- package/src/core/types.ts +1 -1
- package/src/core/view-query.ts +30 -13
- package/src/core/view.ts +131 -53
- package/src/guide.md +128 -51
- package/src/server.ts +7 -3
- package/web/app.tsx +276 -133
- package/web/filter-menu.ts +35 -4
- package/web/how-it-works.tsx +92 -0
- package/web/index-switches.ts +29 -0
- package/web/levels.tsx +15 -0
- package/web/notices.ts +74 -0
- package/web/output-view.ts +61 -0
- package/web/pages.ts +26 -0
- package/web/styles.css +57 -22
- package/web/task-card.ts +6 -0
- package/web/tasks.tsx +122 -0
- package/web/suite-notes.ts +0 -20
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Which suites a view's index covers, and why the others are left out (DEC-019, DEC-020). The rule that picks the
|
|
2
|
+
// default set is here, and so is the one line that `static-fire show` and the dashboard both print. No Node
|
|
3
|
+
// imports: the dashboard bundles this file.
|
|
4
|
+
|
|
5
|
+
/** A suite as the line names it. */
|
|
6
|
+
export interface SuiteRef { id: string; title: string }
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Why a suite is not in view: the user did not pick it, no config has completed it, or too few configs have
|
|
10
|
+
* completed it together with the suites in view.
|
|
11
|
+
*/
|
|
12
|
+
export type LeftOutReason = "not-picked" | "none-completed" | "too-few";
|
|
13
|
+
|
|
14
|
+
/** What a view's index covers. A view of tasks (no suites) has none. */
|
|
15
|
+
export interface Coverage {
|
|
16
|
+
/** True when the suites were picked; false when the view chose them by the default rule. */
|
|
17
|
+
picked: boolean;
|
|
18
|
+
/** The suites in view, in catalog order: the Agent Performance Index is the mean of their Suite Indexes. */
|
|
19
|
+
covered: SuiteRef[];
|
|
20
|
+
/**
|
|
21
|
+
* Every other suite of the catalog, in catalog order, with the reason and how many configs in view have completed
|
|
22
|
+
* it. That count is null for a suite left out because it was not picked: the view does not read its trials.
|
|
23
|
+
*/
|
|
24
|
+
leftOut: (SuiteRef & { reason: LeftOutReason; completed: number | null })[];
|
|
25
|
+
/** How many configs have completed every covered suite: the configs that have an index. */
|
|
26
|
+
complete: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The default set of suites (DEC-019). `suites` are the suite ids in catalog order; `completedBy` gives, for each,
|
|
31
|
+
* the configs that have a valid score on every task of it. Start with every suite and drop the suite the fewest
|
|
32
|
+
* configs have completed (on a tie, the one listed last), until at least two configs have completed all that
|
|
33
|
+
* remain. When no set has two, keep the largest set that one config has completed (on a tie, the set that comes
|
|
34
|
+
* first in catalog order). When no config has completed any suite, keep every suite. The result is in catalog order, and the same input always gives the same set.
|
|
35
|
+
*/
|
|
36
|
+
export function defaultSuites(suites: string[], completedBy: Map<string, Set<string>>): string[] {
|
|
37
|
+
const done = (id: string) => completedBy.get(id) ?? new Set<string>();
|
|
38
|
+
const completers = (ids: string[]) => {
|
|
39
|
+
if (!ids.length) return 0;
|
|
40
|
+
let all = new Set(done(ids[0]!));
|
|
41
|
+
for (const id of ids.slice(1)) all = new Set([...all].filter((c) => done(id).has(c)));
|
|
42
|
+
return all.size;
|
|
43
|
+
};
|
|
44
|
+
const left = [...suites];
|
|
45
|
+
while (left.length && completers(left) < 2) {
|
|
46
|
+
let drop = left[0]!;
|
|
47
|
+
for (const id of left) if (done(id).size <= done(drop).size) drop = id; // <=: on a tie the later one
|
|
48
|
+
left.splice(left.indexOf(drop), 1);
|
|
49
|
+
}
|
|
50
|
+
if (left.length && completers(left) >= 2) return left;
|
|
51
|
+
|
|
52
|
+
// No set has two configs: the largest set one config has completed.
|
|
53
|
+
const configs = [...new Set([...completedBy.values()].flatMap((s) => [...s]))].sort();
|
|
54
|
+
const pos = (ids: string[]) => ids.map((id) => suites.indexOf(id));
|
|
55
|
+
const earlier = (a: string[], b: string[]) => { const pa = pos(a), pb = pos(b); for (let i = 0; i < pa.length; i++) if (pa[i] !== pb[i]) return pa[i]! < pb[i]!; return false; };
|
|
56
|
+
let best: string[] = [];
|
|
57
|
+
for (const c of configs) {
|
|
58
|
+
const set = suites.filter((id) => done(id).has(c));
|
|
59
|
+
if (set.length > best.length || (set.length === best.length && earlier(set, best))) best = set;
|
|
60
|
+
}
|
|
61
|
+
return best.length ? best : [...suites];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const list = (names: string[]) => names.length <= 1 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The one line a view gives about its index: which suites it covers and, grouped by reason, which it leaves out
|
|
68
|
+
* and why. `judge` is the label of the judge in view. Null for a view of tasks.
|
|
69
|
+
*/
|
|
70
|
+
export function coverageLine(coverage: Coverage | null, judge: string): string | null {
|
|
71
|
+
if (!coverage) return null;
|
|
72
|
+
const parts = [`Covers ${list(coverage.covered.map((s) => s.title)) || "no suite"}.`];
|
|
73
|
+
const why: Record<LeftOutReason, (many: boolean) => string> = {
|
|
74
|
+
"none-completed": () => `no config has a score on every task under judge ${judge}`,
|
|
75
|
+
"too-few": (many) => `fewer than two configs have completed ${many ? "them" : "it"} with the suites covered`,
|
|
76
|
+
"not-picked": () => "not picked",
|
|
77
|
+
};
|
|
78
|
+
const groups = (["none-completed", "too-few", "not-picked"] as const)
|
|
79
|
+
.map((r) => [r, coverage.leftOut.filter((s) => s.reason === r)] as const).filter(([, s]) => s.length)
|
|
80
|
+
.map(([r, s]) => `${list(s.map((x) => x.title))} (${why[r](s.length > 1)})`);
|
|
81
|
+
if (groups.length) parts.push(`Left out: ${groups.join("; ")}.`);
|
|
82
|
+
if (!coverage.complete && coverage.covered.length) parts.push(`No config has a score on every task of ${coverage.covered.length > 1 ? "these suites" : "it"} yet.`);
|
|
83
|
+
return parts.join(" ");
|
|
84
|
+
}
|
package/src/core/db.ts
CHANGED
|
@@ -344,6 +344,42 @@ export function openStore(path: string): Store {
|
|
|
344
344
|
return storeOn(db);
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
+
/** A new, empty store in memory, with no file: what a plan reads when the project has no record store yet. */
|
|
348
|
+
export function emptyStore(): Store {
|
|
349
|
+
const db = new Database(":memory:", { strict: true });
|
|
350
|
+
db.run("PRAGMA foreign_keys = ON");
|
|
351
|
+
migrate(db, ":memory:");
|
|
352
|
+
return storeOn(db);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* A copy in memory of the store at `path`, for a plan, which must write nothing (#74). The file is opened read-only
|
|
357
|
+
* and read in one go; an older schema is brought up to SCHEMA_VERSION in the copy only, so the file keeps its
|
|
358
|
+
* schema, its journal mode and its content. Writes to the copy are lost when it is closed.
|
|
359
|
+
*/
|
|
360
|
+
export function snapshotStore(path: string): Store {
|
|
361
|
+
const file = new Database(path, { readonly: true, strict: true });
|
|
362
|
+
let bytes: Uint8Array;
|
|
363
|
+
try {
|
|
364
|
+
file.run(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
365
|
+
bytes = file.serialize();
|
|
366
|
+
} finally {
|
|
367
|
+
file.close();
|
|
368
|
+
}
|
|
369
|
+
// Bytes 18 and 19 of the header say WAL mode; an image in memory cannot use WAL, so mark it as a rollback journal.
|
|
370
|
+
bytes[18] = 1;
|
|
371
|
+
bytes[19] = 1;
|
|
372
|
+
const db = Database.deserialize(bytes, { strict: true });
|
|
373
|
+
try {
|
|
374
|
+
db.run("PRAGMA foreign_keys = ON");
|
|
375
|
+
migrate(db, path);
|
|
376
|
+
} catch (e) {
|
|
377
|
+
db.close();
|
|
378
|
+
throw e;
|
|
379
|
+
}
|
|
380
|
+
return storeOn(db);
|
|
381
|
+
}
|
|
382
|
+
|
|
347
383
|
/**
|
|
348
384
|
* Switches a new database to WAL mode (the mode is kept in the file, so later opens only read it).
|
|
349
385
|
* SQLite does not apply the busy timeout to this switch: when another process is creating the same file
|
package/src/core/facets.ts
CHANGED
|
@@ -36,7 +36,7 @@ export interface GradingOption extends VersionName {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export interface Facets {
|
|
39
|
-
/** The
|
|
39
|
+
/** The Agent Performance Index and every suite and task with a stored trial. A suite's count is the sum over its tasks. */
|
|
40
40
|
scope: { index: number; suites: FacetValue[]; tasks: FacetValue[] };
|
|
41
41
|
/** The judges in judges.yaml that have graded a task in scope. */
|
|
42
42
|
judge: FacetValue[];
|
|
@@ -63,8 +63,11 @@ const tally = (values: string[]): Map<string, number> => {
|
|
|
63
63
|
};
|
|
64
64
|
|
|
65
65
|
/** The facets for `filters`. Throws on the same bad filters as buildView. */
|
|
66
|
-
export function buildFacets(store: Store,
|
|
67
|
-
const base = buildView(store,
|
|
66
|
+
export function buildFacets(store: Store, given: ViewFilters = {}, catalog: Catalog = projectCatalog()): Facets {
|
|
67
|
+
const base = buildView(store, given, catalog); // validates the filters and resolves scope, judge and grading
|
|
68
|
+
// The counts are over the suites the view resolved: without a pick, the default set moves with the filters,
|
|
69
|
+
// and a count without one filter must not count other suites than the view it is offered in.
|
|
70
|
+
const filters: ViewFilters = given.tasks?.length || !base.filters.suiteIds.length ? given : { ...given, suites: base.filters.suiteIds };
|
|
68
71
|
const { taskIds } = base.filters;
|
|
69
72
|
const picked = base.filters.grading ? store.getGradingSetup(base.filters.grading) : null;
|
|
70
73
|
const pickedTasks = picked ? store.gradingSetupTaskIds(picked.hash) : [];
|
|
@@ -73,16 +76,28 @@ export function buildFacets(store: Store, filters: ViewFilters = {}, catalog: Ca
|
|
|
73
76
|
const inScope = all.filter((r) => taskIds.includes(r.taskId));
|
|
74
77
|
const listed = (f: ViewFilters): TrialRecord[] => buildView(store, f, catalog).trials.map((t) => byId.get(t.id)!);
|
|
75
78
|
|
|
76
|
-
// Scope
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
// Scope. A count is the number of trials the view would list with that value chosen (#80). A task: the view of
|
|
80
|
+
// that task, with the picked grading setup only when it is of that task (the page drops it for another task), so
|
|
81
|
+
// a task outside the default set of suites counts its own trials. A suite: the sum over its tasks. The index:
|
|
82
|
+
// the default set, or, with a picked grading setup, the suite of its task (the page picks it, web/filter-menu.ts
|
|
83
|
+
// withGradingPick).
|
|
84
|
+
const unscoped = without(filters, "suites", "tasks", "grading");
|
|
80
85
|
const stored = new Set(all.map((r) => r.taskId));
|
|
86
|
+
const loads = (t: string) => { try { catalog.loadTask(t); return true; } catch { return false; } };
|
|
87
|
+
const storedTasks = [...stored].filter(loads).sort();
|
|
88
|
+
const others = storedTasks.filter((t) => !pickedTasks.includes(t));
|
|
89
|
+
const ofTasks = [
|
|
90
|
+
...(others.length ? listed({ ...unscoped, tasks: others }) : []),
|
|
91
|
+
...(picked ? listed({ ...unscoped, tasks: pickedTasks, grading: picked.hash }) : []),
|
|
92
|
+
];
|
|
93
|
+
const perTask = tally(ofTasks.map((r) => r.taskId));
|
|
94
|
+
const home = picked ? catalog.suites.find((s) => s.tasks.some((t) => pickedTasks.includes(t))) : undefined;
|
|
95
|
+
const index = home ? listed({ ...unscoped, suites: [home.id], grading: picked!.hash }) : listed(unscoped);
|
|
81
96
|
const scope = {
|
|
82
97
|
index: index.length,
|
|
83
98
|
suites: catalog.suites.filter((s) => s.tasks.some((t) => stored.has(t)))
|
|
84
99
|
.map((s) => ({ value: s.id, count: s.tasks.reduce((n, t) => n + (perTask.get(t) ?? 0), 0) })),
|
|
85
|
-
tasks:
|
|
100
|
+
tasks: storedTasks.map((t) => ({ value: t, count: perTask.get(t) ?? 0 })),
|
|
86
101
|
};
|
|
87
102
|
|
|
88
103
|
// A grading setup is offered for a task only when a stored trial of that task carries a grading by it.
|
package/src/core/init.ts
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
|
-
// `static-fire init`: make
|
|
1
|
+
// `static-fire init [<folder>]`: make the home, report it, or make a second project (ADR-0010). Outside any project
|
|
2
|
+
// and with no home, it makes the project in ~/static-fire (or <folder>) and saves it as the home. With a home and no
|
|
3
|
+
// folder, it reports the home and writes nothing. With a home and a folder, it makes a second project there and
|
|
4
|
+
// leaves the home as it is. Inside a project and with no folder, it writes nothing and names that project. Only the
|
|
5
|
+
// first home is saved here; no init changes a home that is set.
|
|
6
|
+
//
|
|
7
|
+
// Making a project in a folder (initProject): it finds the harness CLIs on the PATH (it never runs
|
|
2
8
|
// them), writes one config per harness found, judges.yaml with one default judge, static-fire.yaml, empty evals/
|
|
3
9
|
// and suites/, a .gitignore and a short AGENTS.md that points an agent to `static-fire guide`.
|
|
4
10
|
//
|
|
5
11
|
// The folder belongs to the user. init only adds: every file is created with the exclusive flag, so a file that
|
|
6
12
|
// exists (or appears while init runs) is kept as it is and reported. In a project, or a subfolder of one, init
|
|
7
13
|
// writes nothing. static-fire.yaml, the project marker, is written last.
|
|
8
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
9
15
|
import { join, resolve } from "node:path";
|
|
10
16
|
import { configLabel } from "./label";
|
|
11
17
|
import { findOutsideTools, type HarnessTools } from "./outside-tools";
|
|
12
|
-
import { findProject, PROJECT_FILE } from "./project";
|
|
18
|
+
import { defaultHomeFolder, findProject, HomeNotProjectError, PROJECT_FILE, readHome, saveHome, type Env } from "./project";
|
|
13
19
|
import type { AgentConfig, AgentHarness, JudgeConfig } from "./types";
|
|
14
20
|
|
|
15
21
|
/** The harnesses init looks for, in the order a default judge is chosen. */
|
|
@@ -41,8 +47,9 @@ export type HarnessFound = { found: true; path: string } | { found: false };
|
|
|
41
47
|
/** A harness as init reports it: where its program is, and the outside tools in its user setup (outside-tools.ts). */
|
|
42
48
|
export type HarnessReport = HarnessFound & HarnessTools;
|
|
43
49
|
|
|
44
|
-
/** What init found and did. `harnesses` is keyed by harness, so each entry can carry more facts later. */
|
|
45
|
-
export interface
|
|
50
|
+
/** What init found and did in one folder. `harnesses` is keyed by harness, so each entry can carry more facts later. */
|
|
51
|
+
export interface ProjectInit {
|
|
52
|
+
/** The project's folder: the one made, or the root of the project the folder is already in. */
|
|
46
53
|
project: string;
|
|
47
54
|
/** false when the folder is already in a project: nothing was written. */
|
|
48
55
|
created: boolean;
|
|
@@ -56,6 +63,24 @@ export interface InitReport {
|
|
|
56
63
|
notes: string[];
|
|
57
64
|
}
|
|
58
65
|
|
|
66
|
+
/** What `static-fire init` found and did, with the home. */
|
|
67
|
+
export interface InitReport extends ProjectInit {
|
|
68
|
+
/** The saved home's folder after this init, or null when none is set. */
|
|
69
|
+
home: string | null;
|
|
70
|
+
/** The project is the home. */
|
|
71
|
+
isHome: boolean;
|
|
72
|
+
/** This init saved the home: it was the first one. */
|
|
73
|
+
homeSaved: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The folder given to init is a file, or is under one. */
|
|
77
|
+
export class NotAFolderError extends Error {
|
|
78
|
+
constructor(path: string) {
|
|
79
|
+
super(`${path} is not a folder, so no project can be made in it. Nothing was written.`);
|
|
80
|
+
this.name = "NotAFolderError";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
59
84
|
/** Each harness's program on `PATH`, found and never run. */
|
|
60
85
|
export function findHarnesses(PATH = process.env.PATH ?? ""): Record<InitHarness, HarnessFound> {
|
|
61
86
|
return Object.fromEntries(INIT_HARNESSES.map((h) => {
|
|
@@ -135,8 +160,61 @@ judges: []
|
|
|
135
160
|
return `${JUDGES_HEAD}judges:\n - id: ${id}\n${yamlLines(rest, " ")}`;
|
|
136
161
|
}
|
|
137
162
|
|
|
138
|
-
|
|
139
|
-
|
|
163
|
+
export interface InitOptions {
|
|
164
|
+
/** The folder init was given, resolved against `cwd`. None: the current project, the home, or ~/static-fire. */
|
|
165
|
+
folder?: string;
|
|
166
|
+
cwd?: string;
|
|
167
|
+
/** Where HOME (for ~/static-fire and the saved home) and XDG_CONFIG_HOME are read. */
|
|
168
|
+
env?: Env;
|
|
169
|
+
PATH?: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* `static-fire init`. Throws HomeNotProjectError with no folder when the saved home is not a project any more, and
|
|
174
|
+
* NotAFolderError when the folder is a file; both before anything is written.
|
|
175
|
+
*/
|
|
176
|
+
export function runInit(o: InitOptions = {}): InitReport {
|
|
177
|
+
const env = o.env ?? process.env;
|
|
178
|
+
const cwd = resolve(o.cwd ?? process.cwd());
|
|
179
|
+
const PATH = o.PATH ?? env.PATH ?? "";
|
|
180
|
+
const saved = readHome(env);
|
|
181
|
+
const savedIsProject = saved !== null && existsSync(join(saved, PROJECT_FILE));
|
|
182
|
+
|
|
183
|
+
// With no folder: the project init runs in, else the home, else the default home folder.
|
|
184
|
+
const here = o.folder === undefined ? findProject(cwd) : null;
|
|
185
|
+
let target: string;
|
|
186
|
+
if (o.folder !== undefined) target = resolve(cwd, o.folder);
|
|
187
|
+
else if (here) target = here;
|
|
188
|
+
else if (saved) {
|
|
189
|
+
if (!savedIsProject) throw new HomeNotProjectError(saved);
|
|
190
|
+
target = saved;
|
|
191
|
+
} else target = defaultHomeFolder(env);
|
|
192
|
+
|
|
193
|
+
if (!findProject(target)) {
|
|
194
|
+
if (existsSync(target) && !statSync(target).isDirectory()) throw new NotAFolderError(target);
|
|
195
|
+
try {
|
|
196
|
+
mkdirSync(target, { recursive: true });
|
|
197
|
+
} catch (e) {
|
|
198
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
199
|
+
if (code === "ENOTDIR" || code === "EEXIST") throw new NotAFolderError(target);
|
|
200
|
+
throw e;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const report = initProject(target, PATH);
|
|
204
|
+
|
|
205
|
+
// The first home only: init inside a project with no folder writes nothing, and a home that is set never changes.
|
|
206
|
+
let home = saved, homeSaved = false;
|
|
207
|
+
if (!saved && !here) {
|
|
208
|
+
home = saveHome(report.project, env);
|
|
209
|
+
homeSaved = true;
|
|
210
|
+
}
|
|
211
|
+
const notes = [...report.notes];
|
|
212
|
+
if (saved && !savedIsProject) notes.push(new HomeNotProjectError(saved).message);
|
|
213
|
+
return { ...report, notes, home, isHome: home !== null && resolve(home) === report.project, homeSaved };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Make a project in `dir` (the current folder by default), or change nothing when `dir` is already in one. The home is not read or saved here. */
|
|
217
|
+
export function initProject(dir = process.cwd(), PATH = process.env.PATH ?? ""): ProjectInit {
|
|
140
218
|
const root = resolve(dir);
|
|
141
219
|
const harnesses = reportHarnesses(PATH);
|
|
142
220
|
const existing = findProject(root);
|
|
@@ -195,7 +273,7 @@ export function initProject(dir = process.cwd(), PATH = process.env.PATH ?? ""):
|
|
|
195
273
|
}
|
|
196
274
|
|
|
197
275
|
/** The outside tools section of the text report. */
|
|
198
|
-
function formatTools(r:
|
|
276
|
+
function formatTools(r: ProjectInit): string[] {
|
|
199
277
|
const lines = ["outside tools in each harness's setup. To block one in trials, add its entry under trials.blockedTools.<harness> in static-fire.yaml:"];
|
|
200
278
|
for (const h of INIT_HARNESSES) {
|
|
201
279
|
const x = r.harnesses[h];
|
|
@@ -211,13 +289,25 @@ function formatTools(r: InitReport): string[] {
|
|
|
211
289
|
return lines;
|
|
212
290
|
}
|
|
213
291
|
|
|
292
|
+
/** The line that says if the project is the home. */
|
|
293
|
+
function homeLine(r: InitReport): string {
|
|
294
|
+
if (r.homeSaved) return "it is now the home: a command run outside any project uses it";
|
|
295
|
+
if (r.isHome) return "it is the home: a command run outside any project uses it";
|
|
296
|
+
if (r.home) return `it is not the home. The home is ${r.home}, and it is not changed.`;
|
|
297
|
+
return "it is not the home, and no home is set. Run `static-fire home <folder>` with a project folder to set one.";
|
|
298
|
+
}
|
|
299
|
+
|
|
214
300
|
/** The report as text, for a person. */
|
|
215
301
|
export function formatReport(r: InitReport): string {
|
|
216
|
-
if (!r.created)
|
|
302
|
+
if (!r.created) {
|
|
303
|
+
const head = r.isHome && !r.homeSaved ? `the home project: ${r.project}` : `already a Static Fire project: ${r.project}`;
|
|
304
|
+
return `${[head, "nothing changed in it", homeLine(r), ...r.notes.map((n) => `note: ${n}`), ...formatTools(r)].join("\n")}\n`;
|
|
305
|
+
}
|
|
217
306
|
const found = INIT_HARNESSES.filter((h) => r.harnesses[h].found);
|
|
218
307
|
const missing = INIT_HARNESSES.filter((h) => !r.harnesses[h].found);
|
|
219
308
|
const lines = [
|
|
220
309
|
`made a Static Fire project in ${r.project}`,
|
|
310
|
+
homeLine(r),
|
|
221
311
|
`harnesses found: ${found.length ? found.map((h) => `${h} (${(r.harnesses[h] as { path: string }).path})`).join(", ") : "none"}`,
|
|
222
312
|
...(missing.length && found.length ? [`not found: ${missing.join(", ")}`] : []),
|
|
223
313
|
`judge: ${r.judge ?? "none"}`,
|
package/src/core/plan.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The plan in runs: what a batch or a regrade will start, in the units a subscription counts. Agent runs for each
|
|
2
|
+
// config and judge calls for the judge (DEC-008 of #71). The counts come from the plans the engine runs:
|
|
3
|
+
// batch.ts plannedTrials (the trials startBatch queues) and judgeCallRange below, which counts with grade.ts
|
|
4
|
+
// planBatches and score.ts firstRoundVotes (the rules `grade` runs), so they cannot drift from what starts.
|
|
5
|
+
// `run --plan` and `regrade --plan` print it and start nothing; `--json` prints it as JSON.
|
|
6
|
+
import { plannedTrials, type BatchPlan } from "./batch";
|
|
7
|
+
import { planBatches } from "./grade";
|
|
8
|
+
import { configLabel } from "./label";
|
|
9
|
+
import type { RegradePlan } from "./regrade";
|
|
10
|
+
import { firstRoundVotes } from "./score";
|
|
11
|
+
import type { Task } from "./types";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The judge calls one grading of an output makes, as the fewest and the most: one call per vote for each batch of
|
|
15
|
+
* planBatches (the batching `grade` uses). The fewest is when the first round of votes agrees in every batch
|
|
16
|
+
* (firstRoundVotes), the most when every planned vote runs. A retry of an answer that does not parse is not
|
|
17
|
+
* counted, and an output that is missing gets no judge call.
|
|
18
|
+
*/
|
|
19
|
+
export function judgeCallRange(task: Pick<Task, "gates" | "measures">, votes: number): { min: number; max: number } {
|
|
20
|
+
const calls = planBatches(task).length;
|
|
21
|
+
return { min: calls * Math.min(firstRoundVotes(votes), votes), max: calls * votes };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What the judge call counts leave out (#74): judge.ts tries an answer that does not parse once more. */
|
|
25
|
+
export const NOT_COUNTED = "the one retry of a judge call whose answer does not parse";
|
|
26
|
+
|
|
27
|
+
export interface PlanInRuns {
|
|
28
|
+
command: "run" | "regrade";
|
|
29
|
+
/** Trials the batch starts, or the regrade grades. */
|
|
30
|
+
trials: number;
|
|
31
|
+
/** One per config of a batch, in the batch's order; empty for a regrade. A human config needs no agent run. */
|
|
32
|
+
agentRuns: { config: string; label: string; harness: string; trials: number; agentRuns: number }[];
|
|
33
|
+
/** Fewest: the first round of votes agrees in every call group. Most: every planned vote runs. `notCounted` says what is left out. */
|
|
34
|
+
judgeCalls: { judge: string; label: string; votes: number; min: number; max: number; notCounted: string };
|
|
35
|
+
/** Regrade only: the trials that passed the filters, and why the others are not graded. */
|
|
36
|
+
matched?: number;
|
|
37
|
+
skipped?: RegradePlan["skipped"];
|
|
38
|
+
/** Run only: how many tasks and configs. */
|
|
39
|
+
tasks?: number;
|
|
40
|
+
configs?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The plan in runs of a planned batch. */
|
|
44
|
+
export function batchPlanInRuns(plan: BatchPlan): PlanInRuns {
|
|
45
|
+
const trials = plannedTrials(plan);
|
|
46
|
+
const judgeCalls = { judge: plan.judge.id, label: plan.judge.label, votes: plan.votes, min: 0, max: 0, notCounted: NOT_COUNTED };
|
|
47
|
+
for (const { task } of trials) {
|
|
48
|
+
const r = judgeCallRange(task, plan.votes);
|
|
49
|
+
judgeCalls.min += r.min;
|
|
50
|
+
judgeCalls.max += r.max;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
command: "run", trials: trials.length, tasks: plan.tasks.length, configs: plan.configs.length,
|
|
54
|
+
agentRuns: plan.configs.map((c) => {
|
|
55
|
+
const n = trials.filter((t) => t.config.id === c.id).length;
|
|
56
|
+
return { config: c.id, label: configLabel(c), harness: c.harness, trials: n, agentRuns: c.harness === "human" ? 0 : n };
|
|
57
|
+
}),
|
|
58
|
+
judgeCalls,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The plan in runs of a regrade: judge calls only. */
|
|
63
|
+
export function regradePlanInRuns(plan: RegradePlan): PlanInRuns {
|
|
64
|
+
return {
|
|
65
|
+
command: "regrade", trials: plan.todo.length, agentRuns: [], matched: plan.matched, skipped: plan.skipped,
|
|
66
|
+
judgeCalls: { judge: plan.judge.id, label: plan.judge.label, votes: plan.votes, ...plan.judgeCalls, notCounted: NOT_COUNTED },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const s = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
71
|
+
|
|
72
|
+
/** The plan as text: a heading line, one line per config, one line for the judge, and what is not counted when there are judge calls. */
|
|
73
|
+
export function formatPlan(p: PlanInRuns): string {
|
|
74
|
+
const head = p.command === "run"
|
|
75
|
+
? `plan: ${s(p.tasks!, "task")} × ${s(p.configs!, "config")}, ${s(p.trials, "trial")}`
|
|
76
|
+
: `plan: ${s(p.trials, "trial")} to grade, of ${p.matched} matching; no agent runs`;
|
|
77
|
+
const rows = p.agentRuns.map((r) => ` agent runs ${String(r.agentRuns).padStart(4)} ${r.label} (${r.config}${r.harness === "human" ? `; ${s(r.trials, "trial")}, no agent run for a human config` : ""})`);
|
|
78
|
+
const j = p.judgeCalls;
|
|
79
|
+
const calls = j.min === j.max ? String(j.max) : `${j.min} to ${j.max}`;
|
|
80
|
+
const why = j.min === j.max ? "" : `; the fewest when the first votes agree`;
|
|
81
|
+
rows.push(` judge calls ${calls.padStart(4)} ${j.label} (${j.judge}; ${s(j.votes, "vote")}${why})`);
|
|
82
|
+
if (j.max) rows.push(` not counted ${j.notCounted}`);
|
|
83
|
+
return [head, ...rows].join("\n");
|
|
84
|
+
}
|
package/src/core/project.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
// The two roots. The engine root holds the engine's own files: its source, the dashboard and pricing.yaml. It
|
|
2
2
|
// comes from this file's location, never from the current folder, so it holds when the engine runs from
|
|
3
3
|
// node_modules. The project root is the user's folder: static-fire.yaml, configs/, judges.yaml, suites/,
|
|
4
|
-
// evals/, the record store and trial artifacts in runs/, and the Profile cache in .cache/. A command
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
// evals/, the record store and trial artifacts in runs/, and the Profile cache in .cache/. A command uses the
|
|
5
|
+
// nearest project at or above the current folder; outside any project it uses the home, the one project saved in
|
|
6
|
+
// the user's config folder (ADR-0010). Nothing of a project is written to the engine root.
|
|
7
|
+
//
|
|
8
|
+
// This module is the one place that finds the project a command uses and that reads and writes the saved home.
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
8
12
|
|
|
9
13
|
export const ENGINE_ROOT = resolve(import.meta.dir, "../..");
|
|
10
14
|
export const PROJECT_FILE = "static-fire.yaml";
|
|
@@ -17,7 +21,91 @@ export class NoProjectError extends Error {
|
|
|
17
21
|
}
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
/** The
|
|
24
|
+
/** The saved home is not a project any more: its folder is gone, or has no static-fire.yaml. */
|
|
25
|
+
export class HomeNotProjectError extends Error {
|
|
26
|
+
constructor(readonly home: string) {
|
|
27
|
+
super(`the home, ${home}, is not a Static Fire project any more: there is no ${PROJECT_FILE} in it. Run \`static-fire home <folder>\` with a project folder to set the home again.`);
|
|
28
|
+
this.name = "HomeNotProjectError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A folder given as the home holds no static-fire.yaml. */
|
|
33
|
+
export class NotAProjectError extends Error {
|
|
34
|
+
constructor(folder: string) {
|
|
35
|
+
super(`${folder} is not a Static Fire project: there is no ${PROJECT_FILE} in it. The home is not changed.`);
|
|
36
|
+
this.name = "NotAProjectError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type Env = Record<string, string | undefined>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The file that holds the saved home: XDG_CONFIG_HOME/static-fire/home when XDG_CONFIG_HOME is an absolute path,
|
|
44
|
+
* else HOME/.config/static-fire/home. It holds one line, the home's absolute folder, and nothing else. HOME is read
|
|
45
|
+
* from `env`, because Bun's os.homedir() does not see a HOME changed while the process runs (tests move it).
|
|
46
|
+
*/
|
|
47
|
+
export function homeFile(env: Env = process.env): string {
|
|
48
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
49
|
+
const config = xdg && isAbsolute(xdg) ? xdg : join(userFolder(env), ".config");
|
|
50
|
+
return join(config, "static-fire", "home");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The user's own folder: HOME from `env`, for the reason homeFile gives. */
|
|
54
|
+
function userFolder(env: Env): string {
|
|
55
|
+
return env.HOME || homedir();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Where the first `init` outside any project makes the home: HOME/static-fire. XDG_CONFIG_HOME does not move it. */
|
|
59
|
+
export function defaultHomeFolder(env: Env = process.env): string {
|
|
60
|
+
return join(userFolder(env), "static-fire");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The saved home's folder, or null when none is saved (no file, or an empty one). */
|
|
64
|
+
export function readHome(env: Env = process.env): string | null {
|
|
65
|
+
const file = homeFile(env);
|
|
66
|
+
if (!existsSync(file)) return null;
|
|
67
|
+
return readFileSync(file, "utf8").trim() || null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Saves `folder` as the home when it holds static-fire.yaml; else throws NotAProjectError and changes nothing. */
|
|
71
|
+
export function saveHome(folder: string, env: Env = process.env): string {
|
|
72
|
+
const root = resolve(folder);
|
|
73
|
+
if (!existsSync(join(root, PROJECT_FILE))) throw new NotAProjectError(root);
|
|
74
|
+
const file = homeFile(env);
|
|
75
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
76
|
+
const tmp = `${file}.${process.pid}.tmp`; // a reader never sees half a file
|
|
77
|
+
writeFileSync(tmp, `${root}\n`);
|
|
78
|
+
renameSync(tmp, file);
|
|
79
|
+
return root;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The project a command uses: `home` is true when it is the saved home, used from outside any project. */
|
|
83
|
+
export interface ResolvedProject { root: string; home: boolean }
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The nearest project from `from` upward; outside any project, the saved home. Throws NoProjectError when there
|
|
87
|
+
* is neither, and HomeNotProjectError when the saved home is not a project any more. Reads only.
|
|
88
|
+
*/
|
|
89
|
+
export function resolveProject(from = process.cwd(), env: Env = process.env): ResolvedProject {
|
|
90
|
+
const near = findProject(from);
|
|
91
|
+
if (near) return { root: near, home: false };
|
|
92
|
+
const home = readHome(env);
|
|
93
|
+
if (!home) throw new NoProjectError(resolve(from));
|
|
94
|
+
if (!existsSync(join(home, PROJECT_FILE))) throw new HomeNotProjectError(home);
|
|
95
|
+
return { root: home, home: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The project resolveProject finds, or null where it would throw. */
|
|
99
|
+
export function currentProject(from = process.cwd(), env: Env = process.env): string | null {
|
|
100
|
+
try {
|
|
101
|
+
return resolveProject(from, env).root;
|
|
102
|
+
} catch (e) {
|
|
103
|
+
if (e instanceof NoProjectError || e instanceof HomeNotProjectError) return null;
|
|
104
|
+
throw e;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The first folder from `from` upward that holds static-fire.yaml, or null. Does not read the home. */
|
|
21
109
|
export function findProject(from = process.cwd()): string | null {
|
|
22
110
|
for (let dir = resolve(from); ; dir = dirname(dir)) {
|
|
23
111
|
if (existsSync(join(dir, PROJECT_FILE))) return dir;
|
|
@@ -25,11 +113,9 @@ export function findProject(from = process.cwd()): string | null {
|
|
|
25
113
|
}
|
|
26
114
|
}
|
|
27
115
|
|
|
28
|
-
/** The project
|
|
116
|
+
/** The root of the project resolveProject finds from `from`. Throws as resolveProject does. */
|
|
29
117
|
export function projectRoot(from = process.cwd()): string {
|
|
30
|
-
|
|
31
|
-
if (!root) throw new NoProjectError(resolve(from));
|
|
32
|
-
return root;
|
|
118
|
+
return resolveProject(from).root;
|
|
33
119
|
}
|
|
34
120
|
|
|
35
121
|
export interface ProjectPaths {
|
package/src/core/regrade.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// grading and the trial is graded again. The stale row is left as it is: the store cannot tell it from a grading
|
|
9
9
|
// another process is doing right now.
|
|
10
10
|
import { DEFAULT_VOTES, gradeTrial } from "./batch";
|
|
11
|
+
import { judgeCallRange } from "./plan";
|
|
11
12
|
import type { Store, TrialRecord } from "./db";
|
|
12
13
|
import { gradingIdentity, taskIdentity, configKey } from "./identity";
|
|
13
14
|
import { loadConfigs } from "./authored";
|
|
@@ -69,6 +70,8 @@ export interface RegradePlan {
|
|
|
69
70
|
/** The trials to grade, in id order, with the grading hash each gets. */
|
|
70
71
|
todo: { trialId: string; taskId: string; gradingHash: string }[];
|
|
71
72
|
skipped: RegradeSkipped;
|
|
73
|
+
/** The judge calls grading `todo` makes, fewest and most (plan.ts judgeCallRange). */
|
|
74
|
+
judgeCalls: { min: number; max: number };
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
export interface RegradeCounts {
|
|
@@ -157,7 +160,13 @@ function resolve(store: Store, f: RegradeFilters, o: RegradeOptions): Resolved {
|
|
|
157
160
|
todo.push({ trialId: t.id, taskId: t.taskId, gradingHash: h.gradingHash });
|
|
158
161
|
}
|
|
159
162
|
const tasks = new Map([...current].flatMap(([id, h]) => (h ? [[id, h.task] as const] : [])));
|
|
160
|
-
|
|
163
|
+
const judgeCalls = { min: 0, max: 0 };
|
|
164
|
+
for (const x of todo) {
|
|
165
|
+
const r = judgeCallRange(tasks.get(x.taskId)!, votes);
|
|
166
|
+
judgeCalls.min += r.min;
|
|
167
|
+
judgeCalls.max += r.max;
|
|
168
|
+
}
|
|
169
|
+
return { plan: { judge, votes, matched: trials.length, todo, skipped, judgeCalls }, trials: new Map(trials.map((t) => [t.id, t])), tasks };
|
|
161
170
|
}
|
|
162
171
|
|
|
163
172
|
function canLoad(catalog: Catalog, id: string) {
|
package/src/core/score.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Pure scoring (ADR-0001). Verdicts → measure % → task score → Suite Index →
|
|
1
|
+
// Pure scoring (ADR-0001). Verdicts → measure % → task score → Suite Index → Agent Performance Index.
|
|
2
2
|
// No I/O here, so every rule is covered by tests/score.test.ts.
|
|
3
3
|
import { configLabel, EFFORTS } from "./label";
|
|
4
4
|
import type { ConfigSummary, Experiment, ExperimentSummary, Grades, Stat, Task, TaskSummary, Trial, Verdict, Vote } from "./types";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// What the dashboard's Tasks page shows of a task (#79): what the agent gets, the output path, the gates (the
|
|
2
|
+
// engine's output gate first) and the criteria by measure. Built from an allowlist, so the graders' own material (references, sources, the held-out
|
|
3
|
+
// file) and grading details (context, check details, evidence, derived evidence scripts) never reach the page.
|
|
4
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
5
|
+
import { join, relative, sep } from "node:path";
|
|
6
|
+
import { OUTPUT_GATE } from "./grade";
|
|
7
|
+
import type { Criterion, Level, Task } from "./types";
|
|
8
|
+
|
|
9
|
+
export interface TaskPageCriterion { id: string; question: string; level: Level; grader: Criterion["grader"] }
|
|
10
|
+
|
|
11
|
+
export interface TaskPage {
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
description: string;
|
|
15
|
+
version?: number;
|
|
16
|
+
prompt: string;
|
|
17
|
+
/** The input files as the agent finds them in its workspace (`inputs/brief.md`). */
|
|
18
|
+
inputFiles: string[];
|
|
19
|
+
/** The project files, copied to the workspace root. Only when the task has `project`. */
|
|
20
|
+
projectFiles?: string[];
|
|
21
|
+
output: string;
|
|
22
|
+
/** The engine's own output gate (grade.ts OUTPUT_GATE), which every task has, first, then the task's own gates. */
|
|
23
|
+
gates: TaskPageCriterion[];
|
|
24
|
+
measures: { id: string; label: string; weight: number; criteria: TaskPageCriterion[] }[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** File paths under `dir`, relative to `from`, with "/" separators. Names that start with "." are left out, as the task hash leaves them out. */
|
|
28
|
+
function fileNames(dir: string, from: string): string[] {
|
|
29
|
+
const out: string[] = [];
|
|
30
|
+
const walk = (p: string) => {
|
|
31
|
+
if (statSync(p).isFile()) { out.push(relative(from, p).split(sep).join("/")); return; }
|
|
32
|
+
for (const f of readdirSync(p).sort()) if (!f.startsWith(".")) walk(join(p, f));
|
|
33
|
+
};
|
|
34
|
+
if (existsSync(dir)) walk(dir);
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const criterion = (c: Criterion): TaskPageCriterion => ({ id: c.id, question: c.question, level: c.level, grader: c.grader });
|
|
39
|
+
|
|
40
|
+
export function taskPage(task: Task): TaskPage {
|
|
41
|
+
const projectDir = task.project ? join(task.dir, task.project) : null;
|
|
42
|
+
return {
|
|
43
|
+
id: task.id,
|
|
44
|
+
title: task.title,
|
|
45
|
+
description: task.description ?? "",
|
|
46
|
+
...(task.version != null ? { version: task.version } : {}),
|
|
47
|
+
prompt: task.prompt,
|
|
48
|
+
inputFiles: fileNames(join(task.dir, task.inputs), task.dir),
|
|
49
|
+
...(projectDir ? { projectFiles: fileNames(projectDir, projectDir) } : {}),
|
|
50
|
+
output: task.output,
|
|
51
|
+
gates: [OUTPUT_GATE, ...(task.gates ?? [])].map(criterion),
|
|
52
|
+
measures: task.measures.map((m) => ({ id: m.id, label: m.label, weight: m.weight ?? 1, criteria: m.criteria.map(criterion) })),
|
|
53
|
+
};
|
|
54
|
+
}
|