Commits
README: a levelset/conf section — worked example, grammar table, param
mapping, value conventions and coded errors, the comment-lossy round-trip
policy, the reserved [sweep] extension point, and a for-block-authors note
(enabled-shaped policy belongs on the instance lever; model unbounded as an
absent optional, never Infinity). Adds the ./conf subpath row, a note on
deriveBlockMeta/dumpBlockMeta and generated/block-meta.json, and the
generate:block-meta script.
Prettier-ignore generated/block-meta.json (it must match its generator byte
for byte; the drift test guards its content) and reformat the conf fixture
config goldens so `bun run check` is clean.
serializeConf renders a RunConfig to canonical TOML (hand-rolled, byte-exact
goldens): fixed section order, default flags omitted, deterministic key
order. Empty and scalar arrays render inline; object arrays become
array-of-tables; nested objects become dotted keys. Options: header comment
lines and annotate (each block's rendered assumptionTemplate as a comment).
The round trip is value-lossless: parseConf(serializeConf(c)) reproduces c
through the schema — asserted over the goldens, every standard block at its
defaultShadow, and a deterministic generated corpus (random subsets,
weights incl. "report"/null, lexicographic tiers). A schema-coverage test
pins runConfigSchema's top-level keys so a future addition fails loudly. An
integration test runs a project, serializes the filled config, reparses, and
reruns — identical schedule, scores, and embedded provenance.
lintRegistryForConf flags conf-inexpressible blocks (reserved-key param
collisions, null-admitting or unrenderable schemas); the standard registry
is clean.
New levelset/conf subpath: parseConf reads a .conf (TOML 1.0) file — a text
projection of RunConfig — into a raw RunConfig. Sections map [levelset]/
[resolve]/[pipeline]/[blocks.<id>]/[weights]/[objective] onto the config;
reserved block keys (code/enabled/provenance/shadow) peel off as instance
fields, everything else is a param (dotted keys nest, array-of-tables become
object arrays, "report" ⇄ null). Every framework section is strict.
Coded issues E301–E308: TOML syntax (line/column), config shape (conf-path),
missing/unsupported format, datetime literal, reserved-key collision,
non-finite number, and registry validation (instanceId-attributed). With a
registry, parse also runs validateGraph + the shared checkRunConfigRefs
(extracted from fillRunConfig so the two can't drift). Adds smol-toml as a
regular dependency and the ./conf export.
Grammar goldens (fixture .conf ⇄ raw config JSON) plus one error fixture per
code.
scripts/generate-block-meta.ts writes dumpBlockMeta(standardRegistry()) to
generated/block-meta.json (17 blocks), added to package files so it ships.
A drift test deep-equals the committed JSON against a fresh dump and, on
mismatch, points at re-running the generator — so a schema/shadow or
composition change can't land without regenerating the artifact.
Port a zod→JSON-Schema walk into src/level-blocks/meta.ts, minus all
Blockly assembly: deriveBlockMeta flattens a block's input schema into
editable scalar fields (date/number/slider/dropdown/checkbox with
labels/bounds/defaults), variadic scalar-array params, and complex
(field-less) params, alongside the block's family/tier/doc/shadow and its
JSON-Schema. dumpBlockMeta wraps the whole registry with the composition
grammar. Exports the ISO-date pattern both sides agree on by construction.
Zod-only, exported from levelset/leveling.
Lift the family grammar into src/level-blocks/composition.ts as pure data
and predicates: FAMILY_ORDER, FAMILY_FOLLOWS, canFollow, sortIntoBandOrder,
and FAMILY_CARDINALITY. validateGraph now loops over FAMILY_CARDINALITY
instead of hard-coding the exactly-one-search check — behavior identical,
existing tests unchanged — so a future per-family bound is a data edit.
This is the levelset-side source of truth a canvas connection checker
mirrors; a canFollow table test captures the contract so the vendor sync
can delete its own copy.
Document the exclusive-finish frame of PhaseWindow.targetDay (finishing ON a
deadline date costs a day unless targetDay is the day after), clarify the
PhaseLateness nl doc that the zero-cost boundary is the exclusive finishDay
index, and note the MiniZinc fixed-identifier collision when two instances of
the same block share a graph (latent; per-instance varName is future work).
Add a completion-priority trade-off test proving the phase weight ranks
schedules (a heavier phase slipping fewer days beats a lighter phase slipping
more), and a partially-scheduled phase test asserting the boundary derives
from present tasks only.
Generalize earlier phase-lateness and early-start scoring terms into
standard registry blocks. Phases are data (an array of
{ name?, taskUniqueIds, targetDay, weight? }) rather than hardcoded:
- PhaseLateness charges the working days a phase's latest task finishes past
its target day; PhaseEarliness charges the working days its earliest task
starts before its target. Each is weighted by the phase's
completion-priority weight (an earlier completionWeight, a static per-phase
priority) and summed. Two blocks, not one moded block: the originals anchor
them on different targets (window end vs start) and rank them in different
lexicographic tiers, and a single phase carries both at once.
- Deviation is counted in working days via the shared calendar prefix sum;
the per-day scale (phaseLatenessPerDay/earlyStartPerDay in the original
formulation) comes from the run config's scorer weight. Both declare
reads.taskUniqueIds so analyzeRun flags a phase pointing at a missing task.
Registered as levelset.PhaseLateness.v1 / levelset.PhaseEarliness.v1
(estimator tier); shared logic in phaseScoring.ts. Tests cover schema,
hand-computed scoring on the Mon-Fri fixture (including a realistic-shaped
partially-complete phase), the MiniZinc fragment, and RunConfig wiring.
Gate keeps only schedules that satisfy every limit, where a limit bounds
one scorer's value by max and/or min (an absent bound is unbounded on that
side, never Infinity; at least one bound is required per limit). When at
least one schedule passes, the passing set (order-preserving) is the
result. When none pass, fallback decides: "none" yields the empty set;
"leastBad" yields the single least-bad schedule.
Least-bad compares schedules lexicographically by per-limit violation in
the limits' declared order (first limit = binding). Violations of
different scorers are not commensurable, so they are prioritized by order,
never summed across scorers.
Extends the Collector ADT with the Gate variant and GateLimit — the
exhaustive collectorScorerRefs / collectors() switches (guarded by
assertNeverCollector) force both dispatch sites to handle it. Adds the
pure selectGate engine, levelset.Gate.v1 registration, leveling.ts
re-exports, and README. Gate execution collects the stream once
and filters/falls back in memory.
Extract collector scorer refs from the compiled Collector spec (via the
block's pure apply()) instead of duck-typing parsed params as {by, over}:
a custom collector naming its refs under a different param no longer
skips layer-1 validation, and a non-array by/over can't surface as a raw
TypeError. Distinguish a missing scorer from a disabled one in the
layer-2 runFromConfig check. Convert both collector dispatch ternaries to
exhaustive switches ending in assertNeverCollector so a future variant is
a compile error. Fix stale docs (collectors labeled by, not keyed by;
single-scorer Pareto keeps all co-best; ES2019 sort stability).
Collectors are the selection layer above scoring: a collector picks a set
out of the candidate stream instead of ranking one schedule. KBest keeps
the best k schedules lexicographically over referenced scorer instances;
Pareto keeps the non-dominated set. Both reference scorers by instanceId,
resolved at run-wiring time (like objective.lexicographic) so the spec
stays plain JSON and round-trips through fillRunConfig untouched.
Adds the Collector ADT and assertNeverCollector, ScheduleStream.kBestByLex
(reusing lexBetter), the KBest/Pareto blocks under levelset.KBest.v1 /
levelset.Pareto.v1, and LevelingRun.collectors() which runs every enabled
collector against a fresh stream keyed by graph instanceId. Validation
mirrors the lexicographic path: fillRunConfig rejects collector references
to missing/non-scorer instances, runFromConfig rejects references to
disabled scorers.
Require at least one mode so toMiniZinc no longer emits an unsatisfiable
empty domain, and forbid duplicate resourceUniqueId within a mode's demand
so serialSGS does not double-count load or materialize duplicate rows.
Document that multiple instances per task union under serial-SGS but
intersect (likely UNSAT) under the MiniZinc contract.
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.
The constraint table predated the engine's multi-mode support landing
(preprocess modesByTask + per-mode placement); supports("ModeSelection")
returns true and constraintMeta.test.ts asserts it.
RunProvenanceSchema (and its warning items) parsed with key-stripping
z.object, so a levelset block written by a newer levelset version — or
annotated by app tooling — was silently truncated to this version's
fields on read → write. Parse with z.looseObject so unknown keys survive
the round trip; garbage that fails the known-field contract is still
dropped whole, as documented.
serialSGS places milestones with zero span regardless of their nominal
durationDays, but analyzeRun applied the (deadline - release) < duration
bound to them. A milestone carrying an explicit duration (as MPP/JSON
files can) with a same-day release + deadline gate was reported as
LEVELSET_E105 'no feasible placement exists' — and runFromConfig threw —
for a run serialSGS places successfully.
analyzeRun ran every check over enabled: false constraints, so a disabled
infeasible deadline (or dangling reference, or cycle-forming precedence)
produced error-severity issues and runFromConfig threw RunAnalysisError —
blocking exactly the what-if ablation workflow the enabled lever exists
for. Analysis now covers the same active set the search preprocesses;
duplicate-name counting still spans all carried instances.
README gains a 'Declarative runs (RunConfig)' section covering the
versioned registry, analyzeRun, enabled levers, weighted/lexicographic
objectives, shared profiles, and provenance; the OpenUnitPenalty example
now matches the current unitIds schema. CHANGELOG entries added.
ScheduleStream.bestByLex selects by ordered tiers — the second scorer
only breaks ties on the first — and RunConfig gains an
objective.lexicographic section (ordered scorer instanceIds) that best()
honors, with weights staying report-only. Motivated by a reference
pipeline whose selection is a 4-tuple (lateness, labor cost, peak
headcount, makespan) that a single weighted sum cannot express.
Every LevelingRun.materialize output now carries a levelset metadata
block: engine version, the filled run config (rerunnable — the
reproducibility artifact), seed, achieved scorer values, analysis
warnings, and search annotations. ProjectFileSchema gains the optional
field and the JSON reader/writer round-trips it (non-parsing provenance
is dropped on read rather than failing the file). Answers "why does this
schedule look like this?" from the file itself, spaCy config.cfg-style.
One JSON document now fully describes a leveling run — resolve options,
blocks by versioned registry code (reusing the PipelineGraph format as the
config's pipeline section), and objective weights (null = compute and
report but don't optimize). runFromConfig fills the config (block-schema
defaults expanded, levelset version stamped — the filled config is the
persistable artifact), resolves the project, compiles blocks with
instanceIds doubling as constraint names for attribution, statically
analyzes the run (throwing coded RunAnalysisError on errors), and returns
a live run: stream/best/scores/materialize plus the combined
weightedScorer objective. Framework-owned config sections are strict so a
typo'd key is an error, not a silently unconstrained run. Graph instances
gain an optional enabled lever (undefined = enabled, so existing graphs
and constructors stay valid).
standardRegistry() curates every shipping block under a versioned code
(levelset.PeakCap.v1). Versioned names are the forward-compatibility
seam: semantic changes ship as .v2 while .v1 keeps resolving, so saved
run configs stay reproducible across releases. createRegistry remains
singleton-free; apps extend via standardRegistry(extraDefs) or keep
building their own.
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.
analyzeRun walks constraints' and scorers' declared references against a
ResolvedProject before any search runs — spaCy's analyze_pipes +
validate_attrs applied to leveling. Issues carry stable LEVELSET_E###/W###
codes, error/warning severity, and attribute to the named instance:
unknown resource/task/unit/calendar refs, deadline-vs-release and
deadline-vs-duration infeasibility, precedence and unit-precedence cycles,
inverted PeakCap windows, kinds the chosen search won't enforce (via
supports()), empty governed sets, and duplicate instance names. Scorers
gain optional reads metadata so a scorer referencing a missing entity is
caught instead of silently optimizing on a constant zero; the four
shipping scorer blocks now declare it.
Adds level-core/profiles.ts — a per-Schedule memoized provider (WeakMap,
safe because Schedule is immutable by contract) for the derived data every
scorer needs: per-resource day histograms, per-unit open spans, task
lookup. Scoring blocks become listeners that read the shared slot instead
of each recomputing, turning O(scorers × days × tasks) per candidate into
O(days × tasks). This also fixes an inconsistency: ConcurrentResourceCost
and OpenUnitPenalty's discipline filter previously read static assignments
only, ignoring realizedAssignments for ModeSelection tasks — the shared
histogram applies the single realized-override rule everywhere.
Every Constraint variant gains optional instance metadata (spaCy's
factory-vs-instance-name split): `name` for attribution and `enabled`
as the present-but-inactive lever. serialSGS skips `enabled: false`
constraints and records the skip in a disabledConstraints annotation so
reports can tell disabled from absent; deadline violations now carry the
named constraint instance that bound instead of a synthesized copy.
Search backends can declare which kinds they enforce via supports().
The SheetSpec work had grown writeWithGantt a 4th optional parameter,
leaking composition plumbing into a pre-existing public method. The
shared body moves to a private composeGanttWorkbook that both
writeWithGantt and writeWithGanttAndSheets delegate to, and the
buffer-wrapping repeated across write/writeSheets/the empty-gantt path
collapses into one toBuffer helper.
buildWorkbook and applyCellStyle each hard-coded the blue fill + bold
white font; a future palette change would have to land twice or the
Schedule sheet and SheetSpec 'header' cells would drift apart. Both now
share HEADER_FILL/HEADER_FONT constants.
panelBindings.paramPath and projectAnchoredPaths are consumed app-side
(panel generation, template save), so a typo'd dot-path previously
failed silently at first use. register() now resolves every declared
path against the parsed defaultShadow — curated to be complete — and
throws when one doesn't resolve, matching the fail-at-registration
principle already applied to defaultShadow itself.
A BlockFamily member added later would have fallen through the switch
and been silently dropped from CompiledFamilies. The never-typed
default makes that a compile error and a loud runtime failure.
validateGraph safeParsed every block's params and discarded the result,
then compileGraph parsed them all again — double work, and a divergence
risk for schemas with defaults or transforms. validateGraph now returns
the parsed params keyed by instanceId (the graph keeps raw params as
the canonical artifact) and compileGraph hands those to apply().
Zod issue paths were flattened into message strings, leaving consumers
to re-parse text to locate the offending field. GraphIssue now carries
an optional structured path (graph-rooted for shape issues, params-
rooted for block param issues), param failures emit one issue per zod
issue, and both formatting sites share one helper.
Adds writeSheets() and writeWithGanttAndSheets() driven by
CellSpec/SheetSpec: formula cells written without cached results so
Excel/LibreOffice recalculate on open, workbook named ranges, cell
notes, row outlines, and header/input/derived/muted/internal style
hooks. Formula strings stay opaque to the writer — pricing semantics
live in the consuming app's emitter. Spec sheets append after the
Gantt sheet so chart-injection sheet indices stay stable. The
named-range recalc test (row insertion absorbed by an open slot) runs
under LibreOffice headless when soffice is available.
compileGraph validates (throwing GraphValidationError carrying the
issues), runs each block's apply() over its parsed params, groups the
outputs into CompiledFamilies, and hands the result to a CompileTarget.
Targets live app-side; future targets (direct serialSGS constraint
sets, MiniZinc) slot in behind the same interface without a format
change.
The canonical formatVersion-1 JSON block-graph: a flat block list with
family grouping and no port-wiring edges, matching how the engine
consumes constraints and scorers as sets today. The formatVersion
literal is the migration seam for richer wiring. validateGraph checks
schema shape, registry resolution, per-block params against the
block's input schema, exactly one search-family block, and duplicate
instanceIds, collecting all issues in one pass — the same surface a
canvas's strict-connection checker must mirror.
BlockDef wraps the existing Block contract with the pure-data metadata
PRD-08 front-ends project from (family, tier, defaultShadow, assumption
template, panel bindings, project-anchored paths). createRegistry()
returns an instance — no singleton, levelset stays side-effect-free.
register() throws on duplicate codes and validates defaultShadow
against the block's input schema so a bad default fails at
registration, not first render.
Replace the "mode-change not yet supported" stub with greedy multi-mode
RCPSP placement. Each task placement now tries a list of candidate modes
(preferred/home crew first) and takes the first feasible one, so a borrow
mode only wins when it lets the task start strictly earlier.
The chosen mode's resource demand is the authoritative load for the task:
serialSGS emits it as Schedule.realizedAssignments, and both
buildResourceLoad and materialize override the task's static assignments
with the realized demand under a single rule (a task present in
realizedAssignments is always loaded from there, never from resolved).
Point main/module/types and the exports map at the TypeScript sources
under src/ so levelset can be consumed directly as source in a workspace
(via tsconfig paths or a .ts-aware bundler). The built dist entry points
are preserved for npm publishing via publishConfig.
Also declare the previously-ambient jszip dependency used by
XlsxWriter to inject the Gantt chart.
Paint alternating white / light-gray horizontal bands behind the Gantt
bars, one band per category, so task rows are visually separated in the
exported workbook. Implemented as a hard-step gradient fill on the plot
area (<c:spPr>) with duplicated stop positions per band and a 90deg
top-to-bottom angle aligned to the catAx maxMin orientation.
A standalone literature review of the research the leveling engine draws
on — constraint programming, resource-constrained project scheduling,
solution diversity, hyper-heuristics, LLM-assisted modeling, and
location-based scheduling. Every cited work carries a DOI or arXiv id.
A CHANGELOG capturing the current unreleased state (the leveling engine,
the blocks, and packaging) and an llms.txt (per llmstxt.org) describing
the toolkit and its API in domain-neutral terms for LLM consumers.
Lead with the resource-leveling and scheduling toolkit as the core, and
present the MPP/MSPDI/JSON/CSV/XLSX readers and writers as the data
on-ramp. Documents the leveling pipeline (resolveCalendar → serialSGS →
ScheduleStream → materialize), the WorkUnit concept, a constraints table
marking which the greedy search enforces vs. annotates, the scoring
blocks, and the subpath exports.
Make levelset a publishable package. package.json carries npm metadata and
a conditional exports map over dist/ (types plus import), including the
./leveling subpath; the files allowlist ships dist, src, and docs.
tsconfig.build.json emits JS and .d.ts with rewriteRelativeImportExtensions,
and prepack builds so the gitignored dist/ lands in the tarball and on git
installs. typescript is a devDependency. Adds the MIT LICENSE. Repository,
homepage, and bugs point at the levelset repo on tangled.org.
A curated leveling subpath barrel exposing the resource-leveling toolkit —
the pipeline functions, serialSGS, the constraint and scorer types, and
the blocks — as one public surface. Types are re-exported from level-core;
the barrel imports only zod and the internal model/schema, pulling no
file-I/O dependencies into a consumer's bundle.
Three soft scorers that shape search below the hard caps.
ConcurrentResourceCost prices each marginal concurrent worker on an
exponential curve over working days, with an optional threshold that makes
the first N concurrent units free. HiringLagPenalty treats each
week-over-week headcount increase as a hire costing trainingWeeks ×
costPerCrewWeek for the unproductive ramp. UnimodalDeviation is the
quantitative companion to the unimodal-profile constraint, summing dip
magnitudes before the dominant weekly peak and bump magnitudes after it so
search has a deviation magnitude to drive on when the hard shape
constraint is not enforced.
Two WIP-limiting blocks that prioritize unit completion. ConcurrentUnitsLimit
is the hard cap on how many units have active work on a day, with an
optional discipline scope; OpenUnitPenalty is its soft companion, pricing
each open-unit-day over a softMax across a unit's whole open span (idle
gaps included). It subsumes the former LaydownSpaceCap.
Both reference units by id into the resolved registry rather than carrying
unit definitions by value. OpenUnitPenalty.score iterates distinct ids
(new Set(unitIds)) so a duplicated reference is counted once, matching
MiniZinc's set-literal semantics.
The Block abstraction: a typed quadruple of a zod input/output schema, an
apply step, a toMiniZinc step (with an optional toCpSat), and a doc string
(natural language plus pseudocode). Blocks are the composition unit shared
by a visual editor and an LLM agent; the three families differ only in
their output (Constraint / Scorer / Search), and constraint variants
remain the interchange format with the search.
Ships MaxConcurrentResource as the first concrete block. Its MiniZinc
fragment references the well-known harness symbols (active[t,d],
tasks_demanding[r], DAYS) rather than taking a context argument —
composing fragments is the compiler's job.
Shared weekly demand binning lifted into level-core so every scorer uses
one rule: Sunday-snapped, working-day-only weekly peaks. Exposes
buildResourceLoad, weeklyProfile, and scheduleSpan, which the hiring-lag
and unimodal-deviation scorers (and future smoothness/idle scorers) build
on.
The search surface and the first concrete search.
ScheduleStream wraps an async iterable of schedules and stays lazy through
filter/map/take/branch; only bestBy/paretoFrontier/collect drain it. The
Pareto frontier uses standard non-dominated semantics (direction-aware).
serialSGS is a greedy serial schedule-generation scheme: topo-sort by
precedence (Kahn's, with a stable (outlineLevel, uniqueId) tie-break),
then place each task at the earliest working-day window that satisfies
every resource cap. Precedence, calendars, MaxConcurrentResource (task
count), PeakCap (units sum), and the unit-WIP cap are enforced; FF/SF
edges are approximated as FS and the approximation is surfaced as a
Schedule annotation rather than hidden; constraint variants the greedy
pass cannot honor are recorded under annotations so consumers can see what
was ignored. run is an AsyncGenerator that yields feasible schedules and
returns a typed Failure on placement failure.
The two bridges between a ResolvedProject and a Schedule. currentSchedule
reads existing source dates into Schedule form, so round-trip identity
holds before any search has run — the no-search-yet case is first-class.
materialize goes the other way, carrying the entire source ProjectFile
through and overriding only start/finish/duration for tasks present in the
schedule, so a partial schedule passes the rest through untouched.
Durations are re-emitted in the source unit via the project's unit
conversions, and both honor per-task calendars. A mode change throws until
mode selection is implemented.
resolveCalendar turns a ProjectFile into a ResolvedProject — the entry
point of the leveling pipeline. The epoch is opts.epoch ?? statusDate ??
earliest task start, and resolving with no available origin throws rather
than silently defaulting. capacityPerDay is derived as work-unit-hours per
day; calendars are built into a per-id map with a synthetic Mon–Fri
fallback; durations and lags are converted to working-day units from the
project's minutes-per-day and minutes-per-week.
WorkUnit definitions are resolved into the by-id registry here, and
duplicate unit ids are rejected at resolve time so a duplicate cannot
diverge the greedy and MiniZinc objectives downstream.
Working-day arithmetic over the bitmap calendar. countWorkingDays is an
O(1) prefix-sum query (cumWorking[hi] - cumWorking[lo]) — the dominant
operation in the scheduler. day-to-date conversion is DST-safe and
round-trips exactly. resolveWorkingCalendar centralizes the per-task →
project-default → first-available → throw fallback chain the schedulers
share, and a null calendar yields a synthetic all-working calendar so a
project with no calendars still resolves. Forward walks clamp at the
horizon instead of running off the end of the bitmap.
The type foundation for the leveling engine.
The Constraint ADT is a kind-tagged discriminated union — the interchange
format between blocks (which emit constraints) and the search (which
consumes them); assertNeverConstraint forces exhaustive handling so a new
variant cannot be silently dropped.
WorkingCalendar is backed by a Uint8Array working-day bitmap plus an
Int32Array prefix sum, the shape that makes working-day counting O(1).
ResolvedProject owns a default calendar id and a Map of calendars, so
per-task and per-resource calendars are first-class, and ResolvedResource
carries capacityPerDay. ResolvedTask holds structure only; scheduling
state lives on ScheduledTask. Schedule (makespan plus an annotations map
for per-block scores and telemetry) is kept separate from Failure, so a
failed placement is a distinct value rather than a malformed schedule.
WorkUnit is the largest independent work package and is resolved into a
by-id registry on ResolvedProject, giving unit definitions a single owner.
Export a ProjectFile to XLSX, and writeWithGantt additionally emits a
hidden data sheet plus a horizontal stacked-bar Gantt chart. The chart
uses two series — a transparent date-offset series and a visible duration
series colored per phase — to fake a Gantt bar from a stacked bar.
ExcelJS does not emit charts natively, so a JSZip post-pass injects the
chart and drawing XML with matching content-type overrides. An ambient
declaration (exceljs.d.ts) bridges an ExcelJS Buffer type incompatibility
under Bun. The bar primitive is named in render-neutral terms (bar/item).
An end-to-end suite over the public API that exercises the readers and
writers together against fixtures. Fixture-dependent cases skip gracefully
when the gitignored fixture files are absent, so the suite stays green in a
clean checkout.
README: a levelset/conf section — worked example, grammar table, param
mapping, value conventions and coded errors, the comment-lossy round-trip
policy, the reserved [sweep] extension point, and a for-block-authors note
(enabled-shaped policy belongs on the instance lever; model unbounded as an
absent optional, never Infinity). Adds the ./conf subpath row, a note on
deriveBlockMeta/dumpBlockMeta and generated/block-meta.json, and the
generate:block-meta script.
serializeConf renders a RunConfig to canonical TOML (hand-rolled, byte-exact
goldens): fixed section order, default flags omitted, deterministic key
order. Empty and scalar arrays render inline; object arrays become
array-of-tables; nested objects become dotted keys. Options: header comment
lines and annotate (each block's rendered assumptionTemplate as a comment).
The round trip is value-lossless: parseConf(serializeConf(c)) reproduces c
through the schema — asserted over the goldens, every standard block at its
defaultShadow, and a deterministic generated corpus (random subsets,
weights incl. "report"/null, lexicographic tiers). A schema-coverage test
pins runConfigSchema's top-level keys so a future addition fails loudly. An
integration test runs a project, serializes the filled config, reparses, and
reruns — identical schedule, scores, and embedded provenance.
lintRegistryForConf flags conf-inexpressible blocks (reserved-key param
collisions, null-admitting or unrenderable schemas); the standard registry
is clean.
New levelset/conf subpath: parseConf reads a .conf (TOML 1.0) file — a text
projection of RunConfig — into a raw RunConfig. Sections map [levelset]/
[resolve]/[pipeline]/[blocks.<id>]/[weights]/[objective] onto the config;
reserved block keys (code/enabled/provenance/shadow) peel off as instance
fields, everything else is a param (dotted keys nest, array-of-tables become
object arrays, "report" ⇄ null). Every framework section is strict.
Coded issues E301–E308: TOML syntax (line/column), config shape (conf-path),
missing/unsupported format, datetime literal, reserved-key collision,
non-finite number, and registry validation (instanceId-attributed). With a
registry, parse also runs validateGraph + the shared checkRunConfigRefs
(extracted from fillRunConfig so the two can't drift). Adds smol-toml as a
regular dependency and the ./conf export.
Grammar goldens (fixture .conf ⇄ raw config JSON) plus one error fixture per
code.
scripts/generate-block-meta.ts writes dumpBlockMeta(standardRegistry()) to
generated/block-meta.json (17 blocks), added to package files so it ships.
A drift test deep-equals the committed JSON against a fresh dump and, on
mismatch, points at re-running the generator — so a schema/shadow or
composition change can't land without regenerating the artifact.
Port a zod→JSON-Schema walk into src/level-blocks/meta.ts, minus all
Blockly assembly: deriveBlockMeta flattens a block's input schema into
editable scalar fields (date/number/slider/dropdown/checkbox with
labels/bounds/defaults), variadic scalar-array params, and complex
(field-less) params, alongside the block's family/tier/doc/shadow and its
JSON-Schema. dumpBlockMeta wraps the whole registry with the composition
grammar. Exports the ISO-date pattern both sides agree on by construction.
Zod-only, exported from levelset/leveling.
Lift the family grammar into src/level-blocks/composition.ts as pure data
and predicates: FAMILY_ORDER, FAMILY_FOLLOWS, canFollow, sortIntoBandOrder,
and FAMILY_CARDINALITY. validateGraph now loops over FAMILY_CARDINALITY
instead of hard-coding the exactly-one-search check — behavior identical,
existing tests unchanged — so a future per-family bound is a data edit.
This is the levelset-side source of truth a canvas connection checker
mirrors; a canFollow table test captures the contract so the vendor sync
can delete its own copy.
Document the exclusive-finish frame of PhaseWindow.targetDay (finishing ON a
deadline date costs a day unless targetDay is the day after), clarify the
PhaseLateness nl doc that the zero-cost boundary is the exclusive finishDay
index, and note the MiniZinc fixed-identifier collision when two instances of
the same block share a graph (latent; per-instance varName is future work).
Add a completion-priority trade-off test proving the phase weight ranks
schedules (a heavier phase slipping fewer days beats a lighter phase slipping
more), and a partially-scheduled phase test asserting the boundary derives
from present tasks only.
Generalize earlier phase-lateness and early-start scoring terms into
standard registry blocks. Phases are data (an array of
{ name?, taskUniqueIds, targetDay, weight? }) rather than hardcoded:
- PhaseLateness charges the working days a phase's latest task finishes past
its target day; PhaseEarliness charges the working days its earliest task
starts before its target. Each is weighted by the phase's
completion-priority weight (an earlier completionWeight, a static per-phase
priority) and summed. Two blocks, not one moded block: the originals anchor
them on different targets (window end vs start) and rank them in different
lexicographic tiers, and a single phase carries both at once.
- Deviation is counted in working days via the shared calendar prefix sum;
the per-day scale (phaseLatenessPerDay/earlyStartPerDay in the original
formulation) comes from the run config's scorer weight. Both declare
reads.taskUniqueIds so analyzeRun flags a phase pointing at a missing task.
Registered as levelset.PhaseLateness.v1 / levelset.PhaseEarliness.v1
(estimator tier); shared logic in phaseScoring.ts. Tests cover schema,
hand-computed scoring on the Mon-Fri fixture (including a realistic-shaped
partially-complete phase), the MiniZinc fragment, and RunConfig wiring.
Gate keeps only schedules that satisfy every limit, where a limit bounds
one scorer's value by max and/or min (an absent bound is unbounded on that
side, never Infinity; at least one bound is required per limit). When at
least one schedule passes, the passing set (order-preserving) is the
result. When none pass, fallback decides: "none" yields the empty set;
"leastBad" yields the single least-bad schedule.
Least-bad compares schedules lexicographically by per-limit violation in
the limits' declared order (first limit = binding). Violations of
different scorers are not commensurable, so they are prioritized by order,
never summed across scorers.
Extends the Collector ADT with the Gate variant and GateLimit — the
exhaustive collectorScorerRefs / collectors() switches (guarded by
assertNeverCollector) force both dispatch sites to handle it. Adds the
pure selectGate engine, levelset.Gate.v1 registration, leveling.ts
re-exports, and README. Gate execution collects the stream once
and filters/falls back in memory.
Extract collector scorer refs from the compiled Collector spec (via the
block's pure apply()) instead of duck-typing parsed params as {by, over}:
a custom collector naming its refs under a different param no longer
skips layer-1 validation, and a non-array by/over can't surface as a raw
TypeError. Distinguish a missing scorer from a disabled one in the
layer-2 runFromConfig check. Convert both collector dispatch ternaries to
exhaustive switches ending in assertNeverCollector so a future variant is
a compile error. Fix stale docs (collectors labeled by, not keyed by;
single-scorer Pareto keeps all co-best; ES2019 sort stability).
Collectors are the selection layer above scoring: a collector picks a set
out of the candidate stream instead of ranking one schedule. KBest keeps
the best k schedules lexicographically over referenced scorer instances;
Pareto keeps the non-dominated set. Both reference scorers by instanceId,
resolved at run-wiring time (like objective.lexicographic) so the spec
stays plain JSON and round-trips through fillRunConfig untouched.
Adds the Collector ADT and assertNeverCollector, ScheduleStream.kBestByLex
(reusing lexBetter), the KBest/Pareto blocks under levelset.KBest.v1 /
levelset.Pareto.v1, and LevelingRun.collectors() which runs every enabled
collector against a fresh stream keyed by graph instanceId. Validation
mirrors the lexicographic path: fillRunConfig rejects collector references
to missing/non-scorer instances, runFromConfig rejects references to
disabled scorers.
Require at least one mode so toMiniZinc no longer emits an unsatisfiable
empty domain, and forbid duplicate resourceUniqueId within a mode's demand
so serialSGS does not double-count load or materialize duplicate rows.
Document that multiple instances per task union under serial-SGS but
intersect (likely UNSAT) under the MiniZinc contract.
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.
RunProvenanceSchema (and its warning items) parsed with key-stripping
z.object, so a levelset block written by a newer levelset version — or
annotated by app tooling — was silently truncated to this version's
fields on read → write. Parse with z.looseObject so unknown keys survive
the round trip; garbage that fails the known-field contract is still
dropped whole, as documented.
serialSGS places milestones with zero span regardless of their nominal
durationDays, but analyzeRun applied the (deadline - release) < duration
bound to them. A milestone carrying an explicit duration (as MPP/JSON
files can) with a same-day release + deadline gate was reported as
LEVELSET_E105 'no feasible placement exists' — and runFromConfig threw —
for a run serialSGS places successfully.
analyzeRun ran every check over enabled: false constraints, so a disabled
infeasible deadline (or dangling reference, or cycle-forming precedence)
produced error-severity issues and runFromConfig threw RunAnalysisError —
blocking exactly the what-if ablation workflow the enabled lever exists
for. Analysis now covers the same active set the search preprocesses;
duplicate-name counting still spans all carried instances.
ScheduleStream.bestByLex selects by ordered tiers — the second scorer
only breaks ties on the first — and RunConfig gains an
objective.lexicographic section (ordered scorer instanceIds) that best()
honors, with weights staying report-only. Motivated by a reference
pipeline whose selection is a 4-tuple (lateness, labor cost, peak
headcount, makespan) that a single weighted sum cannot express.
Every LevelingRun.materialize output now carries a levelset metadata
block: engine version, the filled run config (rerunnable — the
reproducibility artifact), seed, achieved scorer values, analysis
warnings, and search annotations. ProjectFileSchema gains the optional
field and the JSON reader/writer round-trips it (non-parsing provenance
is dropped on read rather than failing the file). Answers "why does this
schedule look like this?" from the file itself, spaCy config.cfg-style.
One JSON document now fully describes a leveling run — resolve options,
blocks by versioned registry code (reusing the PipelineGraph format as the
config's pipeline section), and objective weights (null = compute and
report but don't optimize). runFromConfig fills the config (block-schema
defaults expanded, levelset version stamped — the filled config is the
persistable artifact), resolves the project, compiles blocks with
instanceIds doubling as constraint names for attribution, statically
analyzes the run (throwing coded RunAnalysisError on errors), and returns
a live run: stream/best/scores/materialize plus the combined
weightedScorer objective. Framework-owned config sections are strict so a
typo'd key is an error, not a silently unconstrained run. Graph instances
gain an optional enabled lever (undefined = enabled, so existing graphs
and constructors stay valid).
standardRegistry() curates every shipping block under a versioned code
(levelset.PeakCap.v1). Versioned names are the forward-compatibility
seam: semantic changes ship as .v2 while .v1 keeps resolving, so saved
run configs stay reproducible across releases. createRegistry remains
singleton-free; apps extend via standardRegistry(extraDefs) or keep
building their own.
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.
analyzeRun walks constraints' and scorers' declared references against a
ResolvedProject before any search runs — spaCy's analyze_pipes +
validate_attrs applied to leveling. Issues carry stable LEVELSET_E###/W###
codes, error/warning severity, and attribute to the named instance:
unknown resource/task/unit/calendar refs, deadline-vs-release and
deadline-vs-duration infeasibility, precedence and unit-precedence cycles,
inverted PeakCap windows, kinds the chosen search won't enforce (via
supports()), empty governed sets, and duplicate instance names. Scorers
gain optional reads metadata so a scorer referencing a missing entity is
caught instead of silently optimizing on a constant zero; the four
shipping scorer blocks now declare it.
Adds level-core/profiles.ts — a per-Schedule memoized provider (WeakMap,
safe because Schedule is immutable by contract) for the derived data every
scorer needs: per-resource day histograms, per-unit open spans, task
lookup. Scoring blocks become listeners that read the shared slot instead
of each recomputing, turning O(scorers × days × tasks) per candidate into
O(days × tasks). This also fixes an inconsistency: ConcurrentResourceCost
and OpenUnitPenalty's discipline filter previously read static assignments
only, ignoring realizedAssignments for ModeSelection tasks — the shared
histogram applies the single realized-override rule everywhere.
Every Constraint variant gains optional instance metadata (spaCy's
factory-vs-instance-name split): `name` for attribution and `enabled`
as the present-but-inactive lever. serialSGS skips `enabled: false`
constraints and records the skip in a disabledConstraints annotation so
reports can tell disabled from absent; deadline violations now carry the
named constraint instance that bound instead of a synthesized copy.
Search backends can declare which kinds they enforce via supports().
The SheetSpec work had grown writeWithGantt a 4th optional parameter,
leaking composition plumbing into a pre-existing public method. The
shared body moves to a private composeGanttWorkbook that both
writeWithGantt and writeWithGanttAndSheets delegate to, and the
buffer-wrapping repeated across write/writeSheets/the empty-gantt path
collapses into one toBuffer helper.
panelBindings.paramPath and projectAnchoredPaths are consumed app-side
(panel generation, template save), so a typo'd dot-path previously
failed silently at first use. register() now resolves every declared
path against the parsed defaultShadow — curated to be complete — and
throws when one doesn't resolve, matching the fail-at-registration
principle already applied to defaultShadow itself.
validateGraph safeParsed every block's params and discarded the result,
then compileGraph parsed them all again — double work, and a divergence
risk for schemas with defaults or transforms. validateGraph now returns
the parsed params keyed by instanceId (the graph keeps raw params as
the canonical artifact) and compileGraph hands those to apply().
Zod issue paths were flattened into message strings, leaving consumers
to re-parse text to locate the offending field. GraphIssue now carries
an optional structured path (graph-rooted for shape issues, params-
rooted for block param issues), param failures emit one issue per zod
issue, and both formatting sites share one helper.
Adds writeSheets() and writeWithGanttAndSheets() driven by
CellSpec/SheetSpec: formula cells written without cached results so
Excel/LibreOffice recalculate on open, workbook named ranges, cell
notes, row outlines, and header/input/derived/muted/internal style
hooks. Formula strings stay opaque to the writer — pricing semantics
live in the consuming app's emitter. Spec sheets append after the
Gantt sheet so chart-injection sheet indices stay stable. The
named-range recalc test (row insertion absorbed by an open slot) runs
under LibreOffice headless when soffice is available.
compileGraph validates (throwing GraphValidationError carrying the
issues), runs each block's apply() over its parsed params, groups the
outputs into CompiledFamilies, and hands the result to a CompileTarget.
Targets live app-side; future targets (direct serialSGS constraint
sets, MiniZinc) slot in behind the same interface without a format
change.
The canonical formatVersion-1 JSON block-graph: a flat block list with
family grouping and no port-wiring edges, matching how the engine
consumes constraints and scorers as sets today. The formatVersion
literal is the migration seam for richer wiring. validateGraph checks
schema shape, registry resolution, per-block params against the
block's input schema, exactly one search-family block, and duplicate
instanceIds, collecting all issues in one pass — the same surface a
canvas's strict-connection checker must mirror.
BlockDef wraps the existing Block contract with the pure-data metadata
PRD-08 front-ends project from (family, tier, defaultShadow, assumption
template, panel bindings, project-anchored paths). createRegistry()
returns an instance — no singleton, levelset stays side-effect-free.
register() throws on duplicate codes and validates defaultShadow
against the block's input schema so a bad default fails at
registration, not first render.
Replace the "mode-change not yet supported" stub with greedy multi-mode
RCPSP placement. Each task placement now tries a list of candidate modes
(preferred/home crew first) and takes the first feasible one, so a borrow
mode only wins when it lets the task start strictly earlier.
The chosen mode's resource demand is the authoritative load for the task:
serialSGS emits it as Schedule.realizedAssignments, and both
buildResourceLoad and materialize override the task's static assignments
with the realized demand under a single rule (a task present in
realizedAssignments is always loaded from there, never from resolved).
Point main/module/types and the exports map at the TypeScript sources
under src/ so levelset can be consumed directly as source in a workspace
(via tsconfig paths or a .ts-aware bundler). The built dist entry points
are preserved for npm publishing via publishConfig.
Also declare the previously-ambient jszip dependency used by
XlsxWriter to inject the Gantt chart.
Paint alternating white / light-gray horizontal bands behind the Gantt
bars, one band per category, so task rows are visually separated in the
exported workbook. Implemented as a hard-step gradient fill on the plot
area (<c:spPr>) with duplicated stop positions per band and a 90deg
top-to-bottom angle aligned to the catAx maxMin orientation.
Lead with the resource-leveling and scheduling toolkit as the core, and
present the MPP/MSPDI/JSON/CSV/XLSX readers and writers as the data
on-ramp. Documents the leveling pipeline (resolveCalendar → serialSGS →
ScheduleStream → materialize), the WorkUnit concept, a constraints table
marking which the greedy search enforces vs. annotates, the scoring
blocks, and the subpath exports.
Make levelset a publishable package. package.json carries npm metadata and
a conditional exports map over dist/ (types plus import), including the
./leveling subpath; the files allowlist ships dist, src, and docs.
tsconfig.build.json emits JS and .d.ts with rewriteRelativeImportExtensions,
and prepack builds so the gitignored dist/ lands in the tarball and on git
installs. typescript is a devDependency. Adds the MIT LICENSE. Repository,
homepage, and bugs point at the levelset repo on tangled.org.
A curated leveling subpath barrel exposing the resource-leveling toolkit —
the pipeline functions, serialSGS, the constraint and scorer types, and
the blocks — as one public surface. Types are re-exported from level-core;
the barrel imports only zod and the internal model/schema, pulling no
file-I/O dependencies into a consumer's bundle.
Three soft scorers that shape search below the hard caps.
ConcurrentResourceCost prices each marginal concurrent worker on an
exponential curve over working days, with an optional threshold that makes
the first N concurrent units free. HiringLagPenalty treats each
week-over-week headcount increase as a hire costing trainingWeeks ×
costPerCrewWeek for the unproductive ramp. UnimodalDeviation is the
quantitative companion to the unimodal-profile constraint, summing dip
magnitudes before the dominant weekly peak and bump magnitudes after it so
search has a deviation magnitude to drive on when the hard shape
constraint is not enforced.
Two WIP-limiting blocks that prioritize unit completion. ConcurrentUnitsLimit
is the hard cap on how many units have active work on a day, with an
optional discipline scope; OpenUnitPenalty is its soft companion, pricing
each open-unit-day over a softMax across a unit's whole open span (idle
gaps included). It subsumes the former LaydownSpaceCap.
Both reference units by id into the resolved registry rather than carrying
unit definitions by value. OpenUnitPenalty.score iterates distinct ids
(new Set(unitIds)) so a duplicated reference is counted once, matching
MiniZinc's set-literal semantics.
The Block abstraction: a typed quadruple of a zod input/output schema, an
apply step, a toMiniZinc step (with an optional toCpSat), and a doc string
(natural language plus pseudocode). Blocks are the composition unit shared
by a visual editor and an LLM agent; the three families differ only in
their output (Constraint / Scorer / Search), and constraint variants
remain the interchange format with the search.
Ships MaxConcurrentResource as the first concrete block. Its MiniZinc
fragment references the well-known harness symbols (active[t,d],
tasks_demanding[r], DAYS) rather than taking a context argument —
composing fragments is the compiler's job.
The search surface and the first concrete search.
ScheduleStream wraps an async iterable of schedules and stays lazy through
filter/map/take/branch; only bestBy/paretoFrontier/collect drain it. The
Pareto frontier uses standard non-dominated semantics (direction-aware).
serialSGS is a greedy serial schedule-generation scheme: topo-sort by
precedence (Kahn's, with a stable (outlineLevel, uniqueId) tie-break),
then place each task at the earliest working-day window that satisfies
every resource cap. Precedence, calendars, MaxConcurrentResource (task
count), PeakCap (units sum), and the unit-WIP cap are enforced; FF/SF
edges are approximated as FS and the approximation is surfaced as a
Schedule annotation rather than hidden; constraint variants the greedy
pass cannot honor are recorded under annotations so consumers can see what
was ignored. run is an AsyncGenerator that yields feasible schedules and
returns a typed Failure on placement failure.
The two bridges between a ResolvedProject and a Schedule. currentSchedule
reads existing source dates into Schedule form, so round-trip identity
holds before any search has run — the no-search-yet case is first-class.
materialize goes the other way, carrying the entire source ProjectFile
through and overriding only start/finish/duration for tasks present in the
schedule, so a partial schedule passes the rest through untouched.
Durations are re-emitted in the source unit via the project's unit
conversions, and both honor per-task calendars. A mode change throws until
mode selection is implemented.
resolveCalendar turns a ProjectFile into a ResolvedProject — the entry
point of the leveling pipeline. The epoch is opts.epoch ?? statusDate ??
earliest task start, and resolving with no available origin throws rather
than silently defaulting. capacityPerDay is derived as work-unit-hours per
day; calendars are built into a per-id map with a synthetic Mon–Fri
fallback; durations and lags are converted to working-day units from the
project's minutes-per-day and minutes-per-week.
WorkUnit definitions are resolved into the by-id registry here, and
duplicate unit ids are rejected at resolve time so a duplicate cannot
diverge the greedy and MiniZinc objectives downstream.
Working-day arithmetic over the bitmap calendar. countWorkingDays is an
O(1) prefix-sum query (cumWorking[hi] - cumWorking[lo]) — the dominant
operation in the scheduler. day-to-date conversion is DST-safe and
round-trips exactly. resolveWorkingCalendar centralizes the per-task →
project-default → first-available → throw fallback chain the schedulers
share, and a null calendar yields a synthetic all-working calendar so a
project with no calendars still resolves. Forward walks clamp at the
horizon instead of running off the end of the bitmap.
The type foundation for the leveling engine.
The Constraint ADT is a kind-tagged discriminated union — the interchange
format between blocks (which emit constraints) and the search (which
consumes them); assertNeverConstraint forces exhaustive handling so a new
variant cannot be silently dropped.
WorkingCalendar is backed by a Uint8Array working-day bitmap plus an
Int32Array prefix sum, the shape that makes working-day counting O(1).
ResolvedProject owns a default calendar id and a Map of calendars, so
per-task and per-resource calendars are first-class, and ResolvedResource
carries capacityPerDay. ResolvedTask holds structure only; scheduling
state lives on ScheduledTask. Schedule (makespan plus an annotations map
for per-block scores and telemetry) is kept separate from Failure, so a
failed placement is a distinct value rather than a malformed schedule.
WorkUnit is the largest independent work package and is resolved into a
by-id registry on ResolvedProject, giving unit definitions a single owner.
Export a ProjectFile to XLSX, and writeWithGantt additionally emits a
hidden data sheet plus a horizontal stacked-bar Gantt chart. The chart
uses two series — a transparent date-offset series and a visible duration
series colored per phase — to fake a Gantt bar from a stacked bar.
ExcelJS does not emit charts natively, so a JSZip post-pass injects the
chart and drawing XML with matching content-type overrides. An ambient
declaration (exceljs.d.ts) bridges an ExcelJS Buffer type incompatibility
under Bun. The bar primitive is named in render-neutral terms (bar/item).