diff --git a/src/level-blocks/deadline.ts b/src/level-blocks/deadline.ts new file mode 100644 index 0000000..cbd893a --- /dev/null +++ b/src/level-blocks/deadline.ts @@ -0,0 +1,43 @@ +// Deadline — Constraint block. +// +// The task must finish on or before `latestFinish` (a day index in the +// resolved calendar frame). Hard form: a placement that would overrun the +// deadline is rejected and the run fails with an attributed explanation. +// +// MiniZinc compile contract: harness supplies `finish_day[t]`. + +import { z } from "zod"; + +import type { DeadlineConstraint } from "../level-core/types.ts"; + +import type { ConstraintBlock, MiniZincFragment } from "./types.ts"; + +export const DeadlineInputSchema = z.object({ + taskUniqueId: z.number().int(), + latestFinish: z.number().int().nonnegative(), +}); + +export type DeadlineInput = z.infer; + +const DeadlineOutputSchema: z.ZodType = z.object({ + kind: z.literal("Deadline"), + taskUniqueId: z.number().int(), + latestFinish: z.number().int().nonnegative(), +}); + +export const DeadlineBlock: ConstraintBlock = { + id: "Deadline", + schema: { input: DeadlineInputSchema, output: DeadlineOutputSchema }, + apply: ({ taskUniqueId, latestFinish }): DeadlineConstraint => ({ + kind: "Deadline", + taskUniqueId, + latestFinish, + }), + toMiniZinc: ({ taskUniqueId, latestFinish }): MiniZincFragment => ({ + text: `constraint finish_day[${String(taskUniqueId)}] <= ${String(latestFinish)};`, + }), + doc: { + nl: "The task must finish on or before the given day index; later placements are rejected.", + pseudocode: "finish(task) <= latestFinish", + }, +}; diff --git a/src/level-blocks/makespan.ts b/src/level-blocks/makespan.ts new file mode 100644 index 0000000..260cd97 --- /dev/null +++ b/src/level-blocks/makespan.ts @@ -0,0 +1,43 @@ +// Makespan — Scoring block. +// +// The canonical objective: minimize the latest finish day. Shipping it as a +// block (rather than every caller hand-writing the two-line scorer) gives +// the makespan a registry name a run config can reference and weight. +// +// MiniZinc compile contract: harness supplies `finish_day[t]` and `TASKS`. + +import { z } from "zod"; + +import type { Schedule, Scorer } from "../level-core/types.ts"; + +import type { MiniZincFragment, ScoringBlock } from "./types.ts"; + +export const MakespanInputSchema = z.object({}); + +export type MakespanInput = z.infer; + +const ScorerOutputSchema: z.ZodType = z.custom( + (val): val is Scorer => + typeof val === "object" && + val !== null && + typeof (val as Scorer).name === "string" && + ((val as Scorer).direction === "min" || (val as Scorer).direction === "max") && + typeof (val as Scorer).score === "function", +); + +export const MakespanBlock: ScoringBlock = { + id: "Makespan", + schema: { input: MakespanInputSchema, output: ScorerOutputSchema }, + apply: (): Scorer => ({ + name: "Makespan", + direction: "min", + score: (schedule: Schedule): number => schedule.makespan, + }), + toMiniZinc: (): MiniZincFragment => ({ + text: `var int: makespan = max(t in TASKS) (finish_day[t]);`, + }), + doc: { + nl: "The latest finish day across all tasks — the schedule's total length.", + pseudocode: "max(finish(task) for task in tasks)", + }, +}; diff --git a/src/level-blocks/peakCap.ts b/src/level-blocks/peakCap.ts new file mode 100644 index 0000000..2069f77 --- /dev/null +++ b/src/level-blocks/peakCap.ts @@ -0,0 +1,66 @@ +// PeakCap — Constraint block. +// +// Caps the summed fractional *unit* demand on a resource per working day — +// the unit-summing companion to `MaxConcurrentResource`'s task-count cap. +// An optional [fromDay, toDay) window scopes the cap to part of the horizon +// (e.g. a seasonal crane embargo); omit it for the whole horizon. +// +// MiniZinc compile contract: harness supplies `active[t,d]`, +// `tasks_demanding[r]`, `units[t,r]`, and `DAYS`. + +import { z } from "zod"; + +import type { PeakCapConstraint } from "../level-core/types.ts"; + +import type { ConstraintBlock, MiniZincFragment } from "./types.ts"; + +export const PeakCapInputSchema = z.object({ + resourceUniqueId: z.number().int(), + cap: z.number().positive(), + window: z + .object({ + fromDay: z.number().int().nonnegative(), + toDay: z.number().int().nonnegative(), + }) + .optional(), +}); + +export type PeakCapInput = z.infer; + +const PeakCapOutputSchema: z.ZodType = z.object({ + kind: z.literal("PeakCap"), + resourceUniqueId: z.number().int(), + cap: z.number().positive(), + window: z + .object({ + fromDay: z.number().int().nonnegative(), + toDay: z.number().int().nonnegative(), + }) + .optional(), +}); + +export const PeakCapBlock: ConstraintBlock = { + id: "PeakCap", + schema: { input: PeakCapInputSchema, output: PeakCapOutputSchema }, + apply: ({ resourceUniqueId, cap, window }): PeakCapConstraint => ({ + kind: "PeakCap", + resourceUniqueId, + cap, + ...(window ? { window } : {}), + }), + toMiniZinc: ({ resourceUniqueId, cap, window }): MiniZincFragment => { + const dayFilter = window + ? `d in DAYS where d >= ${String(window.fromDay)} /\\ d < ${String(window.toDay)}` + : `d in DAYS`; + return { + text: + `constraint forall(${dayFilter}) ` + + `( sum(t in tasks_demanding[${String(resourceUniqueId)}]) ` + + `(bool2int(active[t,d]) * units[t,${String(resourceUniqueId)}]) <= ${String(cap)} );`, + }; + }, + doc: { + nl: "Summed fractional unit demand on the named resource may not exceed the cap on any working day (optionally within a day window).", + pseudocode: "forall day d [in window]: sum(units of active tasks demanding r) <= cap", + }, +}; diff --git a/src/level-blocks/release.ts b/src/level-blocks/release.ts new file mode 100644 index 0000000..970d8c0 --- /dev/null +++ b/src/level-blocks/release.ts @@ -0,0 +1,43 @@ +// Release — Constraint block. +// +// The task may not start before `earliestStart` (a day index in the +// resolved calendar frame) — material arrival, permit dates, or frozen +// in-progress work compiled down from a re-leveling run. +// +// MiniZinc compile contract: harness supplies `start_day[t]`. + +import { z } from "zod"; + +import type { ReleaseConstraint } from "../level-core/types.ts"; + +import type { ConstraintBlock, MiniZincFragment } from "./types.ts"; + +export const ReleaseInputSchema = z.object({ + taskUniqueId: z.number().int(), + earliestStart: z.number().int().nonnegative(), +}); + +export type ReleaseInput = z.infer; + +const ReleaseOutputSchema: z.ZodType = z.object({ + kind: z.literal("Release"), + taskUniqueId: z.number().int(), + earliestStart: z.number().int().nonnegative(), +}); + +export const ReleaseBlock: ConstraintBlock = { + id: "Release", + schema: { input: ReleaseInputSchema, output: ReleaseOutputSchema }, + apply: ({ taskUniqueId, earliestStart }): ReleaseConstraint => ({ + kind: "Release", + taskUniqueId, + earliestStart, + }), + toMiniZinc: ({ taskUniqueId, earliestStart }): MiniZincFragment => ({ + text: `constraint start_day[${String(taskUniqueId)}] >= ${String(earliestStart)};`, + }), + doc: { + nl: "The task may not start before the given day index.", + pseudocode: "start(task) >= earliestStart", + }, +}; diff --git a/src/level-blocks/serialSgsSearch.ts b/src/level-blocks/serialSgsSearch.ts new file mode 100644 index 0000000..4250c29 --- /dev/null +++ b/src/level-blocks/serialSgsSearch.ts @@ -0,0 +1,41 @@ +// SerialSGS — Search block. +// +// Wraps the greedy serial Schedule Generation Scheme as a registry-named +// search so a run config can select the backend declaratively. Future +// backends (CP-SAT, MiniZinc subprocess) register beside it under their own +// versioned codes rather than forking the run API. + +import { z } from "zod"; + +import { serialSGS } from "../level-core/search/serialSGS.ts"; +import type { Search } from "../level-core/types.ts"; + +import type { MiniZincFragment, SearchBlock } from "./types.ts"; + +export const SerialSGSInputSchema = z.object({}); + +export type SerialSGSInput = z.infer; + +const SearchOutputSchema: z.ZodType = z.custom( + (val): val is Search => + typeof val === "object" && + val !== null && + typeof (val as Search).name === "string" && + typeof (val as Search).run === "function", +); + +export const SerialSGSBlock: SearchBlock = { + id: "SerialSGS", + schema: { input: SerialSGSInputSchema, output: SearchOutputSchema }, + apply: (): Search => serialSGS, + toMiniZinc: (): MiniZincFragment => ({ + // The search itself never lowers to MiniZinc — a MiniZinc backend + // replaces it wholesale. Emitted as a comment so concatenated compiles + // stay valid. + text: `% search: greedy serial-SGS (in-process; not a MiniZinc construct)`, + }), + doc: { + nl: "Greedy serial schedule-generation scheme: topological order, earliest feasible placement per task. Deterministic, single schedule per run.", + pseudocode: "for task in topo order: place at earliest day satisfying precedence + caps", + }, +}; diff --git a/src/level-core/types.ts b/src/level-core/types.ts index 08308ff..9482cd9 100644 --- a/src/level-core/types.ts +++ b/src/level-core/types.ts @@ -176,7 +176,9 @@ export interface PeakCapConstraint extends ConstraintMeta { readonly kind: "PeakCap"; readonly resourceUniqueId: number; readonly cap: number; - readonly window?: { readonly fromDay: DayIndex; readonly toDay: DayIndex }; + // `| undefined` so zod's `.optional()` round-trips under + // exactOptionalPropertyTypes (same convention as WorkUnit). + readonly window?: { readonly fromDay: DayIndex; readonly toDay: DayIndex } | undefined; } // WIP cap on whole work units. Prioritizes *completion*: caps how many units diff --git a/src/leveling.ts b/src/leveling.ts index a5ddf80..f8dafda 100644 --- a/src/leveling.ts +++ b/src/leveling.ts @@ -72,8 +72,13 @@ export * from "./level-blocks/compile.ts"; // ── Blocks (configured constraints & scorers + MiniZinc fragments) ─── export * from "./level-blocks/types.ts"; export * from "./level-blocks/maxConcurrentResource.ts"; +export * from "./level-blocks/peakCap.ts"; +export * from "./level-blocks/deadline.ts"; +export * from "./level-blocks/release.ts"; export * from "./level-blocks/concurrentResourceCost.ts"; export * from "./level-blocks/concurrentUnitsLimit.ts"; export * from "./level-blocks/hiringLagPenalty.ts"; export * from "./level-blocks/openUnitPenalty.ts"; export * from "./level-blocks/unimodalDeviation.ts"; +export * from "./level-blocks/makespan.ts"; +export * from "./level-blocks/serialSgsSearch.ts"; diff --git a/test/level-blocks/newBlocks.test.ts b/test/level-blocks/newBlocks.test.ts new file mode 100644 index 0000000..82e8e36 --- /dev/null +++ b/test/level-blocks/newBlocks.test.ts @@ -0,0 +1,68 @@ +// PeakCap / Deadline / Release constraint blocks, Makespan scorer block, +// SerialSGS search block. + +import { describe, expect, test } from "bun:test"; + +import { DeadlineBlock } from "../../src/level-blocks/deadline.ts"; +import { MakespanBlock } from "../../src/level-blocks/makespan.ts"; +import { PeakCapBlock } from "../../src/level-blocks/peakCap.ts"; +import { ReleaseBlock } from "../../src/level-blocks/release.ts"; +import { SerialSGSBlock } from "../../src/level-blocks/serialSgsSearch.ts"; +import { serialSGS } from "../../src/level-core/search/serialSGS.ts"; +import type { Schedule } from "../../src/level-core/types.ts"; + +import { MON_JAN_5, SAT_JAN_10, makeProject, makeTask, runOnce } from "../level-core/fixtures.ts"; + +describe("constraint blocks", () => { + test("PeakCap emits the constraint, with and without a window", () => { + expect(PeakCapBlock.apply({ resourceUniqueId: 100, cap: 2.5 })).toEqual({ + kind: "PeakCap", + resourceUniqueId: 100, + cap: 2.5, + }); + expect( + PeakCapBlock.apply({ resourceUniqueId: 100, cap: 2, window: { fromDay: 0, toDay: 10 } }), + ).toEqual({ + kind: "PeakCap", + resourceUniqueId: 100, + cap: 2, + window: { fromDay: 0, toDay: 10 }, + }); + expect( + PeakCapBlock.toMiniZinc({ resourceUniqueId: 100, cap: 2, window: { fromDay: 0, toDay: 10 } }) + .text, + ).toContain("d >= 0"); + }); + + test("Deadline and Release emit their constraints and reject bad input", () => { + expect(DeadlineBlock.apply({ taskUniqueId: 1, latestFinish: 30 })).toEqual({ + kind: "Deadline", + taskUniqueId: 1, + latestFinish: 30, + }); + expect(ReleaseBlock.apply({ taskUniqueId: 1, earliestStart: 5 })).toEqual({ + kind: "Release", + taskUniqueId: 1, + earliestStart: 5, + }); + expect(DeadlineBlock.schema.input.safeParse({ taskUniqueId: 1, latestFinish: -1 }).success).toBe( + false, + ); + }); +}); + +describe("Makespan block", () => { + test("scores a schedule's makespan", async () => { + const t1 = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }); + const schedule: Schedule = await runOnce(makeProject([t1]), []); + const scorer = MakespanBlock.apply({}); + expect(scorer.direction).toBe("min"); + expect(scorer.score(schedule)).toBe(schedule.makespan); + }); +}); + +describe("SerialSGS block", () => { + test("resolves to the serialSGS search singleton", () => { + expect(SerialSGSBlock.apply({})).toBe(serialSGS); + }); +});