Something went wrong. Try again.
A functional toolkit for authoring resource-leveling algorithms — compose constraint and scoring blocks, enumerate feasible schedules
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323// serializeConf / lintRegistryForConf — the write side of the `.conf` layer.//// serializeConf renders a RunConfig to canonical TOML text: deterministic key// order, default-valued flags omitted (curated provenance, shadow=false,// absent enabled), sections in a fixed order. The round trip is value-lossless// (parseConf(serializeConf(c)) reproduces c through the schema) but// comment/format-lossy — hand-authored comments and spacing are not preserved.//// The serializer is hand-rolled (not smol-toml's stringify) so the exact byte// output stays under our control: goldens are byte-exact, and the canonical// form is what diffs and reviews read.
import { z } from "zod";
import type { BlockRegistry } from "../level-blocks/registry.ts";import type { BlockInstance } from "../level-blocks/graph.ts";import type { RunConfig } from "../level-blocks/runConfig.ts";
import type { ConfIssue } from "./issues.ts";import { RESERVED_BLOCK_KEYS, reservedParamCollisions } from "./parse.ts";
export interface SerializeConfOptions { /** Comment lines to emit at the top of the file (each prefixed with `# `). */ readonly header?: readonly string[]; /** When set, emit each block's rendered assumptionTemplate as a comment * line above its `[blocks.<id>]` section. */ readonly annotate?: BlockRegistry;}
function isPlainObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value);}
const BARE_KEY = /^[A-Za-z0-9_-]+$/;
/** A TOML key segment — bare when safe, else a quoted basic string. */function tomlKey(key: string): string { return BARE_KEY.test(key) ? key : tomlString(key);}
/** A TOML basic string with the minimal escapes. */function tomlString(value: string): string { const escaped = value .replace(/\\/g, "\\\\") .replace(/"/g, '\\"') .replace(/\n/g, "\\n") .replace(/\r/g, "\\r") .replace(/\t/g, "\\t"); return `"${escaped}"`;}
/** A scalar TOML value. Numbers render through String (finite by * construction — E306 rejects inf/nan at parse, and RunConfig values are * validated), so `2 → "2"`, `2.5 → "2.5"`; round-trips to the same JS value. */function tomlScalar(value: unknown): string { if (typeof value === "string") return tomlString(value); if (typeof value === "boolean") return String(value); if (typeof value === "number") return String(value); if (Array.isArray(value)) return inlineArray(value); if (isPlainObject(value)) { const entries = Object.entries(value).map( ([key, child]) => `${tomlKey(key)} = ${tomlScalar(child)}`, ); return `{ ${entries.join(", ")} }`; } // Should not occur in a validated RunConfig; render defensively. return tomlString(String(value));}
function inlineArray(values: readonly unknown[]): string { return `[${values.map((v) => tomlScalar(v)).join(", ")}]`;}
interface SubTable { /** Key path from the table root to the object-array param. */ path: string[]; items: Record<string, unknown>[];}
/** Flatten an object's scalar/array leaves into `key = value` lines (nested * objects become dotted keys), collecting non-empty object arrays as * sub-tables to emit afterwards. */function collectLeaves( obj: Record<string, unknown>, prefix: string[], lines: string[], subTables: SubTable[],): void { for (const [key, value] of Object.entries(obj)) { const path = [...prefix, key]; if (isPlainObject(value)) { collectLeaves(value, path, lines, subTables); } else if (Array.isArray(value)) { if (value.length > 0 && value.every(isPlainObject)) { subTables.push({ path, items: value }); } else { // Empty arrays and scalar arrays render inline (an empty array is // ambiguous otherwise — `[]` round-trips, a missing key does not). lines.push(`${path.map(tomlKey).join(".")} = ${inlineArray(value)}`); } } else { lines.push(`${path.map(tomlKey).join(".")} = ${tomlScalar(value)}`); } }}
/** Emit a table (or array-of-tables item) rooted at `headerPath`: a header * line, its scalar/dotted leaves, then each object-array child recursively. */function emitTable( headerPath: string[], leadLines: readonly string[], obj: Record<string, unknown>, isArrayItem: boolean, sections: string[],): void { const lines: string[] = [...leadLines]; const subTables: SubTable[] = []; collectLeaves(obj, [], lines, subTables); const dotted = headerPath.map(tomlKey).join("."); const header = isArrayItem ? `[[${dotted}]]` : `[${dotted}]`; sections.push([header, ...lines].join("\n")); for (const sub of subTables) { for (const item of sub.items) { emitTable([...headerPath, ...sub.path], [], item, true, sections); } }}
/** Substitute `{param}` placeholders in an assumptionTemplate with the block's * param values; unresolved placeholders are left as-is. */function renderAssumption( template: string, params: Record<string, unknown>,): string { return template.replace(/\{([A-Za-z0-9_.]+)\}/g, (whole, path: string) => { let cur: unknown = params; for (const seg of path.split(".")) { if (!isPlainObject(cur)) return whole; cur = cur[seg]; } // Params are user-supplied TOML values. `String()` is the documented // substitution behavior (arrays render comma-joined); JSON-encoding here // would change rendered assumption text. // oxlint-disable-next-line typescript/no-base-to-string return cur === undefined ? whole : String(cur); });}
function serializeBlock( instance: BlockInstance, annotate: BlockRegistry | undefined,): string[] { const lead: string[] = [`code = ${tomlString(instance.code)}`]; // Reserved flags, in canonical order; only the non-default values. if (instance.enabled === false) lead.push(`enabled = false`); if (instance.provenance !== undefined && instance.provenance !== "curated") { lead.push(`provenance = ${tomlString(instance.provenance)}`); } if (instance.shadow === true) lead.push(`shadow = true`);
const sections: string[] = []; emitTable( ["blocks", instance.instanceId], lead, instance.params, false, sections, );
const def = annotate?.get(instance.code); if (def?.assumptionTemplate !== undefined && sections.length > 0) { const comment = `# ${renderAssumption(def.assumptionTemplate, instance.params)}`; sections[0] = `${comment}\n${sections[0]!}`; } return sections;}
/** * Render a RunConfig to canonical `.conf` (TOML 1.0) text. Deterministic: * the same config always produces the same bytes. Default-valued block flags * are omitted. Pass `annotate` to comment each block with its rendered * assumption, or `header` for top-of-file comment lines. */export function serializeConf( config: RunConfig, opts?: SerializeConfOptions,): string { const sections: string[] = [];
if (opts?.header && opts.header.length > 0) { sections.push(opts.header.map((line) => `# ${line}`).join("\n")); }
// [levelset] const levelsetLines = [`format = 1`]; if (config.levelset !== undefined) levelsetLines.push(`version = ${tomlString(config.levelset)}`); if (config.seed !== undefined) levelsetLines.push(`seed = ${tomlScalar(config.seed)}`); sections.push([`[levelset]`, ...levelsetLines].join("\n"));
// [resolve] if (config.resolve !== undefined) { emitTable(["resolve"], [], config.resolve, false, sections); }
// [pipeline] — systemCode only; formatVersion is implied by [levelset]. if (config.pipeline.systemCode !== undefined) { sections.push( [ `[pipeline]`, `systemCode = ${tomlString(config.pipeline.systemCode)}`, ].join("\n"), ); }
// [blocks.<id>] in document order. for (const instance of config.pipeline.blocks) { sections.push(...serializeBlock(instance, opts?.annotate)); }
// [weights] if (config.weights !== undefined) { const lines = Object.entries(config.weights).map( ([name, weight]) => `${tomlKey(name)} = ${weight === null ? '"report"' : tomlScalar(weight)}`, ); sections.push([`[weights]`, ...lines].join("\n")); }
// [objective] if (config.objective !== undefined) { sections.push( [ `[objective]`, `lexicographic = ${inlineArray(config.objective.lexicographic)}`, ].join("\n"), ); }
// [sweep] + [[sweep.axes]] — emitted through the generic table walker so // targets and zip-axis record values retain their exact JSON structure. if (config.sweep !== undefined) { emitTable(["sweep"], [], config.sweep, false, sections); }
return sections.join("\n\n") + "\n";}
/** The JSON-schema subset lint scans for null-typed / unrepresentable nodes. */interface SchemaNode { type?: string | string[]; enum?: unknown[]; properties?: Record<string, SchemaNode>; items?: SchemaNode; anyOf?: SchemaNode[]; allOf?: SchemaNode[]; oneOf?: SchemaNode[];}
function schemaRequiresNull(node: SchemaNode | undefined): boolean { if (!node) return false; if (node.type === "null") return true; if (Array.isArray(node.type) && node.type.includes("null")) return true; if (node.enum?.some((v) => v === null)) return true; for (const child of Object.values(node.properties ?? {})) { if (schemaRequiresNull(child)) return true; } if (schemaRequiresNull(node.items)) return true; for (const group of [node.anyOf, node.allOf, node.oneOf]) { if (group?.some((n) => schemaRequiresNull(n))) return true; } return false;}
/** * Static-check a registry for blocks the `.conf` format can't faithfully * express: a param name that collides with a reserved block-table key (E305), * a schema that admits `null` where params can't be null, or an input schema * `z.toJSONSchema` can't render at all. Returns [] for a clean registry (the * standard registry is clean); a build/test asserts that. */export function lintRegistryForConf(registry: BlockRegistry): ConfIssue[] { const issues: ConfIssue[] = []; for (const def of registry.list()) { for (const collision of reservedParamCollisions(def)) { issues.push({ code: "LEVELSET_E305", message: `block "${def.code}" declares a param "${collision}" that collides with the reserved conf key "${collision}" — it cannot be set through the conf format`, }); } let schema: SchemaNode; try { schema = z.toJSONSchema(def.block.schema.input, { io: "input", }) as SchemaNode; } catch { issues.push({ code: "LEVELSET_E302", message: `block "${def.code}" has an input schema that cannot be rendered as conf (z.toJSONSchema failed)`, }); continue; } if (schemaRequiresNull(schema)) { issues.push({ code: "LEVELSET_E302", message: `block "${def.code}" has an input schema that admits null, which the conf format cannot express (model "unbounded" as an absent optional param)`, }); } } // Confirm every reserved key is a bare-safe token (a defensive invariant — // a non-bare reserved key would need quoting everywhere). for (const key of RESERVED_BLOCK_KEYS) { if (!BARE_KEY.test(key)) { issues.push({ code: "LEVELSET_E302", message: `reserved key "${key}" is not bare-safe`, }); } } return issues;}