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.

docs: document the declarative run layer; fix stale OpenUnitPenalty example

README gains a 'Declarative runs (RunConfig)' section covering the
versioned registry, analyzeRun, enabled levers, weighted/lexicographic
objectives, shared profiles, and provenance; the OpenUnitPenalty example
now matches the current unitIds schema. CHANGELOG entries added.

Russ T. Fugal (Jul 16, 2026, 12:14 AM -0600) 6c22c30e 2604949b

+116 -41
+30
CHANGELOG.md
··· 7 7 8 8 ### Added 9 9 10 + - **Declarative run configs (`RunConfig` / `runFromConfig`).** One JSON document 11 + describes a whole leveling run — resolve options, blocks by versioned registry 12 + code, objective weights or lexicographic tiers. `runFromConfig` fills defaults 13 + (the filled config is the persistable artifact), compiles blocks with 14 + instanceIds doubling as constraint names, statically analyzes the run, and 15 + returns a live pipeline (`stream` / `best` / `scores` / `materialize`). 16 + - **Standard registry with versioned block codes.** `standardRegistry()` ships 17 + every block as `levelset.<Name>.v1`; new blocks `PeakCapBlock`, 18 + `DeadlineBlock`, `ReleaseBlock`, `MakespanBlock`, `SerialSGSBlock`. 19 + - **Static run analysis (`analyzeRun`).** Coded (`LEVELSET_E###`/`W###`), 20 + instance-attributed issues found before any search: unknown 21 + resource/task/unit/calendar references, infeasible deadline/release pairs, 22 + precedence and unit-precedence cycles, kinds the chosen search won't enforce 23 + (via the new `Search.supports()`), empty governed sets, duplicate names. 24 + Scorers can declare `reads` metadata to join the check. 25 + - **Named, toggleable constraint instances.** Every constraint accepts `name` 26 + (failure attribution) and `enabled: false` (present-but-inactive, recorded as 27 + a `disabledConstraints` annotation). Graph block instances gained the same 28 + `enabled` lever. 29 + - **Shared schedule profiles (`scheduleProfiles`).** Per-schedule memoized 30 + resource histograms and unit open spans; all scoring blocks now read the 31 + shared slot (also fixes `ConcurrentResourceCost` / `OpenUnitPenalty` 32 + ignoring `realizedAssignments` for mode-governed tasks). 33 + - **Multi-objective selection as data.** `weightedScorer` / `scoreReport`, and 34 + lexicographic tiers via `ScheduleStream.bestByLex` + 35 + `objective.lexicographic` in run configs. 36 + - **Run provenance.** `LevelingRun.materialize` embeds a `levelset` block 37 + (engine version, filled config, seed, scores, warnings, annotations) in the 38 + output `ProjectFile`; `ProjectFileSchema` and the JSON reader/writer 39 + round-trip it. 10 40 - **Resource-leveling engine (`levelset/leveling` subpath).** A composable 11 41 scheduling toolkit that operates on any `ProjectFile` and pulls only `zod` — 12 42 none of the file-I/O dependencies. The pipeline is
+49 -10
README.md
··· 214 214 ```ts 215 215 import { OpenUnitPenaltyBlock } from "levelset/leveling"; 216 216 217 - // Soft WIP limit: leave 2 units open for free, then penalize each extra open-unit-day. 218 - const wipPenalty = OpenUnitPenaltyBlock.apply({ 219 - units: [ 220 - { id: 10, taskUniqueIds: [1, 2, 3] }, 221 - { id: 20, taskUniqueIds: [4, 5, 6] }, 222 - ], 223 - softMax: 2, 224 - weight: 50, 225 - }); 217 + // Soft WIP limit: leave 2 units open for free, then penalize each extra 218 + // open-unit-day. Unit definitions live in ResolveOptions.workUnits; blocks 219 + // reference them by id. 220 + const wipPenalty = OpenUnitPenaltyBlock.apply({ unitIds: [10, 20], softMax: 2, weight: 50 }); 226 221 const best = await stream.bestBy(wipPenalty); 227 222 ``` 228 223 229 224 > **Note:** `serialSGS` emits a single schedule, so a scorer currently _ranks_ output rather than _steering_ it — `bestBy` over a one-schedule stream returns that schedule. Scorers become an optimization lever once a multi-candidate search (restart / LDS / LNS transformer) consumes them. Hard constraints (the table above) are what shape `serialSGS` output today. 230 225 231 - Constraint blocks pair with the hard variants — `MaxConcurrentResourceBlock` and `ConcurrentUnitsLimitBlock` build the corresponding `Constraint` and a MiniZinc fragment. 226 + Constraint blocks pair with the hard variants — `MaxConcurrentResourceBlock`, `PeakCapBlock`, `ConcurrentUnitsLimitBlock`, `DeadlineBlock`, and `ReleaseBlock` build the corresponding `Constraint` and a MiniZinc fragment. 227 + 228 + ### Declarative runs (`RunConfig`) 229 + 230 + One JSON document fully describes a leveling run: resolve options, blocks referenced by **versioned registry code** (`levelset.PeakCap.v1`), and how scorer outputs combine. `runFromConfig` validates it, statically analyzes the run, and hands back a live pipeline: 231 + 232 + ```ts 233 + import { runFromConfig } from "levelset/leveling"; 234 + 235 + const run = runFromConfig(project, { 236 + formatVersion: 1, 237 + resolve: { workUnits: [{ id: 10, taskUniqueIds: [1, 2, 3] }] }, 238 + pipeline: { 239 + formatVersion: 1, 240 + blocks: [ 241 + { 242 + instanceId: "crew-cap", 243 + code: "levelset.MaxConcurrentResource.v1", 244 + params: { resourceUniqueId: 100, max: 3 }, 245 + }, 246 + { 247 + instanceId: "wip", 248 + code: "levelset.OpenUnitPenalty.v1", 249 + params: { unitIds: [10], softMax: 2 }, 250 + }, 251 + { instanceId: "makespan", code: "levelset.Makespan.v1", params: {} }, 252 + { instanceId: "search", code: "levelset.SerialSGS.v1", params: {} }, 253 + ], 254 + }, 255 + weights: { makespan: 1, wip: 0.3 }, // null = compute and report, don't optimize 256 + // or tiered selection: objective: { lexicographic: ["makespan", "wip"] } 257 + }); 258 + 259 + const best = await run.best(); 260 + const leveled = run.materialize(best!); // embeds provenance (see below) 261 + ``` 262 + 263 + The moving parts, each usable on its own: 264 + 265 + - **`standardRegistry()`** — every shipping block under a versioned code; semantic changes ship as `.v2` while `.v1` keeps resolving, so saved configs stay reproducible. Extend with your own defs via `standardRegistry(extraDefs)`. 266 + - **`analyzeRun(resolved, constraints, { scorers, search })`** — static validation _before_ searching: unknown resource/task/unit references, contradictory or unfittable deadline/release pairs, precedence and unit-precedence cycles, kinds the chosen search won't enforce. Issues carry stable `LEVELSET_E###`/`W###` codes and attribute to the **named constraint instance** (`name` on any constraint; instanceIds fill in for config-built runs). `runFromConfig` throws `RunAnalysisError` on errors; warnings ride along. 267 + - **`enabled: false`** on a constraint or block — the present-but-inactive lever for what-if comparisons; searches skip it and record the skip as a `disabledConstraints` annotation, so "disabled" stays distinguishable from "absent". 268 + - **`weightedScorer` / `bestByLex`** — multi-objective combination as data: a weight map (with `null` = reported-but-unweighted) or ordered lexicographic tiers. 269 + - **`scheduleProfiles(schedule)`** — shared derived data (per-resource day histograms, per-unit open spans) computed once per schedule; every scoring block reads these slots instead of recomputing. 270 + - **Provenance** — `run.materialize(schedule)` embeds a `levelset` block in the output `ProjectFile`: engine version, the _filled_ run config (defaults expanded — rerunning it reproduces the schedule), seed, achieved scores, warnings, and search annotations. The JSON writer/reader round-trips it, so "why does this schedule look like this?" is answerable from the file itself. 232 271 233 272 ## Scripts 234 273
+9 -8
src/level-blocks/runConfig.ts
··· 242 242 workUnits: filled.resolve?.workUnits, 243 243 }); 244 244 245 - const { constraints, scorers, search } = compileGraph(registry, filled.pipeline, runTarget(filled)); 245 + const { constraints, scorers, search } = compileGraph( 246 + registry, 247 + filled.pipeline, 248 + runTarget(filled), 249 + ); 246 250 247 251 // fillRunConfig checked lexicographic ids reference scorer blocks; they 248 252 // must also be *active* (a disabled scorer can't drive selection). ··· 265 269 }); 266 270 if (!analysis.ok) throw new RunAnalysisError(analysis.issues); 267 271 268 - const objective = 269 - scorers.some((s) => s.weight !== null && s.weight !== 0) 270 - ? weightedScorer(scorers, "objective") 271 - : { name: "Makespan", direction: "min" as const, score: (s: Schedule) => s.makespan }; 272 + const objective = scorers.some((s) => s.weight !== null && s.weight !== 0) 273 + ? weightedScorer(scorers, "objective") 274 + : { name: "Makespan", direction: "min" as const, score: (s: Schedule) => s.makespan }; 272 275 273 276 const run: LevelingRun = { 274 277 config: filled, ··· 317 320 // streamFromFactory expects a plain AsyncGenerator<Schedule>. Re-yield to 318 321 // drop the return value (the stream just ends, matching ScheduleStream's 319 322 // documented contract). 320 - async function* stripReturn( 321 - gen: AsyncGenerator<Schedule, unknown>, 322 - ): AsyncGenerator<Schedule> { 323 + async function* stripReturn(gen: AsyncGenerator<Schedule, unknown>): AsyncGenerator<Schedule> { 323 324 for await (const schedule of gen) yield schedule; 324 325 }
+2 -1
src/level-core/analyzeRun.ts
··· 223 223 224 224 const unitCycle = findCycle( 225 225 [...resolved.workUnits.keys()], 226 - unitEdges.filter((e) => resolved.workUnits.has(e.from) && resolved.workUnits.has(e.to)) 226 + unitEdges 227 + .filter((e) => resolved.workUnits.has(e.from) && resolved.workUnits.has(e.to)) 227 228 .map((e) => [e.from, e.to]), 228 229 ); 229 230 if (unitCycle) {
+1 -4
src/level-core/weightedScorer.ts
··· 16 16 * contribute negated, so a higher raw value still improves the combined 17 17 * score. Entries with null/zero weight are excluded from the objective 18 18 * (use `scoreReport` to still see their values). */ 19 - export function weightedScorer( 20 - entries: ReadonlyArray<WeightedEntry>, 21 - name = "objective", 22 - ): Scorer { 19 + export function weightedScorer(entries: ReadonlyArray<WeightedEntry>, name = "objective"): Scorer { 23 20 const active = entries.filter( 24 21 (e): e is WeightedEntry & { weight: number } => e.weight !== null && e.weight !== 0, 25 22 );
+3 -3
test/level-blocks/newBlocks.test.ts
··· 45 45 taskUniqueId: 1, 46 46 earliestStart: 5, 47 47 }); 48 - expect(DeadlineBlock.schema.input.safeParse({ taskUniqueId: 1, latestFinish: -1 }).success).toBe( 49 - false, 50 - ); 48 + expect( 49 + DeadlineBlock.schema.input.safeParse({ taskUniqueId: 1, latestFinish: -1 }).success, 50 + ).toBe(false); 51 51 }); 52 52 }); 53 53
+3 -3
test/level-blocks/runConfig.test.ts
··· 81 81 }); 82 82 83 83 test("rejects weights keyed by non-scorer instances", () => { 84 - expect(() => 85 - fillRunConfig({ ...baseConfig(), weights: { "crew-cap": 1 } }), 86 - ).toThrow(GraphValidationError); 84 + expect(() => fillRunConfig({ ...baseConfig(), weights: { "crew-cap": 1 } })).toThrow( 85 + GraphValidationError, 86 + ); 87 87 }); 88 88 }); 89 89
+15 -7
test/level-core/analyzeRun.test.ts
··· 110 110 111 111 test("precedence cycles from constraint edges are LEVELSET_E107", () => { 112 112 const edges = [ 113 - { predecessorUniqueId: 1, successorUniqueId: 2, type: RelationType.FinishToStart, lagDays: 0 }, 114 - { predecessorUniqueId: 2, successorUniqueId: 1, type: RelationType.FinishToStart, lagDays: 0 }, 113 + { 114 + predecessorUniqueId: 1, 115 + successorUniqueId: 2, 116 + type: RelationType.FinishToStart, 117 + lagDays: 0, 118 + }, 119 + { 120 + predecessorUniqueId: 2, 121 + successorUniqueId: 1, 122 + type: RelationType.FinishToStart, 123 + lagDays: 0, 124 + }, 115 125 ]; 116 126 const analysis = analyzeRun(resolvedFixture(), [{ kind: "Precedence", edges }]); 117 127 expect(codes(analysis.issues)).toContain("LEVELSET_E107"); ··· 127 137 expect(withSearch.ok).toBe(true); // warning, not error 128 138 expect(codes(withSearch.issues)).toContain("LEVELSET_W201"); 129 139 // Disabled constraints don't warn — they're not going to run. 130 - const disabled = analyzeRun( 131 - resolvedFixture(), 132 - [{ ...constraints[0]!, enabled: false }], 133 - { search: serialSGS }, 134 - ); 140 + const disabled = analyzeRun(resolvedFixture(), [{ ...constraints[0]!, enabled: false }], { 141 + search: serialSGS, 142 + }); 135 143 expect(codes(disabled.issues)).not.toContain("LEVELSET_W201"); 136 144 }); 137 145
+3 -1
test/level-core/constraintMeta.test.ts
··· 104 104 | { message: string }[] 105 105 | undefined; 106 106 expect(unsupported).toHaveLength(1); 107 - expect(unsupported![0]!.message).toMatch(/"floors-in-order" \(UnitPrecedence\).*not implemented/); 107 + expect(unsupported![0]!.message).toMatch( 108 + /"floors-in-order" \(UnitPrecedence\).*not implemented/, 109 + ); 108 110 }); 109 111 }); 110 112
+1 -4
test/level-core/weightedScorer.test.ts
··· 41 41 42 42 describe("scoreReport", () => { 43 43 test("reports every scorer's raw value regardless of weight", () => { 44 - const report = scoreReport(fakeSchedule, [ 45 - constScorer("a", 5), 46 - constScorer("b", 7, "max"), 47 - ]); 44 + const report = scoreReport(fakeSchedule, [constScorer("a", 5), constScorer("b", 7, "max")]); 48 45 expect(report).toEqual({ a: 5, b: 7 }); 49 46 }); 50 47 });