A functional toolkit for authoring resource-leveling algorithms — compose constraint and scoring blocks, enumerate feasible schedules
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(conf): parseConf — TOML pipeline configs (levelset/conf)

New levelset/conf subpath: parseConf reads a .conf (TOML 1.0) file — a text
projection of RunConfig — into a raw RunConfig. Sections map [levelset]/
[resolve]/[pipeline]/[blocks.<id>]/[weights]/[objective] onto the config;
reserved block keys (code/enabled/provenance/shadow) peel off as instance
fields, everything else is a param (dotted keys nest, array-of-tables become
object arrays, "report" ⇄ null). Every framework section is strict.

Coded issues E301–E308: TOML syntax (line/column), config shape (conf-path),
missing/unsupported format, datetime literal, reserved-key collision,
non-finite number, and registry validation (instanceId-attributed). With a
registry, parse also runs validateGraph + the shared checkRunConfigRefs
(extracted from fillRunConfig so the two can't drift). Adds smol-toml as a
regular dependency and the ./conf export.

Grammar goldens (fixture .conf ⇄ raw config JSON) plus one error fixture per
code.

Russ T. Fugal (Jul 16, 2026, 4:22 PM -0600) 1c99900b a6a19642

+965 -34
+3
bun.lock
··· 9 9 "exceljs": "^4.4.0", 10 10 "fast-xml-parser": "^5.8.0", 11 11 "jszip": "^3.10.1", 12 + "smol-toml": "^1.7.0", 12 13 }, 13 14 "devDependencies": { 14 15 "@eslint/js": "^10.0.1", ··· 328 329 "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], 329 330 330 331 "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], 332 + 333 + "smol-toml": ["smol-toml@1.7.0", "", {}, "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ=="], 331 334 332 335 "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], 333 336
+8 -2
package.json
··· 31 31 ".": "./src/index.ts", 32 32 "./advanced": "./src/advanced.ts", 33 33 "./schema": "./src/schema/index.ts", 34 - "./leveling": "./src/leveling.ts" 34 + "./leveling": "./src/leveling.ts", 35 + "./conf": "./src/conf/index.ts" 35 36 }, 36 37 "publishConfig": { 37 38 "module": "./dist/index.js", ··· 53 54 "./leveling": { 54 55 "types": "./dist/leveling.d.ts", 55 56 "import": "./dist/leveling.js" 57 + }, 58 + "./conf": { 59 + "types": "./dist/conf/index.d.ts", 60 + "import": "./dist/conf/index.js" 56 61 } 57 62 } 58 63 }, ··· 95 100 "cfb": "^1.2.2", 96 101 "exceljs": "^4.4.0", 97 102 "fast-xml-parser": "^5.8.0", 98 - "jszip": "^3.10.1" 103 + "jszip": "^3.10.1", 104 + "smol-toml": "^1.7.0" 99 105 } 100 106 }
+19
src/conf/index.ts
··· 1 + // levelset/conf — the `.conf` (TOML 1.0) pipeline-configuration surface: a 2 + // text projection of RunConfig for humans to author and diff, with JSON 3 + // staying canonical. `parseConf` reads text into a raw RunConfig; 4 + // `serializeConf` writes a RunConfig back to canonical `.conf` text; 5 + // `lintRegistryForConf` flags blocks a registry can't express in the format. 6 + // 7 + // Deliberately a separate subpath from `levelset/leveling` (which documents a 8 + // pulls-only-zod guarantee): conf adds a `smol-toml` dependency, so it must 9 + // not be reachable from the leveling entry point. 10 + 11 + export type { ConfIssue, ConfIssueCode, ConfSourcePos } from "./issues.ts"; 12 + export { 13 + parseConf, 14 + reservedParamCollisions, 15 + blockParamNames, 16 + RESERVED_BLOCK_KEYS, 17 + type ParseConfResult, 18 + type ParseConfOptions, 19 + } from "./parse.ts";
+53
src/conf/issues.ts
··· 1 + // Coded issues for the `.conf` layer — the LEVELSET_E3## band, joining the 2 + // E1## run-analysis / E2## warning namespace (level-core/errors.ts) as conf's 3 + // own union. A `.conf` file is a text projection of a RunConfig; every way it 4 + // can be malformed gets a stable, searchable code and an actionable message, 5 + // with line/column for syntax errors and instanceId attribution for the 6 + // registry-validation ones (so a canvas/editor can point at the offending 7 + // block). 8 + 9 + /** Where in the source a conf issue lands (1-based), when known. */ 10 + export interface ConfSourcePos { 11 + readonly line: number; 12 + readonly column: number; 13 + } 14 + 15 + export type ConfIssueCode = 16 + /** TOML syntax error — the text is not a well-formed TOML document. Carries 17 + * the parser's line/column. */ 18 + | "LEVELSET_E301" 19 + /** Config shape error — a value in a valid TOML document violates the 20 + * RunConfig schema (wrong type, unknown key in a strict section, a missing 21 + * required field). Carries the dotted config path. */ 22 + | "LEVELSET_E302" 23 + /** Missing or unsupported `[levelset] format` — the required format marker 24 + * is absent or not `1`. */ 25 + | "LEVELSET_E303" 26 + /** A TOML datetime literal was used where an ISO date string is required — 27 + * quote it (`epoch = "2026-01-01"`), don't write a bare date. */ 28 + | "LEVELSET_E304" 29 + /** A block param name collides with a reserved block-table key 30 + * (`code` / `enabled` / `provenance` / `shadow`), so the param is 31 + * unreachable through the conf format. */ 32 + | "LEVELSET_E305" 33 + /** A non-finite number (`inf` / `nan`) appeared — configs must be finite; 34 + * model "unbounded" as an absent optional param, never Infinity. */ 35 + | "LEVELSET_E306" 36 + /** Registry validation failed — a mapped GraphIssue (unknown code, invalid 37 + * params, cardinality, or a weights/objective/collector reference that 38 + * doesn't resolve to a scorer-family instance), attributed to its 39 + * instanceId. */ 40 + | "LEVELSET_E308"; 41 + 42 + export interface ConfIssue { 43 + readonly code: ConfIssueCode; 44 + readonly message: string; 45 + /** The block instanceId the issue attributes to, when it is block-scoped 46 + * (E308, E305). */ 47 + readonly instanceId?: string; 48 + /** Dotted path to the offending value, when known (E302 shape issues, 49 + * E304/E306 value issues). */ 50 + readonly path?: string; 51 + /** Source line/column, when known (E301). */ 52 + readonly pos?: ConfSourcePos; 53 + }
+389
src/conf/parse.ts
··· 1 + // parseConf — read a `.conf` (TOML 1.0) pipeline configuration into a raw 2 + // RunConfig. The `.conf` grammar is a text projection of RunConfig: JSON stays 3 + // canonical, this is the human-authoring surface. Every framework section is 4 + // strict (an unknown section or a typo'd reserved key is an error, never 5 + // silently ignored), and every failure mode gets a coded ConfIssue. 6 + // 7 + // parseConf returns the *raw* config (block params not yet expanded through 8 + // their schemas, no levelset version stamp) — callers compose it with 9 + // fillRunConfig / runFromConfig. With a registry it additionally runs 10 + // validateGraph and the shared referential checks (checkRunConfigRefs), so a 11 + // weights/objective/collector reference that doesn't resolve fails here too. 12 + 13 + import { z } from "zod"; 14 + import { parse as parseToml, TomlError } from "smol-toml"; 15 + 16 + import { validateGraph } from "../level-blocks/graph.ts"; 17 + import type { BlockDef, BlockRegistry } from "../level-blocks/registry.ts"; 18 + import { checkRunConfigRefs, runConfigSchema, type RunConfig } from "../level-blocks/runConfig.ts"; 19 + 20 + import type { ConfIssue } from "./issues.ts"; 21 + 22 + export type ParseConfResult = 23 + | { ok: true; config: RunConfig; issues: ConfIssue[] } 24 + | { ok: false; issues: ConfIssue[] }; 25 + 26 + export interface ParseConfOptions { 27 + readonly registry?: BlockRegistry | undefined; 28 + } 29 + 30 + /** Keys peeled off a `[blocks.<id>]` table as instance fields rather than 31 + * params. A block whose schema declares a param of one of these names can't 32 + * round-trip through conf (the reserved reading always wins) — E305. */ 33 + export const RESERVED_BLOCK_KEYS = ["code", "enabled", "provenance", "shadow"] as const; 34 + 35 + const ALLOWED_SECTIONS = new Set([ 36 + "levelset", 37 + "resolve", 38 + "pipeline", 39 + "blocks", 40 + "weights", 41 + "objective", 42 + ]); 43 + 44 + const LEVELSET_KEYS = new Set(["format", "version", "seed"]); 45 + const PIPELINE_KEYS = new Set(["systemCode"]); 46 + 47 + // Named-property views over the loosely-typed TOML tables — so the known 48 + // sections read as `doc.levelset`, not index-signature access. 49 + interface ConfDoc { 50 + levelset?: unknown; 51 + resolve?: unknown; 52 + pipeline?: unknown; 53 + blocks?: unknown; 54 + weights?: unknown; 55 + objective?: unknown; 56 + [key: string]: unknown; 57 + } 58 + interface LevelsetTbl { 59 + format?: unknown; 60 + version?: unknown; 61 + seed?: unknown; 62 + [key: string]: unknown; 63 + } 64 + interface PipelineTbl { 65 + systemCode?: unknown; 66 + [key: string]: unknown; 67 + } 68 + interface AssembledConfig { 69 + formatVersion: 1; 70 + levelset?: unknown; 71 + seed?: unknown; 72 + resolve?: unknown; 73 + pipeline?: unknown; 74 + weights?: unknown; 75 + objective?: unknown; 76 + } 77 + interface BlockInstanceDraft { 78 + instanceId: string; 79 + code: unknown; 80 + params: unknown; 81 + provenance?: unknown; 82 + shadow?: unknown; 83 + enabled?: unknown; 84 + } 85 + 86 + function isPlainObject(value: unknown): value is Record<string, unknown> { 87 + return ( 88 + typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date) 89 + ); 90 + } 91 + 92 + /** The block schema's top-level property names — the conf param names — used 93 + * to detect reserved-key collisions. */ 94 + export function blockParamNames(def: BlockDef): string[] { 95 + try { 96 + const js = z.toJSONSchema(def.block.schema.input as z.ZodType, { io: "input" }) as { 97 + properties?: Record<string, unknown>; 98 + }; 99 + return Object.keys(js.properties ?? {}); 100 + } catch { 101 + return []; 102 + } 103 + } 104 + 105 + /** Reserved conf keys a block's params collide with (empty when clean). */ 106 + export function reservedParamCollisions(def: BlockDef): string[] { 107 + const params = new Set(blockParamNames(def)); 108 + return RESERVED_BLOCK_KEYS.filter((k) => params.has(k)); 109 + } 110 + 111 + function unknownSectionMessage(key: string): string { 112 + if (key === "sweep") { 113 + return `[sweep] is a reserved, not-yet-implemented extension point (see the gridsweep design doc) — remove it`; 114 + } 115 + if (key === "search") { 116 + return `[search] is not a conf section — search is a block; add it under [blocks.<id>] with code = "levelset.SerialSGS.v1"`; 117 + } 118 + if (key === "profiles") { 119 + return `[profiles] is not a conf section — schedule profiles are runtime memoization, not config state`; 120 + } 121 + return `Unknown top-level section [${key}] — the conf grammar allows only levelset, resolve, pipeline, blocks, weights, objective`; 122 + } 123 + 124 + /** Deep-scan for TOML datetime literals (E304) and non-finite numbers (E306), 125 + * which valid TOML admits but a config forbids. Reports each with its path. */ 126 + function scanValues(value: unknown, path: string, issues: ConfIssue[]): void { 127 + if (value instanceof Date) { 128 + issues.push({ 129 + code: "LEVELSET_E304", 130 + path, 131 + message: `${path}: TOML datetime literals are not accepted — quote the date as an ISO string (e.g. "2026-01-01")`, 132 + }); 133 + return; 134 + } 135 + if (typeof value === "number" && !Number.isFinite(value)) { 136 + issues.push({ 137 + code: "LEVELSET_E306", 138 + path, 139 + message: `${path}: non-finite number (${String(value)}) — configs must be finite; model "unbounded" as an absent optional param`, 140 + }); 141 + return; 142 + } 143 + if (Array.isArray(value)) { 144 + value.forEach((v, i) => scanValues(v, `${path}[${String(i)}]`, issues)); 145 + return; 146 + } 147 + if (isPlainObject(value)) { 148 + for (const [k, v] of Object.entries(value)) 149 + scanValues(v, path === "" ? k : `${path}.${k}`, issues); 150 + } 151 + } 152 + 153 + /** Translate a zod issue path (over the assembled RunConfig) into a 154 + * conf-oriented dotted path, so a param error reads `blocks.<id>.max` 155 + * rather than `pipeline.blocks.0.params.max`. */ 156 + function confPath( 157 + path: ReadonlyArray<PropertyKey>, 158 + blocks: ReadonlyArray<{ instanceId: string }>, 159 + ): string { 160 + const seg = path.map(String); 161 + if (seg[0] === "pipeline" && seg[1] === "blocks") { 162 + const idx = Number(seg[2]); 163 + const id = blocks[idx]?.instanceId ?? seg[2]; 164 + const rest = seg.slice(seg[3] === "params" ? 4 : 3); 165 + return ["blocks", id, ...rest].join("."); 166 + } 167 + if (seg[0] === "formatVersion") return "levelset.format"; 168 + if (seg[0] === "seed") return "levelset.seed"; 169 + if (seg[0] === "levelset") return "levelset.version"; 170 + return seg.join("."); 171 + } 172 + 173 + /** 174 + * Parse `.conf` text into a raw RunConfig. Returns `{ ok: true, config }` when 175 + * the text is a well-formed config, else `{ ok: false, issues }`. Pass a 176 + * registry to additionally validate the graph and its scorer references. 177 + */ 178 + export function parseConf(text: string, opts?: ParseConfOptions): ParseConfResult { 179 + let doc: ConfDoc; 180 + try { 181 + doc = parseToml(text) as ConfDoc; 182 + } catch (err) { 183 + if (err instanceof TomlError) { 184 + return { 185 + ok: false, 186 + issues: [ 187 + { 188 + code: "LEVELSET_E301", 189 + message: `TOML syntax error: ${err.message.split("\n")[0] ?? err.message}`, 190 + pos: { line: err.line, column: err.column }, 191 + }, 192 + ], 193 + }; 194 + } 195 + throw err; 196 + } 197 + 198 + // Datetime / non-finite scan first: these are otherwise-valid TOML values a 199 + // config can't hold, and reporting them here beats a confusing downstream 200 + // shape error. 201 + const valueIssues: ConfIssue[] = []; 202 + scanValues(doc, "", valueIssues); 203 + if (valueIssues.length > 0) return { ok: false, issues: valueIssues }; 204 + 205 + const issues: ConfIssue[] = []; 206 + 207 + // Strict top level: unknown sections are errors. 208 + for (const key of Object.keys(doc)) { 209 + if (!ALLOWED_SECTIONS.has(key)) { 210 + issues.push({ code: "LEVELSET_E302", path: key, message: unknownSectionMessage(key) }); 211 + } 212 + } 213 + 214 + // [levelset] — required, `format = 1` required and authoritative for both 215 + // runConfig.formatVersion and pipeline.formatVersion. 216 + const levelsetTbl: LevelsetTbl | undefined = isPlainObject(doc.levelset) 217 + ? doc.levelset 218 + : undefined; 219 + if (!levelsetTbl || levelsetTbl.format === undefined) { 220 + issues.push({ 221 + code: "LEVELSET_E303", 222 + path: "levelset.format", 223 + message: `[levelset] format is required — add \`[levelset]\` with \`format = 1\``, 224 + }); 225 + } else { 226 + if (levelsetTbl.format !== 1) { 227 + issues.push({ 228 + code: "LEVELSET_E303", 229 + path: "levelset.format", 230 + message: `Unsupported [levelset] format ${String(levelsetTbl.format)} — this engine supports format 1`, 231 + }); 232 + } 233 + for (const key of Object.keys(levelsetTbl)) { 234 + if (!LEVELSET_KEYS.has(key)) { 235 + issues.push({ 236 + code: "LEVELSET_E302", 237 + path: `levelset.${key}`, 238 + message: `Unknown key [levelset].${key} — allowed: format, version, seed`, 239 + }); 240 + } 241 + } 242 + } 243 + 244 + // Assemble a RunConfig. formatVersion is forced to its only legal value (1) 245 + // so the `format` marker's own validation (E303) is the sole authority and 246 + // zod never double-reports it. 247 + const assembled: AssembledConfig = { formatVersion: 1 }; 248 + if (levelsetTbl?.version !== undefined) assembled.levelset = levelsetTbl.version; 249 + if (levelsetTbl?.seed !== undefined) assembled.seed = levelsetTbl.seed; 250 + if (doc.resolve !== undefined) assembled.resolve = doc.resolve; 251 + 252 + // [pipeline] carries only systemCode; formatVersion comes from [levelset], 253 + // blocks from [blocks.*]. 254 + const pipelineTbl: PipelineTbl | undefined = isPlainObject(doc.pipeline) 255 + ? doc.pipeline 256 + : undefined; 257 + if (pipelineTbl) { 258 + for (const key of Object.keys(pipelineTbl)) { 259 + if (!PIPELINE_KEYS.has(key)) { 260 + issues.push({ 261 + code: "LEVELSET_E302", 262 + path: `pipeline.${key}`, 263 + message: `Unknown key [pipeline].${key} — a block goes under [blocks.<id>], not [pipeline]`, 264 + }); 265 + } 266 + } 267 + } 268 + 269 + // [blocks.<id>] tables, in document order. 270 + const blocks: BlockInstanceDraft[] = []; 271 + const blocksTbl = isPlainObject(doc.blocks) ? doc.blocks : undefined; 272 + if (blocksTbl) { 273 + for (const [instanceId, raw] of Object.entries(blocksTbl)) { 274 + if (!isPlainObject(raw)) { 275 + issues.push({ 276 + code: "LEVELSET_E302", 277 + instanceId, 278 + path: `blocks.${instanceId}`, 279 + message: `[blocks.${instanceId}] must be a table`, 280 + }); 281 + continue; 282 + } 283 + const { code, enabled, provenance, shadow, ...params } = raw; 284 + if (code === undefined) { 285 + issues.push({ 286 + code: "LEVELSET_E302", 287 + instanceId, 288 + path: `blocks.${instanceId}.code`, 289 + message: `[blocks.${instanceId}] is missing the required \`code\` (its registry block code)`, 290 + }); 291 + continue; 292 + } 293 + const instance: BlockInstanceDraft = { instanceId, code, params }; 294 + if (provenance !== undefined) instance.provenance = provenance; 295 + if (shadow !== undefined) instance.shadow = shadow; 296 + if (enabled !== undefined) instance.enabled = enabled; 297 + blocks.push(instance); 298 + 299 + // E305: a registry-resolved block whose schema declares a reserved-named 300 + // param — the value the user gave was silently peeled as an instance 301 + // field, so the param is unreachable. 302 + const def = opts?.registry?.get(String(code)); 303 + if (def) { 304 + for (const collision of reservedParamCollisions(def)) { 305 + issues.push({ 306 + code: "LEVELSET_E305", 307 + instanceId, 308 + path: `blocks.${instanceId}.${collision}`, 309 + message: `block "${instanceId}" (${String(code)}) declares a param "${collision}" that collides with the reserved conf key "${collision}" — it cannot be set through the conf format`, 310 + }); 311 + } 312 + } 313 + } 314 + } 315 + assembled.pipeline = { 316 + formatVersion: 1, 317 + ...(pipelineTbl?.systemCode !== undefined ? { systemCode: pipelineTbl.systemCode } : {}), 318 + blocks, 319 + }; 320 + 321 + // [weights] — number, or the string sentinel "report" ⇄ null (report-only). 322 + if (doc.weights !== undefined) { 323 + const wTbl = isPlainObject(doc.weights) ? doc.weights : undefined; 324 + if (!wTbl) { 325 + issues.push({ code: "LEVELSET_E302", path: "weights", message: `[weights] must be a table` }); 326 + } else { 327 + const weights: Record<string, number | null> = {}; 328 + for (const [name, value] of Object.entries(wTbl)) { 329 + if (value === "report") weights[name] = null; 330 + else if (typeof value === "number") weights[name] = value; 331 + else { 332 + issues.push({ 333 + code: "LEVELSET_E302", 334 + path: `weights.${name}`, 335 + message: `weights.${name} must be a number or the string "report" (report-only)`, 336 + }); 337 + } 338 + } 339 + assembled.weights = weights; 340 + } 341 + } 342 + 343 + if (doc.objective !== undefined) assembled.objective = doc.objective; 344 + 345 + // Shape validation. formatVersion is pre-forced; everything else is checked 346 + // here (types, strict resolve/objective sections, required fields). 347 + const parsed = runConfigSchema.safeParse(assembled); 348 + if (!parsed.success) { 349 + for (const issue of parsed.error.issues) { 350 + const path = confPath(issue.path, blocks as Array<{ instanceId: string }>); 351 + issues.push({ 352 + code: "LEVELSET_E302", 353 + ...(path !== "" ? { path } : {}), 354 + message: `${path}: ${issue.message}`, 355 + }); 356 + } 357 + } 358 + 359 + if (issues.length > 0) return { ok: false, issues }; 360 + 361 + const config = parsed.success ? parsed.data : (assembled as RunConfig); 362 + 363 + // Registry validation (E308): graph shape + cardinality + scorer references. 364 + if (opts?.registry) { 365 + const validated = validateGraph(opts.registry, config.pipeline); 366 + if (!validated.ok) { 367 + return { ok: false, issues: validated.issues.map((i) => graphIssueToConf(i)) }; 368 + } 369 + const refIssues = checkRunConfigRefs(config, opts.registry, validated); 370 + if (refIssues.length > 0) { 371 + return { ok: false, issues: refIssues.map((i) => graphIssueToConf(i)) }; 372 + } 373 + } 374 + 375 + return { ok: true, config, issues: [] }; 376 + } 377 + 378 + function graphIssueToConf(issue: { 379 + instanceId?: string; 380 + path?: Array<string | number>; 381 + message: string; 382 + }): ConfIssue { 383 + return { 384 + code: "LEVELSET_E308", 385 + message: issue.message, 386 + ...(issue.instanceId !== undefined ? { instanceId: issue.instanceId } : {}), 387 + ...(issue.path !== undefined ? { path: issue.path.map(String).join(".") } : {}), 388 + }; 389 + }
+54 -32
src/level-blocks/runConfig.ts
··· 37 37 38 38 import { compileGraph, GraphValidationError, type CompileTarget } from "./compile.ts"; 39 39 import { selectGate, type ResolvedGateLimit } from "./gate.ts"; 40 - import { pipelineGraphSchema, validateGraph } from "./graph.ts"; 40 + import { 41 + pipelineGraphSchema, 42 + validateGraph, 43 + type GraphIssue, 44 + type ValidateGraphResult, 45 + } from "./graph.ts"; 41 46 import type { BlockRegistry } from "./registry.ts"; 42 47 import { standardRegistry } from "./standard.ts"; 43 48 ··· 137 142 materialize(schedule: Schedule): Project; 138 143 } 139 144 140 - /** Validate a config and expand it: block params parsed through each 141 - * block's input schema (defaults applied), levelset version stamped. 142 - * Throws GraphValidationError / ZodError on invalid input. */ 143 - export function fillRunConfig(config: unknown, registry?: BlockRegistry): RunConfig { 144 - const reg = registry ?? standardRegistry(); 145 - const parsed = runConfigSchema.parse(config); 146 - const validated = validateGraph(reg, parsed.pipeline); 147 - if (!validated.ok) throw new GraphValidationError(validated.issues); 148 - 145 + /** 146 + * Referential-integrity checks over a *validated* graph: every weights key, 147 + * every `objective.lexicographic` tier, and every collector's scorer 148 + * reference (`KBest.by` / `Pareto.over` / `Gate.limits[].scorer`) must name a 149 + * scorer-family block instance. Returns the issues (empty when clean) rather 150 + * than throwing, so both `fillRunConfig` and `parseConf` can surface them the 151 + * same way and never drift. `validated` must be the successful result of 152 + * `validateGraph(registry, config.pipeline)` — its `parsedParams` are what the 153 + * collector `apply()` reads. 154 + */ 155 + export function checkRunConfigRefs( 156 + config: RunConfig, 157 + registry: BlockRegistry, 158 + validated: Extract<ValidateGraphResult, { ok: true }>, 159 + ): GraphIssue[] { 149 160 const scorerIds = new Set( 150 161 validated.graph.blocks 151 - .filter((b) => reg.get(b.code)?.family === "scorer") 162 + .filter((b) => registry.get(b.code)?.family === "scorer") 152 163 .map((b) => b.instanceId), 153 164 ); 154 165 // Collector blocks reference scorers by instanceId (KBest.by / Pareto.over); ··· 159 170 // under a different param still gets layer-1 validation and a non-array 160 171 // by/over can't leak through as a raw TypeError. apply() is pure and 161 172 // compileGraph re-applies it later, so calling it here costs nothing. 173 + const compileIssues: GraphIssue[] = []; 162 174 const collectorRefs = validated.graph.blocks 163 - .filter((b) => reg.get(b.code)?.family === "collector") 175 + .filter((b) => registry.get(b.code)?.family === "collector") 164 176 .flatMap((b) => { 165 - const def = reg.get(b.code)!; 177 + const def = registry.get(b.code)!; 166 178 const params = validated.parsedParams.get(b.instanceId); 167 179 let collector: Collector; 168 180 try { 169 181 collector = (def.block as { apply(input: unknown): Collector }).apply(params); 170 182 } catch (err) { 171 - throw new GraphValidationError([ 172 - { 173 - instanceId: b.instanceId, 174 - code: b.code, 175 - message: `collector "${b.instanceId}" failed to compile its spec: ${ 176 - err instanceof Error ? err.message : String(err) 177 - }`, 178 - }, 179 - ]); 183 + compileIssues.push({ 184 + instanceId: b.instanceId, 185 + code: b.code, 186 + message: `collector "${b.instanceId}" failed to compile its spec: ${ 187 + err instanceof Error ? err.message : String(err) 188 + }`, 189 + }); 190 + return []; 180 191 } 181 192 return collectorScorerRefs(collector).map((k) => ({ 182 193 k, 183 194 where: `collector "${b.instanceId}" scorer reference`, 184 195 })); 185 196 }); 197 + if (compileIssues.length > 0) return compileIssues; 198 + 186 199 const badRefs = [ 187 - ...Object.keys(parsed.weights ?? {}).map((k) => ({ k, where: "weights key" })), 188 - ...(parsed.objective?.lexicographic ?? []).map((k) => ({ 200 + ...Object.keys(config.weights ?? {}).map((k) => ({ k, where: "weights key" })), 201 + ...(config.objective?.lexicographic ?? []).map((k) => ({ 189 202 k, 190 203 where: "objective.lexicographic entry", 191 204 })), 192 205 ...collectorRefs, 193 206 ].filter(({ k }) => !scorerIds.has(k)); 194 - if (badRefs.length > 0) { 195 - throw new GraphValidationError( 196 - badRefs.map(({ k, where }) => ({ 197 - instanceId: k, 198 - message: `${where} "${k}" does not reference a scorer-family block instance`, 199 - })), 200 - ); 201 - } 207 + return badRefs.map(({ k, where }) => ({ 208 + instanceId: k, 209 + message: `${where} "${k}" does not reference a scorer-family block instance`, 210 + })); 211 + } 212 + 213 + /** Validate a config and expand it: block params parsed through each 214 + * block's input schema (defaults applied), levelset version stamped. 215 + * Throws GraphValidationError / ZodError on invalid input. */ 216 + export function fillRunConfig(config: unknown, registry?: BlockRegistry): RunConfig { 217 + const reg = registry ?? standardRegistry(); 218 + const parsed = runConfigSchema.parse(config); 219 + const validated = validateGraph(reg, parsed.pipeline); 220 + if (!validated.ok) throw new GraphValidationError(validated.issues); 221 + 222 + const refIssues = checkRunConfigRefs(parsed, reg, validated); 223 + if (refIssues.length > 0) throw new GraphValidationError(refIssues); 202 224 203 225 return { 204 226 ...parsed,
+34
test/conf/fixtures/lexicographic.conf
··· 1 + [levelset] 2 + format = 1 3 + seed = 7 4 + 5 + [resolve] 6 + epoch = "2026-01-01" 7 + horizonDays = 120 8 + 9 + [blocks.deadlineA] 10 + code = "levelset.Deadline.v1" 11 + taskUniqueId = 42 12 + latestFinish = 90 13 + 14 + [blocks.lateness] 15 + code = "levelset.PhaseLateness.v1" 16 + 17 + [[blocks.lateness.phases]] 18 + name = "phase-1" 19 + taskUniqueIds = [1, 2, 3] 20 + targetDay = 80 21 + weight = 2 22 + 23 + [blocks.makespan] 24 + code = "levelset.Makespan.v1" 25 + 26 + [blocks.sgs] 27 + code = "levelset.SerialSGS.v1" 28 + 29 + [weights] 30 + makespan = "report" 31 + lateness = 1 32 + 33 + [objective] 34 + lexicographic = ["lateness", "makespan"]
+67
test/conf/fixtures/lexicographic.config.json
··· 1 + { 2 + "formatVersion": 1, 3 + "seed": 7, 4 + "resolve": { 5 + "epoch": "2026-01-01", 6 + "horizonDays": 120 7 + }, 8 + "pipeline": { 9 + "formatVersion": 1, 10 + "blocks": [ 11 + { 12 + "instanceId": "deadlineA", 13 + "code": "levelset.Deadline.v1", 14 + "params": { 15 + "taskUniqueId": 42, 16 + "latestFinish": 90 17 + }, 18 + "provenance": "curated", 19 + "shadow": false 20 + }, 21 + { 22 + "instanceId": "lateness", 23 + "code": "levelset.PhaseLateness.v1", 24 + "params": { 25 + "phases": [ 26 + { 27 + "name": "phase-1", 28 + "taskUniqueIds": [ 29 + 1, 30 + 2, 31 + 3 32 + ], 33 + "targetDay": 80, 34 + "weight": 2 35 + } 36 + ] 37 + }, 38 + "provenance": "curated", 39 + "shadow": false 40 + }, 41 + { 42 + "instanceId": "makespan", 43 + "code": "levelset.Makespan.v1", 44 + "params": {}, 45 + "provenance": "curated", 46 + "shadow": false 47 + }, 48 + { 49 + "instanceId": "sgs", 50 + "code": "levelset.SerialSGS.v1", 51 + "params": {}, 52 + "provenance": "curated", 53 + "shadow": false 54 + } 55 + ] 56 + }, 57 + "weights": { 58 + "makespan": null, 59 + "lateness": 1 60 + }, 61 + "objective": { 62 + "lexicographic": [ 63 + "lateness", 64 + "makespan" 65 + ] 66 + } 67 + }
+6
test/conf/fixtures/minimal.conf
··· 1 + [levelset] 2 + format = 1 3 + version = "0.1.0" 4 + 5 + [blocks.sgs] 6 + code = "levelset.SerialSGS.v1"
+16
test/conf/fixtures/minimal.config.json
··· 1 + { 2 + "formatVersion": 1, 3 + "levelset": "0.1.0", 4 + "pipeline": { 5 + "formatVersion": 1, 6 + "blocks": [ 7 + { 8 + "instanceId": "sgs", 9 + "code": "levelset.SerialSGS.v1", 10 + "params": {}, 11 + "provenance": "curated", 12 + "shadow": false 13 + } 14 + ] 15 + } 16 + }
+28
test/conf/fixtures/variadic-nested-disabled.conf
··· 1 + [levelset] 2 + format = 1 3 + 4 + [pipeline] 5 + systemCode = "STK" 6 + 7 + [blocks.peak] 8 + code = "levelset.PeakCap.v1" 9 + resourceUniqueId = 3 10 + cap = 5 11 + window.fromDay = 10 12 + window.toDay = 20 13 + 14 + [blocks.wip] 15 + code = "levelset.ConcurrentUnitsLimit.v1" 16 + enabled = false 17 + unitIds = [100, 101, 102] 18 + max = 4 19 + 20 + [blocks."units.open"] 21 + code = "levelset.OpenUnitPenalty.v1" 22 + provenance = "candidate" 23 + shadow = true 24 + unitIds = [100, 101] 25 + softMax = 2 26 + 27 + [blocks.sgs] 28 + code = "levelset.SerialSGS.v1"
+58
test/conf/fixtures/variadic-nested-disabled.config.json
··· 1 + { 2 + "formatVersion": 1, 3 + "pipeline": { 4 + "formatVersion": 1, 5 + "systemCode": "STK", 6 + "blocks": [ 7 + { 8 + "instanceId": "peak", 9 + "code": "levelset.PeakCap.v1", 10 + "params": { 11 + "resourceUniqueId": 3, 12 + "cap": 5, 13 + "window": { 14 + "fromDay": 10, 15 + "toDay": 20 16 + } 17 + }, 18 + "provenance": "curated", 19 + "shadow": false 20 + }, 21 + { 22 + "instanceId": "wip", 23 + "code": "levelset.ConcurrentUnitsLimit.v1", 24 + "params": { 25 + "unitIds": [ 26 + 100, 27 + 101, 28 + 102 29 + ], 30 + "max": 4 31 + }, 32 + "provenance": "curated", 33 + "shadow": false, 34 + "enabled": false 35 + }, 36 + { 37 + "instanceId": "units.open", 38 + "code": "levelset.OpenUnitPenalty.v1", 39 + "params": { 40 + "unitIds": [ 41 + 100, 42 + 101 43 + ], 44 + "softMax": 2 45 + }, 46 + "provenance": "candidate", 47 + "shadow": true 48 + }, 49 + { 50 + "instanceId": "sgs", 51 + "code": "levelset.SerialSGS.v1", 52 + "params": {}, 53 + "provenance": "curated", 54 + "shadow": false 55 + } 56 + ] 57 + } 58 + }
+230
test/conf/parse.test.ts
··· 1 + import { readFileSync } from "node:fs"; 2 + 3 + import { test, expect, describe } from "bun:test"; 4 + import { z } from "zod"; 5 + 6 + import lexicographicRaw from "./fixtures/lexicographic.config.json"; 7 + import minimalRaw from "./fixtures/minimal.config.json"; 8 + import variadicRaw from "./fixtures/variadic-nested-disabled.config.json"; 9 + import { parseConf, type ConfIssue } from "../../src/conf/index.ts"; 10 + import { runConfigSchema, type RunConfig } from "../../src/level-blocks/runConfig.ts"; 11 + import { standardRegistry } from "../../src/level-blocks/standard.ts"; 12 + 13 + // Imported JSON widens literal types (formatVersion → number); read the 14 + // goldens back through the schema so they compare as RunConfig. 15 + const lexicographicJson: RunConfig = runConfigSchema.parse(lexicographicRaw); 16 + const variadicJson: RunConfig = runConfigSchema.parse(variadicRaw); 17 + const minimalJson: RunConfig = runConfigSchema.parse(minimalRaw); 18 + 19 + const registry = standardRegistry(); 20 + 21 + function fixture(name: string): string { 22 + return readFileSync(`${import.meta.dir}/fixtures/${name}.conf`, "utf8"); 23 + } 24 + 25 + function parseOk(name: string) { 26 + const result = parseConf(fixture(name), { registry }); 27 + if (!result.ok) 28 + throw new Error(`${name} unexpectedly failed:\n${JSON.stringify(result.issues, null, 2)}`); 29 + return result.config; 30 + } 31 + 32 + function firstIssue(text: string): ConfIssue { 33 + const result = parseConf(text, { registry }); 34 + expect(result.ok).toBe(false); 35 + if (result.ok) throw new Error("expected parse to fail"); 36 + expect(result.issues.length).toBe(1); 37 + return result.issues[0]!; 38 + } 39 + 40 + describe("parseConf — grammar goldens", () => { 41 + test("lexicographic fixture parses to its expected raw config", () => { 42 + expect(parseOk("lexicographic")).toEqual(lexicographicJson); 43 + }); 44 + 45 + test("variadic/nested/disabled fixture parses to its expected raw config", () => { 46 + expect(parseOk("variadic-nested-disabled")).toEqual(variadicJson); 47 + }); 48 + 49 + test("minimal fixture parses to its expected raw config", () => { 50 + expect(parseOk("minimal")).toEqual(minimalJson); 51 + }); 52 + 53 + test("every golden config is a valid RunConfig", () => { 54 + for (const raw of [lexicographicRaw, variadicRaw, minimalRaw]) { 55 + expect(() => runConfigSchema.parse(raw)).not.toThrow(); 56 + } 57 + }); 58 + }); 59 + 60 + describe("parseConf — grammar features", () => { 61 + test("block document order is preserved", () => { 62 + const config = parseOk("lexicographic"); 63 + expect(config.pipeline.blocks.map((b) => b.instanceId)).toEqual([ 64 + "deadlineA", 65 + "lateness", 66 + "makespan", 67 + "sgs", 68 + ]); 69 + }); 70 + 71 + test('the "report" weight sentinel maps to null; a number stays a number', () => { 72 + const config = parseOk("lexicographic"); 73 + expect(config.weights).toEqual({ makespan: null, lateness: 1 }); 74 + }); 75 + 76 + test("array-of-tables becomes an object array param", () => { 77 + const config = parseOk("lexicographic"); 78 + const lateness = config.pipeline.blocks.find((b) => b.instanceId === "lateness")!; 79 + expect(lateness.params).toEqual({ 80 + phases: [{ name: "phase-1", taskUniqueIds: [1, 2, 3], targetDay: 80, weight: 2 }], 81 + }); 82 + }); 83 + 84 + test("dotted keys nest into an object param", () => { 85 + const config = parseOk("variadic-nested-disabled"); 86 + const peak = config.pipeline.blocks.find((b) => b.instanceId === "peak")!; 87 + expect(peak.params).toEqual({ 88 + resourceUniqueId: 3, 89 + cap: 5, 90 + window: { fromDay: 10, toDay: 20 }, 91 + }); 92 + }); 93 + 94 + test("a quoted table key is the verbatim instanceId", () => { 95 + const config = parseOk("variadic-nested-disabled"); 96 + expect(config.pipeline.blocks.some((b) => b.instanceId === "units.open")).toBe(true); 97 + }); 98 + 99 + test("reserved flags: enabled=false, candidate provenance, shadow=true", () => { 100 + const config = parseOk("variadic-nested-disabled"); 101 + const wip = config.pipeline.blocks.find((b) => b.instanceId === "wip")!; 102 + expect(wip.enabled).toBe(false); 103 + const open = config.pipeline.blocks.find((b) => b.instanceId === "units.open")!; 104 + expect(open.provenance).toBe("candidate"); 105 + expect(open.shadow).toBe(true); 106 + }); 107 + 108 + test("format maps to both runConfig and pipeline formatVersion; version is stamped back", () => { 109 + const config = parseOk("minimal"); 110 + expect(config.formatVersion).toBe(1); 111 + expect(config.pipeline.formatVersion).toBe(1); 112 + expect(config.levelset).toBe("0.1.0"); 113 + }); 114 + 115 + test("parsing without a registry skips graph validation (raw config)", () => { 116 + // No search block — validateGraph would reject, but registry-less parse 117 + // returns the raw config for the caller to fill/validate. 118 + const result = parseConf(`[levelset]\nformat = 1\n[blocks.m]\ncode = "levelset.Makespan.v1"`); 119 + expect(result.ok).toBe(true); 120 + }); 121 + }); 122 + 123 + describe("parseConf — error fixtures (one per code)", () => { 124 + test("E301 TOML syntax carries line and column", () => { 125 + const issue = firstIssue(`[levelset]\nformat = = 1`); 126 + expect(issue.code).toBe("LEVELSET_E301"); 127 + expect(issue.pos).toEqual({ line: 2, column: 10 }); 128 + }); 129 + 130 + test("E302 unknown top-level section", () => { 131 + const issue = firstIssue( 132 + `[levelset]\nformat = 1\n[nonsense]\nx = 1\n[blocks.s]\ncode = "levelset.SerialSGS.v1"`, 133 + ); 134 + expect(issue.code).toBe("LEVELSET_E302"); 135 + expect(issue.path).toBe("nonsense"); 136 + }); 137 + 138 + test("E302 config shape error carries a conf-oriented path", () => { 139 + const issue = firstIssue( 140 + `[levelset]\nformat = 1\n[resolve]\nhorizonDays = "ninety"\n[blocks.s]\ncode = "levelset.SerialSGS.v1"`, 141 + ); 142 + expect(issue.code).toBe("LEVELSET_E302"); 143 + expect(issue.path).toBe("resolve.horizonDays"); 144 + }); 145 + 146 + test("E303 missing format", () => { 147 + const issue = firstIssue(`[blocks.s]\ncode = "levelset.SerialSGS.v1"`); 148 + expect(issue.code).toBe("LEVELSET_E303"); 149 + expect(issue.path).toBe("levelset.format"); 150 + }); 151 + 152 + test("E303 unsupported format", () => { 153 + const issue = firstIssue(`[levelset]\nformat = 2\n[blocks.s]\ncode = "levelset.SerialSGS.v1"`); 154 + expect(issue.code).toBe("LEVELSET_E303"); 155 + }); 156 + 157 + test("E304 rejects a bare TOML datetime literal", () => { 158 + const issue = firstIssue( 159 + `[levelset]\nformat = 1\n[resolve]\nepoch = 2026-01-01\n[blocks.s]\ncode = "levelset.SerialSGS.v1"`, 160 + ); 161 + expect(issue.code).toBe("LEVELSET_E304"); 162 + expect(issue.path).toBe("resolve.epoch"); 163 + }); 164 + 165 + test("E305 a reserved-named param collision", () => { 166 + // A synthetic block whose schema declares a `shadow` param — unreachable 167 + // through the conf format. 168 + const collidingRegistry = standardRegistry([ 169 + { 170 + code: "test.Colliding.v1", 171 + family: "constraint", 172 + tier: "engineer", 173 + block: { 174 + id: "Colliding", 175 + schema: { input: z.object({ shadow: z.number() }), output: z.unknown() }, 176 + apply: (i: unknown) => i, 177 + toMiniZinc: () => ({ text: "" }), 178 + doc: { nl: "", pseudocode: "" }, 179 + }, 180 + defaultShadow: { shadow: 1 }, 181 + }, 182 + ]); 183 + const result = parseConf( 184 + `[levelset]\nformat = 1\n[blocks.x]\ncode = "test.Colliding.v1"\nshadow = 3\n[blocks.s]\ncode = "levelset.SerialSGS.v1"`, 185 + { registry: collidingRegistry }, 186 + ); 187 + expect(result.ok).toBe(false); 188 + if (result.ok) throw new Error("expected failure"); 189 + const e305 = result.issues.find((i) => i.code === "LEVELSET_E305")!; 190 + expect(e305).toBeDefined(); 191 + expect(e305.instanceId).toBe("x"); 192 + }); 193 + 194 + test("E306 rejects a non-finite number", () => { 195 + const issue = firstIssue( 196 + `[levelset]\nformat = 1\n[resolve]\nhorizonDays = inf\n[blocks.s]\ncode = "levelset.SerialSGS.v1"`, 197 + ); 198 + expect(issue.code).toBe("LEVELSET_E306"); 199 + }); 200 + 201 + test("E308 unknown block code, attributed to its instanceId", () => { 202 + const issue = firstIssue( 203 + `[levelset]\nformat = 1\n[blocks.mystery]\ncode = "levelset.Nope.v1"\n[blocks.s]\ncode = "levelset.SerialSGS.v1"`, 204 + ); 205 + expect(issue.code).toBe("LEVELSET_E308"); 206 + expect(issue.instanceId).toBe("mystery"); 207 + }); 208 + 209 + test("E308 zero searches", () => { 210 + const issue = firstIssue(`[levelset]\nformat = 1\n[blocks.m]\ncode = "levelset.Makespan.v1"`); 211 + expect(issue.code).toBe("LEVELSET_E308"); 212 + expect(issue.message).toMatch(/exactly one search/); 213 + }); 214 + 215 + test("E308 invalid params, attributed to its instanceId", () => { 216 + const issue = firstIssue( 217 + `[levelset]\nformat = 1\n[blocks.cap]\ncode = "levelset.MaxConcurrentResource.v1"\nresourceUniqueId = 1\nmax = -3\n[blocks.s]\ncode = "levelset.SerialSGS.v1"`, 218 + ); 219 + expect(issue.code).toBe("LEVELSET_E308"); 220 + expect(issue.instanceId).toBe("cap"); 221 + }); 222 + 223 + test("E308 a weight referencing a non-scorer instance", () => { 224 + const issue = firstIssue( 225 + `[levelset]\nformat = 1\n[blocks.s]\ncode = "levelset.SerialSGS.v1"\n[weights]\nghost = 2`, 226 + ); 227 + expect(issue.code).toBe("LEVELSET_E308"); 228 + expect(issue.instanceId).toBe("ghost"); 229 + }); 230 + });