blob: b23d2baf75d1c448c5d2252ca67335fb469e68fc [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 { decodeJsonObject, type ExperimentSpec } from './experiment.js';
export function parseExperimentSpec(value: unknown): ExperimentSpec {
const root = exact(
value,
'experiment',
[
'schemaVersion',
'id',
'benchmark',
'executor',
'subjects',
'tasks',
'repetitions',
'budget',
'verifier',
],
['execution'],
);
if (root.schemaVersion !== 'maka.eval.v1') throw new Error('unsupported experiment schema');
const benchmark = exact(root.benchmark, 'benchmark', ['id', 'version', 'config']);
const executor = exact(root.executor, 'executor', ['kind', 'config']);
const execution =
root.execution === undefined
? { maxConcurrentTaskGroups: 1 }
: exact(root.execution, 'execution', ['maxConcurrentTaskGroups']);
const subjects: ExperimentSpec['subjects'][number][] = array(root.subjects, 'subjects').map(
(value, index) => {
const subject = exact(value, `subjects[${index}]`, ['id', 'kind', 'credentials', 'config']);
if (subject.kind !== 'maka' && subject.kind !== 'external') {
throw new Error(`subjects[${index}].kind is invalid`);
}
return {
id: identifier(subject.id, `subjects[${index}].id`),
kind: subject.kind === 'maka' ? 'maka' : 'external',
credentials: uniqueStrings(subject.credentials, `subjects[${index}].credentials`),
config: decodeJsonObject(subject.config, `subjects[${index}].config`),
};
},
);
const tasks = array(root.tasks, 'tasks').map((value, index) => {
const task = exact(value, `tasks[${index}]`, ['id', 'input', 'config']);
return {
id: identifier(task.id, `tasks[${index}].id`),
input: nonempty(task.input, `tasks[${index}].input`),
config: decodeJsonObject(task.config, `tasks[${index}].config`),
};
});
if (subjects.length === 0 || tasks.length === 0) throw new Error('experiment arms are empty');
uniqueIds(subjects, 'subject');
uniqueIds(tasks, 'task');
return deepFreeze({
schemaVersion: 'maka.eval.v1',
id: identifier(root.id, 'experiment.id'),
benchmark: {
id: identifier(benchmark.id, 'benchmark.id'),
version: nonempty(benchmark.version, 'benchmark.version'),
config: decodeJsonObject(benchmark.config, 'benchmark.config'),
},
executor: {
kind: identifier(executor.kind, 'executor.kind'),
config: decodeJsonObject(executor.config, 'executor.config'),
},
execution: {
maxConcurrentTaskGroups: positiveInteger(
execution.maxConcurrentTaskGroups,
'execution.maxConcurrentTaskGroups',
),
},
subjects,
tasks,
repetitions: positiveInteger(root.repetitions, 'repetitions'),
budget: decodeJsonObject(root.budget, 'budget'),
verifier: decodeJsonObject(root.verifier, 'verifier'),
});
}
function exact(
value: unknown,
where: string,
fields: readonly string[],
optional: readonly string[] = [],
): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new Error(`${where} must be an object`);
const record = value as Record<string, unknown>;
if (Object.keys(record).some((key) => !fields.includes(key) && !optional.includes(key)))
throw new Error(`${where} has unsupported fields`);
for (const field of fields)
if (!Object.hasOwn(record, field)) throw new Error(`${where}.${field} is required`);
return record;
}
function array(value: unknown, where: string): unknown[] {
if (!Array.isArray(value)) throw new Error(`${where} must be an array`);
return value;
}
function identifier(value: unknown, where: string): string {
const text = nonempty(value, where);
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(text)) throw new Error(`${where} is invalid`);
return text;
}
function nonempty(value: unknown, where: string): string {
if (typeof value !== 'string' || value.length === 0) throw new Error(`${where} is required`);
return value;
}
function uniqueStrings(value: unknown, where: string): string[] {
const values = array(value, where).map((item) => nonempty(item, where));
if (new Set(values).size !== values.length) throw new Error(`${where} contains duplicates`);
return values;
}
function positiveInteger(value: unknown, where: string): number {
if (!Number.isSafeInteger(value) || (value as number) < 1)
throw new Error(`${where} must be positive`);
return value as number;
}
function uniqueIds(values: readonly { id: string }[], label: string): void {
const ids = values.map(({ id }) => id);
if (new Set(ids).size !== ids.length) throw new Error(`duplicate ${label} id`);
}
function deepFreeze<T>(value: T): T {
if (value && typeof value === 'object') {
Object.freeze(value);
for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child);
}
return value;
}