@staix/agent-research 0.0.0-stage → 0.1.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +572 -2
  4. package/bin/ares.js +11 -0
  5. package/package.json +35 -4
  6. package/src/cli.ts +295 -0
  7. package/src/commands-table.ts +72 -0
  8. package/src/commands.ts +520 -0
  9. package/src/console/registry.ts +97 -0
  10. package/src/exp/ablate.ts +140 -0
  11. package/src/exp/artifacts.ts +229 -0
  12. package/src/exp/attempts.ts +475 -0
  13. package/src/exp/executor.ts +207 -0
  14. package/src/exp/final.ts +197 -0
  15. package/src/exp/ideas.ts +117 -0
  16. package/src/exp/investigate.ts +182 -0
  17. package/src/exp/logs.ts +63 -0
  18. package/src/exp/metrics.ts +132 -0
  19. package/src/exp/protocol.ts +145 -0
  20. package/src/exp/selection.ts +53 -0
  21. package/src/exp/snapshot.ts +147 -0
  22. package/src/http.ts +62 -0
  23. package/src/index.ts +4 -0
  24. package/src/lit/bibtex.ts +61 -0
  25. package/src/lit/candidates.ts +148 -0
  26. package/src/lit/cards.ts +21 -0
  27. package/src/lit/cite.ts +26 -0
  28. package/src/lit/sources.ts +122 -0
  29. package/src/lit/verify.ts +110 -0
  30. package/src/lit/watch.ts +109 -0
  31. package/src/manifest.ts +38 -0
  32. package/src/mcp-tools.ts +200 -0
  33. package/src/mcp.ts +111 -0
  34. package/src/memory/amem.ts +97 -0
  35. package/src/memory/archive.ts +63 -0
  36. package/src/memory/lessons.ts +88 -0
  37. package/src/memory/skills.ts +99 -0
  38. package/src/paper/build.ts +70 -0
  39. package/src/paper/check.ts +180 -0
  40. package/src/paper/placeholders.ts +116 -0
  41. package/src/paper/review.ts +115 -0
  42. package/src/plan/controller.ts +192 -0
  43. package/src/plan/definition.ts +156 -0
  44. package/src/plan/hub.ts +56 -0
  45. package/src/plan/state.ts +234 -0
  46. package/src/playbook.ts +129 -0
  47. package/src/scope.ts +53 -0
  48. package/src/setup.ts +296 -0
  49. package/src/study.ts +296 -0
  50. package/src/ui/panel.css +11 -0
  51. package/src/ui/panel.js +156 -0
  52. package/src/ui/plugin.ts +361 -0
