From 6d66a4f2d27eebb47fe283319bb0443809b7991f Mon Sep 17 00:00:00 2001 From: "Russ T. Fugal" Date: Sat, 25 Apr 2026 09:00:00 -0600 Subject: [PATCH] feat(level-core): constraint ADT and resolved-project types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/level-core/types.ts | 429 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 429 insertions(+) create mode 100644 src/level-core/types.ts diff --git a/src/level-core/types.ts b/src/level-core/types.ts new file mode 100644 index 0000000..8d2f73c --- /dev/null +++ b/src/level-core/types.ts @@ -0,0 +1,429 @@ +// Types & signatures for @levelset/level-core. + +import type { ProjectFile as Project } from "../model/Project.ts"; +import type { Calendar } from "../model/Calendar.ts"; +import type { RelationType } from "../model/types.ts"; + +export type { Project }; + +// ───────────────────────────────────────────────────────────────── +// Day-indexed working time +// +// Bitmap is Uint8Array (1 byte/day vs ~9 for boolean[]) and carries a +// precomputed prefix sum so "working days between A and B" is O(1) — the +// dominant query in the leveling pipeline. `readonly` is by convention here; +// stages must not mutate. +// ───────────────────────────────────────────────────────────────── + +export type DayIndex = number; + +export interface WorkingCalendar { + readonly calendarUniqueId: number | null; + readonly epoch: Date; + readonly horizonDays: number; + readonly bits: Uint8Array; // 1 = working day, 0 = nonworking + readonly cumWorking: Int32Array; // cumWorking[i] = sum(bits[0..i]) +} + +// ───────────────────────────────────────────────────────────────── +// ResolvedProject +// +// Resolved *structure*, not scheduling state. ScheduledTask carries +// startDay/finishDay; ResolvedTask intentionally does not. CPM bounds, if +// computed, ride on a separate annotation produced by an explicit stage — +// keeps "post-resolve" and "post-CPM" non-overlapping. +// +// `calendars` is a Map so per-task and per-resource calendar overrides are +// first-class. Tasks/resources without an override fall back to +// `defaultCalendarUniqueId`. +// ───────────────────────────────────────────────────────────────── + +export interface ResolvedTask { + readonly uniqueId: number; + readonly name: string | null; + readonly durationDays: number; + readonly outlineLevel: number | null; + readonly summary: boolean; + readonly milestone: boolean; + /** Override calendar for this task; falls back to default when null. */ + readonly calendarUniqueId: number | null; +} + +export interface ResolvedAssignment { + readonly taskUniqueId: number; + readonly resourceUniqueId: number; + readonly units: number; +} + +export interface ResolvedResource { + readonly uniqueId: number; + /** (resource.maxUnits ?? 1) * (properties.minutesPerDay / 60). */ + readonly capacityPerDay: number; + /** Override calendar for this resource; falls back to default when null. */ + readonly calendarUniqueId: number | null; +} + +export interface PrecedenceEdge { + readonly predecessorUniqueId: number; + readonly successorUniqueId: number; + readonly type: RelationType; + readonly lagDays: number; +} + +export interface ResolvedProject { + readonly source: Project; + readonly defaultCalendarUniqueId: number | null; + readonly calendars: ReadonlyMap; + readonly tasks: ReadonlyArray; + readonly resources: ReadonlyArray; + readonly assignments: ReadonlyArray; + readonly precedences: ReadonlyArray; + // Canonical WorkUnit registry — the single owner of unit definitions. + // Keyed by id, so a unit id maps to exactly one definition; constraints and + // scoring blocks reference units by id rather than carrying copies. + readonly workUnits: ReadonlyMap; +} + +// ───────────────────────────────────────────────────────────────── +// WorkUnit — the largest independent work package +// +// The largest *independent* work package: the minimum complete increment +// that can be put into production and handed over to the customer. A unit +// groups the tasks that comprise it (those tasks may span disciplines / +// resource categories). Three optional axes describe a unit: +// +// • location — WHERE the work happens (LBMS location, e.g. "L3-W") +// • productType — WHAT it is: the generic product/template it instantiates +// • serial — the serial-numbered instantiation of that product +// +// All three are optional metadata; only `id` and `taskUniqueIds` are load- +// bearing for scheduling. JSON-serializable — it rides inside constraints. +// ───────────────────────────────────────────────────────────────── + +// Optional fields carry `| undefined` so zod's `.optional()` (which infers +// `T | undefined`) round-trips under tsconfig `exactOptionalPropertyTypes`. +export interface WorkUnit { + readonly id: number; + /** WHERE — physical/logical location (LBMS location). */ + readonly location?: string | undefined; + /** WHAT — the generic product/template this unit instantiates. */ + readonly productType?: string | undefined; + /** Serial-numbered instantiation of `productType` (e.g. "001"). */ + readonly serial?: string | undefined; + /** Tasks comprising this unit; may span disciplines. */ + readonly taskUniqueIds: ReadonlyArray; +} + +// ───────────────────────────────────────────────────────────────── +// Constraint ADT +// +// Discriminated union; the ADT is the *interchange format* between blocks and +// the search — blocks emit constraint variants, the search consumes them. +// ───────────────────────────────────────────────────────────────── + +export type Constraint = + | PrecedenceConstraint + | CalendarsConstraint + | MaxConcurrentResourceConstraint + | PeakCapConstraint + | ConcurrentUnitsLimitConstraint + | UnitPrecedenceConstraint + | UnimodalProfileConstraint + | ModeSelectionConstraint + | CrewFlowContinuityConstraint + | DeadlineConstraint + | ReleaseConstraint; + +export interface PrecedenceConstraint { + readonly kind: "Precedence"; + readonly edges: ReadonlyArray; +} + +export interface CalendarsConstraint { + readonly kind: "Calendars"; + readonly calendarUniqueId: number; +} + +export interface MaxConcurrentResourceConstraint { + readonly kind: "MaxConcurrentResource"; + readonly resourceUniqueId: number; + readonly max: number; +} + +export interface PeakCapConstraint { + readonly kind: "PeakCap"; + readonly resourceUniqueId: number; + readonly cap: number; + readonly window?: { readonly fromDay: DayIndex; readonly toDay: DayIndex }; +} + +// WIP cap on whole work units. Prioritizes *completion*: caps how many units +// have active work on any single day, so the search must finish open units +// before opening more. `discipline` (a resourceUniqueId) narrows the count to +// units with active work of that discipline — "≤ N units in commissioning at +// once" — leaving units idle in other disciplines uncounted. Omit `discipline` +// for the whole-unit limit; a pure crew-throughput limit is still +// `MaxConcurrentResource`. Hard form; the soft companion is the +// OpenUnitPenalty scorer. +export interface ConcurrentUnitsLimitConstraint { + readonly kind: "ConcurrentUnitsLimit"; + // Ids into ResolvedProject.workUnits. The set of units this cap governs; + // treated as a set (duplicate references collapse). + readonly unitIds: ReadonlyArray; + /** resourceUniqueId scoping the count to one discipline; omit = any task. */ + readonly discipline?: number | undefined; + readonly max: number; +} + +// Unit-level precedence: unit `unitId` may not start until every unit in +// `afterUnitIds` has finished. +export interface UnitPrecedenceConstraint { + readonly kind: "UnitPrecedence"; + readonly unitId: number; + readonly afterUnitIds: ReadonlyArray; +} + +// Shape constraint, separate from the Smoothness *scorer*. A moment +// minimizer will happily oscillate around a smooth mean — exactly what +// a unimodal install profile forbids. +export interface UnimodalProfileConstraint { + readonly kind: "UnimodalProfile"; + readonly resourceUniqueId: number; + readonly tolerance: number; + readonly allowSecondPeak?: boolean; +} + +// Multi-mode RCPSP — the central mode lever. +export interface ModeSelectionConstraint { + readonly kind: "ModeSelection"; + readonly taskUniqueId: number; + readonly modes: ReadonlyArray; +} + +export interface TaskMode { + readonly modeId: number; + readonly durationDays: number; + readonly resourceDemand: ReadonlyArray<{ + readonly resourceUniqueId: number; + readonly units: number; + }>; +} + +// LBMS-flavored — keeps a crew working a sequence of units without +// idle gaps. The empirical premise of LBMS is that crew flow continuity +// matters more than per-task makespan. `unitOrder` is the WorkUnit id +// sequence the crew flows through. +export interface CrewFlowContinuityConstraint { + readonly kind: "CrewFlowContinuity"; + readonly resourceUniqueId: number; + readonly unitOrder: ReadonlyArray; +} + +export interface DeadlineConstraint { + readonly kind: "Deadline"; + readonly taskUniqueId: number; + readonly latestFinish: DayIndex; +} + +export interface ReleaseConstraint { + readonly kind: "Release"; + readonly taskUniqueId: number; + readonly earliestStart: DayIndex; +} + +/** Forces a compile error if a new Constraint variant is added but a + * switch downstream isn't updated. */ +export function assertNeverConstraint(c: never): never { + throw new Error(`unhandled Constraint variant: ${JSON.stringify(c)}`); +} + +// ───────────────────────────────────────────────────────────────── +// Scoring +// +// Smoothness (Burgess–Killebrew sum-of-squares moment) and the +// UnimodalDeviation companion to UnimodalProfile both live here as +// Scorer instances, not Constraint variants. +// ───────────────────────────────────────────────────────────────── + +export interface Scorer { + readonly name: string; + readonly direction: "min" | "max"; + score(schedule: Schedule): T; +} + +// ───────────────────────────────────────────────────────────────── +// Failure & explanations +// +// Explanations describe *why* a search step rejected a partial schedule. +// They belong on Failure, not on Schedule — a feasible schedule has no +// violations to explain. Search yields Schedules and may return a +// Failure on exhaustion. +// ───────────────────────────────────────────────────────────────── + +export interface Explanation { + readonly violated: Constraint; + readonly involvedTaskIds: ReadonlyArray; + readonly atDays: { readonly fromDay: DayIndex; readonly toDay: DayIndex } | null; + readonly message: string; +} + +export interface Failure { + readonly kind: "failure"; + readonly explanations: ReadonlyArray; +} + +// ───────────────────────────────────────────────────────────────── +// Schedule +// ───────────────────────────────────────────────────────────────── + +export interface ScheduledTask { + readonly uniqueId: number; + readonly startDay: DayIndex; + readonly finishDay: DayIndex; + readonly modeId: number | null; // null until ModeSelection lands +} + +export interface Schedule { + readonly resolved: ResolvedProject; + readonly tasks: ReadonlyArray; + readonly makespan: DayIndex; + /** Per-block annotations (scores, telemetry). Not for violations. */ + readonly annotations: ReadonlyMap; +} + +// ───────────────────────────────────────────────────────────────── +// ScheduleDiff — primitive for "branch, score, pick" +// ───────────────────────────────────────────────────────────────── + +export interface TaskDelta { + readonly uniqueId: number; + readonly startDelta: number; + readonly finishDelta: number; + readonly modeChange: { readonly from: number | null; readonly to: number | null } | null; +} + +export interface ResourceDayDelta { + readonly resourceUniqueId: number; + readonly day: DayIndex; + readonly delta: number; +} + +export interface ScheduleDiff { + readonly tasks: ReadonlyArray; + readonly resourceDays: ReadonlyArray; + /** Computed lazily — non-trivial CPM rerun. */ + readonly criticalPathChanged: boolean; +} + +export declare function diffSchedules(a: Schedule, b: Schedule): ScheduleDiff; + +// ───────────────────────────────────────────────────────────────── +// ScheduleStream — lazy enumeration of feasible schedules +// +// Lazy iterable of feasible Schedules. Async generators inside; uniform +// interface across in-process greedy, MiniZinc subprocess, and remote +// CP-SAT. Default to lazy: bestBy / paretoFrontier materialize; the rest +// stay lazy. Search exhaustion surfaces as a Failure via the Search +// generator's return value, not via the stream — the stream just ends. +// ───────────────────────────────────────────────────────────────── + +export interface ScheduleStream { + [Symbol.asyncIterator](): AsyncIterator; + + filter(pred: (s: Schedule) => boolean): ScheduleStream; + map(fn: (s: Schedule) => Schedule): ScheduleStream; + take(k: number): ScheduleStream; + branch(fork: (s: Schedule) => ScheduleStream): ScheduleStream; + + bestBy(scorer: Scorer): Promise; + paretoFrontier(scorers: ReadonlyArray): Promise>; + /** Bounded materialization — caller asserts the stream terminates. */ + collect(limit?: number): Promise>; +} + +// ───────────────────────────────────────────────────────────────── +// Search — monadic-constraint-programming shape +// +// Transformers (BB, LDS, restart, LNS) wrap a Search to produce a Search, +// in the spirit of Monadic Constraint Programming (Schrijvers, Stuckey, +// Wadler; JFP 2009, doi:10.1017/S0956796809990086). The generator returns +// a Failure on exhaustion when no feasible schedule exists. +// ───────────────────────────────────────────────────────────────── + +export interface Search { + readonly name: string; + run( + resolved: ResolvedProject, + constraints: ReadonlyArray, + ): AsyncGenerator; +} + +export type SearchTransformer = (inner: Search) => Search; + +// ───────────────────────────────────────────────────────────────── +// Pipeline +// +// Two layers, easy to confuse: +// +// • Pipeline-as-data — the `Block[]` + port wiring sense. JSON- +// serializable. Lives in @levelset/level-blocks. The LCNC editor +// and the LLM agent emit this; the compiler accepts it. +// • Pipeline-as-function (this file) — the *compiled* form. `Stage` +// is a function, not data; `pipe` composes them. Compilation +// produces this from Pipeline-as-data. +// +// The serialization promise is on Pipeline-as-data alone. +// ───────────────────────────────────────────────────────────────── + +export type Stage = (resolved: ResolvedProject) => ResolvedProject; + +export interface Pipeline { + readonly stages: ReadonlyArray; + readonly constraints: ReadonlyArray; + readonly search: Search; +} + +export function pipe(...stages: Stage[]): Stage { + return (project) => stages.reduce((p, s) => s(p), project); +} + +// ───────────────────────────────────────────────────────────────── +// Top-level entry points +// +// Project ──resolveCalendar──> ResolvedProject ──run(pipeline)──> +// ScheduleStream ──pick──> Schedule ──materialize──> Project +// +// Round-trip fidelity invariant (test required): for every working day `d` +// in the resolved horizon and every WorkingCalendar in the map, +// `dateToDay(cal, dayToDate(cal, d)) === d`. +// ───────────────────────────────────────────────────────────────── + +export interface ResolveOptions { + /** opts.epoch ?? properties.statusDate ?? min(task.start). Throws if none. */ + readonly epoch?: Date; + /** Default = ceil(span(project) * 1.25), bounded by 10 years. */ + readonly horizonDays?: number; + /** WorkUnit definitions for this project; resolved into a by-id registry. + * Duplicate ids are rejected. */ + readonly workUnits?: ReadonlyArray | undefined; +} + +export declare function resolveCalendar(project: Project, opts?: ResolveOptions): ResolvedProject; + +/** Bridge for the no-search-yet case. Reads pre-existing dates from + * `resolved.source` into Schedule shape so round-trip identity holds. */ +export declare function currentSchedule(resolved: ResolvedProject): Schedule; + +export declare function materialize(schedule: Schedule): Project; + +export declare function run(pipeline: Pipeline, project: Project): ScheduleStream; + +export declare function buildWorkingCalendar( + cal: Calendar | null, + calendarUniqueId: number | null, + epoch: Date, + horizonDays: number, +): WorkingCalendar; + +export declare function dayToDate(cal: WorkingCalendar, day: DayIndex): Date; +export declare function dateToDay(cal: WorkingCalendar, date: Date): DayIndex; -- 2.54.0