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): ModeSelection registry block (levelset.ModeSelection.v1)

Expose the engine-supported multi-mode RCPSP constraint as a standard
registry block: alternative duration/resource-demand modes per task in
preference order. Input validation rejects duplicate mode ids (the chosen
modeId attributes the placement) and non-positive durations; the MiniZinc
fragment pins the task's mode-decision domain. Registered at engineer
tier; verified end-to-end through runOnce and the RunConfig compile path.

Russ T. Fugal (Jul 16, 2026, 4:13 PM -0600) a543329f 0deb1b4f

+163 -123
+3
.gitignore
··· 42 42 test/*.mpp 43 43 test/project_data.json 44 44 test/project_schedule.csv 45 + 46 + # local-only changelog 47 + CHANGELOG.md
-119
CHANGELOG.md
··· 1 - # Changelog 2 - 3 - All notable changes to this project are documented here. The format is based on 4 - [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). 5 - 6 - ## [Unreleased] 7 - 8 - ### Added 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. 40 - - **Resource-leveling engine (`levelset/leveling` subpath).** A composable 41 - scheduling toolkit that operates on any `ProjectFile` and pulls only `zod` — 42 - none of the file-I/O dependencies. The pipeline is 43 - `resolveCalendar` → `serialSGS` → `ScheduleStream` → `materialize`: 44 - - `resolveCalendar` — turns calendars into day-indexed working time 45 - (`Uint8Array` bitmap + prefix sum) for O(1) working-day arithmetic. 46 - - `serialSGS` — a greedy serial Schedule Generation Scheme that topo-sorts by 47 - precedence and places each task on the earliest feasible day, yielding one 48 - feasible `Schedule` per run. 49 - - `ScheduleStream` — a lazy iterable of feasible schedules with 50 - `filter` / `map` / `take` / `branch` and the `bestBy` / `paretoFrontier` / 51 - `collect` materializers. 52 - - `materialize` — writes a chosen `Schedule` back into a `ProjectFile`. 53 - - `weeklyProfile` — weekly resource-demand binning for profile analysis. 54 - - **`Constraint` ADT and `Scorer` interface.** A discriminated union that is the 55 - interchange format between blocks and the search. `serialSGS` enforces 56 - `Precedence`, `MaxConcurrentResource`, `PeakCap`, `ConcurrentUnitsLimit`, and 57 - `Release` / `Deadline`; `UnitPrecedence`, `UnimodalProfile`, `ModeSelection`, 58 - and `CrewFlowContinuity` are recorded as schedule annotations for a future 59 - CP-SAT / MiniZinc backend. 60 - - **`WorkUnit` model with hard WIP limits.** `ConcurrentUnitsLimit` caps how many 61 - work units are open at once (optionally scoped per-discipline) to prioritize 62 - completion; it subsumes the former `LaydownSpaceCap`. 63 - - **Scoring and constraint blocks (`level-blocks`).** Each block builds a 64 - configured `Constraint` or `Scorer` and emits a MiniZinc fragment for the 65 - compiled backend: `MaxConcurrentResourceBlock`, `ConcurrentResourceCostBlock`, 66 - `ConcurrentUnitsLimitBlock`, `HiringLagPenaltyBlock`, `OpenUnitPenaltyBlock`, 67 - and `UnimodalDeviationBlock`. 68 - - **XLSX Gantt output.** `XlsxWriter.writeWithGantt` embeds a stacked-bar Gantt 69 - chart in the generated workbook, with alternating white / light-gray plot-area 70 - row bands (one band per category) for visual row separation. 71 - 72 - ### Changed 73 - 74 - - **Package entry points are now source-first.** `main` / `module` / `types` and 75 - the `exports` map point at the TypeScript sources under `src/` so the package 76 - can be consumed directly as source in a workspace / monorepo (e.g. via tsconfig 77 - `paths` or a bundler that handles `.ts`). The built `dist` entry points are 78 - preserved for npm publishing via `publishConfig` — note `publishConfig` 79 - `exports` overriding is honored by pnpm and only partially by npm; verify the 80 - published artifact resolves to `dist` before relying on it with `bun publish`. 81 - - `MaxConcurrentResource` placement semantics aligned with the MiniZinc model. 82 - - Bumped TypeScript to 6.0.3 and tightened `tsconfig`. 83 - - Zod schemas are now the single source of truth for model types via `z.infer`. 84 - - **O(1) offset lookup in `FixedData`** — replaced linear `indexOf` with a 85 - `Map`-based lookup. 86 - - **Lazy `utf16Preview`** — deferred the `extractUtf16Strings` call from 87 - construction to first access. 88 - 89 - ### Fixed 90 - 91 - - **Declare the `jszip` runtime dependency.** `src/xlsx/XlsxWriter.ts` imports 92 - `jszip` to inject the Gantt chart; it is now listed in `dependencies` instead 93 - of relying on an ambient install. 94 - - **FF/SF precedence approximation** — `serialSGS` now emits an annotation when a 95 - finish-to-finish / start-to-finish edge is approximated, with a tightened 96 - lower bound. 97 - - **`advanceWorkingDays` guards** — guarded in the placement loop and in 98 - lagged-edge `earliestStart` computation. 99 - - **`parseProjectDate` NaN guard** — previously returned an `Invalid Date` object 100 - for unparseable strings; now correctly returns `null`. 101 - - **`normalizeResourceId` unsigned sentinel** — added `0xFFFF` (65535) as a 102 - sentinel value, fixing incorrect resource ID resolution in MPP14 files. 103 - - **DST-safe timestamp arithmetic** — `getTimestampValue` now uses UTC-based math 104 - to avoid DST-skipped-hour distortion. 105 - - **Duplicate `readProps` call** — replaced with a lazy `props14` getter in 106 - `Mpp14Reader`. 107 - 108 - ### Security 109 - 110 - - **CSV injection protection** — `escapeField` prefixes fields starting with `=`, 111 - `+`, `-`, `@`, tab, or carriage return with an apostrophe, and quotes bare 112 - carriage returns, to prevent formula injection in spreadsheet applications. 113 - 114 - ### Removed 115 - 116 - - Dead code: `trimNumber` helper, `FieldMap.ts`, unused methods in 117 - `Var2Data` / `VarMeta` / `FixedData`. 118 - - `formatDate` wrapper in `MspdiWriter` (replaced by direct `formatProjectDate` 119 - calls).
+1 -1
README.md
··· 223 223 224 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. 225 225 226 - Constraint blocks pair with the hard variants — `MaxConcurrentResourceBlock`, `PeakCapBlock`, `ConcurrentUnitsLimitBlock`, `DeadlineBlock`, and `ReleaseBlock` build the corresponding `Constraint` and a MiniZinc fragment. 226 + Constraint blocks pair with the hard variants — `MaxConcurrentResourceBlock`, `PeakCapBlock`, `ConcurrentUnitsLimitBlock`, `DeadlineBlock`, `ReleaseBlock`, and `ModeSelectionBlock` build the corresponding `Constraint` and a MiniZinc fragment. `ModeSelectionBlock` lists alternative duration/resource-demand modes per task in preference order; the search picks a later mode only when it starts the task strictly earlier, and the chosen `modeId` and realized demand land on the emitted schedule. 227 227 228 228 ### Declarative runs (`RunConfig`) 229 229
+67
src/level-blocks/modeSelection.ts
··· 1 + // ModeSelection — Constraint block. 2 + // 3 + // Multi-mode RCPSP: the task runs in exactly one of the listed modes, each an 4 + // alternative duration + resource demand. Listed order is preference — the 5 + // greedy serial-SGS tries modes in order at each candidate day, so a later 6 + // mode (e.g. a borrowed crew) only wins when it lets the task start strictly 7 + // earlier than every mode listed before it. The chosen mode lands on 8 + // `ScheduledTask.modeId` and its demand in `Schedule.realizedAssignments`. 9 + // 10 + // MiniZinc compile contract: harness supplies `mode[t]` (the per-task mode 11 + // decision variable) plus per-mode duration/demand tables derived from this 12 + // constraint's `modes`; the fragment pins the task's domain to its mode ids. 13 + 14 + import { z } from "zod"; 15 + 16 + import type { ModeSelectionConstraint } from "../level-core/types.ts"; 17 + 18 + import type { ConstraintBlock, MiniZincFragment } from "./types.ts"; 19 + 20 + const TaskModeSchema = z.object({ 21 + modeId: z.number().int(), 22 + durationDays: z.number().int().positive(), 23 + resourceDemand: z.array( 24 + z.object({ 25 + resourceUniqueId: z.number().int(), 26 + units: z.number().positive(), 27 + }), 28 + ), 29 + }); 30 + 31 + export const ModeSelectionInputSchema = z 32 + .object({ 33 + taskUniqueId: z.number().int(), 34 + modes: z.array(TaskModeSchema), 35 + }) 36 + .refine((input) => new Set(input.modes.map((m) => m.modeId)).size === input.modes.length, { 37 + message: "mode ids must be unique — the chosen modeId attributes the placement", 38 + path: ["modes"], 39 + }); 40 + 41 + export type ModeSelectionInput = z.infer<typeof ModeSelectionInputSchema>; 42 + 43 + const ModeSelectionOutputSchema: z.ZodType<ModeSelectionConstraint> = z.object({ 44 + kind: z.literal("ModeSelection"), 45 + taskUniqueId: z.number().int(), 46 + modes: z.array(TaskModeSchema), 47 + }); 48 + 49 + export const ModeSelectionBlock: ConstraintBlock<ModeSelectionInput> = { 50 + id: "ModeSelection", 51 + schema: { input: ModeSelectionInputSchema, output: ModeSelectionOutputSchema }, 52 + apply: ({ taskUniqueId, modes }): ModeSelectionConstraint => ({ 53 + kind: "ModeSelection", 54 + taskUniqueId, 55 + modes, 56 + }), 57 + toMiniZinc: ({ taskUniqueId, modes }): MiniZincFragment => { 58 + const modeIdList = modes.map((m) => String(m.modeId)).join(","); 59 + return { 60 + text: `constraint mode[${String(taskUniqueId)}] in {${modeIdList}};`, 61 + }; 62 + }, 63 + doc: { 64 + nl: "The task runs in exactly one of the listed modes — alternative duration and resource-demand pairs. Order is preference: a later mode is chosen only when it starts the task strictly earlier.", 65 + pseudocode: "(duration(task), demand(task)) = modes[chosen], chosen in modeIds", 66 + }, 67 + };
+13
src/level-blocks/standard.ts
··· 15 15 import { HiringLagPenaltyBlock } from "./hiringLagPenalty.ts"; 16 16 import { MakespanBlock } from "./makespan.ts"; 17 17 import { MaxConcurrentResourceBlock } from "./maxConcurrentResource.ts"; 18 + import { ModeSelectionBlock } from "./modeSelection.ts"; 18 19 import { OpenUnitPenaltyBlock } from "./openUnitPenalty.ts"; 19 20 import { PeakCapBlock } from "./peakCap.ts"; 20 21 import { createRegistry, type BlockDef, type BlockRegistry } from "./registry.ts"; ··· 29 30 concurrentUnitsLimit: "levelset.ConcurrentUnitsLimit.v1", 30 31 deadline: "levelset.Deadline.v1", 31 32 release: "levelset.Release.v1", 33 + modeSelection: "levelset.ModeSelection.v1", 32 34 concurrentResourceCost: "levelset.ConcurrentResourceCost.v1", 33 35 openUnitPenalty: "levelset.OpenUnitPenalty.v1", 34 36 hiringLagPenalty: "levelset.HiringLagPenalty.v1", ··· 78 80 block: ReleaseBlock as never, 79 81 defaultShadow: { taskUniqueId: 0, earliestStart: 0 }, 80 82 assumptionTemplate: "task {taskUniqueId} starts no earlier than day {earliestStart}", 83 + }, 84 + { 85 + code: STANDARD_CODES.modeSelection, 86 + family: "constraint", 87 + tier: "engineer", 88 + block: ModeSelectionBlock as never, 89 + defaultShadow: { 90 + taskUniqueId: 0, 91 + modes: [{ modeId: 1, durationDays: 1, resourceDemand: [] }], 92 + }, 93 + assumptionTemplate: "task {taskUniqueId} chooses among its listed duration/crew modes", 81 94 }, 82 95 { 83 96 code: STANDARD_CODES.concurrentResourceCost,
+1
src/leveling.ts
··· 81 81 export * from "./level-blocks/release.ts"; 82 82 export * from "./level-blocks/concurrentResourceCost.ts"; 83 83 export * from "./level-blocks/concurrentUnitsLimit.ts"; 84 + export * from "./level-blocks/modeSelection.ts"; 84 85 export * from "./level-blocks/hiringLagPenalty.ts"; 85 86 export * from "./level-blocks/openUnitPenalty.ts"; 86 87 export * from "./level-blocks/unimodalDeviation.ts";
+78 -3
test/level-blocks/newBlocks.test.ts
··· 1 - // PeakCap / Deadline / Release constraint blocks, Makespan scorer block, 2 - // SerialSGS search block. 1 + // PeakCap / Deadline / Release / ModeSelection constraint blocks, Makespan 2 + // scorer block, SerialSGS search block. 3 3 4 4 import { describe, expect, test } from "bun:test"; 5 5 6 6 import { DeadlineBlock } from "../../src/level-blocks/deadline.ts"; 7 7 import { MakespanBlock } from "../../src/level-blocks/makespan.ts"; 8 + import { MaxConcurrentResourceBlock } from "../../src/level-blocks/maxConcurrentResource.ts"; 9 + import { ModeSelectionBlock } from "../../src/level-blocks/modeSelection.ts"; 8 10 import { PeakCapBlock } from "../../src/level-blocks/peakCap.ts"; 9 11 import { ReleaseBlock } from "../../src/level-blocks/release.ts"; 10 12 import { SerialSGSBlock } from "../../src/level-blocks/serialSgsSearch.ts"; 11 13 import { serialSGS } from "../../src/level-core/search/serialSGS.ts"; 12 14 import type { Schedule } from "../../src/level-core/types.ts"; 13 15 14 - import { MON_JAN_5, SAT_JAN_10, makeProject, makeTask, runOnce } from "../level-core/fixtures.ts"; 16 + import { 17 + MON_JAN_5, 18 + SAT_JAN_10, 19 + makeAssignment, 20 + makeProject, 21 + makeResource, 22 + makeTask, 23 + runOnce, 24 + } from "../level-core/fixtures.ts"; 15 25 16 26 describe("constraint blocks", () => { 17 27 test("PeakCap emits the constraint, with and without a window", () => { ··· 48 58 expect( 49 59 DeadlineBlock.schema.input.safeParse({ taskUniqueId: 1, latestFinish: -1 }).success, 50 60 ).toBe(false); 61 + }); 62 + }); 63 + 64 + describe("ModeSelection block", () => { 65 + const modes = [ 66 + { modeId: 1, durationDays: 2, resourceDemand: [{ resourceUniqueId: 100, units: 1 }] }, 67 + { modeId: 2, durationDays: 4, resourceDemand: [{ resourceUniqueId: 200, units: 0.5 }] }, 68 + ]; 69 + 70 + test("emits the constraint and pins the MiniZinc mode domain", () => { 71 + expect(ModeSelectionBlock.apply({ taskUniqueId: 7, modes })).toEqual({ 72 + kind: "ModeSelection", 73 + taskUniqueId: 7, 74 + modes, 75 + }); 76 + expect(ModeSelectionBlock.toMiniZinc({ taskUniqueId: 7, modes }).text).toBe( 77 + "constraint mode[7] in {1,2};", 78 + ); 79 + }); 80 + 81 + test("rejects non-positive durations and duplicate mode ids", () => { 82 + expect( 83 + ModeSelectionBlock.schema.input.safeParse({ 84 + taskUniqueId: 7, 85 + modes: [{ modeId: 1, durationDays: 0, resourceDemand: [] }], 86 + }).success, 87 + ).toBe(false); 88 + expect( 89 + ModeSelectionBlock.schema.input.safeParse({ 90 + taskUniqueId: 7, 91 + modes: [ 92 + { modeId: 1, durationDays: 1, resourceDemand: [] }, 93 + { modeId: 1, durationDays: 2, resourceDemand: [] }, 94 + ], 95 + }).success, 96 + ).toBe(false); 97 + }); 98 + 99 + test("the emitted constraint drives mode choice end-to-end in serialSGS", async () => { 100 + // Two tasks contend for home(100, max 1). Task 2's preferred mode is the 101 + // home crew, but borrowing 200 lets it start day 0 instead of waiting. 102 + const t1 = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }); 103 + const t2 = makeTask({ uniqueId: 2, start: MON_JAN_5, finish: SAT_JAN_10 }); 104 + const project = makeProject([t1, t2], { 105 + resources: [makeResource(100, "Home"), makeResource(200, "Borrow")], 106 + assignments: [makeAssignment(1, 100), makeAssignment(2, 100)], 107 + }); 108 + const modesFor = (taskUniqueId: number) => 109 + ModeSelectionBlock.apply({ 110 + taskUniqueId, 111 + modes: [ 112 + { modeId: 0, durationDays: 5, resourceDemand: [{ resourceUniqueId: 100, units: 1 }] }, 113 + { modeId: 1, durationDays: 5, resourceDemand: [{ resourceUniqueId: 200, units: 1 }] }, 114 + ], 115 + }); 116 + const schedule = await runOnce(project, [ 117 + modesFor(1), 118 + modesFor(2), 119 + MaxConcurrentResourceBlock.apply({ resourceUniqueId: 100, max: 1 }), 120 + MaxConcurrentResourceBlock.apply({ resourceUniqueId: 200, max: 1 }), 121 + ]); 122 + const by = new Map(schedule.tasks.map((t) => [t.uniqueId, t])); 123 + expect(by.get(1)!.modeId).toBe(0); // home crew, day 0 124 + expect(by.get(2)!.modeId).toBe(1); // borrowed to start day 0 125 + expect(by.get(2)!.startDay).toBe(0); 51 126 }); 52 127 }); 53 128