blob: 8c88c4f3cd9bc98674d9d4d534cdf3d9ecd7d50d [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
expandExperiment,
type ExperimentCell,
type ExperimentSpec,
type JsonObject,
} from './experiment.js';
import {
decodeEvalResult,
type CellAttempt,
type EvalResult,
isReplaceableAttempt,
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 environment?: Readonly<Record<string, string>>;
readonly credentialEnvironment: Readonly<Record<string, string>>;
readonly captureStdout?: boolean;
}) => Promise<{
readonly termination: 'exited' | 'framework_timeout';
readonly exitCode: number;
readonly stdout: string;
readonly diagnostic?: {
readonly category:
| 'none'
| 'unstructured-output'
| 'result-frame-missing'
| 'result-frame-invalid'
| 'result-frame-ambiguous'
| 'result-frame-oversize'
| 'execution-scope-unavailable';
readonly bytes?: number;
readonly sha256?: string;
};
}>;
}
export interface SubjectAdapter {
readonly kind: ExperimentCell['subject']['kind'];
validate?(cell: ExperimentCell): void;
prepare?(input: {
readonly spec: ExperimentSpec;
readonly cells: readonly ExperimentCell[];
}): Promise<void>;
canReuse?(input: { readonly cell: ExperimentCell; readonly attempt: CellAttempt }): boolean;
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 type ExecutorPreparationCode =
| 'cancelled'
| 'preparation-failed'
| 'spawn-failed'
| 'exit-before-ready'
| 'invalid-ready'
| 'framework-version-mismatch';
export type ExecutorAttemptOutcome =
| { readonly kind: 'settled'; readonly value: EvalResult }
| {
readonly kind: 'indeterminate';
readonly cause: 'host-cancelled' | 'cleanup-unconfirmed';
readonly value?: EvalResult;
}
| {
readonly kind: 'not_started';
readonly code: ExecutorPreparationCode;
readonly artifacts: readonly JsonObject[];
};
export interface ExperimentExecutor {
readonly kind: string;
validate?(cell: ExperimentCell): void;
runAttempt(
input: {
readonly cell: ExperimentCell;
readonly subjectCredentialNames: readonly string[];
readonly signal?: AbortSignal;
},
operation: (attempt: {
readonly context: SubjectExecutionContext;
verify(): Promise<ExecutorVerification>;
}) => Promise<EvalResult>,
): Promise<ExecutorAttemptOutcome>;
}
export interface AttemptStore {
list(cellId: string): Promise<readonly CellAttempt[]>;
append(attempt: CellAttempt): Promise<void>;
runExclusive<T>(operation: () => Promise<T>): Promise<T>;
}
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]));
const subjectCredentialNames = [
...new Set(input.spec.subjects.flatMap((subject) => subject.credentials)),
];
if (input.executor.kind !== input.spec.executor.kind) throw new Error('executor kind mismatch');
for (const cell of selected) {
input.executor.validate?.(cell);
const subject = subjects.get(cell.subject.kind);
if (!subject) throw new Error(`missing subject adapter: ${cell.subject.kind}`);
subject.validate?.(cell);
}
for (const subject of subjects.values()) {
const cellsForSubject = selected.filter((cell) => cell.subject.kind === subject.kind);
if (cellsForSubject.length > 0) {
await subject.prepare?.({ spec: input.spec, cells: cellsForSubject });
}
}
await runTaskGroups(
groupTaskCells(selected),
input.spec.execution.maxConcurrentTaskGroups,
input.signal,
async (group, fail) => {
if (input.signal?.aborted) return;
const operations = group.map(async (cell) => {
if (input.signal?.aborted) return;
const attempts = await input.store.list(cell.id);
if (input.signal?.aborted) return;
const subject = subjects.get(cell.subject.kind)!;
if (selectSubjectResult(attempts, subject, cell)) return;
const startedAt = (input.now ?? Date.now)();
const result = await executeCell(
input.executor,
subject,
cell,
subjectCredentialNames,
input.signal,
);
await input.store.append({
cellId: cell.id,
sequence: (attempts.at(-1)?.sequence ?? 0) + 1,
startedAt,
completedAt: (input.now ?? Date.now)(),
result,
});
});
await Promise.allSettled(
operations.map((operation) =>
operation.catch((error: unknown) => {
fail(error);
throw error;
}),
),
);
},
);
return new Map(
(
await Promise.all(
cells.map(
async (cell) =>
[
cell.id,
selectSubjectResult(
await input.store.list(cell.id),
subjects.get(cell.subject.kind)!,
cell,
),
] as const,
),
)
).flatMap(([cellId, result]) => (result ? [[cellId, result] as const] : [])),
);
});
}
function selectSubjectResult(
attempts: readonly CellAttempt[],
subject: SubjectAdapter,
cell: ExperimentCell,
): CellAttempt | undefined {
return [...attempts]
.sort((left, right) => left.sequence - right.sequence)
.find(
(attempt) =>
!isReplaceableAttempt(attempt) && (subject.canReuse?.({ cell, attempt }) ?? true),
);
}
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,
signal: AbortSignal | undefined,
run: (group: T, fail: (error: unknown) => void) => Promise<void>,
): Promise<void> {
let next = 0;
let failed = false;
let failure: unknown;
const fail = (error: unknown) => {
if (failed) return;
failed = true;
failure = error;
};
const worker = async () => {
for (;;) {
if (failed || signal?.aborted) return;
const group = groups[next];
next += 1;
if (group === undefined) return;
try {
await run(group, fail);
} catch (error) {
fail(error);
}
}
};
await Promise.all(
Array.from({ length: Math.min(maximum, groups.length) }, async () => await worker()),
);
if (failed) throw failure;
}
async function executeCell(
executor: ExperimentExecutor,
subject: SubjectAdapter,
cell: ExperimentCell,
subjectCredentialNames: readonly string[],
signal?: AbortSignal,
): Promise<EvalResult> {
try {
const attempt = await executor.runAttempt(
{ cell, subjectCredentialNames, ...(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 === 'not_started') {
const cancelled = attempt.code === 'cancelled';
return failure(
cancelled ? 'indeterminate' : 'infra_failed',
cancelled ? 'executor preparation cancelled' : 'executor preparation failed',
undefined,
attempt.artifacts,
);
}
if (attempt.kind === 'settled') return decodeEvalResult(attempt.value);
const failureReason =
attempt.cause === 'host-cancelled'
? 'executor cancelled before verification completed'
: 'executor cleanup did not settle';
if (!attempt.value) return failure('indeterminate', failureReason);
const partial = decodeEvalResult(attempt.value);
return {
...partial,
score: null,
status: 'indeterminate',
failureReason,
};
} catch {
return failure('infra_failed', 'executor preparation failed');
}
}
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));
}