blob: 3b45b700bced747ce7674b1620eba04cfaa38241 [file]
import {
expandExperiment,
type ExperimentCell,
type ExperimentSpec,
type JsonObject,
} from './experiment.js';
import {
decodeEvalResult,
selectCellResult,
type CellAttempt,
type EvalResult,
type NormalizedUsage,
} from './result.js';
export interface SubjectExecutionResult {
readonly output?: string;
readonly usage: NormalizedUsage | null;
readonly costUsd: number | null;
readonly durationMs: number;
readonly status: 'completed' | 'failed' | 'infra_failed' | 'indeterminate';
readonly failureReason: string | null;
readonly artifacts: readonly JsonObject[];
}
export interface SubjectExecutionContext {
readonly cwd: string;
readonly taskInput: string;
readonly metadata: JsonObject;
readonly signal?: AbortSignal;
readonly execute: (input: {
readonly command: string;
readonly args: readonly string[];
readonly credentialNames: readonly string[];
readonly cancel?: { readonly command: string; readonly args: readonly string[] };
}) => Promise<{
readonly termination: 'exited' | 'framework_timeout' | 'cancelled';
readonly exitCode: number;
readonly stdout: string;
}>;
}
export interface SubjectAdapter {
readonly kind: ExperimentCell['subject']['kind'];
validate?(cell: ExperimentCell): void;
execute(input: {
readonly cell: ExperimentCell;
readonly context: SubjectExecutionContext;
}): Promise<SubjectExecutionResult>;
}
export interface ExecutorVerification {
readonly status: 'completed' | 'subject_failed' | 'infra_failed';
readonly score: number | null;
readonly failureReason: string | null;
readonly artifacts: readonly JsonObject[];
}
export interface ExperimentExecutor {
readonly kind: string;
runAttempt(
input: { readonly cell: ExperimentCell; readonly signal?: AbortSignal },
operation: (attempt: {
readonly context: SubjectExecutionContext;
verify(): Promise<ExecutorVerification>;
}) => Promise<EvalResult>,
): Promise<
| { readonly kind: 'settled'; readonly value: EvalResult }
| { readonly kind: 'indeterminate'; readonly value?: EvalResult }
>;
}
export interface AttemptStore {
list(cellId: string): Promise<readonly CellAttempt[]>;
append(attempt: CellAttempt): Promise<void>;
runExclusive<T>(operation: () => Promise<T>): Promise<T>;
}
export class ExecutorPreparationFailure extends Error {
constructor(
readonly artifacts: readonly JsonObject[],
options?: ErrorOptions,
) {
super('executor preparation failed', options);
this.name = 'ExecutorPreparationFailure';
}
}
export async function runExperiment(input: {
readonly spec: ExperimentSpec;
readonly store: AttemptStore;
readonly executor: ExperimentExecutor;
readonly subjects: readonly SubjectAdapter[];
readonly cellIds?: readonly string[];
readonly signal?: AbortSignal;
readonly now?: () => number;
}): Promise<ReadonlyMap<string, CellAttempt>> {
return input.store.runExclusive(async () => {
const cells = expandExperiment(input.spec);
const selected = selectCells(cells, input.cellIds);
const subjects = new Map(input.subjects.map((subject) => [subject.kind, subject]));
if (input.executor.kind !== input.spec.executor.kind) throw new Error('executor kind mismatch');
for (const cell of selected) {
const subject = subjects.get(cell.subject.kind);
if (!subject) throw new Error(`missing subject adapter: ${cell.subject.kind}`);
subject.validate?.(cell);
}
await runTaskGroups(
groupTaskCells(selected),
input.spec.execution.maxConcurrentTaskGroups,
async (group) => {
if (input.signal?.aborted) return;
await Promise.all(
group.map(async (cell) => {
if (input.signal?.aborted) return;
const attempts = await input.store.list(cell.id);
if (selectCellResult(attempts)) return;
const startedAt = (input.now ?? Date.now)();
const result = await executeCell(
input.executor,
subjects.get(cell.subject.kind)!,
cell,
input.signal,
);
await input.store.append({
cellId: cell.id,
sequence: (attempts.at(-1)?.sequence ?? 0) + 1,
startedAt,
completedAt: (input.now ?? Date.now)(),
result,
});
}),
);
},
);
return new Map(
(
await Promise.all(
cells.map(
async (cell) => [cell.id, selectCellResult(await input.store.list(cell.id))] as const,
),
)
).flatMap(([cellId, result]) => (result ? [[cellId, result] as const] : [])),
);
});
}
function groupTaskCells(cells: readonly ExperimentCell[]): ExperimentCell[][] {
const groups = new Map<string, ExperimentCell[]>();
for (const cell of cells) {
const key = `${cell.task.id}\u0000${cell.repetition}`;
const group = groups.get(key);
if (group) group.push(cell);
else groups.set(key, [cell]);
}
return [...groups.values()];
}
async function runTaskGroups<T>(
groups: readonly T[],
maximum: number,
run: (group: T) => Promise<void>,
): Promise<void> {
let next = 0;
const worker = async () => {
for (;;) {
const group = groups[next];
next += 1;
if (group === undefined) return;
await run(group);
}
};
await Promise.all(
Array.from({ length: Math.min(maximum, groups.length) }, async () => await worker()),
);
}
async function executeCell(
executor: ExperimentExecutor,
subject: SubjectAdapter,
cell: ExperimentCell,
signal?: AbortSignal,
): Promise<EvalResult> {
try {
const attempt = await executor.runAttempt(
{ cell, ...(signal ? { signal } : {}) },
async ({ context, verify }) => {
let execution: SubjectExecutionResult;
try {
execution = decodeSubjectExecution(
await subject.execute({
cell,
context: { ...context, ...(signal ? { signal } : {}) },
}),
);
} catch {
return failure('infra_failed', 'subject execution failed');
}
if (
signal?.aborted ||
execution.status === 'infra_failed' ||
execution.status === 'indeterminate'
) {
return fromUncertainSubject(execution, signal?.aborted === true);
}
try {
const verified = decodeVerification(await verify());
return {
score: verified.score,
usage: execution.usage,
costUsd: execution.costUsd,
durationMs: execution.durationMs,
status: settledStatus(execution.status, verified.status),
failureReason:
verified.status === 'infra_failed'
? verified.failureReason
: (execution.failureReason ?? verified.failureReason),
artifacts: [...execution.artifacts, ...verified.artifacts],
};
} catch {
return failure('infra_failed', 'verification failed', execution);
}
},
);
if (attempt.kind === 'settled') return decodeEvalResult(attempt.value);
if (!attempt.value) return failure('indeterminate', 'executor cleanup did not settle');
const partial = decodeEvalResult(attempt.value);
return {
...partial,
score: null,
status: 'indeterminate',
failureReason: 'executor cleanup did not settle',
};
} catch (error) {
return failure(
'infra_failed',
'executor preparation failed',
undefined,
error instanceof ExecutorPreparationFailure ? error.artifacts : [],
);
}
}
function decodeSubjectExecution(value: unknown): SubjectExecutionResult {
const subject = exactRecord(
value,
['usage', 'costUsd', 'durationMs', 'status', 'failureReason', 'artifacts'],
['output'],
);
if (
subject.status !== 'completed' &&
subject.status !== 'failed' &&
subject.status !== 'infra_failed' &&
subject.status !== 'indeterminate'
) {
throw new Error('subject status is invalid');
}
if (subject.output !== undefined && typeof subject.output !== 'string') {
throw new Error('subject output is invalid');
}
const decoded = decodeEvalResult({
score: null,
usage: subject.usage,
costUsd: subject.costUsd,
durationMs: subject.durationMs,
status: subject.status === 'failed' ? 'subject_failed' : subject.status,
failureReason: subject.failureReason,
artifacts: subject.artifacts,
});
return {
...(subject.output === undefined ? {} : { output: subject.output }),
usage: decoded.usage,
costUsd: decoded.costUsd,
durationMs: decoded.durationMs,
status: subject.status,
failureReason: decoded.failureReason,
artifacts: decoded.artifacts,
};
}
function decodeVerification(value: unknown): ExecutorVerification {
const verification = exactRecord(value, ['status', 'score', 'failureReason', 'artifacts']);
if (
verification.status !== 'completed' &&
verification.status !== 'subject_failed' &&
verification.status !== 'infra_failed'
) {
throw new Error('verification status is invalid');
}
const decoded = decodeEvalResult({
score: verification.score,
usage: null,
costUsd: null,
durationMs: 0,
status: verification.status,
failureReason: verification.failureReason,
artifacts: verification.artifacts,
});
return {
status: verification.status,
score: decoded.score,
failureReason: decoded.failureReason,
artifacts: decoded.artifacts,
};
}
function settledStatus(
subject: SubjectExecutionResult['status'],
verification: ExecutorVerification['status'],
): EvalResult['status'] {
if (verification === 'infra_failed') return 'infra_failed';
if (subject === 'failed' || verification === 'subject_failed') return 'subject_failed';
return 'completed';
}
function exactRecord(
value: unknown,
required: readonly string[],
optional: readonly string[] = [],
): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('result envelope must be an object');
}
const record = value as Record<string, unknown>;
const allowed = new Set([...required, ...optional]);
if (
required.some((field) => !Object.hasOwn(record, field)) ||
Object.keys(record).some((field) => !allowed.has(field))
) {
throw new Error('result envelope fields are invalid');
}
return record;
}
function fromUncertainSubject(subject: SubjectExecutionResult, cancelled: boolean): EvalResult {
return {
score: null,
usage: subject.usage,
costUsd: subject.costUsd,
durationMs: subject.durationMs,
status: cancelled
? 'indeterminate'
: subject.status === 'infra_failed'
? 'infra_failed'
: 'indeterminate',
failureReason: subject.failureReason,
artifacts: subject.artifacts,
};
}
function failure(
status: 'infra_failed' | 'indeterminate',
failureReason: string,
subject?: SubjectExecutionResult,
artifacts: readonly JsonObject[] = subject?.artifacts ?? [],
): EvalResult {
return {
score: null,
usage: subject?.usage ?? null,
costUsd: subject?.costUsd ?? null,
durationMs: subject?.durationMs ?? 0,
status,
failureReason,
artifacts,
};
}
function selectCells(cells: readonly ExperimentCell[], ids?: readonly string[]): ExperimentCell[] {
if (!ids) return [...cells];
const selected = new Set(ids);
const known = new Set(cells.map(({ id }) => id));
for (const id of selected) if (!known.has(id)) throw new Error(`unknown experiment cell: ${id}`);
return cells.filter(({ id }) => selected.has(id));
}