diff --git a/README.md b/README.md index 060e5b2..8340e4c 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,7 @@ Constraints are a discriminated union (`Constraint`) — the interchange format | `ConcurrentUnitsLimit` | ≤ N units have active work on any day; optional `discipline` scope (WIP) | Yes | | `Release` / `Deadline` | Earliest start / latest finish bounds per task | Yes | | `ModeSelection` | Multi-mode RCPSP — crew-size × duration trade-off per task | Yes | +| `CrewBorrow` | Prefer a task's home crew; substitute a borrow crew to start earlier | Yes | | `Calendars` | Baked into `resolveCalendar` upstream | n/a | | `UnitPrecedence` | A unit may not start until other units finish | No (annotation) | | `UnimodalProfile` | One-ramp-up-one-ramp-down shape on a resource histogram | No (annotation) | @@ -227,7 +228,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. `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. +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. `CrewBorrowBlock` is the concise one-way policy form: for every listed task, it derives the preferred home mode from static project assignments and a second equal-duration mode that substitutes the borrow resource while preserving all other demand. A task may be governed by exactly one `ModeSelection` or `CrewBorrow` policy. ### Declarative runs (`RunConfig`) diff --git a/generated/block-meta.json b/generated/block-meta.json index ce671d4..3be75ba 100644 --- a/generated/block-meta.json +++ b/generated/block-meta.json @@ -700,6 +700,84 @@ "modes" ] }, + { + "code": "levelset.CrewBorrow.v1", + "family": "constraint", + "tier": "engineer", + "doc": { + "nl": "Listed tasks prefer their static home-crew assignment, but may substitute the borrow crew when that starts the task earlier.", + "pseudocode": "modes(task) = [static demand, static demand with home resource replaced by borrow]" + }, + "defaultShadow": { + "taskUniqueIds": [ + 0 + ], + "homeResourceUniqueId": 0, + "borrowResourceUniqueId": 1 + }, + "assumptionTemplate": "listed tasks may substitute resource {borrowResourceUniqueId} for home resource {homeResourceUniqueId}", + "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 + } + }, + "homeResourceUniqueId": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "borrowResourceUniqueId": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "taskUniqueIds", + "homeResourceUniqueId", + "borrowResourceUniqueId" + ], + "additionalProperties": false + }, + "fields": [ + { + "path": "homeResourceUniqueId", + "kind": "number", + "label": "homeResourceUniqueId", + "integer": true, + "defaultValue": 0 + }, + { + "path": "borrowResourceUniqueId", + "kind": "number", + "label": "borrowResourceUniqueId", + "integer": true, + "defaultValue": 1 + } + ], + "variadic": [ + { + "path": "taskUniqueIds", + "label": "taskUniqueIds", + "itemKind": "number", + "integer": true, + "minItems": 1, + "emptySeed": 0, + "defaultValues": [ + 0 + ] + } + ], + "complexParams": [] + }, { "code": "levelset.ConcurrentResourceCost.v1", "family": "scorer", diff --git a/src/level-blocks/crewBorrow.ts b/src/level-blocks/crewBorrow.ts new file mode 100644 index 0000000..df3ed8e --- /dev/null +++ b/src/level-blocks/crewBorrow.ts @@ -0,0 +1,53 @@ +// CrewBorrow — a one-way crew substitution policy. +// +// The project remains the source of truth for task duration and static +// resource demand. For each governed task, serial-SGS derives two modes: +// home first, then the same demand with the declared home resource replaced +// by the borrow resource. Because mode order is preference, borrowing occurs +// only when it lets the task start strictly earlier. + +import { z } from "zod"; + +import type { CrewBorrowConstraint } from "../level-core/types.ts"; + +import type { ConstraintBlock, MiniZincFragment } from "./types.ts"; + +export const CrewBorrowInputSchema = z + .strictObject({ + taskUniqueIds: z.array(z.number().int()).min(1), + homeResourceUniqueId: z.number().int(), + borrowResourceUniqueId: z.number().int(), + }) + .refine((input) => new Set(input.taskUniqueIds).size === input.taskUniqueIds.length, { + message: "taskUniqueIds must be unique", + path: ["taskUniqueIds"], + }) + .refine((input) => input.homeResourceUniqueId !== input.borrowResourceUniqueId, { + message: "borrowResourceUniqueId must differ from homeResourceUniqueId", + path: ["borrowResourceUniqueId"], + }); + +export type CrewBorrowInput = z.infer; + +const CrewBorrowOutputSchema: z.ZodType = z.object({ + kind: z.literal("CrewBorrow"), + taskUniqueIds: z.array(z.number().int()), + homeResourceUniqueId: z.number().int(), + borrowResourceUniqueId: z.number().int(), +}); + +export const CrewBorrowBlock: ConstraintBlock = { + id: "CrewBorrow", + schema: { input: CrewBorrowInputSchema, output: CrewBorrowOutputSchema }, + apply: (input): CrewBorrowConstraint => ({ kind: "CrewBorrow", ...input }), + toMiniZinc: ({ taskUniqueIds }): MiniZincFragment => ({ + text: taskUniqueIds + .map((taskUniqueId) => `constraint mode[${String(taskUniqueId)}] in {0,1};`) + .join("\n"), + }), + doc: { + nl: "Listed tasks prefer their static home-crew assignment, but may substitute the borrow crew when that starts the task earlier.", + pseudocode: + "modes(task) = [static demand, static demand with home resource replaced by borrow]", + }, +}; diff --git a/src/level-blocks/standard.ts b/src/level-blocks/standard.ts index a007c55..3956025 100644 --- a/src/level-blocks/standard.ts +++ b/src/level-blocks/standard.ts @@ -11,6 +11,7 @@ import { ConcurrentResourceCostBlock } from "./concurrentResourceCost.ts"; import { ConcurrentUnitsLimitBlock } from "./concurrentUnitsLimit.ts"; +import { CrewBorrowBlock } from "./crewBorrow.ts"; import { DeadlineBlock } from "./deadline.ts"; import { GateBlock } from "./gate.ts"; import { HiringLagPenaltyBlock } from "./hiringLagPenalty.ts"; @@ -38,6 +39,7 @@ export const STANDARD_CODES = { release: "levelset.Release.v1", phaseRelease: "levelset.PhaseRelease.v1", modeSelection: "levelset.ModeSelection.v1", + crewBorrow: "levelset.CrewBorrow.v1", concurrentResourceCost: "levelset.ConcurrentResourceCost.v1", openUnitPenalty: "levelset.OpenUnitPenalty.v1", hiringLagPenalty: "levelset.HiringLagPenalty.v1", @@ -112,6 +114,19 @@ export function standardBlockDefs(): BlockDef[] { }, assumptionTemplate: "task {taskUniqueId} chooses among its listed duration/crew modes", }, + { + code: STANDARD_CODES.crewBorrow, + family: "constraint", + tier: "engineer", + block: CrewBorrowBlock as never, + defaultShadow: { + taskUniqueIds: [0], + homeResourceUniqueId: 0, + borrowResourceUniqueId: 1, + }, + assumptionTemplate: + "listed tasks may substitute resource {borrowResourceUniqueId} for home resource {homeResourceUniqueId}", + }, { code: STANDARD_CODES.concurrentResourceCost, family: "scorer", diff --git a/src/level-core/analyzeRun.ts b/src/level-core/analyzeRun.ts index 50c9140..ac5c245 100644 --- a/src/level-core/analyzeRun.ts +++ b/src/level-core/analyzeRun.ts @@ -72,6 +72,21 @@ export function analyzeRun( const constraintEdges: PrecedenceEdge[] = []; const unitEdges: Array<{ from: number; to: number; source: string }> = []; const seenNames = new Map(); + const modePolicyByTask = new Map(); + + const claimModePolicy = (taskUniqueId: number, source: string): void => { + const previous = modePolicyByTask.get(taskUniqueId); + if (previous === undefined) { + modePolicyByTask.set(taskUniqueId, source); + return; + } + issues.push({ + code: "LEVELSET_E111", + severity: "error", + source, + message: `task ${String(taskUniqueId)} is already governed by mode policy "${previous}" — use exactly one ModeSelection or CrewBorrow policy per task.`, + }); + }; for (const c of constraints) { const source = constraintLabel(c); @@ -135,11 +150,37 @@ export function analyzeRun( break; case "ModeSelection": checkTask(source, c.taskUniqueId); + claimModePolicy(c.taskUniqueId, source); for (const mode of c.modes) { for (const demand of mode.resourceDemand) checkResource(source, demand.resourceUniqueId); } if (c.modes.length === 0) warnEmpty(issues, source, "modes"); break; + case "CrewBorrow": + checkResource(source, c.homeResourceUniqueId); + checkResource(source, c.borrowResourceUniqueId); + for (const taskUniqueId of c.taskUniqueIds) { + checkTask(source, taskUniqueId); + claimModePolicy(taskUniqueId, source); + if ( + taskIds.has(taskUniqueId) && + resourceIds.has(c.homeResourceUniqueId) && + !resolved.assignments.some( + (assignment) => + assignment.taskUniqueId === taskUniqueId && + assignment.resourceUniqueId === c.homeResourceUniqueId, + ) + ) { + issues.push({ + code: "LEVELSET_E110", + severity: "error", + source, + message: `task ${String(taskUniqueId)} has no static assignment on homeResourceUniqueId ${String(c.homeResourceUniqueId)} — CrewBorrow has no home demand to substitute.`, + }); + } + } + if (c.taskUniqueIds.length === 0) warnEmpty(issues, source, "taskUniqueIds"); + break; case "CrewFlowContinuity": checkResource(source, c.resourceUniqueId); for (const unitId of c.unitOrder) checkUnit(source, unitId); diff --git a/src/level-core/errors.ts b/src/level-core/errors.ts index 915ccb8..dd8169d 100644 --- a/src/level-core/errors.ts +++ b/src/level-core/errors.ts @@ -22,6 +22,10 @@ export type RunIssueCode = | "LEVELSET_E108" /** A Calendars constraint references a calendar not in the project. */ | "LEVELSET_E109" + /** A CrewBorrow task has no static assignment on its declared home resource. */ + | "LEVELSET_E110" + /** More than one mode-producing policy governs the same task. */ + | "LEVELSET_E111" /** A constraint kind the chosen search records but does not enforce. */ | "LEVELSET_W201" /** A constraint governs an empty set (no units/modes/edges) — it can never bind. */ diff --git a/src/level-core/search/serialSGS.ts b/src/level-core/search/serialSGS.ts index 84ac9a4..78bc46c 100644 --- a/src/level-core/search/serialSGS.ts +++ b/src/level-core/search/serialSGS.ts @@ -13,6 +13,7 @@ import { } from "../calendarDays.ts"; import { type Constraint, + type CrewBorrowConstraint, type DeadlineConstraint, type Explanation, type Failure, @@ -66,7 +67,7 @@ interface Preprocessed { * the day, so violations attribute to the named instance that bound. */ readonly deadlines: ReadonlyMap; readonly releases: ReadonlyMap; - /** taskUniqueId → alternative modes, for ModeSelection-governed tasks only. */ + /** taskUniqueId → alternatives for ModeSelection/CrewBorrow-governed tasks. */ readonly modesByTask: ReadonlyMap>; readonly explanations: ReadonlyArray; } @@ -81,6 +82,7 @@ function preprocess( const deadlines = new Map(); const releases = new Map(); const modesByTask = new Map(); + const crewBorrowPolicies: CrewBorrowConstraint[] = []; const explanations: Explanation[] = []; const addCap = (resourceId: number, cap: PerResourceCap): void => { @@ -174,6 +176,9 @@ function preprocess( } break; } + case "CrewBorrow": + crewBorrowPolicies.push(c); + break; case "UnitPrecedence": case "UnimodalProfile": case "CrewFlowContinuity": @@ -191,9 +196,48 @@ function preprocess( } } + // CrewBorrow is authored as a policy over task ids, but uses the same + // placement-mode wire as explicit ModeSelection. Static assignments remain + // the source of duration and units; mode 0 is home and mode 1 substitutes + // only the named home resource. analyzeRun rejects overlapping policies. + const taskById = new Map(resolved.tasks.map((task) => [task.uniqueId, task])); + for (const policy of crewBorrowPolicies) { + for (const taskUniqueId of policy.taskUniqueIds) { + const task = taskById.get(taskUniqueId); + if (!task) continue; + const homeDemand = mergeDemand( + resolved.assignments.filter((assignment) => assignment.taskUniqueId === taskUniqueId), + ); + const borrowDemand = mergeDemand( + homeDemand.map((demand) => + demand.resourceUniqueId === policy.homeResourceUniqueId + ? { ...demand, resourceUniqueId: policy.borrowResourceUniqueId } + : demand, + ), + ); + modesByTask.set(taskUniqueId, [ + { modeId: 0, durationDays: task.durationDays, demand: homeDemand }, + { modeId: 1, durationDays: task.durationDays, demand: borrowDemand }, + ]); + } + } + return { edges, caps, unitCaps, deadlines, releases, modesByTask, explanations }; } +function mergeDemand( + demand: ReadonlyArray<{ readonly resourceUniqueId: number; readonly units: number }>, +): Array<{ resourceUniqueId: number; units: number }> { + const unitsByResource = new Map(); + for (const item of demand) { + unitsByResource.set( + item.resourceUniqueId, + (unitsByResource.get(item.resourceUniqueId) ?? 0) + item.units, + ); + } + return [...unitsByResource].map(([resourceUniqueId, units]) => ({ resourceUniqueId, units })); +} + function topoSort( tasks: ReadonlyArray, edges: ReadonlyArray, diff --git a/src/level-core/types.ts b/src/level-core/types.ts index 703998a..6f65c94 100644 --- a/src/level-core/types.ts +++ b/src/level-core/types.ts @@ -130,6 +130,7 @@ export type Constraint = | UnitPrecedenceConstraint | UnimodalProfileConstraint | ModeSelectionConstraint + | CrewBorrowConstraint | CrewFlowContinuityConstraint | DeadlineConstraint | ReleaseConstraint; @@ -224,6 +225,16 @@ export interface ModeSelectionConstraint extends ConstraintMeta { readonly modes: ReadonlyArray; } +/** One-way crew substitution policy. Each governed task prefers its static + * assignment on `homeResourceUniqueId`; when that crew is unavailable, the + * same units may be supplied by `borrowResourceUniqueId` to start earlier. */ +export interface CrewBorrowConstraint extends ConstraintMeta { + readonly kind: "CrewBorrow"; + readonly taskUniqueIds: ReadonlyArray; + readonly homeResourceUniqueId: number; + readonly borrowResourceUniqueId: number; +} + export interface TaskMode { readonly modeId: number; readonly durationDays: number; @@ -382,8 +393,8 @@ export interface ScheduledTask { readonly uniqueId: number; readonly startDay: DayIndex; readonly finishDay: DayIndex; - /** Chosen TaskMode.modeId for a ModeSelection-governed task; null for tasks - * scheduled in their single static-assignment mode. */ + /** Chosen mode id for a ModeSelection/CrewBorrow-governed task; null for + * tasks scheduled in their single static-assignment mode. */ readonly modeId: number | null; } @@ -393,8 +404,8 @@ export interface Schedule { readonly makespan: DayIndex; /** Per-block annotations (scores, telemetry). Not for violations. */ readonly annotations: ReadonlyMap; - /** Realized resource demand for ModeSelection-governed tasks — the chosen - * mode's `resourceDemand`, flattened to assignments. When a task appears + /** Realized resource demand for mode-governed tasks — the chosen mode's + * resource demand, flattened to assignments. When a task appears * here it is the AUTHORITATIVE load for that task: every resource/headcount/ * concurrency view (buildResourceLoad, materialize) must use these in place * of the task's static `resolved.assignments`. Populated for every diff --git a/src/leveling.ts b/src/leveling.ts index 045fe56..a1ede47 100644 --- a/src/leveling.ts +++ b/src/leveling.ts @@ -96,6 +96,7 @@ export * from "./level-blocks/phaseRelease.ts"; export * from "./level-blocks/concurrentResourceCost.ts"; export * from "./level-blocks/concurrentUnitsLimit.ts"; export * from "./level-blocks/modeSelection.ts"; +export * from "./level-blocks/crewBorrow.ts"; export * from "./level-blocks/hiringLagPenalty.ts"; export * from "./level-blocks/openUnitPenalty.ts"; export * from "./level-blocks/phaseScoring.ts"; diff --git a/test/level-blocks/crewBorrow.test.ts b/test/level-blocks/crewBorrow.test.ts new file mode 100644 index 0000000..b4fa856 --- /dev/null +++ b/test/level-blocks/crewBorrow.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; + +import { CrewBorrowBlock } from "../../src/level-blocks/crewBorrow.ts"; +import { MaxConcurrentResourceBlock } from "../../src/level-blocks/maxConcurrentResource.ts"; +import { analyzeRun } from "../../src/level-core/analyzeRun.ts"; +import { resolveCalendar } from "../../src/level-core/resolveCalendar.ts"; + +import { + MON_JAN_5, + SAT_JAN_10, + makeAssignment, + makeProject, + makeResource, + makeTask, + runOnce, +} from "../level-core/fixtures.ts"; + +function borrowProject() { + const tasks = [1, 2].map((uniqueId) => + makeTask({ uniqueId, start: MON_JAN_5, finish: SAT_JAN_10 }), + ); + return makeProject(tasks, { + resources: [ + makeResource(100, "Home"), + makeResource(200, "Borrow"), + makeResource(300, "Shared support"), + ], + assignments: [ + makeAssignment(1, 100), + makeAssignment(1, 300, 0.5), + makeAssignment(2, 100), + makeAssignment(2, 300, 0.5), + ], + }); +} + +describe("CrewBorrow block", () => { + const input = { + taskUniqueIds: [1, 2], + homeResourceUniqueId: 100, + borrowResourceUniqueId: 200, + }; + + test("emits the policy and rejects ambiguous inputs", () => { + expect(CrewBorrowBlock.apply(input)).toEqual({ kind: "CrewBorrow", ...input }); + expect(CrewBorrowBlock.toMiniZinc(input).text).toBe( + "constraint mode[1] in {0,1};\nconstraint mode[2] in {0,1};", + ); + expect( + CrewBorrowBlock.schema.input.safeParse({ ...input, taskUniqueIds: [1, 1] }).success, + ).toBe(false); + expect( + CrewBorrowBlock.schema.input.safeParse({ ...input, borrowResourceUniqueId: 100 }).success, + ).toBe(false); + }); + + test("keeps the preferred home crew unless borrowing starts earlier", async () => { + const schedule = await runOnce(borrowProject(), [ + CrewBorrowBlock.apply(input), + MaxConcurrentResourceBlock.apply({ resourceUniqueId: 100, max: 1 }), + MaxConcurrentResourceBlock.apply({ resourceUniqueId: 200, max: 1 }), + ]); + const tasks = new Map(schedule.tasks.map((task) => [task.uniqueId, task])); + expect(tasks.get(1)).toMatchObject({ startDay: 0, modeId: 0 }); + expect(tasks.get(2)).toMatchObject({ startDay: 0, modeId: 1 }); + + const task2Demand = schedule + .realizedAssignments!.filter((assignment) => assignment.taskUniqueId === 2) + .map(({ resourceUniqueId, units }) => ({ resourceUniqueId, units })); + expect(task2Demand).toEqual([ + { resourceUniqueId: 200, units: 1 }, + { resourceUniqueId: 300, units: 0.5 }, + ]); + }); + + test("analysis rejects missing home demand and overlapping mode policies", () => { + const resolved = resolveCalendar(borrowProject()); + const missingHome = analyzeRun(resolved, [ + CrewBorrowBlock.apply({ ...input, taskUniqueIds: [1], homeResourceUniqueId: 200 }), + ]); + expect(missingHome.issues.map((issue) => issue.code)).toContain("LEVELSET_E110"); + + const overlap = analyzeRun(resolved, [ + CrewBorrowBlock.apply({ ...input, taskUniqueIds: [1] }), + { + kind: "ModeSelection", + taskUniqueId: 1, + modes: [{ modeId: 9, durationDays: 1, resourceDemand: [] }], + }, + ]); + expect(overlap.issues.map((issue) => issue.code)).toContain("LEVELSET_E111"); + }); +});