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(level-blocks): generic compile traversal over pipeline graphs

compileGraph validates (throwing GraphValidationError carrying the
issues), runs each block's apply() over its parsed params, groups the
outputs into CompiledFamilies, and hands the result to a CompileTarget.
Targets live app-side; future targets (direct serialSGS constraint
sets, MiniZinc) slot in behind the same interface without a format
change.

Russ T. Fugal (Jun 12, 2026, 3:25 AM -0600) d18eb495 ff4b9525

+206
+80
src/level-blocks/compile.ts
··· 1 + // Generic compile traversal: validate a pipeline graph, run each block's 2 + // `apply()` over its parsed params, group the outputs by family, and hand 3 + // the grouped result to a target. Targets live app-side (e.g. a mapping 4 + // onto an orchestration engine's inputs); future targets (direct serialSGS 5 + // constraint sets, MiniZinc) slot in behind the same interface without a 6 + // format change. 7 + 8 + import type { BlockInstance, PipelineGraph } from "./graph.ts"; 9 + import { validateGraph } from "./graph.ts"; 10 + import type { BlockDef, BlockRegistry } from "./registry.ts"; 11 + 12 + export interface CompiledBlock { 13 + def: BlockDef; 14 + instance: BlockInstance; 15 + output: unknown; 16 + } 17 + 18 + export interface CompiledFamilies { 19 + constraints: CompiledBlock[]; 20 + scorers: CompiledBlock[]; 21 + search: CompiledBlock; 22 + collectors: CompiledBlock[]; 23 + } 24 + 25 + export interface CompileTarget<T> { 26 + fromFamilies(families: CompiledFamilies, graph: PipelineGraph): T; 27 + } 28 + 29 + export class GraphValidationError extends Error { 30 + readonly issues: ReadonlyArray<{ instanceId?: string; code?: string; message: string }>; 31 + 32 + constructor(issues: GraphValidationError["issues"]) { 33 + super(`Pipeline graph failed validation:\n${issues.map((i) => `- ${i.message}`).join("\n")}`); 34 + this.name = "GraphValidationError"; 35 + this.issues = issues; 36 + } 37 + } 38 + 39 + export function compileGraph<T>( 40 + registry: BlockRegistry, 41 + graph: PipelineGraph, 42 + target: CompileTarget<T>, 43 + ): T { 44 + const validated = validateGraph(registry, graph); 45 + if (!validated.ok) throw new GraphValidationError(validated.issues); 46 + 47 + const constraints: CompiledBlock[] = []; 48 + const scorers: CompiledBlock[] = []; 49 + const collectors: CompiledBlock[] = []; 50 + let search: CompiledBlock | undefined; 51 + 52 + for (const instance of validated.graph.blocks) { 53 + // validateGraph guarantees the code resolves and the params parse. 54 + const def = registry.get(instance.code)!; 55 + const params = def.block.schema.input.parse(instance.params); 56 + const output: unknown = (def.block as { apply(input: unknown): unknown }).apply(params); 57 + const compiled: CompiledBlock = { def, instance, output }; 58 + 59 + switch (def.family) { 60 + case "constraint": 61 + constraints.push(compiled); 62 + break; 63 + case "scorer": 64 + scorers.push(compiled); 65 + break; 66 + case "search": 67 + search = compiled; 68 + break; 69 + case "collector": 70 + collectors.push(compiled); 71 + break; 72 + } 73 + } 74 + 75 + // validateGraph enforces exactly one search block. 76 + return target.fromFamilies( 77 + { constraints, scorers, search: search!, collectors }, 78 + validated.graph, 79 + ); 80 + }
+1
src/leveling.ts
··· 62 62 // ── Pipeline-as-data (registry, graph format, compile traversal) ───── 63 63 export * from "./level-blocks/registry.ts"; 64 64 export * from "./level-blocks/graph.ts"; 65 + export * from "./level-blocks/compile.ts"; 65 66 66 67 // ── Blocks (configured constraints & scorers + MiniZinc fragments) ─── 67 68 export * from "./level-blocks/types.ts";
+125
test/level-blocks/compile.test.ts
··· 1 + import { test, expect, describe } from "bun:test"; 2 + 3 + import { createRegistry } from "../../src/level-blocks/registry.ts"; 4 + import type { PipelineGraph } from "../../src/level-blocks/graph.ts"; 5 + import { 6 + compileGraph, 7 + GraphValidationError, 8 + type CompiledFamilies, 9 + type CompileTarget, 10 + } from "../../src/level-blocks/compile.ts"; 11 + 12 + import { makeDefs } from "./pipelineFixtures.ts"; 13 + 14 + const registry = createRegistry(makeDefs()); 15 + 16 + function makeGraph(): PipelineGraph { 17 + return { 18 + formatVersion: 1, 19 + blocks: [ 20 + { 21 + instanceId: "c1", 22 + code: "ToyCap", 23 + params: { resource: "crane", max: 2 }, 24 + provenance: "curated", 25 + shadow: false, 26 + }, 27 + { 28 + instanceId: "c2", 29 + code: "ToyCap", 30 + params: { resource: "forklift", max: 4 }, 31 + provenance: "curated", 32 + shadow: false, 33 + }, 34 + { 35 + instanceId: "s1", 36 + code: "ToyWeight", 37 + params: { weight: 0.5 }, 38 + provenance: "curated", 39 + shadow: false, 40 + }, 41 + { 42 + instanceId: "g1", 43 + code: "ToyGreedy", 44 + params: { seed: 7 }, 45 + provenance: "curated", 46 + shadow: false, 47 + }, 48 + { 49 + instanceId: "p1", 50 + code: "ToyProbe", 51 + params: { label: "trace" }, 52 + provenance: "curated", 53 + shadow: false, 54 + }, 55 + ], 56 + }; 57 + } 58 + 59 + /** Toy target proving the interface: echoes the grouped families + graph. */ 60 + const echoTarget: CompileTarget<{ families: CompiledFamilies; graph: PipelineGraph }> = { 61 + fromFamilies: (families, graph) => ({ families, graph }), 62 + }; 63 + 64 + describe("compileGraph — family grouping", () => { 65 + test("groups outputs by family and routes them to the target", () => { 66 + const { families, graph } = compileGraph(registry, makeGraph(), echoTarget); 67 + 68 + expect(families.constraints.map((c) => c.instance.instanceId)).toEqual(["c1", "c2"]); 69 + expect(families.scorers.map((s) => s.instance.instanceId)).toEqual(["s1"]); 70 + expect(families.search.instance.instanceId).toBe("g1"); 71 + expect(families.collectors.map((c) => c.instance.instanceId)).toEqual(["p1"]); 72 + expect(graph.blocks).toHaveLength(5); 73 + }); 74 + 75 + test("each compiled entry carries def, instance, and apply() output", () => { 76 + const { families } = compileGraph(registry, makeGraph(), echoTarget); 77 + 78 + expect(families.constraints[0]!.def.code).toBe("ToyCap"); 79 + expect(families.constraints[0]!.output).toEqual({ tag: "cap", resource: "crane", max: 2 }); 80 + expect(families.constraints[1]!.output).toEqual({ tag: "cap", resource: "forklift", max: 4 }); 81 + expect(families.scorers[0]!.output).toEqual({ tag: "weight", weight: 0.5 }); 82 + expect(families.search.output).toEqual({ tag: "greedy", seed: 7 }); 83 + expect(families.collectors[0]!.output).toEqual({ tag: "probe", label: "trace" }); 84 + }); 85 + 86 + test("grouping is independent of block order in the graph", () => { 87 + const graph = makeGraph(); 88 + graph.blocks.reverse(); 89 + const { families } = compileGraph(registry, graph, echoTarget); 90 + 91 + expect(families.constraints.map((c) => c.instance.instanceId)).toEqual(["c2", "c1"]); 92 + expect(families.search.instance.instanceId).toBe("g1"); 93 + expect(families.scorers).toHaveLength(1); 94 + expect(families.collectors).toHaveLength(1); 95 + }); 96 + 97 + test("returns the target's value", () => { 98 + const countTarget: CompileTarget<number> = { 99 + fromFamilies: (families) => 100 + families.constraints.length + families.scorers.length + families.collectors.length + 1, 101 + }; 102 + expect(compileGraph(registry, makeGraph(), countTarget)).toBe(5); 103 + }); 104 + }); 105 + 106 + describe("compileGraph — validation", () => { 107 + test("throws GraphValidationError on an invalid graph", () => { 108 + const graph = makeGraph(); 109 + graph.blocks[0]!.code = "Mystery"; 110 + expect(() => compileGraph(registry, graph, echoTarget)).toThrow(GraphValidationError); 111 + }); 112 + 113 + test("the thrown error carries the issues", () => { 114 + const graph = makeGraph(); 115 + graph.blocks[0]!.code = "Mystery"; 116 + try { 117 + compileGraph(registry, graph, echoTarget); 118 + throw new Error("expected compileGraph to throw"); 119 + } catch (err) { 120 + expect(err).toBeInstanceOf(GraphValidationError); 121 + const issues = (err as GraphValidationError).issues; 122 + expect(issues.some((i) => i.code === "Mystery")).toBe(true); 123 + } 124 + }); 125 + });