# levelset A TypeScript **resource-leveling and project-scheduling toolkit**. You author constraints and scorers as composable blocks, run a schedule-generation search, and materialize a re-leveled schedule. The leveling layer (`level-core` + `level-blocks`) is the heart of the library and operates on any `ProjectFile`, wherever it came from. To get real project data in and out, levelset also bundles readers and writers for common formats — Microsoft Project (`.mpp` binary, `.mspdi` XML), plus JSON, CSV, and XLSX — so you can level an existing plan and write the result back. The reader extracts tasks, resources, assignments, calendars, and relations into a structured `ProjectFile`. Jump to **[Resource leveling](#resource-leveling)** for the scheduling toolkit, or read on for the file I/O. ## Install ```bash bun install ``` ## Usage ```ts import { readMpp, readMspdi, readJson, writeMspdi, writeJson, writeCsv, writeXlsx, } from "levelset"; // Read a binary MPP buffer (MPP14+ / Microsoft Project 2010+) // Accepts Uint8Array or ArrayBuffer — no filesystem access required const mppBytes = await file.arrayBuffer(); // e.g. from a client upload const project = readMpp(mppBytes); // Read MSPDI XML const project2 = readMspdi(xmlString); // Read/write JSON const project3 = readJson(jsonString); const json = writeJson(project); const compact = writeJson(project, { pretty: false }); // Write MSPDI XML const xml = writeMspdi(project); // Write CSV (leaf tasks by default, with resource names from assignments) const csv = writeCsv(project); const csvAll = writeCsv(project, { includeSummaryTasks: true, includeResources: false, }); // Write Excel (returns a Uint8Array) const xlsx = await writeXlsx(project); await Bun.write("schedule.xlsx", xlsx); const xlsxCustom = await writeXlsx(project, { sheetName: "Tasks" }); ``` ### Validation with Zod Validate untrusted data with the schema subpath (requires `zod` peer dependency): ```ts import { ProjectFileSchema, TaskSchema } from "levelset/schema"; // Validate unknown JSON from an API or file const result = ProjectFileSchema.safeParse(untrustedData); if (result.success) { const project = result.data; // fully typed ProjectFile // Duration fields are real Duration instances, dates are Date objects } // Validate individual entities const taskResult = TaskSchema.safeParse(someTaskData); ``` All schemas include transforms — `{ duration: 8, units: "hours" }` objects become `Duration` class instances, date strings become `Date` objects. ### Advanced API For lower-level access (container inspection, variant detection): ```ts import { MppReader, MspdiReader, MspdiWriter, JsonReader, JsonWriter, CsvWriter, XlsxWriter, parseMppBuffer, detectMppVariant, } from "levelset/advanced"; const reader = new MppReader(); // Read from a buffer (Uint8Array or ArrayBuffer) const project = reader.read(mppBytes); // Inspect without full parse const inspection = reader.inspect(mppBytes); // Parse buffer into a container and work with it directly const container = parseMppBuffer(mppBytes); const project2 = reader.readContainer(container); ``` ### What it extracts - **Tasks** — name, dates, duration, % complete, WBS, summary/subtask hierarchy, critical path, scheduling analysis (free/total slack, early/late start/finish, leveling delay, deadline) - **Resources** — people, equipment, or cost items, resource pool - **Assignments** — resource-to-task allocations with units, actual work, remaining work - **Calendars** — working hours, week days, exceptions/holidays - **Relations** — task dependencies (FS, FF, SS, SF) with lag - **Properties** — title, author, start/finish dates, scheduling defaults ### Supported formats | Format | Read | Write | | ------------------------------------ | ---- | ----- | | MPP (binary, MPP14+ / Project 2010+) | Yes | No | | MSPDI (XML) | Yes | Yes | | JSON | Yes | Yes | | CSV | No | Yes | | XLSX (Excel) | No | Yes | Older MPP versions (8, 9, 12) are detected and produce a clear error message explaining that only MPP14+ is supported. ### Subpath exports | Path | Contents | | -------------------------- | ----------------------------------------------------------------------------- | | `levelset` | Convenience functions (`readMpp`, `writeJson`, `writeCsv`, `writeXlsx`, etc.) | | `levelset/leveling` | Resource-leveling toolkit (`level-core` + `level-blocks`) — no file-I/O deps | | `levelset/conf` | `.conf` (TOML) run-config parse/serialize (`parseConf`, `serializeConf`) | | `levelset/block-meta.json` | Generated, runtime-free registry and composition metadata | | `levelset/advanced` | Reader/writer classes, container utilities | | `levelset/schema` | Zod validation schemas for all model types | ## Resource leveling > **Experimental.** The leveling layer is published under the `levelset/leveling` subpath, but its API is still evolving and may change between minor versions before `1.0`. Importing it pulls only `zod` (a peer dependency) — none of the file-I/O dependencies (`exceljs`, `cfb`, `fast-xml-parser`), so the scheduler stays out of your bundle if you don't use the readers/writers. The engine's design is grounded in published research across constraint programming, project scheduling, solution diversity, hyper-heuristics, and LLM-assisted optimization modeling — see the [literature review](docs/literature-review.md) for the sources and how each one shapes the architecture. ### Pipeline The leveling flow is a sequence of small, composable steps: ``` ProjectFile ──resolveCalendar──▶ ResolvedProject ──serialSGS.run(constraints)──▶ ScheduleStream ──bestBy(scorer)──▶ Schedule ──materialize──▶ ProjectFile ``` - **`resolveCalendar`** turns calendars into day-indexed working time (a bitmap + prefix sum) so "working days between A and B" is O(1). - **`serialSGS`** is a greedy serial Schedule Generation Scheme: topo-sort by precedence, then place each task on the earliest day that satisfies every constraint. One feasible schedule per run. - **`ScheduleStream`** is a lazy iterable of feasible schedules with `filter` / `map` / `take` / `branch` and the materializers `bestBy` / `paretoFrontier` / `collect`. - **`materialize`** writes a chosen `Schedule` back into a `ProjectFile` (round-tripping dates), ready for `writeMspdi` / `writeXlsx` / etc. ```ts import { resolveCalendar, serialSGS, streamFromFactory, materialize, } from "levelset/leveling"; import type { Constraint, Scorer } from "levelset/leveling"; // 1. Resolve calendars → day-indexed working time const resolved = resolveCalendar(project); // 2. Author constraints (the interchange ADT between blocks and the search) const constraints: Constraint[] = [ { kind: "MaxConcurrentResource", resourceUniqueId: 100, max: 3 }, { kind: "ConcurrentUnitsLimit", discipline: 200, // a resourceUniqueId; omit for a whole-unit cap max: 2, units: [ { id: 10, location: "Zone A", taskUniqueIds: [1, 2, 3] }, { id: 20, location: "Zone B", taskUniqueIds: [4, 5, 6] }, ], }, ]; // 3. Search → lazy stream of feasible schedules const stream = streamFromFactory(() => serialSGS.run(resolved, constraints)); // 4. Pick the best by a scorer const makespan: Scorer = { name: "makespan", direction: "min", score: (s) => s.makespan, }; const best = await stream.bestBy(makespan); // 5. Materialize back to a ProjectFile (then write it out however you like) if (best) { const leveled = materialize(best); } ``` ### Work units A **`WorkUnit`** is the largest independent work package — the minimum complete increment that can be handed over (a building floor, a workcell, a release milestone — whatever the smallest shippable package is in your domain). It carries optional `location` (where), `productType` + `serial` (the serial-numbered instance of a generic product), and the `taskUniqueIds` that comprise it. Units are what the WIP-limiting constraints and scorers operate on. ### Constraints Constraints are a discriminated union (`Constraint`) — the interchange format between blocks and the search. `serialSGS` enforces a subset directly; the rest are recorded as `unsupportedConstraints` annotations on the emitted schedule (intended for a future CP-SAT / MiniZinc backend). | `kind` | What it does | Enforced by `serialSGS` | | ----------------------- | ------------------------------------------------------------------------ | ----------------------- | | `Precedence` | FS / SS / FF / SF edges with lag (FF/SF are approximated) | Yes | | `MaxConcurrentResource` | ≤ N tasks may demand a resource on any day (crew/throughput cap) | Yes | | `PeakCap` | Sum of fractional units on a resource ≤ cap (optionally windowed) | Yes | | `ConcurrentUnitsLimit` | ≤ N units have active work on any day; optional `discipline` scope (WIP) | Yes | | `Release` / `Deadline` | Earliest start / latest finish bounds per task | Yes | | `ModeSelection` | Multi-mode RCPSP — crew-size × duration trade-off per task | Yes | | `CrewBorrow` | Prefer a task's home crew; substitute a borrow crew to start earlier | Yes | | `Calendars` | Baked into `resolveCalendar` upstream | n/a | | `UnitPrecedence` | A unit may not start until other units finish | No (annotation) | | `UnimodalProfile` | One-ramp-up-one-ramp-down shape on a resource histogram | No (annotation) | | `CrewFlowContinuity` | Keep a crew flowing through a unit sequence without idle gaps | No (annotation) | `ConcurrentUnitsLimit` is the hard WIP cap that prioritizes **completion**: cap how many units are open at once so the search finishes started units before opening more. Omit `discipline` for a whole-unit cap; set it to a `resourceUniqueId` for a per-discipline cap ("≤ 2 units in a given phase at once"). It subsumes the former `LaydownSpaceCap`. ### Scorers (scoring blocks) A `Scorer` ranks a `Schedule` (`direction: "min" | "max"`); `stream.bestBy(scorer)` picks the best. Scoring **blocks** (`level-blocks`) build configured scorers and also emit a MiniZinc fragment for the compiled backend. | Block | Prices… | | ----------------------------- | -------------------------------------------------------------------------------------- | | `ConcurrentResourceCostBlock` | Each marginal concurrent worker on an exponential curve (soft `MaxConcurrentResource`) | | `OpenUnitPenaltyBlock` | Each open-unit-day beyond a `softMax` — a soft WIP limit (soft `ConcurrentUnitsLimit`) | | `HiringLagPenaltyBlock` | Week-over-week headcount increases (training/ramp cost before productivity) | | `PhaseLatenessBlock` | Working days each phase's latest task finishes past its target, × phase weight | | `PhaseEarlinessBlock` | Working days each phase's earliest task starts before its target, × phase weight | | `UnimodalDeviationBlock` | How far a resource histogram departs from a single-peak shape | ```ts import { OpenUnitPenaltyBlock } from "levelset/leveling"; // Soft WIP limit: leave 2 units open for free, then penalize each extra // open-unit-day. Unit definitions live in ResolveOptions.workUnits; blocks // reference them by id. const wipPenalty = OpenUnitPenaltyBlock.apply({ unitIds: [10, 20], softMax: 2, weight: 50, }); const best = await stream.bestBy(wipPenalty); ``` > **Note:** `serialSGS` emits one schedule per concrete config. A GridSweep widens that into one candidate per feasible cell, making scorers and collectors selection levers across the grid; without a sweep, `bestBy` over serialSGS still returns its single schedule. Hard constraints (the table above) are what shape each serialSGS result. Constraint blocks pair with the hard variants — `MaxConcurrentResourceBlock`, `PeakCapBlock`, `ConcurrentUnitsLimitBlock`, `DeadlineBlock`, `ReleaseBlock`, and `ModeSelectionBlock` build the corresponding `Constraint` and a MiniZinc fragment. `PhaseReleaseBlock` is the group authoring form: one shared phase boundary expands to an attributed `Release` for every listed task. `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. `CrewBorrowBlock` is the concise one-way policy form: for every listed task, it derives the preferred home mode from static project assignments and a second equal-duration mode that substitutes the borrow resource while preserving all other demand. A task may be governed by exactly one `ModeSelection` or `CrewBorrow` policy. ### Declarative runs (`RunConfig`) 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: ```ts import { runFromConfig } from "levelset/leveling"; const run = runFromConfig(project, { formatVersion: 1, resolve: { workUnits: [{ id: 10, taskUniqueIds: [1, 2, 3] }] }, pipeline: { formatVersion: 1, blocks: [ { instanceId: "crew-cap", code: "levelset.MaxConcurrentResource.v1", params: { resourceUniqueId: 100, max: 3 }, }, { instanceId: "wip", code: "levelset.OpenUnitPenalty.v1", params: { unitIds: [10], softMax: 2 }, }, { instanceId: "makespan", code: "levelset.Makespan.v1", params: {} }, { instanceId: "search", code: "levelset.SerialSGS.v1", params: {} }, ], }, weights: { makespan: 1, wip: 0.3 }, // null = compute and report, don't optimize // Each tier is one scorer or a weighted scorer group: objective: { lexicographic: ["makespan", { weightedSum: { wip: 0.3, makespan: 1 } }], }, }); const best = await run.best(); const leveled = run.materialize(best!); // embeds provenance (see below) ``` The moving parts, each usable on its own: - **`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)`. - **`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. - **`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". - **`weightedScorer` / `bestByLex`** — multi-objective combination as data: a weight map (with `null` = reported-but-unweighted) or ordered lexicographic tiers. A tier may name one scorer or combine commensurable scorers as `{ weightedSum: { scorerId: weight } }`; only later tiers break ties. - **GridSweep (`sweep`)** — a bounded, deterministic cross-product over constraint/scorer parameters on one fixed resolved project. Broadcast axes apply one point to several targets; zip axes use `valueKey` to deliver coordinated per-target values. `paramPath: "$enabled"` toggles a constraint instance. Search/collector targets and scorer enablement changes are rejected so pipeline membership stays fixed. The default limit is 64 combinations (hard ceiling 4096); infeasible cells are skipped and recorded, and an entirely infeasible grid throws `SweepError`. Every candidate carries `sweep.point`, and materialized provenance records the winner and skipped cells. - **Collector blocks (`KBestBlock`, `ParetoBlock`, `GateBlock`)** — the selection layer, one step above scoring: where a scorer ranks a single schedule, a collector picks a _set_ out of the candidate stream. `KBest` (`levelset.KBest.v1`, params `{ k, by: [scorerInstanceId…] }`) keeps the best `k` schedules ranked lexicographically over the referenced scorers; `Pareto` (`levelset.Pareto.v1`, params `{ over: [scorerInstanceId…] }`) keeps the non-dominated set; `Gate` (`levelset.Gate.v1`, params `{ limits: [{ scorer, max?, min? }…], fallback: "leastBad" | "none" }`) is a hard feasibility gate — it keeps only schedules satisfying every limit (an absent bound is unbounded on that side, never `Infinity`), and when none pass it either yields nothing (`"none"`) or the single least-bad schedule (`"leastBad"`). Least-bad compares schedules **lexicographically** by per-limit violation in the limits' declared order — the first limit is the binding one — because violations of different scorers (a day over a duration cap vs a person over a headcount cap) are not commensurable and must be prioritized, never summed. All three reference scorers by instanceId (resolved at run-wiring time, like `objective.lexicographic`), so the spec stays plain JSON. `run.collectors()` runs every enabled collector against a fresh stream and returns each one's schedule set, each result labeled by its instanceId. A GridSweep supplies multiple candidates even when the configured search emits only one schedule per cell. - **`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. - **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. ### `.conf` run configs (`levelset/conf`) The same `RunConfig` has a **TOML text projection** for humans to author, review, and diff — spaCy's `config.cfg` for leveling. JSON stays canonical (provenance always stores JSON); `.conf` is the editing surface. `parseConf(text, { registry })` reads a `.conf` into a **raw** `RunConfig` (compose it with `runFromConfig` / `fillRunConfig`); `serializeConf(config)` writes one back in canonical form. ```ts import { parseConf, serializeConf } from "levelset/conf"; import { runFromConfig, standardRegistry } from "levelset/leveling"; const registry = standardRegistry(); const parsed = parseConf(text, { registry }); if (!parsed.ok) throw new Error( parsed.issues.map((i) => `[${i.code}] ${i.message}`).join("\n"), ); const run = runFromConfig(project, parsed.config, { registry }); const canonical = serializeConf(run.config); // the filled config, as .conf text ``` ```toml [levelset] format = 1 # required; maps to runConfig + pipeline formatVersion seed = 7 # optional [resolve] epoch = "2026-01-01" # ISO date as a *quoted string* — bare TOML dates are rejected (E304) horizonDays = 120 [blocks.crew-cap] code = "levelset.MaxConcurrentResource.v1" # required: the versioned registry code resourceUniqueId = 100 # everything non-reserved is a param max = 3 [blocks.lateness] code = "levelset.PhaseLateness.v1" [[blocks.lateness.phases]] # object-array param → array-of-tables name = "phase-1" taskUniqueIds = [1, 2, 3] # scalar array → inline array targetDay = 80 weight = 2 [blocks.makespan] code = "levelset.Makespan.v1" [blocks.search] code = "levelset.SerialSGS.v1" [weights] makespan = "report" # the string sentinel "report" ⇄ null (compute, don't optimize) lateness = 1 [objective] lexicographic = ["lateness", { weightedSum = { labor = 1, makespan = 0.25 } }] [sweep] maxCombinations = 64 [[sweep.axes]] id = "crew-cap" values = [2, 3, 4] # broadcast points [[sweep.axes.targets]] instanceId = "crew-cap" paramPath = "max" # or "$enabled" for a constraint toggle ``` Grammar, at a glance: | Section | Purpose | | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `[levelset]` | Required. `format` (required, `1`), optional `version`, `seed`. | | `[resolve]` | Optional. `epoch` (quoted ISO string), `horizonDays`, `[[resolve.workUnits]]`. | | `[pipeline]` | Optional. `systemCode` only (an app label); the block list is `[blocks.*]`. | | `[blocks.]` | One table per block instance — the **TOML key is the instanceId** (quote it if it isn't bare-safe). Reserved keys `code` (required), `enabled` (omit = enabled), `provenance` (omit = `"curated"`), `shadow` (omit = `false`); every other key is a param. | | `[weights]` | Optional. `name = 1.0`, or `name = "report"` for report-only (⇄ `null`). | | `[objective]` | Optional ordered tiers. Each entry is a scorer id or `{ weightedSum = { scorerId = weight } }`; top-level `[weights]` remains report-only when this section is present. | | `[sweep]` | Optional. `maxCombinations` plus `[[sweep.axes]]`; each axis has an `id`, `values`, and one or more `[[sweep.axes.targets]]`. Add `valueKey` to every target and use `[[sweep.axes.values]]` records for a zip axis. | Params map structurally: nested objects ⇄ dotted keys (`window.fromDay = 10`), object arrays ⇄ `[[blocks.x.param]]` array-of-tables, scalar arrays ⇄ inline arrays. Document order of `[blocks.*]` is preserved as `blocks[]` order; the serializer never reorders. **Value conventions** (each with a coded error): bare TOML datetime literals are rejected — quote dates as ISO strings (`E304`); non-finite numbers (`inf`/`nan`) are rejected (`E306`); every framework section is strict, so an unknown section or key is an error (`E302`). All conf issues carry a stable `LEVELSET_E3##` code (`E301` TOML syntax with line/column, `E302` config shape, `E303` missing/unsupported format, `E304` datetime literal, `E305` reserved-key collision, `E306` non-finite, `E308` registry validation attributed to its instanceId). The round trip is **value-lossless but comment/format-lossy**: `parseConf(serializeConf(c))` reproduces `c`, but hand-written comments and spacing are not preserved (JSON, not `.conf`, is the durable artifact). There is deliberately **no `[search]` section** (search is just a block — add it under `[blocks.]`; `validateGraph` enforces exactly one) and **no `[profiles]` section** (schedule profiles are runtime memoization, not config state). GridSweep remains above the flat graph: each cell compiles the same exactly-one-search pipeline with substituted values, so `formatVersion` remains `1`. **For block authors** — two field-validated rules keep a block conf-expressible (`lintRegistryForConf(registry)` checks them): 1. An `enabled`-shaped on/off policy belongs on the instance-level `enabled` lever, **never as a param name** — `code`/`enabled`/`provenance`/`shadow` are reserved block-table keys, so a param of that name is unreachable through `.conf` (`E305`). 2. Model "unbounded" as an **absent optional param**, never `Infinity` — non-finite numbers can't be written (`E306`), and an absent optional is the honest encoding. **Block metadata.** `deriveBlockMeta(def)` / `dumpBlockMeta(registry)` (from `levelset/leveling`) project the registry into the field/variadic/complex-param taxonomy every downstream view needs (canvas, panel, LLM tool spec), alongside the composition grammar (`FAMILY_ORDER`, `canFollow`, `FAMILY_CARDINALITY`). The dump's `authoring` section also carries JSON Schemas for the framework-owned block-instance and GridSweep shapes, reserved instance-param names, the `$enabled` sweep target, and combination limits. Consumers that do not want to import the engine can load the committed artifact from `levelset/block-meta.json` using their runtime's JSON-module syntax. The source-tree copy lives at `generated/block-meta.json`; regenerate it with `bun run generate:block-meta` (a drift test guards it). ## Scripts | Script | Description | | ----------------------------- | ---------------------------------------------------- | | `bun test` | Run tests | | `bun run generate:block-meta` | Regenerate `generated/block-meta.json` | | `bun run typecheck` | Type-check with `tsc --noEmit` (incremental, cached) | | `bun run lint` | Lint with ESLint | | `bun run lint:fix` | Lint and auto-fix | | `bun run format` | Format with Prettier | | `bun run format:check` | Check formatting | | `bun run check` | Run all checks (typecheck + lint + format) | ## Tooling - **Runtime**: [Bun](https://bun.sh) - **Language**: TypeScript (strict mode) - **Linter**: ESLint with `@typescript-eslint` - **Formatter**: Prettier - **Validation**: [Zod](https://zod.dev) (optional peer dependency) All tool caches (tsc, ESLint, Prettier) write to `node_modules/.cache/`.