// Machine versions. A version is what decides how a machine behaves and is // graded: its own instructions, its model, and its rubric. Versions are // numbered (v1, v2, …) in a small history file, machines//versions.json, // checked in beside the machine; every run records the version that made it. // // The machine's files stay the truth: instructions.md, model.json (read by // modelFrom), and rubric.ts define what runs. The history records each // definition that became live and the order they did, so `revert` can put the // previous one back. A definition that matches no recorded version (an edit // by hand) is recorded as a new version the first time it is needed. import { createHash } from "node:crypto"; import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import path from "node:path"; import type { Machine } from "./machine.ts"; import { rubricFingerprint } from "./rubric.ts"; export type VersionSource = | { kind: "initial" } | { kind: "recorded"; note?: string } | { kind: "update"; note?: string } | { kind: "train"; candidate: string; checkpoint: string } | { kind: "optimize"; candidate: string } | { kind: "propose"; candidate: string }; export type VersionEntry = { /** "v1", "v2", … */ id: string; /** The machine's own instructions (a child's parent instructions are not part of its version). */ instructions: string; model: string; /** For a tinker:// checkpoint, the base model it was trained from. */ baseModel?: string; /** rubricFingerprint of the rubric at the time. */ rubric: string; /** Fingerprint of (instructions, model, rubric). */ fingerprint: string; createdAt: string; source: VersionSource; /** One line for people: why this version exists, e.g. a candidate report's summary. */ note?: string; }; export type VersionHistory = { machine: string; /** Every version, in creation order. */ versions: VersionEntry[]; /** Ids in the order they became live; the last is live. Revert drops the last. */ live: string[]; }; export function definitionFingerprint(def: { ownInstructions: string; model: string; rubric: Machine["rubric"] }): string { return createHash("sha256") .update(JSON.stringify([def.ownInstructions, def.model, rubricFingerprint(def.rubric)])) .digest("hex") .slice(0, 12); } /** Where a machine's history is kept, and how it is read and written. */ export interface VersionFile { readonly path: string; read(machine: string): Promise; write(history: VersionHistory): Promise; } /** machines//versions.json in a workspace, or any JSON file. */ export function versionFile(file: string): VersionFile { return { path: file, async read(machine) { try { const parsed = JSON.parse(await readFile(file, "utf8")) as VersionHistory; if (parsed.machine !== machine) throw new Error(`${file} holds versions of "${parsed.machine}", not "${machine}".`); return parsed; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { machine, versions: [], live: [] }; throw error; } }, async write(history) { await mkdir(path.dirname(file), { recursive: true }); const tmp = `${file}.${process.pid}.tmp`; await writeFile(tmp, `${JSON.stringify(history, null, 2)}\n`); await rename(tmp, file); }, }; } export function liveVersion(history: VersionHistory): VersionEntry | undefined { const id = history.live.at(-1); return id ? history.versions.find((v) => v.id === id) : undefined; } /** The recorded version matching this definition, preferring the live one. */ export function findVersion(history: VersionHistory, def: Machine): VersionEntry | undefined { const fp = definitionFingerprint(def); const live = liveVersion(history); if (live?.fingerprint === fp) return live; return history.versions.findLast((v) => v.fingerprint === fp); } /** Add a version for `def` and make it live. Returns the new history and entry. */ export function addVersion( history: VersionHistory, def: Machine, source: VersionSource, options: { baseModel?: string; note?: string; now?: Date } = {}, ): { history: VersionHistory; entry: VersionEntry } { const entry: VersionEntry = { id: `v${history.versions.length + 1}`, instructions: def.ownInstructions, model: def.model, ...(options.baseModel && def.model.startsWith("tinker://") ? { baseModel: options.baseModel } : {}), rubric: rubricFingerprint(def.rubric), fingerprint: definitionFingerprint(def), createdAt: (options.now ?? new Date()).toISOString(), source, ...(options.note ? { note: options.note } : {}), }; return { history: { ...history, versions: [...history.versions, entry], live: [...history.live, entry.id] }, entry }; }