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): PeakCap, Deadline, Release, Makespan, SerialSGS blocks

Completes block coverage for the constraint kinds serialSGS enforces plus
the two pieces every declarative run needs: the canonical Makespan scorer
(previously hand-written inline by every caller) and the search itself as
a registry-nameable block, so backends are selected by config rather than
hard-wired.

Russ T. Fugal (Jul 16, 2026, 12:00 AM -0600) 166169a7 f0c46569

+312 -1
+43
src/level-blocks/deadline.ts
··· 1 + // Deadline — Constraint block. 2 + // 3 + // The task must finish on or before `latestFinish` (a day index in the 4 + // resolved calendar frame). Hard form: a placement that would overrun the 5 + // deadline is rejected and the run fails with an attributed explanation. 6 + // 7 + // MiniZinc compile contract: harness supplies `finish_day[t]`. 8 + 9 + import { z } from "zod"; 10 + 11 + import type { DeadlineConstraint } from "../level-core/types.ts"; 12 + 13 + import type { ConstraintBlock, MiniZincFragment } from "./types.ts"; 14 + 15 + export const DeadlineInputSchema = z.object({ 16 + taskUniqueId: z.number().int(), 17 + latestFinish: z.number().int().nonnegative(), 18 + }); 19 + 20 + export type DeadlineInput = z.infer<typeof DeadlineInputSchema>; 21 + 22 + const DeadlineOutputSchema: z.ZodType<DeadlineConstraint> = z.object({ 23 + kind: z.literal("Deadline"), 24 + taskUniqueId: z.number().int(), 25 + latestFinish: z.number().int().nonnegative(), 26 + }); 27 + 28 + export const DeadlineBlock: ConstraintBlock<DeadlineInput> = { 29 + id: "Deadline", 30 + schema: { input: DeadlineInputSchema, output: DeadlineOutputSchema }, 31 + apply: ({ taskUniqueId, latestFinish }): DeadlineConstraint => ({ 32 + kind: "Deadline", 33 + taskUniqueId, 34 + latestFinish, 35 + }), 36 + toMiniZinc: ({ taskUniqueId, latestFinish }): MiniZincFragment => ({ 37 + text: `constraint finish_day[${String(taskUniqueId)}] <= ${String(latestFinish)};`, 38 + }), 39 + doc: { 40 + nl: "The task must finish on or before the given day index; later placements are rejected.", 41 + pseudocode: "finish(task) <= latestFinish", 42 + }, 43 + };
+43
src/level-blocks/makespan.ts
··· 1 + // Makespan — Scoring block. 2 + // 3 + // The canonical objective: minimize the latest finish day. Shipping it as a 4 + // block (rather than every caller hand-writing the two-line scorer) gives 5 + // the makespan a registry name a run config can reference and weight. 6 + // 7 + // MiniZinc compile contract: harness supplies `finish_day[t]` and `TASKS`. 8 + 9 + import { z } from "zod"; 10 + 11 + import type { Schedule, Scorer } from "../level-core/types.ts"; 12 + 13 + import type { MiniZincFragment, ScoringBlock } from "./types.ts"; 14 + 15 + export const MakespanInputSchema = z.object({}); 16 + 17 + export type MakespanInput = z.infer<typeof MakespanInputSchema>; 18 + 19 + const ScorerOutputSchema: z.ZodType<Scorer> = z.custom<Scorer>( 20 + (val): val is Scorer => 21 + typeof val === "object" && 22 + val !== null && 23 + typeof (val as Scorer).name === "string" && 24 + ((val as Scorer).direction === "min" || (val as Scorer).direction === "max") && 25 + typeof (val as Scorer).score === "function", 26 + ); 27 + 28 + export const MakespanBlock: ScoringBlock<MakespanInput> = { 29 + id: "Makespan", 30 + schema: { input: MakespanInputSchema, output: ScorerOutputSchema }, 31 + apply: (): Scorer => ({ 32 + name: "Makespan", 33 + direction: "min", 34 + score: (schedule: Schedule): number => schedule.makespan, 35 + }), 36 + toMiniZinc: (): MiniZincFragment => ({ 37 + text: `var int: makespan = max(t in TASKS) (finish_day[t]);`, 38 + }), 39 + doc: { 40 + nl: "The latest finish day across all tasks — the schedule's total length.", 41 + pseudocode: "max(finish(task) for task in tasks)", 42 + }, 43 + };
+66
src/level-blocks/peakCap.ts
··· 1 + // PeakCap — Constraint block. 2 + // 3 + // Caps the summed fractional *unit* demand on a resource per working day — 4 + // the unit-summing companion to `MaxConcurrentResource`'s task-count cap. 5 + // An optional [fromDay, toDay) window scopes the cap to part of the horizon 6 + // (e.g. a seasonal crane embargo); omit it for the whole horizon. 7 + // 8 + // MiniZinc compile contract: harness supplies `active[t,d]`, 9 + // `tasks_demanding[r]`, `units[t,r]`, and `DAYS`. 10 + 11 + import { z } from "zod"; 12 + 13 + import type { PeakCapConstraint } from "../level-core/types.ts"; 14 + 15 + import type { ConstraintBlock, MiniZincFragment } from "./types.ts"; 16 + 17 + export const PeakCapInputSchema = z.object({ 18 + resourceUniqueId: z.number().int(), 19 + cap: z.number().positive(), 20 + window: z 21 + .object({ 22 + fromDay: z.number().int().nonnegative(), 23 + toDay: z.number().int().nonnegative(), 24 + }) 25 + .optional(), 26 + }); 27 + 28 + export type PeakCapInput = z.infer<typeof PeakCapInputSchema>; 29 + 30 + const PeakCapOutputSchema: z.ZodType<PeakCapConstraint> = z.object({ 31 + kind: z.literal("PeakCap"), 32 + resourceUniqueId: z.number().int(), 33 + cap: z.number().positive(), 34 + window: z 35 + .object({ 36 + fromDay: z.number().int().nonnegative(), 37 + toDay: z.number().int().nonnegative(), 38 + }) 39 + .optional(), 40 + }); 41 + 42 + export const PeakCapBlock: ConstraintBlock<PeakCapInput> = { 43 + id: "PeakCap", 44 + schema: { input: PeakCapInputSchema, output: PeakCapOutputSchema }, 45 + apply: ({ resourceUniqueId, cap, window }): PeakCapConstraint => ({ 46 + kind: "PeakCap", 47 + resourceUniqueId, 48 + cap, 49 + ...(window ? { window } : {}), 50 + }), 51 + toMiniZinc: ({ resourceUniqueId, cap, window }): MiniZincFragment => { 52 + const dayFilter = window 53 + ? `d in DAYS where d >= ${String(window.fromDay)} /\\ d < ${String(window.toDay)}` 54 + : `d in DAYS`; 55 + return { 56 + text: 57 + `constraint forall(${dayFilter}) ` + 58 + `( sum(t in tasks_demanding[${String(resourceUniqueId)}]) ` + 59 + `(bool2int(active[t,d]) * units[t,${String(resourceUniqueId)}]) <= ${String(cap)} );`, 60 + }; 61 + }, 62 + doc: { 63 + nl: "Summed fractional unit demand on the named resource may not exceed the cap on any working day (optionally within a day window).", 64 + pseudocode: "forall day d [in window]: sum(units of active tasks demanding r) <= cap", 65 + }, 66 + };
+43
src/level-blocks/release.ts
··· 1 + // Release — Constraint block. 2 + // 3 + // The task may not start before `earliestStart` (a day index in the 4 + // resolved calendar frame) — material arrival, permit dates, or frozen 5 + // in-progress work compiled down from a re-leveling run. 6 + // 7 + // MiniZinc compile contract: harness supplies `start_day[t]`. 8 + 9 + import { z } from "zod"; 10 + 11 + import type { ReleaseConstraint } from "../level-core/types.ts"; 12 + 13 + import type { ConstraintBlock, MiniZincFragment } from "./types.ts"; 14 + 15 + export const ReleaseInputSchema = z.object({ 16 + taskUniqueId: z.number().int(), 17 + earliestStart: z.number().int().nonnegative(), 18 + }); 19 + 20 + export type ReleaseInput = z.infer<typeof ReleaseInputSchema>; 21 + 22 + const ReleaseOutputSchema: z.ZodType<ReleaseConstraint> = z.object({ 23 + kind: z.literal("Release"), 24 + taskUniqueId: z.number().int(), 25 + earliestStart: z.number().int().nonnegative(), 26 + }); 27 + 28 + export const ReleaseBlock: ConstraintBlock<ReleaseInput> = { 29 + id: "Release", 30 + schema: { input: ReleaseInputSchema, output: ReleaseOutputSchema }, 31 + apply: ({ taskUniqueId, earliestStart }): ReleaseConstraint => ({ 32 + kind: "Release", 33 + taskUniqueId, 34 + earliestStart, 35 + }), 36 + toMiniZinc: ({ taskUniqueId, earliestStart }): MiniZincFragment => ({ 37 + text: `constraint start_day[${String(taskUniqueId)}] >= ${String(earliestStart)};`, 38 + }), 39 + doc: { 40 + nl: "The task may not start before the given day index.", 41 + pseudocode: "start(task) >= earliestStart", 42 + }, 43 + };
+41
src/level-blocks/serialSgsSearch.ts
··· 1 + // SerialSGS — Search block. 2 + // 3 + // Wraps the greedy serial Schedule Generation Scheme as a registry-named 4 + // search so a run config can select the backend declaratively. Future 5 + // backends (CP-SAT, MiniZinc subprocess) register beside it under their own 6 + // versioned codes rather than forking the run API. 7 + 8 + import { z } from "zod"; 9 + 10 + import { serialSGS } from "../level-core/search/serialSGS.ts"; 11 + import type { Search } from "../level-core/types.ts"; 12 + 13 + import type { MiniZincFragment, SearchBlock } from "./types.ts"; 14 + 15 + export const SerialSGSInputSchema = z.object({}); 16 + 17 + export type SerialSGSInput = z.infer<typeof SerialSGSInputSchema>; 18 + 19 + const SearchOutputSchema: z.ZodType<Search> = z.custom<Search>( 20 + (val): val is Search => 21 + typeof val === "object" && 22 + val !== null && 23 + typeof (val as Search).name === "string" && 24 + typeof (val as Search).run === "function", 25 + ); 26 + 27 + export const SerialSGSBlock: SearchBlock<SerialSGSInput> = { 28 + id: "SerialSGS", 29 + schema: { input: SerialSGSInputSchema, output: SearchOutputSchema }, 30 + apply: (): Search => serialSGS, 31 + toMiniZinc: (): MiniZincFragment => ({ 32 + // The search itself never lowers to MiniZinc — a MiniZinc backend 33 + // replaces it wholesale. Emitted as a comment so concatenated compiles 34 + // stay valid. 35 + text: `% search: greedy serial-SGS (in-process; not a MiniZinc construct)`, 36 + }), 37 + doc: { 38 + nl: "Greedy serial schedule-generation scheme: topological order, earliest feasible placement per task. Deterministic, single schedule per run.", 39 + pseudocode: "for task in topo order: place at earliest day satisfying precedence + caps", 40 + }, 41 + };
+3 -1
src/level-core/types.ts
··· 176 176 readonly kind: "PeakCap"; 177 177 readonly resourceUniqueId: number; 178 178 readonly cap: number; 179 - readonly window?: { readonly fromDay: DayIndex; readonly toDay: DayIndex }; 179 + // `| undefined` so zod's `.optional()` round-trips under 180 + // exactOptionalPropertyTypes (same convention as WorkUnit). 181 + readonly window?: { readonly fromDay: DayIndex; readonly toDay: DayIndex } | undefined; 180 182 } 181 183 182 184 // WIP cap on whole work units. Prioritizes *completion*: caps how many units
+5
src/leveling.ts
··· 72 72 // ── Blocks (configured constraints & scorers + MiniZinc fragments) ─── 73 73 export * from "./level-blocks/types.ts"; 74 74 export * from "./level-blocks/maxConcurrentResource.ts"; 75 + export * from "./level-blocks/peakCap.ts"; 76 + export * from "./level-blocks/deadline.ts"; 77 + export * from "./level-blocks/release.ts"; 75 78 export * from "./level-blocks/concurrentResourceCost.ts"; 76 79 export * from "./level-blocks/concurrentUnitsLimit.ts"; 77 80 export * from "./level-blocks/hiringLagPenalty.ts"; 78 81 export * from "./level-blocks/openUnitPenalty.ts"; 79 82 export * from "./level-blocks/unimodalDeviation.ts"; 83 + export * from "./level-blocks/makespan.ts"; 84 + export * from "./level-blocks/serialSgsSearch.ts";
+68
test/level-blocks/newBlocks.test.ts
··· 1 + // PeakCap / Deadline / Release constraint blocks, Makespan scorer block, 2 + // SerialSGS search block. 3 + 4 + import { describe, expect, test } from "bun:test"; 5 + 6 + import { DeadlineBlock } from "../../src/level-blocks/deadline.ts"; 7 + import { MakespanBlock } from "../../src/level-blocks/makespan.ts"; 8 + import { PeakCapBlock } from "../../src/level-blocks/peakCap.ts"; 9 + import { ReleaseBlock } from "../../src/level-blocks/release.ts"; 10 + import { SerialSGSBlock } from "../../src/level-blocks/serialSgsSearch.ts"; 11 + import { serialSGS } from "../../src/level-core/search/serialSGS.ts"; 12 + import type { Schedule } from "../../src/level-core/types.ts"; 13 + 14 + import { MON_JAN_5, SAT_JAN_10, makeProject, makeTask, runOnce } from "../level-core/fixtures.ts"; 15 + 16 + describe("constraint blocks", () => { 17 + test("PeakCap emits the constraint, with and without a window", () => { 18 + expect(PeakCapBlock.apply({ resourceUniqueId: 100, cap: 2.5 })).toEqual({ 19 + kind: "PeakCap", 20 + resourceUniqueId: 100, 21 + cap: 2.5, 22 + }); 23 + expect( 24 + PeakCapBlock.apply({ resourceUniqueId: 100, cap: 2, window: { fromDay: 0, toDay: 10 } }), 25 + ).toEqual({ 26 + kind: "PeakCap", 27 + resourceUniqueId: 100, 28 + cap: 2, 29 + window: { fromDay: 0, toDay: 10 }, 30 + }); 31 + expect( 32 + PeakCapBlock.toMiniZinc({ resourceUniqueId: 100, cap: 2, window: { fromDay: 0, toDay: 10 } }) 33 + .text, 34 + ).toContain("d >= 0"); 35 + }); 36 + 37 + test("Deadline and Release emit their constraints and reject bad input", () => { 38 + expect(DeadlineBlock.apply({ taskUniqueId: 1, latestFinish: 30 })).toEqual({ 39 + kind: "Deadline", 40 + taskUniqueId: 1, 41 + latestFinish: 30, 42 + }); 43 + expect(ReleaseBlock.apply({ taskUniqueId: 1, earliestStart: 5 })).toEqual({ 44 + kind: "Release", 45 + taskUniqueId: 1, 46 + earliestStart: 5, 47 + }); 48 + expect(DeadlineBlock.schema.input.safeParse({ taskUniqueId: 1, latestFinish: -1 }).success).toBe( 49 + false, 50 + ); 51 + }); 52 + }); 53 + 54 + describe("Makespan block", () => { 55 + test("scores a schedule's makespan", async () => { 56 + const t1 = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }); 57 + const schedule: Schedule = await runOnce(makeProject([t1]), []); 58 + const scorer = MakespanBlock.apply({}); 59 + expect(scorer.direction).toBe("min"); 60 + expect(scorer.score(schedule)).toBe(schedule.makespan); 61 + }); 62 + }); 63 + 64 + describe("SerialSGS block", () => { 65 + test("resolves to the serialSGS search singleton", () => { 66 + expect(SerialSGSBlock.apply({})).toBe(serialSGS); 67 + }); 68 + });