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 for the scheduling toolkit, or read on for the file I/O.
Install#
bun install
Usage#
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):
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):
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/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/levelingsubpath, but its API is still evolving and may change between minor versions before1.0. Importing it pulls onlyzod(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 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
resolveCalendarturns calendars into day-indexed working time (a bitmap + prefix sum) so "working days between A and B" is O(1).serialSGSis 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.ScheduleStreamis a lazy iterable of feasible schedules withfilter/map/take/branchand the materializersbestBy/paretoFrontier/collect.materializewrites a chosenScheduleback into aProjectFile(round-tripping dates), ready forwriteMspdi/writeXlsx/ etc.
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 |
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 |
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:
serialSGSemits a single schedule, so a scorer currently ranks output rather than steering it —bestByover 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 shapeserialSGSoutput today.
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.
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:
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
// or tiered selection: objective: { lexicographic: ["makespan", "wip"] }
});
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.v2while.v1keeps resolving, so saved configs stay reproducible. Extend with your own defs viastandardRegistry(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 stableLEVELSET_E###/W###codes and attribute to the named constraint instance (nameon any constraint; instanceIds fill in for config-built runs).runFromConfigthrowsRunAnalysisErroron errors; warnings ride along.enabled: falseon a constraint or block — the present-but-inactive lever for what-if comparisons; searches skip it and record the skip as adisabledConstraintsannotation, so "disabled" stays distinguishable from "absent".weightedScorer/bestByLex— multi-objective combination as data: a weight map (withnull= reported-but-unweighted) or ordered lexicographic tiers.- 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 bestkschedules 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, neverInfinity), 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, likeobjective.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. SinceserialSGSemits a single schedule today, a collector currently selects that one schedule — collectors become a genuine lever once a multi-candidate search lands. 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 alevelsetblock in the outputProjectFile: 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.
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
[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", "makespan"] # ordered tiers; weights stay report-only
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.<id>] |
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. lexicographic = ["a", "b"] — ordered tiers. |
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.<id>]; validateGraph enforces exactly one) and no [profiles] section (schedule profiles are runtime memoization, not config state). A [sweep] section is a reserved, not-yet-implemented extension point (parameter-grid search, see docs/gridsweep design) — until it lands, [sweep] is an unknown-section error, fail-closed.
For block authors — two field-validated rules keep a block conf-expressible (lintRegistryForConf(registry) checks them):
- An
enabled-shaped on/off policy belongs on the instance-levelenabledlever, never as a param name —code/enabled/provenance/shadoware reserved block-table keys, so a param of that name is unreachable through.conf(E305). - 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). A committed dump 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
- Language: TypeScript (strict mode)
- Linter: ESLint with
@typescript-eslint - Formatter: Prettier
- Validation: Zod (optional peer dependency)
All tool caches (tsc, ESLint, Prettier) write to node_modules/.cache/.