@@ -0,0 +1,182 @@
1
+ import { readFinalSeeds, finalSeedsHash } from "./final.ts";
2
+ import { foldIdeas } from "./ideas.ts";
3
+ import { existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { assertWritable, commit, openStudy, plain, readLedger, readTextBounded, requireExperiment, sha256, type Actor, type LedgerEvent } from "../study.ts";
6
+ import { listAttempts, type Attempt, type AttemptStatus } from "./attempts.ts";
7
+ import { protocolHash, readProtocol } from "./protocol.ts";
8
+ import { readSelection, selectionHash } from "./selection.ts";
9
+
10
+ export const INVESTIGATE_BYTES = 1_048_576;
11
+ export type NoWinner = "no-baseline" | "baseline-seeds" | "no-candidate" | "below-margin" | "tie";
12
+ type Exclusion = "other-protocol" | "before-selection" | "missing-start-event" | "undeclared-seed" | "later-success" | "reserved-seed";
13
+ export interface Sample { seed: number; attempt: string; value: number }
14
+ export interface CommitResult {
15
+ commit: string; idea?: string; attempts: Record<AttemptStatus, string[]>; perSeed: Sample[];
16
+ pairedSeeds: number[]; pairs: { seed: number; attempt: string; baselineAttempt: string; value: number; baselineValue: number }[];
17
+ mean: number | null; baselineMean: number | null; delta: number | null; eligible: boolean; reason: string;
18
+ }
19
+ export interface InvestigateResult {
20
+ v: 1; run: string; ruleSha256: string; protocolHash: string; inputsSha256: string;
21
+ metric: { name: string; unit: string; direction: "max" | "min" };
22
+ baseline: CommitResult; candidates: CommitResult[];
23
+ totals: { attempts: number; byStatus: Record<AttemptStatus, number>; countedSuccesses: number; excluded: number };
24
+ excluded: { id: string; commit: string; seed: number; status: AttemptStatus; reasons: Exclusion[] }[];
25
+ winner: { commit: string; idea?: string; attempts: string[]; mean: number; delta: number } | null; reason?: NoWinner;
26
+ }
27
+ const recover = "; inspect ares attempt show and restore the last valid study inputs before ares investigate show";
28
+ function bad(reason: string): never { throw new Error(`investigate: ${reason}${recover}`); }
29
+ function canonical(raw: unknown): string {
30
+ if (Array.isArray(raw)) return `[${raw.map(canonical).join(",")}]`;
31
+ if (plain(raw)) return `{${Object.keys(raw).sort().map(k => `${JSON.stringify(k)}:${canonical(raw[k])}`).join(",")}}`;
32
+ return JSON.stringify(raw) ?? "null";
33
+ }
34
+ function mean(values: number[]): number | null {
35
+ if (!values.length) return null;
36
+ const total = values.reduce((sum, value) => sum + value, 0);
37
+ if (Number.isFinite(total)) return total / values.length;
38
+ const scale = Math.max(...values.map(Math.abs));
39
+ if (!scale) return 0;
40
+ // Normalize before summing so finite evaluator values never overflow their mean.
41
+ return Math.max(-1, Math.min(1, values.reduce((sum, v) => sum + v / scale / values.length, 0))) * scale;
42
+ }
43
+ export function metricValue(attempt: Attempt, metric: InvestigateResult["metric"]): number {
44
+ if (!Array.isArray(attempt.metrics)) bad(`attempt ${attempt.id}: primary metrics missing`);
45
+ const values = attempt.metrics.filter(m => m?.name === metric.name);
46
+ const value = values[0];
47
+ if (values.length !== 1 || !value || value.unit !== metric.unit || value.provenance !== "ares-observed" || typeof value.value !== "number" || !Number.isFinite(value.value)) bad(`attempt ${attempt.id}: primary metric missing, duplicate, non-finite or unvalidated`);
48
+ return value.value;
49
+ }
50
+ export interface PairedSamples {
51
+ pairedSeeds: number[]; pairs: { seed: number; attempt: string; referenceAttempt: string; value: number; referenceValue: number }[];
52
+ mean: number | null; referenceMean: number | null; delta: number | null;
53
+ }
54
+ /** Descriptive shared-seed comparison; callers decide eligibility and never infer significance. */
55
+ export function pairSamples(samples: Sample[], reference: Sample[], direction: "max" | "min", commit: string): PairedSamples {
56
+ const pairs = [...samples].sort((a, b) => a.seed - b.seed).flatMap(sample => {
57
+ const base = reference.find(b => b.seed === sample.seed);
58
+ return base ? [{ seed: sample.seed, attempt: sample.attempt, referenceAttempt: base.attempt, value: sample.value, referenceValue: base.value }] : [];
59
+ });
60
+ const candidateMean = mean(pairs.map(p => p.value)), referenceMean = mean(pairs.map(p => p.referenceValue));
61
+ const delta = candidateMean === null || referenceMean === null ? null : direction === "max" ? candidateMean - referenceMean : referenceMean - candidateMean;
62
+ if (delta !== null && !Number.isFinite(delta)) bad(`commit ${commit}: primary metric delta overflows; rescale the metric's unit in a new protocol and rerun attempts`);
63
+ return { pairedSeeds: pairs.map(p => p.seed), pairs, mean: candidateMean, referenceMean, delta };
64
+ }
65
+ function empty(commit: string): CommitResult {
66
+ return { commit, attempts: { running: [], succeeded: [], failed: [], cancelled: [], unknown: [] }, perSeed: [], pairedSeeds: [], pairs: [], mean: null, baselineMean: null, delta: null, eligible: false, reason: "no-counted-success" };
67
+ }
68
+ function inputs(dirArg: string) {
69
+ const { dir, study } = openStudy(dirArg); requireExperiment(study);
70
+ const protocol = readProtocol(dir);
71
+ if (!protocol) throw new Error("investigate: protocol missing; run ares protocol before selecting an investigation rule");
72
+ const rule = readSelection(dir, protocol), ruleSha = selectionHash(dir, protocol);
73
+ if (!rule || !ruleSha) throw new Error("investigate: selection missing; run ares selection <dir> --file PATH before investigating");
74
+ const events = readLedger(dir), cutoff = events.findLastIndex(e => e.type === "selection.set");
75
+ if (cutoff < 0 || events[cutoff]!.data.sha256 !== ruleSha || events[cutoff]!.data.run !== rule.run) throw new Error("investigate: selection bytes have no current selection.set receipt; run ares selection <dir> --file PATH to record the rule before new attempts");
76
+ const hash = protocolHash(protocol), metric = protocol.metrics.find(m => m.name === rule.metric)!;
77
+ const attempts = listAttempts(dir, { run: rule.run }).attempts;
78
+ for (const attempt of attempts) {
79
+ if (attempt.studyId !== study.id || typeof attempt.commit !== "string" || !/^[a-f0-9]{40}$/.test(attempt.commit) || !Number.isSafeInteger(attempt.seed)) bad(`attempt ${attempt.id}: invalid study, commit or seed identity`);
80
+ }
81
+ const reserved = readFinalSeeds(dir, protocol)?.seeds ?? [], reservedSha = finalSeedsHash(dir, protocol);
82
+ return { dir, protocol, rule, ruleSha, events, cutoff, hash, metric, attempts, reserved, reservedSha };
83
+ }
84
+ function calculate(input: ReturnType<typeof inputs>): InvestigateResult {
85
+ const { rule, ruleSha, events, cutoff, hash, metric, attempts, protocol, reserved, reservedSha } = input;
86
+ const ideas = foldIdeas(events), owners = new Map<string, string>();
87
+ // The journal enforces exactly one idea owner for any associated commit.
88
+ for (const idea of ideas) for (const commit of idea.commits) owners.set(commit, idea.id);
89
+ const starts = new Map<string, { index: number; event: LedgerEvent }>();
90
+ events.forEach((event, index) => {
91
+ if (event.type !== "attempt.started" || typeof event.data.id !== "string") return;
92
+ if (starts.has(event.data.id)) bad(`attempt ${event.data.id}: duplicate attempt.started receipt`);
93
+ starts.set(event.data.id, { index, event });
94
+ });
95
+ const ordered = [...attempts].sort((a, b) => (starts.get(a.id)?.index ?? -1) - (starts.get(b.id)?.index ?? -1) || a.id.localeCompare(b.id));
96
+ const byStatus = { running: 0, succeeded: 0, failed: 0, cancelled: 0, unknown: 0 };
97
+ const rows = new Map<string, CommitResult>([[rule.baseline.commit, empty(rule.baseline.commit)]]);
98
+ const excluded: InvestigateResult["excluded"] = [];
99
+ for (const attempt of ordered) {
100
+ const row = rows.get(attempt.commit) ?? empty(attempt.commit); rows.set(row.commit, row);
101
+ byStatus[attempt.status]++; row.attempts[attempt.status].push(attempt.id);
102
+ const reasons: Exclusion[] = [], start = starts.get(attempt.id);
103
+ if (attempt.protocolHash !== hash) reasons.push("other-protocol");
104
+ if (!start) reasons.push("missing-start-event");
105
+ else {
106
+ if (start.event.data.run !== attempt.run || start.event.data.commit !== attempt.commit || start.event.data.seed !== attempt.seed) bad(`attempt ${attempt.id}: start receipt identity disagrees with its record`);
107
+ if (start.index <= cutoff) reasons.push("before-selection");
108
+ }
109
+ if (!protocol.seeds.includes(attempt.seed)) reasons.push("undeclared-seed");
110
+ if (reserved.includes(attempt.seed)) reasons.push("reserved-seed");
111
+ if (!reasons.length && attempt.status === "succeeded") {
112
+ if (row.perSeed.some(s => s.seed === attempt.seed)) reasons.push("later-success");
113
+ else row.perSeed.push({ seed: attempt.seed, attempt: attempt.id, value: metricValue(attempt, metric) });
114
+ }
115
+ if (reasons.length) excluded.push({ id: attempt.id, commit: attempt.commit, seed: attempt.seed, status: attempt.status, reasons });
116
+ }
117
+ for (const row of rows.values()) { const idea = owners.get(row.commit); if (idea !== undefined) row.idea = idea; }
118
+ const baseline = rows.get(rule.baseline.commit)!;
119
+ for (const row of rows.values()) row.perSeed.sort((a, b) => a.seed - b.seed);
120
+ baseline.mean = mean(baseline.perSeed.map(s => s.value)); baseline.baselineMean = baseline.mean;
121
+ baseline.pairedSeeds = baseline.perSeed.map(s => s.seed); baseline.delta = baseline.mean === null ? null : 0;
122
+ baseline.eligible = baseline.perSeed.length >= rule.minSeeds;
123
+ baseline.reason = !baseline.perSeed.length ? "no-baseline" : baseline.eligible ? "baseline" : "baseline-seeds";
124
+ const candidates = [...rows.values()].filter(row => row !== baseline).sort((a, b) => a.commit.localeCompare(b.commit));
125
+ for (const row of candidates) {
126
+ const paired = pairSamples(row.perSeed, baseline.perSeed, metric.direction, row.commit);
127
+ row.pairedSeeds = paired.pairedSeeds; row.mean = paired.mean; row.baselineMean = paired.referenceMean; row.delta = paired.delta;
128
+ row.pairs = paired.pairs.map(p => ({ seed: p.seed, attempt: p.attempt, baselineAttempt: p.referenceAttempt, value: p.value, baselineValue: p.referenceValue }));
129
+ row.reason = !baseline.perSeed.length ? "no-baseline" : !baseline.eligible ? "baseline-seeds" : !row.perSeed.length ? "no-counted-success" : row.pairs.length < rule.minSeeds ? "candidate-seeds" : row.delta! <= 0 || row.delta! < rule.minDelta ? "below-margin" : "eligible";
130
+ row.eligible = row.reason === "eligible";
131
+ }
132
+ const eligible = candidates.filter(row => row.eligible).sort((a, b) => b.delta! - a.delta!);
133
+ let winner: InvestigateResult["winner"] = null, reason: NoWinner | undefined;
134
+ if (!baseline.perSeed.length) reason = "no-baseline";
135
+ else if (!baseline.eligible) reason = "baseline-seeds";
136
+ else if (!candidates.some(row => row.pairedSeeds.length >= rule.minSeeds)) reason = "no-candidate";
137
+ else if (!eligible.length) reason = "below-margin";
138
+ else if (eligible.length > 1 && eligible[0]!.delta === eligible[1]!.delta) reason = "tie";
139
+ else { const row = eligible[0]!; winner = { commit: row.commit, ...(row.idea !== undefined ? { idea: row.idea } : {}), attempts: row.pairs.map(p => p.attempt), mean: row.mean!, delta: row.delta! }; }
140
+ const result: InvestigateResult = {
141
+ v: 1, run: rule.run, ruleSha256: ruleSha, protocolHash: hash,
142
+ inputsSha256: sha256(canonical({ ruleSha, protocolHash: hash, ...(reservedSha !== undefined ? { finalSeedsSha256: reservedSha } : {}), cutoff, ideas: sha256(canonical(ideas)), attempts: ordered.map(a => ({ record: sha256(canonical(a)), start: starts.get(a.id)?.index ?? null })) })),
143
+ metric, baseline, candidates, totals: { attempts: attempts.length, byStatus, countedSuccesses: [...rows.values()].reduce((n, r) => n + r.perSeed.length, 0), excluded: excluded.length }, excluded,
144
+ winner, ...(reason ? { reason } : {}),
145
+ };
146
+ assertWritable(JSON.stringify(result, null, 2) + "\n", INVESTIGATE_BYTES, "investigate result; archive this study and use a new bounded run");
147
+ return result;
148
+ }
149
+ export function investigateResultPath(dir: string, run: string): string { return join(dir, "runs", run, "investigate.json"); }
150
+ function previousResult(file: string): string | undefined {
151
+ if (!existsSync(file)) return undefined;
152
+ let text: string;
153
+ try { text = readTextBounded(file, INVESTIGATE_BYTES); }
154
+ catch { throw new Error(`investigate result: unreadable, non-regular, invalid UTF-8 or larger than ${INVESTIGATE_BYTES} bytes; restore the last valid regular result within that bound before show or close`); }
155
+ let value: unknown;
156
+ try { value = JSON.parse(text); } catch { throw new Error("investigate result: invalid JSON; restore the last valid investigate.json before show or close"); }
157
+ if (!plain(value) || value.v !== 1) throw new Error("investigate result: unsupported version; use an ares version that supports this result, keeping investigate.json unchanged");
158
+ return text;
159
+ }
160
+ export function showInvestigation(dirArg: string): InvestigateResult {
161
+ const input = inputs(dirArg); previousResult(investigateResultPath(input.dir, input.rule.run)); return calculate(input);
162
+ }
163
+ /** Caller holds the study lock; every refusal precedes the single file/event commit. */
164
+ export function closeInvestigation(dirArg: string, actor: Actor): InvestigateResult {
165
+ const input = inputs(dirArg), file = investigateResultPath(input.dir, input.rule.run), previous = previousResult(file);
166
+ if (input.attempts.some(a => a.status === "running")) throw new Error("investigate close: an attempt in the selected run is running; run ares attempt reconcile before closing");
167
+ const result = calculate(input), text = JSON.stringify(result, null, 2) + "\n";
168
+ if (previous !== text) commit(input.dir, [[file, text, INVESTIGATE_BYTES]], [{ actor, type: "investigate.closed", data: { run: input.rule.run, sha256: sha256(text), ruleSha256: input.ruleSha, winner: result.winner?.commit ?? null } }]);
169
+ return result;
170
+ }
171
+
172
+
173
+ /** Read only a current, committed investigation result, never a hand-edited or stale winner. */
174
+ export function readClosedInvestigation(dirArg: string): { result: InvestigateResult; sha256: string } {
175
+ const input = inputs(dirArg), text = previousResult(investigateResultPath(input.dir, input.rule.run));
176
+ if (text === undefined) throw new Error("investigate result: not closed; run ares investigate close before the next phase");
177
+ const hash = sha256(text), receipt = input.events.findLast(e => e.type === "investigate.closed" && e.data.run === input.rule.run);
178
+ if (!receipt || receipt.data.sha256 !== hash) throw new Error("investigate result: bytes do not match the latest close receipt; run ares investigate close to restore the committed result");
179
+ const result = calculate(input);
180
+ if (text !== JSON.stringify(result, null, 2) + "\n") throw new Error("investigate result: stale inputs; run ares investigate close again before the next phase");
181
+ return { result, sha256: hash };
182
+ }
@@ -0,0 +1,63 @@
1
+ import { createHash } from "node:crypto";
2
+ import { posix } from "node:path";
3
+ import { openStudy, requireExperiment } from "../study.ts";
4
+ import { showAttempt } from "./attempts.ts";
5
+ import { runOnExecutor, shellQuote, spawnRunner, validateExecutor, type ProcessRunner, type Stage } from "./executor.ts";
6
+ import { LOG_COPY_BYTES } from "./metrics.ts";
7
+
8
+ export type LogStream = "stdout" | "stderr";
9
+ export interface LogOptions { stage?: string; stream?: string; tailBytes?: number }
10
+ export interface LogReadResult { id: string; logs: { stage: Stage; stream: LogStream; path: string; sha256: string; bytes: number; truncated: boolean; text: string; tailBytes: number }[] }
11
+ export const DEFAULT_LOG_TAIL_BYTES = 16_384, MAX_LOG_TAIL_BYTES = 65_536;
12
+
13
+ /** Explicit read-only log access. Nothing, including the returned text, is written to the study. */
14
+ export async function readAttemptLogs(dir: string, wanted: string, options: LogOptions = {}, runner: ProcessRunner = spawnRunner): Promise<LogReadResult> {
15
+ const { study } = openStudy(dir); requireExperiment(study);
16
+ const attempt = showAttempt(dir, wanted), executor = validateExecutor(attempt.executor);
17
+ if (attempt.studyId !== study.id || !/^[A-Za-z0-9_-]{1,80}$/.test(attempt.studyId)) throw new Error("attempt logs: study identity differs from the record; restore the original attempt record before reading logs");
18
+ if (options.stage !== undefined && options.stage !== "candidate" && options.stage !== "evaluator") throw new Error("attempt logs: invalid stage; use --stage candidate or evaluator");
19
+ if (options.stream !== undefined && options.stream !== "stdout" && options.stream !== "stderr") throw new Error("attempt logs: invalid stream; use --stream stdout or stderr");
20
+ const count = options.tailBytes ?? DEFAULT_LOG_TAIL_BYTES;
21
+ if (!Number.isSafeInteger(count) || count < 0 || count > MAX_LOG_TAIL_BYTES) throw new Error(`attempt logs: invalid tail bound; use --tail 0..${MAX_LOG_TAIL_BYTES}`);
22
+ const root = posix.join(executor.workdir, attempt.studyId, attempt.id), logsDir = posix.join(root, "logs");
23
+ if (!attempt.paths || attempt.paths.snapshot !== posix.join(root, "snapshot")) throw new Error("attempt logs: workspace differs from recorded executor identity; restore the original record before reading logs");
24
+ const result: LogReadResult = { id: attempt.id, logs: [] };
25
+ const stages: Stage[] = options.stage ? [options.stage as Stage] : ["candidate", "evaluator"];
26
+ const streams: LogStream[] = options.stream ? [options.stream as LogStream] : ["stdout", "stderr"];
27
+ const requests: { stage: Stage; stream: LogStream; path: string; sha256: string; bytes: number; truncated: boolean }[] = [];
28
+ for (const stage of stages) for (const stream of streams) {
29
+ const metadata = attempt.stages[stage]?.evidence?.logs?.[stream];
30
+ if (!metadata) {
31
+ if (options.stage) throw new Error("attempt logs: the selected stage has no collected logs; reconcile after the stage finishes, then retry");
32
+ continue;
33
+ }
34
+ const expected = posix.join(logsDir, `${stage}.${stream}.log`);
35
+ if (metadata.path !== expected || typeof metadata.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(metadata.sha256) || !Number.isSafeInteger(metadata.bytes) || metadata.bytes < 0 || metadata.bytes > LOG_COPY_BYTES || typeof metadata.truncated !== "boolean") throw new Error("attempt logs: invalid log receipt or path; restore the original metadata before reading logs");
36
+ requests.push({ stage, stream, path: expected, sha256: metadata.sha256, bytes: metadata.bytes, truncated: metadata.truncated });
37
+ }
38
+ if (!requests.length) throw new Error("attempt logs: no collected logs yet; reconcile after a stage finishes, then retry");
39
+ for (const entry of requests) {
40
+ const controller = new AbortController(), hash = createHash("sha256");
41
+ let bytes = 0, tail: Buffer = Buffer.alloc(0);
42
+ const script = [
43
+ "set -eu", `root=${shellQuote(root)}; logs=${shellQuote(logsDir)}; file=${shellQuote(entry.path)}`,
44
+ 'test -d "$root"; test ! -L "$root"; test -O "$root"; test "$(cd -P "$root" && pwd -P)" = "$root"',
45
+ 'mode=$(stat -c %a "$root" 2>/dev/null || stat -f %Lp "$root"); test "$mode" = 700',
46
+ 'test -d "$logs"; test ! -L "$logs"; test -O "$logs"; test -f "$file"; test ! -L "$file"; test -O "$file"',
47
+ `head -c ${LOG_COPY_BYTES + 1} "$file"`,
48
+ ].join("\n");
49
+ try {
50
+ const response = await runOnExecutor(executor, "sh", ["-c", script], runner, { signal: controller.signal, timeoutMs: 300_000, stdoutSink: chunk => {
51
+ if (bytes + chunk.byteLength > LOG_COPY_BYTES) { controller.abort(); throw new Error("log receipt read bound"); }
52
+ bytes += chunk.byteLength; hash.update(chunk);
53
+ if (count) tail = chunk.byteLength >= count ? Buffer.from(chunk.subarray(chunk.byteLength - count)) : Buffer.concat([tail.subarray(Math.max(0, tail.length + chunk.byteLength - count)), chunk]);
54
+ } });
55
+ if (response.exitCode !== 0 || response.timedOut || response.truncated) throw new Error("log read failed");
56
+ } catch {
57
+ controller.abort(); throw new Error(`executor ${executor.host ?? "local"}: log read failed; check the recorded private log file and retry ares attempt logs`);
58
+ }
59
+ if (bytes !== entry.bytes || hash.digest("hex") !== entry.sha256) throw new Error("attempt logs: host log differs from the recorded hash or size; inspect the executor copy before retrying");
60
+ result.logs.push({ ...entry, text: new TextDecoder().decode(tail), tailBytes: tail.byteLength });
61
+ }
62
+ return result;
63
+ }
@@ -0,0 +1,132 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { posix } from "node:path";
3
+ import { plain } from "../study.ts";
4
+ import { runOnExecutor, shellQuote, type ProcessRunner, type Stage } from "./executor.ts";
5
+ import { ARTIFACT_BYTES, ARTIFACT_FILES, ArtifactError, readArtifactArchive } from "./artifacts.ts";
6
+ import type { Attempt, Evaluation } from "./attempts.ts";
7
+ import type { Protocol } from "./protocol.ts";
8
+
9
+ export const METRICS_BYTES = 65_536;
10
+ export const OUTPUT_METADATA_BYTES = 256 * 1024;
11
+ export const LOG_COPY_BYTES = 16 * 1024 * 1024;
12
+ export interface LogEvidence { path: string; sha256: string; bytes: number; truncated: boolean }
13
+ export interface StageEvidence { logs: { stdout: LogEvidence; stderr: LogEvidence }; outputs: Evaluation["outputs"] }
14
+ export class EvidenceError extends Error {
15
+ constructor(readonly reason: string, message: string) { super(message); this.name = "EvidenceError"; }
16
+ }
17
+ export class MetricsError extends EvidenceError {
18
+ constructor(reason: string, cause: string) { super(reason, `evaluator metrics: ${cause}; write /result/metrics.json as an object of all declared metric names and finite numbers, then retry the attempt`); this.name = "MetricsError"; }
19
+ }
20
+ export function validateMetrics(raw: unknown, protocol: Protocol): Evaluation["metrics"] {
21
+ if (!plain(raw)) throw new MetricsError("malformed-metrics", "a metric-name to number object is required");
22
+ const declared = new Set(protocol.metrics.map(metric => metric.name));
23
+ if (Object.keys(raw).some(name => !declared.has(name))) throw new MetricsError("undeclared-metric", "an undeclared metric name is present");
24
+ if (protocol.metrics.some(metric => !Object.hasOwn(raw, metric.name))) throw new MetricsError("missing-metric", "a declared metric is missing");
25
+ return protocol.metrics.map(metric => {
26
+ const value = raw[metric.name];
27
+ if (typeof value !== "number") throw new MetricsError("invalid-metric-value", "a metric value is not a number");
28
+ if (!Number.isFinite(value)) throw new MetricsError("nonfinite-metric", "a metric value is not finite");
29
+ return { name: metric.name, unit: metric.unit, value, provenance: "ares-observed" };
30
+ });
31
+ }
32
+ export function parseMetrics(bytes: Uint8Array, protocol: Protocol): Evaluation["metrics"] {
33
+ if (bytes.byteLength > METRICS_BYTES) throw new MetricsError("metrics-limit", "metrics.json exceeds 65536 bytes");
34
+ let text: string, raw: unknown;
35
+ try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); raw = JSON.parse(text); }
36
+ catch { throw new MetricsError("malformed-metrics", "metrics.json is not valid UTF-8 JSON"); }
37
+ // After parsing, only a flat numeric object is valid; reject duplicate keys rather than silently use the last number.
38
+ const keys = new Set<string>();
39
+ for (const match of text.matchAll(/"((?:[^"\\]|\\.)*)"\s*:/g)) {
40
+ let key: string; try { key = JSON.parse(`"${match[1]}"`) as string; } catch { throw new MetricsError("malformed-metrics", "a metric name is malformed"); }
41
+ if (keys.has(key)) throw new MetricsError("duplicate-metric", "a metric name appears more than once"); keys.add(key);
42
+ }
43
+ return validateMetrics(raw, protocol);
44
+ }
45
+ function container(attempt: Attempt, stage: Stage): string {
46
+ const cid = attempt.stages[stage]?.containerId;
47
+ if (!cid || !/^[a-f0-9]{64}$/.test(cid)) throw new EvidenceError("missing-receipt", "stage evidence: verified container id is missing; run ares attempt reconcile before collecting evidence");
48
+ return cid;
49
+ }
50
+ export function outputBudget(outputs: Evaluation["outputs"]): Evaluation["outputs"] {
51
+ const unique = new Map<string, Evaluation["outputs"][number]>();
52
+ for (const output of outputs) {
53
+ const previous = unique.get(output.path);
54
+ if (previous && (previous.sha256 !== output.sha256 || previous.bytes !== output.bytes)) throw new EvidenceError("changed-output", "stage evidence: output changed after collection; inspect the executor workspace and retry with a fresh attempt");
55
+ unique.set(output.path, output);
56
+ }
57
+ const result = [...unique.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
58
+ if (result.length > ARTIFACT_FILES || result.reduce((total, item) => total + item.bytes, 0) > ARTIFACT_BYTES) throw new EvidenceError("artifact-limit", "stage evidence: output budget exceeded; reduce outputs to 500 files and 256 MiB before retrying");
59
+ if (Buffer.byteLength(JSON.stringify(result, null, 2)) > OUTPUT_METADATA_BYTES) throw new EvidenceError("artifact-limit", "stage evidence: output manifest is too large for the attempt record; shorten artifact paths or reduce their count before retrying");
60
+ return result;
61
+ }
62
+ function remaining(attempt: Attempt, extra: Evaluation["outputs"] = []) {
63
+ const outputs = outputBudget([...attempt.outputs, ...extra]);
64
+ return { maxFiles: ARTIFACT_FILES - outputs.length, maxBytes: ARTIFACT_BYTES - outputs.reduce((total, file) => total + file.bytes, 0) };
65
+ }
66
+ function archiveFailure(error: unknown, attempt: Attempt, step: string): EvidenceError {
67
+ const reason = error instanceof ArtifactError ? error.code : "artifact-read";
68
+ return new EvidenceError(reason, `executor ${attempt.executor.host ?? "local"}: ${step} failed; inspect the stage files for unsafe links, oversized output or malformed archives and retry the attempt`);
69
+ }
70
+ /** Only the evaluator's result mount supplies numbers. Candidate files and printed numbers are never parsed. */
71
+ export async function collectEvaluation(attempt: Attempt, runner: ProcessRunner): Promise<Evaluation> {
72
+ let archive;
73
+ try { archive = await readArtifactArchive(attempt.executor, "docker", ["cp", `${container(attempt, "evaluator")}:/result/.`, "-"], runner, { capture: "metrics.json", captureBytes: METRICS_BYTES, ...remaining(attempt) }); }
74
+ catch (error) { throw archiveFailure(error, attempt, "evaluator result archive"); }
75
+ if (!archive.captured) throw new MetricsError("missing-metrics", "metrics.json is missing from the evaluator result directory");
76
+ const metrics = parseMetrics(archive.captured, attempt.protocol);
77
+ const outputs = archive.files.map(file => ({ path: posix.join(attempt.paths.result, file.path), sha256: file.sha256, bytes: file.bytes }));
78
+ outputBudget([...attempt.outputs, ...outputs]);
79
+ return { metrics, outputs };
80
+ }
81
+
82
+ /** Raw copies remain in the executor's private attempt directory; only metadata enters the study. */
83
+ export async function collectStageEvidence(attempt: Attempt, stage: Stage, runner: ProcessRunner): Promise<StageEvidence> {
84
+ const cid = container(attempt, stage), root = posix.dirname(attempt.paths.snapshot), logs = posix.join(root, "logs");
85
+ const stdout = posix.join(logs, `${stage}.stdout.log`), stderr = posix.join(logs, `${stage}.stderr.log`), suffix = randomUUID();
86
+ const statePath = `logs/${stage}.state.json`, stateFile = posix.join(root, statePath);
87
+ const script = [
88
+ "set -eu", "umask 077", `logs=${shellQuote(logs)}`,
89
+ 'if test ! -e "$logs"; then mkdir -m 700 "$logs"; fi', 'test -d "$logs"; test ! -L "$logs"; test -O "$logs"',
90
+ `out=${shellQuote(stdout)}; err=${shellQuote(stderr)}; state=${shellQuote(stateFile)}`,
91
+ 'for file in "$out" "$err" "$state"; do if test -e "$file" || test -L "$file"; then test -f "$file"; test ! -L "$file"; fi; done',
92
+ `if test -f "$out" && test -f "$err" && test -f "$state" && test "$(wc -c < "$out")" -le ${LOG_COPY_BYTES} && test "$(wc -c < "$err")" -le ${LOG_COPY_BYTES}; then :; else`,
93
+ `outtmp=${shellQuote(`${stdout}.${suffix}.tmp`)}; errtmp=${shellQuote(`${stderr}.${suffix}.tmp`)}; statetmp=${shellQuote(`${stateFile}.${suffix}.tmp`)}`,
94
+ `outpipe=${shellQuote(posix.join(logs, `${stage}.${suffix}.stdout.pipe`))}; errpipe=${shellQuote(posix.join(logs, `${stage}.${suffix}.stderr.pipe`))}`,
95
+ 'trap \'rm -f "$outtmp" "$errtmp" "$statetmp" "$outpipe" "$errpipe"\' 0',
96
+ 'mkfifo "$outpipe" "$errpipe"',
97
+ `head -c ${LOG_COPY_BYTES} < "$outpipe" > "$outtmp" & outpid=$!`,
98
+ `head -c ${LOG_COPY_BYTES} < "$errpipe" > "$errtmp" & errpid=$!`,
99
+ `status=0; docker logs ${cid} > "$outpipe" 2> "$errpipe" || status=$?`,
100
+ 'wait "$outpid"; wait "$errpid"', 'outbytes=$(wc -c < "$outtmp"); errbytes=$(wc -c < "$errtmp")',
101
+ 'outcut=false; errcut=false',
102
+ `if test "$outbytes" -ge ${LOG_COPY_BYTES}; then outcut=true; fi; if test "$errbytes" -ge ${LOG_COPY_BYTES}; then errcut=true; fi`,
103
+ 'if test "$status" -ne 0; then if test "$outcut" = true || test "$errcut" = true; then outcut=true; errcut=true; else exit "$status"; fi; fi',
104
+ 'printf \'{"stdout":%s,"stderr":%s}\\n\' "$outcut" "$errcut" > "$statetmp"',
105
+ 'mv "$outtmp" "$out"; mv "$errtmp" "$err"; mv "$statetmp" "$state"', 'fi',
106
+ // Archive content only: BSD tar otherwise emits binary xattr PAX and AppleDouble entries.
107
+ // ponytail: requires GNU tar >= 1.27 or bsdtar with --no-xattrs; unsupported tar
108
+ // (e.g. BusyBox) fails closed with the generic stage-log collection error, never a metadata fallback.
109
+ `COPYFILE_DISABLE=1 tar --no-xattrs -C ${shellQuote(root)} -cf - ${shellQuote(`logs/${stage}.stdout.log`)} ${shellQuote(`logs/${stage}.stderr.log`)} ${shellQuote(statePath)}`,
110
+ ].join("\n");
111
+ let logArchive;
112
+ try { logArchive = await readArtifactArchive(attempt.executor, "sh", ["-c", script], runner, { capture: statePath, captureBytes: 1024, maxFiles: 3, maxBytes: LOG_COPY_BYTES * 2 + 1024 }); }
113
+ catch (error) { throw archiveFailure(error, attempt, "stage log collection"); }
114
+ let truncated: unknown;
115
+ try { truncated = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(logArchive.captured)); } catch { /* validated below without copying metadata */ }
116
+ if (!plain(truncated) || Object.keys(truncated).length !== 2 || typeof truncated.stdout !== "boolean" || typeof truncated.stderr !== "boolean") throw new EvidenceError("invalid-log-state", "stage evidence: truncation metadata is missing or malformed; remove incomplete log copies on the executor host and retry collection");
117
+ const cuts = truncated as { stdout: boolean; stderr: boolean };
118
+ const log = (kind: "stdout" | "stderr"): LogEvidence => {
119
+ const file = logArchive.files.find(item => item.path === `logs/${stage}.${kind}.log`);
120
+ if (!file) throw new EvidenceError("missing-logs", "stage evidence: a stdout/stderr copy is missing; inspect the executor log directory and retry collection");
121
+ return { path: posix.join(root, file.path), sha256: file.sha256, bytes: file.bytes, truncated: cuts[kind] || file.bytes >= LOG_COPY_BYTES };
122
+ };
123
+ const outputs: Evaluation["outputs"] = [];
124
+ if (stage === "candidate") {
125
+ let archive;
126
+ try { archive = await readArtifactArchive(attempt.executor, "docker", ["cp", `${cid}:/out/.`, "-"], runner, { ...remaining(attempt, outputs) }); }
127
+ catch (error) { throw archiveFailure(error, attempt, "candidate output archive"); }
128
+ outputs.push(...archive.files.map(file => ({ path: posix.join(attempt.paths.output, file.path), sha256: file.sha256, bytes: file.bytes })));
129
+ }
130
+ outputBudget([...attempt.outputs, ...outputs]);
131
+ return { logs: { stdout: log("stdout"), stderr: log("stderr") }, outputs };
132
+ }
@@ -0,0 +1,145 @@
1
+ import { finalSeedsHash } from "./final.ts";
2
+ import { selectionHash } from "./selection.ts";
3
+ import { existsSync, lstatSync, opendirSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { commit, openStudy, plain, readBytesBounded, readJsonFile, readTextBounded, requireExperiment, sha256, type Actor } from "../study.ts";
6
+
7
+ export interface Limits { cpus: number; memoryMb: number; pidsLimit: number; wallSeconds: number; gpus: number }
8
+ export interface Protocol {
9
+ v: 1; method: string;
10
+ candidate: { image: string; command: string[]; env?: Record<string, string> };
11
+ evaluator: { image: string; command: string[] };
12
+ metrics: { name: string; unit: string; direction: "max" | "min" }[];
13
+ seeds: number[]; limits: Limits; network: "none";
14
+ budget: { maxAttempts: number; maxWallHours: number; maxGpuHours: number };
15
+ }
16
+ export const PROTOCOL_BYTES = 65_536, TASK_FILES = 500, TASK_BYTES = 64 * 1024 * 1024;
17
+ export const LIMIT_BOUNDS = { cpus: [0.1, 256], memoryMb: [16, 1_048_576], pidsLimit: [1, 65_536], wallSeconds: [1, 604_800], gpus: [0, 64] } as const;
18
+ function object(raw: unknown, keys: string[], what: string): Record<string, unknown> {
19
+ if (!plain(raw)) throw new Error(`${what}: object required; set ${what} to a JSON object`);
20
+ const extra = Object.keys(raw).find(key => !keys.includes(key));
21
+ if (extra !== undefined) throw new Error(`${what}: unknown field ${extra}; remove it and use only ${keys.join(", ")}`);
22
+ return raw;
23
+ }
24
+ function number(raw: unknown, what: string, min: number, max: number, integer = true): number {
25
+ if (typeof raw !== "number" || !Number.isFinite(raw) || (integer && !Number.isSafeInteger(raw)) || raw < min || raw > max) throw new Error(`${what}: invalid number; set ${what} to a finite ${integer ? "integer" : "number"} in ${min}..${max}`);
26
+ return raw;
27
+ }
28
+ function array(raw: unknown, what: string, max: number): unknown[] {
29
+ if (!Array.isArray(raw) || raw.length < 1 || raw.length > max) throw new Error(`${what}: invalid array; set ${what} to a JSON array of 1..${max} items`);
30
+ return raw;
31
+ }
32
+ // Preserve argv and environment bytes; trimming these would change execution.
33
+ function literal(raw: unknown, what: string, max: number, empty = false): string {
34
+ if (typeof raw !== "string" || raw.includes("\0") || raw.length > max || (!empty && !raw.trim())) throw new Error(`${what}: invalid text; set ${what} to a ${empty ? "possibly empty" : "nonblank"} string of at most ${max} characters without NUL`);
35
+ return raw;
36
+ }
37
+ function stage(raw: unknown, what: string, envAllowed: boolean): Protocol["candidate"] {
38
+ const value = object(raw, envAllowed ? ["image", "command", "env"] : ["image", "command"], what);
39
+ const image = literal(value.image, `${what}.image`, 512);
40
+ if (!/^[A-Za-z0-9][A-Za-z0-9._/:\-]*@sha256:[a-f0-9]{64}$/.test(image)) throw new Error(`${what}.image: digest-pinned image required; set ${what}.image to name@sha256:<64 lowercase hex digits>`);
41
+ const command = array(value.command, `${what}.command`, 128).map((v, i) => literal(v, `${what}.command[${i}]`, 4096, true));
42
+ if (!command[0]?.trim()) throw new Error(`${what}.command[0]: executable missing; set the first argv item to a nonblank executable`);
43
+ if (value.env === undefined) return { image, command };
44
+ const rawEnv = object(value.env, Object.keys(plain(value.env) ? value.env : {}), `${what}.env`);
45
+ if (Object.keys(rawEnv).length > 64) throw new Error(`${what}.env: too many keys; reduce ${what}.env to at most 64 keys`);
46
+ const entries: [string, string][] = [];
47
+ for (const [key, val] of Object.entries(rawEnv)) {
48
+ if (key === "ARES_SEED") throw new Error(`${what}.env: key ARES_SEED is reserved for the selected attempt seed; remove it from candidate.env and select a declared seed with ares attempt run --seed N`);
49
+ if (["__proto__", "constructor", "prototype"].includes(key)) throw new Error(`${what}.env: key ${key} is reserved for object internals; remove or rename that env entry before setting the protocol`);
50
+ if (/KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL/i.test(key)) throw new Error(`${what}.env: key ${key} looks like a secret; pass secrets through the image, not the protocol`);
51
+ if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(key)) throw new Error(`${what}.env: key ${key} is invalid; use 1..128 letters, digits or underscores, starting with a letter or underscore`);
52
+ entries.push([key, literal(val, `${what}.env.${key}`, 4096, true)]);
53
+ }
54
+ return { image, command, env: Object.fromEntries(entries) };
55
+ }
56
+ export function validateProtocol(raw: unknown): Protocol {
57
+ const p = object(raw, ["v", "method", "candidate", "evaluator", "metrics", "seeds", "limits", "network", "budget"], "protocol");
58
+ if (p.v !== 1) throw new Error("protocol.v: unsupported version; set protocol.v to 1");
59
+ if (p.network !== "none") throw new Error('protocol.network: network access is unsupported; set protocol.network to "none"');
60
+ const limitsRaw = object(p.limits, Object.keys(LIMIT_BOUNDS), "protocol.limits");
61
+ const limits = Object.fromEntries(Object.entries(LIMIT_BOUNDS).map(([key, [min, max]]) => [key, number(limitsRaw[key], `protocol.limits.${key}`, min, max, key !== "cpus")])) as unknown as Limits;
62
+ const budget = object(p.budget, ["maxAttempts", "maxWallHours", "maxGpuHours"], "protocol.budget");
63
+ const metrics = array(p.metrics, "protocol.metrics", 100).map((rawMetric): Protocol["metrics"][number] => {
64
+ const m = object(rawMetric, ["name", "unit", "direction"], "metric");
65
+ if (m.direction !== "max" && m.direction !== "min") throw new Error("metric.direction: unknown direction; set metric.direction to max or min");
66
+ return { name: literal(m.name, "metric.name", 128).trim(), unit: literal(m.unit, "metric.unit", 64).trim(), direction: m.direction };
67
+ });
68
+ if (new Set(metrics.map(m => m.name)).size !== metrics.length) throw new Error("metrics: duplicate name; give each metric a unique name");
69
+ const seeds = array(p.seeds, "protocol.seeds", 1000).map(v => number(v, "protocol.seed", -2_147_483_648, 2_147_483_647));
70
+ if (new Set(seeds).size !== seeds.length) throw new Error("seeds: duplicate seed; remove repeated seeds from protocol.seeds");
71
+ const protocol: Protocol = { v: 1, method: literal(p.method, "protocol.method", 4000).trim(), candidate: stage(p.candidate, "candidate", true), evaluator: stage(p.evaluator, "evaluator", false), metrics, seeds, limits, network: "none", budget: {
72
+ maxAttempts: number(budget.maxAttempts, "budget.maxAttempts", 1, 1_000_000),
73
+ maxWallHours: number(budget.maxWallHours, "budget.maxWallHours", 0.000001, 1_000_000, false),
74
+ maxGpuHours: number(budget.maxGpuHours, "budget.maxGpuHours", 0, 64_000_000, false),
75
+ } };
76
+ if (Buffer.byteLength(JSON.stringify(protocol, null, 2) + "\n") > PROTOCOL_BYTES) throw new Error(`protocol: too large; reduce the formatted protocol to at most ${PROTOCOL_BYTES} bytes`);
77
+ return protocol;
78
+ }
79
+ function canonical(value: unknown): string {
80
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
81
+ if (plain(value)) return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${canonical(value[k])}`).join(",")}}`;
82
+ return JSON.stringify(value);
83
+ }
84
+ export function protocolHash(protocol: Protocol): string {
85
+ const { budget: _budget, ...comparable } = validateProtocol(protocol);
86
+ return sha256(canonical(comparable));
87
+ }
88
+ export function readProtocol(dir: string): Protocol | undefined {
89
+ const file = join(dir, "design", "protocol.json");
90
+ return existsSync(file) ? validateProtocol(readJsonFile(file, PROTOCOL_BYTES)) : undefined;
91
+ }
92
+ /** Hash unambiguous sorted [relative path, file hash] pairs; directory entries are never followed. */
93
+ export function taskTreeHash(dir: string): string {
94
+ const root = join(dir, "task"), entries: [string, string][] = [];
95
+ let bytes = 0, directories = 0;
96
+ function walk(relative: string, depth: number): void {
97
+ const path = relative ? join(root, relative) : root, label = relative ? `task/${relative}` : "task/";
98
+ let stat;
99
+ try { stat = lstatSync(path); }
100
+ catch (error) {
101
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error(`${label}: missing; write task/task.md and task/evaluator/ before ares protocol`);
102
+ throw new Error(`${label}: metadata could not be read; check its permissions and restore a readable task tree`);
103
+ }
104
+ if (stat.isSymbolicLink()) throw new Error(`${label}: symlink refused; replace it with a regular file or directory inside task/`);
105
+ if (!relative && !stat.isDirectory()) throw new Error("task/: directory required; replace task with a directory containing task.md and evaluator/");
106
+ if (stat.isDirectory()) {
107
+ if (depth > 64 || ++directories > 1000) throw new Error(`${label}: too many directories or nesting too deep; reduce task/ to at most 1000 directories and 64 levels`);
108
+ const directory = opendirSync(path);
109
+ try {
110
+ for (let entry = directory.readSync(); entry; entry = directory.readSync()) walk(relative ? `${relative}/${entry.name}` : entry.name, depth + 1);
111
+ } finally { directory.closeSync(); }
112
+ } else {
113
+ if (!stat.isFile()) throw new Error(`${label}: regular files only; replace this special file with a regular file or remove it`);
114
+ if (entries.length >= TASK_FILES) throw new Error(`task/: more than ${TASK_FILES} files; remove files until task/ contains at most ${TASK_FILES}`);
115
+ if (stat.size > TASK_BYTES - bytes) throw new Error(`task/: larger than ${TASK_BYTES} bytes; reduce total task/ file bytes to at most ${TASK_BYTES}`);
116
+ const content = readBytesBounded(path, TASK_BYTES - bytes);
117
+ bytes += content.length; entries.push([relative, sha256(content)]);
118
+ }
119
+ }
120
+ walk("", 0);
121
+ return sha256(JSON.stringify(entries.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)));
122
+ }
123
+ export function designBinding(dir: string): string | undefined {
124
+ return readDesign(dir)?.binding;
125
+ }
126
+ /** Capture the validated protocol and its raw-byte seal together for a frozen attempt. */
127
+ export function readDesign(dir: string): { protocol: Protocol; treeHash: string; binding: string } | undefined {
128
+ const file = join(dir, "design", "protocol.json");
129
+ if (!existsSync(file)) return undefined;
130
+ const text = readTextBounded(file, PROTOCOL_BYTES);
131
+ let raw: unknown; try { raw = JSON.parse(text); } catch { throw new Error("protocol: invalid JSON; restore design/protocol.json with a valid ares protocol input"); }
132
+ const protocol = validateProtocol(raw), treeHash = taskTreeHash(dir);
133
+ const selected = selectionHash(dir, protocol), reserved = finalSeedsHash(dir, protocol);
134
+ return { protocol, treeHash, binding: sha256(JSON.stringify([sha256(text), treeHash, ...(selected !== undefined ? [selected] : []), ...(reserved !== undefined ? [reserved] : [])])) };
135
+ }
136
+ /** Caller holds the study lock, as with setScope. Validate everything before any write. */
137
+ export function setProtocol(dir: string, raw: unknown, actor: Actor): Protocol {
138
+ requireExperiment(openStudy(dir).study);
139
+ const protocol = validateProtocol(raw), treeHash = taskTreeHash(dir), text = JSON.stringify(protocol, null, 2) + "\n";
140
+ // Never commit a protocol that makes its existing selection unreadable.
141
+ selectionHash(dir, protocol);
142
+ finalSeedsHash(dir, protocol);
143
+ commit(dir, [[join(dir, "design", "protocol.json"), text, PROTOCOL_BYTES]], [{ actor, type: "protocol.set", data: { sha256: sha256(text), protocolHash: protocolHash(protocol), treeHash } }]);
144
+ return protocol;
145
+ }
@@ -0,0 +1,53 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { boundedText, commit, onlyKeys, openStudy, plain, readTextBounded, requireExperiment, sha256, type Actor } from "../study.ts";
4
+ import type { Protocol } from "./protocol.ts";
5
+
6
+ export interface Selection { v: 1; run: string; metric: string; baseline: { commit: string }; minSeeds: number; minDelta: number }
7
+ export const SELECTION_BYTES = 65_536;
8
+ const compatible = "use an ares version that supports this selection, keeping design/selection.json unchanged";
9
+ export function selectionPath(dir: string): string { return join(dir, "design", "selection.json"); }
10
+ export function validateSelection(raw: unknown, protocol: Protocol): Selection {
11
+ try {
12
+ if (!plain(raw)) throw new Error("selection: JSON object required");
13
+ if (raw.v !== 1) throw new Error(`selection.v: unsupported version; ${compatible}`);
14
+ onlyKeys(raw, ["v", "run", "metric", "baseline", "minSeeds", "minDelta"], "selection");
15
+ if (typeof raw.run !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(raw.run)) throw new Error("selection.run: use 1..64 letters, digits, underscores or hyphens, starting with a letter or digit");
16
+ if (["ablate", "final", "posthoc"].includes(raw.run)) throw new Error(`selection.run: ${raw.run} is a reserved phase run; choose another run such as investigate`);
17
+ const metric = boundedText(raw.metric, "selection.metric", 128);
18
+ if (!protocol.metrics.some(m => m.name === metric)) throw new Error("selection.metric: undeclared; choose a metric from design/protocol.json");
19
+ if (!plain(raw.baseline)) throw new Error("selection.baseline: object with commit required");
20
+ onlyKeys(raw.baseline, ["commit"], "selection.baseline");
21
+ if (typeof raw.baseline.commit !== "string" || !/^[a-fA-F0-9]{40}$/.test(raw.baseline.commit)) throw new Error("selection.baseline.commit: full 40-hex commit required");
22
+ if (!Number.isSafeInteger(raw.minSeeds) || (raw.minSeeds as number) < 1 || (raw.minSeeds as number) > protocol.seeds.length) throw new Error(`selection.minSeeds: integer in 1..${protocol.seeds.length} required (declared protocol seed count)`);
23
+ if (typeof raw.minDelta !== "number" || !Number.isFinite(raw.minDelta) || raw.minDelta < 0) throw new Error("selection.minDelta: finite number >= 0 required in the metric's unit");
24
+ return { v: 1, run: raw.run, metric, baseline: { commit: raw.baseline.commit.toLowerCase() }, minSeeds: raw.minSeeds as number, minDelta: raw.minDelta };
25
+ } catch (error) {
26
+ const message = error instanceof Error ? error.message : "selection: invalid rule";
27
+ throw new Error(message.includes(compatible) ? message : `${message}; correct the rule and run ares selection <dir> --file PATH again`);
28
+ }
29
+ }
30
+ function record(dir: string, protocol: Protocol): { selection: Selection; text: string } | undefined {
31
+ const file = selectionPath(dir);
32
+ if (!existsSync(file)) return undefined;
33
+ let text: string;
34
+ try { text = readTextBounded(file, SELECTION_BYTES); }
35
+ catch { throw new Error(`design/selection.json: unreadable, non-regular, invalid UTF-8 or larger than ${SELECTION_BYTES} bytes; restore a readable regular UTF-8 rule within that limit before ares selection`); }
36
+ let raw: unknown;
37
+ try { raw = JSON.parse(text); } catch { throw new Error("design/selection.json: invalid JSON; restore its last valid rule before running ares selection"); }
38
+ return { selection: validateSelection(raw, protocol), text };
39
+ }
40
+ export function readSelection(dir: string, protocol: Protocol): Selection | undefined { return record(dir, protocol)?.selection; }
41
+ /** The design seal binds original file bytes, including formatting, just like the protocol. */
42
+ export function selectionHash(dir: string, protocol: Protocol): string | undefined {
43
+ const value = record(dir, protocol);
44
+ return value === undefined ? undefined : sha256(value.text);
45
+ }
46
+ /** Caller holds the study lock. Identical bytes do not move the investigation cutoff. */
47
+ export function setSelection(dir: string, raw: unknown, protocol: Protocol, actor: Actor) {
48
+ requireExperiment(openStudy(dir).study);
49
+ const selection = validateSelection(raw, protocol), previous = record(dir, protocol);
50
+ const text = JSON.stringify(selection, null, 2) + "\n", hash = sha256(text), changed = previous?.text !== text;
51
+ if (changed) commit(dir, [[selectionPath(dir), text, SELECTION_BYTES]], [{ actor, type: "selection.set", data: { sha256: hash, run: selection.run, metric: selection.metric } }]);
52
+ return { selection, sha256: hash, changed };
53
+ }