A functional toolkit for authoring resource-leveling algorithms — compose constraint and scoring blocks, enumerate feasible schedules
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(level-core): constraint ADT and resolved-project types

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.

Russ T. Fugal (Apr 25, 2026, 9:00 AM -0600) 6d66a4f2 c420c194

+429
+429
src/level-core/types.ts
··· 1 + // Types & signatures for @levelset/level-core. 2 + 3 + import type { ProjectFile as Project } from "../model/Project.ts"; 4 + import type { Calendar } from "../model/Calendar.ts"; 5 + import type { RelationType } from "../model/types.ts"; 6 + 7 + export type { Project }; 8 + 9 + // ───────────────────────────────────────────────────────────────── 10 + // Day-indexed working time 11 + // 12 + // Bitmap is Uint8Array (1 byte/day vs ~9 for boolean[]) and carries a 13 + // precomputed prefix sum so "working days between A and B" is O(1) — the 14 + // dominant query in the leveling pipeline. `readonly` is by convention here; 15 + // stages must not mutate. 16 + // ───────────────────────────────────────────────────────────────── 17 + 18 + export type DayIndex = number; 19 + 20 + export interface WorkingCalendar { 21 + readonly calendarUniqueId: number | null; 22 + readonly epoch: Date; 23 + readonly horizonDays: number; 24 + readonly bits: Uint8Array; // 1 = working day, 0 = nonworking 25 + readonly cumWorking: Int32Array; // cumWorking[i] = sum(bits[0..i]) 26 + } 27 + 28 + // ───────────────────────────────────────────────────────────────── 29 + // ResolvedProject 30 + // 31 + // Resolved *structure*, not scheduling state. ScheduledTask carries 32 + // startDay/finishDay; ResolvedTask intentionally does not. CPM bounds, if 33 + // computed, ride on a separate annotation produced by an explicit stage — 34 + // keeps "post-resolve" and "post-CPM" non-overlapping. 35 + // 36 + // `calendars` is a Map so per-task and per-resource calendar overrides are 37 + // first-class. Tasks/resources without an override fall back to 38 + // `defaultCalendarUniqueId`. 39 + // ───────────────────────────────────────────────────────────────── 40 + 41 + export interface ResolvedTask { 42 + readonly uniqueId: number; 43 + readonly name: string | null; 44 + readonly durationDays: number; 45 + readonly outlineLevel: number | null; 46 + readonly summary: boolean; 47 + readonly milestone: boolean; 48 + /** Override calendar for this task; falls back to default when null. */ 49 + readonly calendarUniqueId: number | null; 50 + } 51 + 52 + export interface ResolvedAssignment { 53 + readonly taskUniqueId: number; 54 + readonly resourceUniqueId: number; 55 + readonly units: number; 56 + } 57 + 58 + export interface ResolvedResource { 59 + readonly uniqueId: number; 60 + /** (resource.maxUnits ?? 1) * (properties.minutesPerDay / 60). */ 61 + readonly capacityPerDay: number; 62 + /** Override calendar for this resource; falls back to default when null. */ 63 + readonly calendarUniqueId: number | null; 64 + } 65 + 66 + export interface PrecedenceEdge { 67 + readonly predecessorUniqueId: number; 68 + readonly successorUniqueId: number; 69 + readonly type: RelationType; 70 + readonly lagDays: number; 71 + } 72 + 73 + export interface ResolvedProject { 74 + readonly source: Project; 75 + readonly defaultCalendarUniqueId: number | null; 76 + readonly calendars: ReadonlyMap<number, WorkingCalendar>; 77 + readonly tasks: ReadonlyArray<ResolvedTask>; 78 + readonly resources: ReadonlyArray<ResolvedResource>; 79 + readonly assignments: ReadonlyArray<ResolvedAssignment>; 80 + readonly precedences: ReadonlyArray<PrecedenceEdge>; 81 + // Canonical WorkUnit registry — the single owner of unit definitions. 82 + // Keyed by id, so a unit id maps to exactly one definition; constraints and 83 + // scoring blocks reference units by id rather than carrying copies. 84 + readonly workUnits: ReadonlyMap<number, WorkUnit>; 85 + } 86 + 87 + // ───────────────────────────────────────────────────────────────── 88 + // WorkUnit — the largest independent work package 89 + // 90 + // The largest *independent* work package: the minimum complete increment 91 + // that can be put into production and handed over to the customer. A unit 92 + // groups the tasks that comprise it (those tasks may span disciplines / 93 + // resource categories). Three optional axes describe a unit: 94 + // 95 + // • location — WHERE the work happens (LBMS location, e.g. "L3-W") 96 + // • productType — WHAT it is: the generic product/template it instantiates 97 + // • serial — the serial-numbered instantiation of that product 98 + // 99 + // All three are optional metadata; only `id` and `taskUniqueIds` are load- 100 + // bearing for scheduling. JSON-serializable — it rides inside constraints. 101 + // ───────────────────────────────────────────────────────────────── 102 + 103 + // Optional fields carry `| undefined` so zod's `.optional()` (which infers 104 + // `T | undefined`) round-trips under tsconfig `exactOptionalPropertyTypes`. 105 + export interface WorkUnit { 106 + readonly id: number; 107 + /** WHERE — physical/logical location (LBMS location). */ 108 + readonly location?: string | undefined; 109 + /** WHAT — the generic product/template this unit instantiates. */ 110 + readonly productType?: string | undefined; 111 + /** Serial-numbered instantiation of `productType` (e.g. "001"). */ 112 + readonly serial?: string | undefined; 113 + /** Tasks comprising this unit; may span disciplines. */ 114 + readonly taskUniqueIds: ReadonlyArray<number>; 115 + } 116 + 117 + // ───────────────────────────────────────────────────────────────── 118 + // Constraint ADT 119 + // 120 + // Discriminated union; the ADT is the *interchange format* between blocks and 121 + // the search — blocks emit constraint variants, the search consumes them. 122 + // ───────────────────────────────────────────────────────────────── 123 + 124 + export type Constraint = 125 + | PrecedenceConstraint 126 + | CalendarsConstraint 127 + | MaxConcurrentResourceConstraint 128 + | PeakCapConstraint 129 + | ConcurrentUnitsLimitConstraint 130 + | UnitPrecedenceConstraint 131 + | UnimodalProfileConstraint 132 + | ModeSelectionConstraint 133 + | CrewFlowContinuityConstraint 134 + | DeadlineConstraint 135 + | ReleaseConstraint; 136 + 137 + export interface PrecedenceConstraint { 138 + readonly kind: "Precedence"; 139 + readonly edges: ReadonlyArray<PrecedenceEdge>; 140 + } 141 + 142 + export interface CalendarsConstraint { 143 + readonly kind: "Calendars"; 144 + readonly calendarUniqueId: number; 145 + } 146 + 147 + export interface MaxConcurrentResourceConstraint { 148 + readonly kind: "MaxConcurrentResource"; 149 + readonly resourceUniqueId: number; 150 + readonly max: number; 151 + } 152 + 153 + export interface PeakCapConstraint { 154 + readonly kind: "PeakCap"; 155 + readonly resourceUniqueId: number; 156 + readonly cap: number; 157 + readonly window?: { readonly fromDay: DayIndex; readonly toDay: DayIndex }; 158 + } 159 + 160 + // WIP cap on whole work units. Prioritizes *completion*: caps how many units 161 + // have active work on any single day, so the search must finish open units 162 + // before opening more. `discipline` (a resourceUniqueId) narrows the count to 163 + // units with active work of that discipline — "≤ N units in commissioning at 164 + // once" — leaving units idle in other disciplines uncounted. Omit `discipline` 165 + // for the whole-unit limit; a pure crew-throughput limit is still 166 + // `MaxConcurrentResource`. Hard form; the soft companion is the 167 + // OpenUnitPenalty scorer. 168 + export interface ConcurrentUnitsLimitConstraint { 169 + readonly kind: "ConcurrentUnitsLimit"; 170 + // Ids into ResolvedProject.workUnits. The set of units this cap governs; 171 + // treated as a set (duplicate references collapse). 172 + readonly unitIds: ReadonlyArray<number>; 173 + /** resourceUniqueId scoping the count to one discipline; omit = any task. */ 174 + readonly discipline?: number | undefined; 175 + readonly max: number; 176 + } 177 + 178 + // Unit-level precedence: unit `unitId` may not start until every unit in 179 + // `afterUnitIds` has finished. 180 + export interface UnitPrecedenceConstraint { 181 + readonly kind: "UnitPrecedence"; 182 + readonly unitId: number; 183 + readonly afterUnitIds: ReadonlyArray<number>; 184 + } 185 + 186 + // Shape constraint, separate from the Smoothness *scorer*. A moment 187 + // minimizer will happily oscillate around a smooth mean — exactly what 188 + // a unimodal install profile forbids. 189 + export interface UnimodalProfileConstraint { 190 + readonly kind: "UnimodalProfile"; 191 + readonly resourceUniqueId: number; 192 + readonly tolerance: number; 193 + readonly allowSecondPeak?: boolean; 194 + } 195 + 196 + // Multi-mode RCPSP — the central mode lever. 197 + export interface ModeSelectionConstraint { 198 + readonly kind: "ModeSelection"; 199 + readonly taskUniqueId: number; 200 + readonly modes: ReadonlyArray<TaskMode>; 201 + } 202 + 203 + export interface TaskMode { 204 + readonly modeId: number; 205 + readonly durationDays: number; 206 + readonly resourceDemand: ReadonlyArray<{ 207 + readonly resourceUniqueId: number; 208 + readonly units: number; 209 + }>; 210 + } 211 + 212 + // LBMS-flavored — keeps a crew working a sequence of units without 213 + // idle gaps. The empirical premise of LBMS is that crew flow continuity 214 + // matters more than per-task makespan. `unitOrder` is the WorkUnit id 215 + // sequence the crew flows through. 216 + export interface CrewFlowContinuityConstraint { 217 + readonly kind: "CrewFlowContinuity"; 218 + readonly resourceUniqueId: number; 219 + readonly unitOrder: ReadonlyArray<number>; 220 + } 221 + 222 + export interface DeadlineConstraint { 223 + readonly kind: "Deadline"; 224 + readonly taskUniqueId: number; 225 + readonly latestFinish: DayIndex; 226 + } 227 + 228 + export interface ReleaseConstraint { 229 + readonly kind: "Release"; 230 + readonly taskUniqueId: number; 231 + readonly earliestStart: DayIndex; 232 + } 233 + 234 + /** Forces a compile error if a new Constraint variant is added but a 235 + * switch downstream isn't updated. */ 236 + export function assertNeverConstraint(c: never): never { 237 + throw new Error(`unhandled Constraint variant: ${JSON.stringify(c)}`); 238 + } 239 + 240 + // ───────────────────────────────────────────────────────────────── 241 + // Scoring 242 + // 243 + // Smoothness (Burgess–Killebrew sum-of-squares moment) and the 244 + // UnimodalDeviation companion to UnimodalProfile both live here as 245 + // Scorer instances, not Constraint variants. 246 + // ───────────────────────────────────────────────────────────────── 247 + 248 + export interface Scorer<T = number> { 249 + readonly name: string; 250 + readonly direction: "min" | "max"; 251 + score(schedule: Schedule): T; 252 + } 253 + 254 + // ───────────────────────────────────────────────────────────────── 255 + // Failure & explanations 256 + // 257 + // Explanations describe *why* a search step rejected a partial schedule. 258 + // They belong on Failure, not on Schedule — a feasible schedule has no 259 + // violations to explain. Search yields Schedules and may return a 260 + // Failure on exhaustion. 261 + // ───────────────────────────────────────────────────────────────── 262 + 263 + export interface Explanation { 264 + readonly violated: Constraint; 265 + readonly involvedTaskIds: ReadonlyArray<number>; 266 + readonly atDays: { readonly fromDay: DayIndex; readonly toDay: DayIndex } | null; 267 + readonly message: string; 268 + } 269 + 270 + export interface Failure { 271 + readonly kind: "failure"; 272 + readonly explanations: ReadonlyArray<Explanation>; 273 + } 274 + 275 + // ───────────────────────────────────────────────────────────────── 276 + // Schedule 277 + // ───────────────────────────────────────────────────────────────── 278 + 279 + export interface ScheduledTask { 280 + readonly uniqueId: number; 281 + readonly startDay: DayIndex; 282 + readonly finishDay: DayIndex; 283 + readonly modeId: number | null; // null until ModeSelection lands 284 + } 285 + 286 + export interface Schedule { 287 + readonly resolved: ResolvedProject; 288 + readonly tasks: ReadonlyArray<ScheduledTask>; 289 + readonly makespan: DayIndex; 290 + /** Per-block annotations (scores, telemetry). Not for violations. */ 291 + readonly annotations: ReadonlyMap<string, unknown>; 292 + } 293 + 294 + // ───────────────────────────────────────────────────────────────── 295 + // ScheduleDiff — primitive for "branch, score, pick" 296 + // ───────────────────────────────────────────────────────────────── 297 + 298 + export interface TaskDelta { 299 + readonly uniqueId: number; 300 + readonly startDelta: number; 301 + readonly finishDelta: number; 302 + readonly modeChange: { readonly from: number | null; readonly to: number | null } | null; 303 + } 304 + 305 + export interface ResourceDayDelta { 306 + readonly resourceUniqueId: number; 307 + readonly day: DayIndex; 308 + readonly delta: number; 309 + } 310 + 311 + export interface ScheduleDiff { 312 + readonly tasks: ReadonlyArray<TaskDelta>; 313 + readonly resourceDays: ReadonlyArray<ResourceDayDelta>; 314 + /** Computed lazily — non-trivial CPM rerun. */ 315 + readonly criticalPathChanged: boolean; 316 + } 317 + 318 + export declare function diffSchedules(a: Schedule, b: Schedule): ScheduleDiff; 319 + 320 + // ───────────────────────────────────────────────────────────────── 321 + // ScheduleStream — lazy enumeration of feasible schedules 322 + // 323 + // Lazy iterable of feasible Schedules. Async generators inside; uniform 324 + // interface across in-process greedy, MiniZinc subprocess, and remote 325 + // CP-SAT. Default to lazy: bestBy / paretoFrontier materialize; the rest 326 + // stay lazy. Search exhaustion surfaces as a Failure via the Search 327 + // generator's return value, not via the stream — the stream just ends. 328 + // ───────────────────────────────────────────────────────────────── 329 + 330 + export interface ScheduleStream { 331 + [Symbol.asyncIterator](): AsyncIterator<Schedule>; 332 + 333 + filter(pred: (s: Schedule) => boolean): ScheduleStream; 334 + map(fn: (s: Schedule) => Schedule): ScheduleStream; 335 + take(k: number): ScheduleStream; 336 + branch(fork: (s: Schedule) => ScheduleStream): ScheduleStream; 337 + 338 + bestBy(scorer: Scorer): Promise<Schedule | null>; 339 + paretoFrontier(scorers: ReadonlyArray<Scorer>): Promise<ReadonlyArray<Schedule>>; 340 + /** Bounded materialization — caller asserts the stream terminates. */ 341 + collect(limit?: number): Promise<ReadonlyArray<Schedule>>; 342 + } 343 + 344 + // ───────────────────────────────────────────────────────────────── 345 + // Search — monadic-constraint-programming shape 346 + // 347 + // Transformers (BB, LDS, restart, LNS) wrap a Search to produce a Search, 348 + // in the spirit of Monadic Constraint Programming (Schrijvers, Stuckey, 349 + // Wadler; JFP 2009, doi:10.1017/S0956796809990086). The generator returns 350 + // a Failure on exhaustion when no feasible schedule exists. 351 + // ───────────────────────────────────────────────────────────────── 352 + 353 + export interface Search { 354 + readonly name: string; 355 + run( 356 + resolved: ResolvedProject, 357 + constraints: ReadonlyArray<Constraint>, 358 + ): AsyncGenerator<Schedule, Failure | undefined>; 359 + } 360 + 361 + export type SearchTransformer = (inner: Search) => Search; 362 + 363 + // ───────────────────────────────────────────────────────────────── 364 + // Pipeline 365 + // 366 + // Two layers, easy to confuse: 367 + // 368 + // • Pipeline-as-data — the `Block[]` + port wiring sense. JSON- 369 + // serializable. Lives in @levelset/level-blocks. The LCNC editor 370 + // and the LLM agent emit this; the compiler accepts it. 371 + // • Pipeline-as-function (this file) — the *compiled* form. `Stage` 372 + // is a function, not data; `pipe` composes them. Compilation 373 + // produces this from Pipeline-as-data. 374 + // 375 + // The serialization promise is on Pipeline-as-data alone. 376 + // ───────────────────────────────────────────────────────────────── 377 + 378 + export type Stage = (resolved: ResolvedProject) => ResolvedProject; 379 + 380 + export interface Pipeline { 381 + readonly stages: ReadonlyArray<Stage>; 382 + readonly constraints: ReadonlyArray<Constraint>; 383 + readonly search: Search; 384 + } 385 + 386 + export function pipe(...stages: Stage[]): Stage { 387 + return (project) => stages.reduce((p, s) => s(p), project); 388 + } 389 + 390 + // ───────────────────────────────────────────────────────────────── 391 + // Top-level entry points 392 + // 393 + // Project ──resolveCalendar──> ResolvedProject ──run(pipeline)──> 394 + // ScheduleStream ──pick──> Schedule ──materialize──> Project 395 + // 396 + // Round-trip fidelity invariant (test required): for every working day `d` 397 + // in the resolved horizon and every WorkingCalendar in the map, 398 + // `dateToDay(cal, dayToDate(cal, d)) === d`. 399 + // ───────────────────────────────────────────────────────────────── 400 + 401 + export interface ResolveOptions { 402 + /** opts.epoch ?? properties.statusDate ?? min(task.start). Throws if none. */ 403 + readonly epoch?: Date; 404 + /** Default = ceil(span(project) * 1.25), bounded by 10 years. */ 405 + readonly horizonDays?: number; 406 + /** WorkUnit definitions for this project; resolved into a by-id registry. 407 + * Duplicate ids are rejected. */ 408 + readonly workUnits?: ReadonlyArray<WorkUnit> | undefined; 409 + } 410 + 411 + export declare function resolveCalendar(project: Project, opts?: ResolveOptions): ResolvedProject; 412 + 413 + /** Bridge for the no-search-yet case. Reads pre-existing dates from 414 + * `resolved.source` into Schedule shape so round-trip identity holds. */ 415 + export declare function currentSchedule(resolved: ResolvedProject): Schedule; 416 + 417 + export declare function materialize(schedule: Schedule): Project; 418 + 419 + export declare function run(pipeline: Pipeline, project: Project): ScheduleStream; 420 + 421 + export declare function buildWorkingCalendar( 422 + cal: Calendar | null, 423 + calendarUniqueId: number | null, 424 + epoch: Date, 425 + horizonDays: number, 426 + ): WorkingCalendar; 427 + 428 + export declare function dayToDate(cal: WorkingCalendar, day: DayIndex): Date; 429 + export declare function dateToDay(cal: WorkingCalendar, date: Date): DayIndex;