diff --git a/README.md b/README.md index eb7d2df..060e5b2 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ const best = await stream.bestBy(wipPenalty); > **Note:** `serialSGS` emits one schedule per concrete config. A GridSweep widens that into one candidate per feasible cell, making scorers and collectors selection levers across the grid; without a sweep, `bestBy` over serialSGS still returns its single schedule. Hard constraints (the table above) are what shape each serialSGS result. -Constraint blocks pair with the hard variants — `MaxConcurrentResourceBlock`, `PeakCapBlock`, `ConcurrentUnitsLimitBlock`, `DeadlineBlock`, `ReleaseBlock`, and `ModeSelectionBlock` build the corresponding `Constraint` and a MiniZinc fragment. `ModeSelectionBlock` lists alternative duration/resource-demand modes per task in preference order; the search picks a later mode only when it starts the task strictly earlier, and the chosen `modeId` and realized demand land on the emitted schedule. +Constraint blocks pair with the hard variants — `MaxConcurrentResourceBlock`, `PeakCapBlock`, `ConcurrentUnitsLimitBlock`, `DeadlineBlock`, `ReleaseBlock`, and `ModeSelectionBlock` build the corresponding `Constraint` and a MiniZinc fragment. `PhaseReleaseBlock` is the group authoring form: one shared phase boundary expands to an attributed `Release` for every listed task. `ModeSelectionBlock` lists alternative duration/resource-demand modes per task in preference order; the search picks a later mode only when it starts the task strictly earlier, and the chosen `modeId` and realized demand land on the emitted schedule. ### Declarative runs (`RunConfig`) diff --git a/generated/block-meta.json b/generated/block-meta.json index 1193246..ce671d4 100644 --- a/generated/block-meta.json +++ b/generated/block-meta.json @@ -543,6 +543,70 @@ "variadic": [], "complexParams": [] }, + { + "code": "levelset.PhaseRelease.v1", + "family": "constraint", + "tier": "estimator", + "doc": { + "nl": "Every task in the phase may start no earlier than the shared day index.", + "pseudocode": "for task in phase: start(task) >= earliestStart" + }, + "defaultShadow": { + "taskUniqueIds": [ + 0 + ], + "earliestStart": 0 + }, + "assumptionTemplate": "phase tasks start no earlier than day {earliestStart}", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "taskUniqueIds": { + "minItems": 1, + "type": "array", + "items": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "earliestStart": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "taskUniqueIds", + "earliestStart" + ] + }, + "fields": [ + { + "path": "earliestStart", + "kind": "number", + "label": "earliestStart", + "min": 0, + "integer": true, + "defaultValue": 0 + } + ], + "variadic": [ + { + "path": "taskUniqueIds", + "label": "taskUniqueIds", + "itemKind": "number", + "integer": true, + "minItems": 1, + "emptySeed": 0, + "defaultValues": [ + 0 + ] + } + ], + "complexParams": [] + }, { "code": "levelset.ModeSelection.v1", "family": "constraint", diff --git a/src/level-blocks/phaseRelease.ts b/src/level-blocks/phaseRelease.ts new file mode 100644 index 0000000..fba5ba6 --- /dev/null +++ b/src/level-blocks/phaseRelease.ts @@ -0,0 +1,47 @@ +// PhaseRelease — one authored phase expands to ordinary per-task Release +// constraints. The engine wire stays the existing Release ADT; the block +// compiler owns the one-to-many expansion and gives each emitted constraint +// an attributed sub-instance name. + +import { z } from "zod"; + +import type { ReleaseConstraint } from "../level-core/types.ts"; + +import type { MiniZincFragment, MultiConstraintBlock } from "./types.ts"; + +export const PhaseReleaseInputSchema = z + .object({ + taskUniqueIds: z.array(z.number().int()).min(1), + earliestStart: z.number().int().nonnegative(), + }) + .refine((input) => new Set(input.taskUniqueIds).size === input.taskUniqueIds.length, { + message: "taskUniqueIds must be unique within a phase", + path: ["taskUniqueIds"], + }); + +export type PhaseReleaseInput = z.infer; + +const ReleaseOutputSchema: z.ZodType = z.object({ + kind: z.literal("Release"), + taskUniqueId: z.number().int(), + earliestStart: z.number().int().nonnegative(), +}); + +export const PhaseReleaseBlock: MultiConstraintBlock = { + id: "PhaseRelease", + schema: { input: PhaseReleaseInputSchema, output: z.array(ReleaseOutputSchema) }, + apply: ({ taskUniqueIds, earliestStart }): ReleaseConstraint[] => + taskUniqueIds.map((taskUniqueId) => ({ kind: "Release", taskUniqueId, earliestStart })), + toMiniZinc: ({ taskUniqueIds, earliestStart }): MiniZincFragment => ({ + text: taskUniqueIds + .map( + (taskUniqueId) => + `constraint start_day[${String(taskUniqueId)}] >= ${String(earliestStart)};`, + ) + .join("\n"), + }), + doc: { + nl: "Every task in the phase may start no earlier than the shared day index.", + pseudocode: "for task in phase: start(task) >= earliestStart", + }, +}; diff --git a/src/level-blocks/runConfig.ts b/src/level-blocks/runConfig.ts index 6f6419b..8f5f8a9 100644 --- a/src/level-blocks/runConfig.ts +++ b/src/level-blocks/runConfig.ts @@ -432,11 +432,19 @@ function runTarget(config: RunConfig): CompileTarget { fromFamilies(families): CompiledRun { const constraints: Constraint[] = []; for (const compiled of families.constraints) { - const constraint = compiled.output as Constraint; - constraints.push({ - ...constraint, - name: constraint.name ?? compiled.instance.instanceId, - enabled: compiled.instance.enabled, + const emitted = Array.isArray(compiled.output) + ? (compiled.output as Constraint[]) + : [compiled.output as Constraint]; + emitted.forEach((constraint, index) => { + constraints.push({ + ...constraint, + name: + constraint.name ?? + (emitted.length === 1 + ? compiled.instance.instanceId + : `${compiled.instance.instanceId}[${String(index)}]`), + enabled: compiled.instance.enabled, + }); }); } diff --git a/src/level-blocks/standard.ts b/src/level-blocks/standard.ts index b1d17f6..a007c55 100644 --- a/src/level-blocks/standard.ts +++ b/src/level-blocks/standard.ts @@ -20,6 +20,7 @@ import { MaxConcurrentResourceBlock } from "./maxConcurrentResource.ts"; import { ModeSelectionBlock } from "./modeSelection.ts"; import { OpenUnitPenaltyBlock } from "./openUnitPenalty.ts"; import { ParetoBlock } from "./pareto.ts"; +import { PhaseReleaseBlock } from "./phaseRelease.ts"; import { PeakCapBlock } from "./peakCap.ts"; import { PhaseEarlinessBlock } from "./phaseEarliness.ts"; import { PhaseLatenessBlock } from "./phaseLateness.ts"; @@ -35,6 +36,7 @@ export const STANDARD_CODES = { concurrentUnitsLimit: "levelset.ConcurrentUnitsLimit.v1", deadline: "levelset.Deadline.v1", release: "levelset.Release.v1", + phaseRelease: "levelset.PhaseRelease.v1", modeSelection: "levelset.ModeSelection.v1", concurrentResourceCost: "levelset.ConcurrentResourceCost.v1", openUnitPenalty: "levelset.OpenUnitPenalty.v1", @@ -91,6 +93,14 @@ export function standardBlockDefs(): BlockDef[] { defaultShadow: { taskUniqueId: 0, earliestStart: 0 }, assumptionTemplate: "task {taskUniqueId} starts no earlier than day {earliestStart}", }, + { + code: STANDARD_CODES.phaseRelease, + family: "constraint", + tier: "estimator", + block: PhaseReleaseBlock as never, + defaultShadow: { taskUniqueIds: [0], earliestStart: 0 }, + assumptionTemplate: "phase tasks start no earlier than day {earliestStart}", + }, { code: STANDARD_CODES.modeSelection, family: "constraint", diff --git a/src/level-blocks/types.ts b/src/level-blocks/types.ts index 111369f..a119dd7 100644 --- a/src/level-blocks/types.ts +++ b/src/level-blocks/types.ts @@ -54,6 +54,9 @@ export interface Block { } export type ConstraintBlock = Block; +/** A policy/group block that expands one authored instance into several + * ordinary engine constraints. */ +export type MultiConstraintBlock = Block>; export type ScoringBlock = Block; export type SearchBlock = Block; export type CollectorBlock = Block; diff --git a/src/leveling.ts b/src/leveling.ts index 6626e28..045fe56 100644 --- a/src/leveling.ts +++ b/src/leveling.ts @@ -92,6 +92,7 @@ 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/phaseRelease.ts"; export * from "./level-blocks/concurrentResourceCost.ts"; export * from "./level-blocks/concurrentUnitsLimit.ts"; export * from "./level-blocks/modeSelection.ts"; diff --git a/test/level-blocks/newBlocks.test.ts b/test/level-blocks/newBlocks.test.ts index 9f4a755..4bb6fab 100644 --- a/test/level-blocks/newBlocks.test.ts +++ b/test/level-blocks/newBlocks.test.ts @@ -8,6 +8,7 @@ import { MakespanBlock } from "../../src/level-blocks/makespan.ts"; import { MaxConcurrentResourceBlock } from "../../src/level-blocks/maxConcurrentResource.ts"; import { ModeSelectionBlock } from "../../src/level-blocks/modeSelection.ts"; import { PeakCapBlock } from "../../src/level-blocks/peakCap.ts"; +import { PhaseReleaseBlock } from "../../src/level-blocks/phaseRelease.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"; @@ -59,6 +60,19 @@ describe("constraint blocks", () => { DeadlineBlock.schema.input.safeParse({ taskUniqueId: 1, latestFinish: -1 }).success, ).toBe(false); }); + + test("PhaseRelease expands a shared boundary into attributed Release constraints", () => { + expect(PhaseReleaseBlock.apply({ taskUniqueIds: [1, 2], earliestStart: 5 })).toEqual([ + { kind: "Release", taskUniqueId: 1, earliestStart: 5 }, + { kind: "Release", taskUniqueId: 2, earliestStart: 5 }, + ]); + expect(PhaseReleaseBlock.toMiniZinc({ taskUniqueIds: [1, 2], earliestStart: 5 }).text).toBe( + "constraint start_day[1] >= 5;\nconstraint start_day[2] >= 5;", + ); + expect( + PhaseReleaseBlock.schema.input.safeParse({ taskUniqueIds: [1, 1], earliestStart: 5 }).success, + ).toBe(false); + }); }); describe("ModeSelection block", () => { diff --git a/test/level-blocks/runConfig.test.ts b/test/level-blocks/runConfig.test.ts index 33ba0a4..e8168a0 100644 --- a/test/level-blocks/runConfig.test.ts +++ b/test/level-blocks/runConfig.test.ts @@ -142,6 +142,22 @@ describe("runFromConfig", () => { expect(await run.best()).not.toBeNull(); }); + test("a PhaseRelease instance expands to per-task releases", async () => { + const config = baseConfig(); + config.pipeline.blocks.unshift({ + instanceId: "phase-release", + code: STANDARD_CODES.phaseRelease, + params: { taskUniqueIds: [1, 2], earliestStart: 3 }, + }); + const run = runFromConfig(crewProject(), config); + expect(run.constraints.slice(0, 2).map((constraint) => constraint.name)).toEqual([ + "phase-release[0]", + "phase-release[1]", + ]); + const best = await run.best(); + expect(best!.tasks.every((task) => task.startDay >= 3)).toBe(true); + }); + test("filled config round-trips: running it again reproduces the schedule", async () => { const run1 = runFromConfig(crewProject(), baseConfig()); const best1 = await run1.best();