From e09f0308eb17f3b7ca9fe489c33b3b502e11f18e Mon Sep 17 00:00:00 2001 From: "Russ T. Fugal" Date: Tue, 11 Aug 2026 00:39:39 -0600 Subject: [PATCH] feat(constraints): one-way CrewBorrow policy Add CrewBorrow as a versioned engineer block and core constraint. Derive preferred home and fallback borrow modes from resolved task duration and static assignments, preserving secondary demand and selecting the borrowed crew only when it enables an earlier start. Reject missing home assignments and overlapping mode policies with stable analysis codes. Regenerate the 19-block metadata artifact and cover schema validation, MiniZinc domains, realized assignments, preference semantics, and policy conflicts. --- README.md | 3 +- generated/block-meta.json | 78 +++++++++++++++++++++++ src/level-blocks/crewBorrow.ts | 53 ++++++++++++++++ src/level-blocks/standard.ts | 15 +++++ src/level-core/analyzeRun.ts | 41 ++++++++++++ src/level-core/errors.ts | 4 ++ src/level-core/search/serialSGS.ts | 46 +++++++++++++- src/level-core/types.ts | 19 ++++-- src/leveling.ts | 1 + test/level-blocks/crewBorrow.test.ts | 93 ++++++++++++++++++++++++++++ 10 files changed, 347 insertions(+), 6 deletions(-) create mode 100644 src/level-blocks/crewBorrow.ts create mode 100644 test/level-blocks/crewBorrow.test.ts 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"); + }); +}); -- 2.54.0