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.

75 1 0

Clone this repository

https://tangled.org/russ.fugl.dev/levelset https://tangled.org/did:plc:bjtac72b2nx7aisib4yjjhmn
git@tangled.org:russ.fugl.dev/levelset git@tangled.org:did:plc:bjtac72b2nx7aisib4yjjhmn

For self-hosted knots, clone URLs may differ based on your setup.



README.md

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/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 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.
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: 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.

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 .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.
  • 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. Since serialSGS emits 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.
  • Provenancerun.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.

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):

  1. An enabled-shaped on/off policy belongs on the instance-level enabled lever, never as a param namecode/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). 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/.