A functional toolkit for authoring resource-leveling algorithms — compose constraint and scoring blocks, enumerate feasible schedules
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(level-blocks): phase completion-priority lateness/earliness scorers

Generalize earlier phase-lateness and early-start scoring terms into
standard registry blocks. Phases are data (an array of
{ name?, taskUniqueIds, targetDay, weight? }) rather than hardcoded:

- PhaseLateness charges the working days a phase's latest task finishes past
its target day; PhaseEarliness charges the working days its earliest task
starts before its target. Each is weighted by the phase's
completion-priority weight (an earlier completionWeight, a static per-phase
priority) and summed. Two blocks, not one moded block: the originals anchor
them on different targets (window end vs start) and rank them in different
lexicographic tiers, and a single phase carries both at once.
- Deviation is counted in working days via the shared calendar prefix sum;
the per-day scale (phaseLatenessPerDay/earlyStartPerDay in the original
formulation) comes from the run config's scorer weight. Both declare
reads.taskUniqueIds so analyzeRun flags a phase pointing at a missing task.

Registered as levelset.PhaseLateness.v1 / levelset.PhaseEarliness.v1
(estimator tier); shared logic in phaseScoring.ts. Tests cover schema,
hand-computed scoring on the Mon-Fri fixture (including a realistic-shaped
partially-complete phase), the MiniZinc fragment, and RunConfig wiring.

Russ T. Fugal (Jul 16, 2026, 4:18 PM -0600) cbfc2d53 99b3ec41

+518
+2
README.md
··· 209 209 | `ConcurrentResourceCostBlock` | Each marginal concurrent worker on an exponential curve (soft `MaxConcurrentResource`) | 210 210 | `OpenUnitPenaltyBlock` | Each open-unit-day beyond a `softMax` — a soft WIP limit (soft `ConcurrentUnitsLimit`) | 211 211 | `HiringLagPenaltyBlock` | Week-over-week headcount increases (training/ramp cost before productivity) | 212 + | `PhaseLatenessBlock` | Working days each phase's latest task finishes past its target, × phase weight | 213 + | `PhaseEarlinessBlock` | Working days each phase's earliest task starts before its target, × phase weight | 212 214 | `UnimodalDeviationBlock` | How far a resource histogram departs from a single-peak shape | 213 215 214 216 ```ts
+73
src/level-blocks/phaseEarliness.ts
··· 1 + // PhaseEarliness — Scoring block (companion to PhaseLateness, generalized 2 + // from an earlier early-start term). 3 + // 4 + // The mirror of PhaseLateness on the start side. Each phase has a target 5 + // earliest day (its planned window start). A phase's realized start is the 6 + // EARLIEST start across its tasks; the penalty is the working days that 7 + // start precedes the target, weighted by the phase's completion-priority 8 + // `weight` and summed over phases: 9 + // 10 + // penalty = sum_p weight_p × workingDays(phaseStart_p, target_p) [start < target] 11 + // 12 + // Why a phase should not start too early: pulling a phase in ahead of plan 13 + // costs factory-build / customer-readiness pressure. The original formulation 14 + // charged this term in a lower objective tier than lateness — earliness 15 + // trades off against cost, whereas lateness is the hard priority — so this is 16 + // a separate block, not a mode of PhaseLateness: the two anchor on different 17 + // target days (window start vs window end) and land in different objective 18 + // tiers, and a single phase carries both at once. The per-day scale 19 + // (`earlyStartPerDay` in the original formulation) comes from the run 20 + // config's scorer weight. 21 + // 22 + // MiniZinc compile contract: harness supplies `start_day[t]` and the 23 + // working-day set `DAYS`. 24 + 25 + import { z } from "zod"; 26 + 27 + import type { Schedule, Scorer } from "../level-core/types.ts"; 28 + 29 + import { 30 + PhaseWindowSchema, 31 + phaseDeviation, 32 + phaseDeviationMiniZinc, 33 + phaseTaskIds, 34 + } from "./phaseScoring.ts"; 35 + import type { MiniZincFragment, ScoringBlock } from "./types.ts"; 36 + 37 + export const PhaseEarlinessInputSchema = z.object({ 38 + phases: z.array(PhaseWindowSchema), 39 + }); 40 + 41 + export type PhaseEarlinessInput = z.infer<typeof PhaseEarlinessInputSchema>; 42 + 43 + const ScorerOutputSchema: z.ZodType<Scorer> = z.custom<Scorer>( 44 + (val): val is Scorer => 45 + typeof val === "object" && 46 + val !== null && 47 + typeof (val as Scorer).name === "string" && 48 + ((val as Scorer).direction === "min" || (val as Scorer).direction === "max") && 49 + typeof (val as Scorer).score === "function", 50 + ); 51 + 52 + export const PhaseEarlinessBlock: ScoringBlock<PhaseEarlinessInput> = { 53 + id: "PhaseEarliness", 54 + schema: { input: PhaseEarlinessInputSchema, output: ScorerOutputSchema }, 55 + apply: (input): Scorer => { 56 + const { phases } = PhaseEarlinessInputSchema.parse(input); 57 + return { 58 + name: "PhaseEarliness", 59 + direction: "min", 60 + reads: { taskUniqueIds: phaseTaskIds(phases) }, 61 + score: (schedule: Schedule): number => phaseDeviation(schedule, phases, "start"), 62 + }; 63 + }, 64 + toMiniZinc: (input): MiniZincFragment => { 65 + const { phases } = PhaseEarlinessInputSchema.parse(input); 66 + return { text: phaseDeviationMiniZinc("phase_earliness", phases, "start") }; 67 + }, 68 + doc: { 69 + nl: "Early-start penalty: for each phase, the working days its earliest task starts before the phase's target day, weighted by the phase's completion-priority weight and summed. Starting on or after target costs nothing.", 70 + pseudocode: 71 + "sum_p weight_p * workingDays(min(start(t) for t in phase_p), target_p) when that start is early", 72 + }, 73 + };
+71
src/level-blocks/phaseLateness.ts
··· 1 + // PhaseLateness — Scoring block (completion-priority, generalized from an 2 + // earlier phase-lateness term). 3 + // 4 + // A schedule is carved into phases (groups of tasks). Each phase has a 5 + // target completion day (its deadline / window end). A phase's realized 6 + // finish is the LATEST finish across its tasks; the penalty is the working 7 + // days that finish overruns the target, weighted by the phase's 8 + // completion-priority `weight` and summed over phases: 9 + // 10 + // penalty = sum_p weight_p × workingDays(target_p, phaseFinish_p) [finish > target] 11 + // 12 + // This is the hard-priority "finish the phases on time" pressure. Earlier or 13 + // more-critical phases get a heavier `weight`, so their slippage dominates — 14 + // the original formulation used weights 3/2/1 across three phases and ranked 15 + // this term first in a lexicographic objective. The per-day scale 16 + // (`phaseLatenessPerDay` in the original formulation) is supplied by the run 17 + // config's scorer weight, not baked in here. `weight` here is a static 18 + // per-phase priority, NOT a dynamic fraction of tasks complete. 19 + // 20 + // MiniZinc compile contract: harness supplies `finish_day[t]` and the 21 + // working-day set `DAYS`. 22 + 23 + import { z } from "zod"; 24 + 25 + import type { Schedule, Scorer } from "../level-core/types.ts"; 26 + 27 + import { 28 + PhaseWindowSchema, 29 + phaseDeviation, 30 + phaseDeviationMiniZinc, 31 + phaseTaskIds, 32 + } from "./phaseScoring.ts"; 33 + import type { MiniZincFragment, ScoringBlock } from "./types.ts"; 34 + 35 + export const PhaseLatenessInputSchema = z.object({ 36 + phases: z.array(PhaseWindowSchema), 37 + }); 38 + 39 + export type PhaseLatenessInput = z.infer<typeof PhaseLatenessInputSchema>; 40 + 41 + const ScorerOutputSchema: z.ZodType<Scorer> = z.custom<Scorer>( 42 + (val): val is Scorer => 43 + typeof val === "object" && 44 + val !== null && 45 + typeof (val as Scorer).name === "string" && 46 + ((val as Scorer).direction === "min" || (val as Scorer).direction === "max") && 47 + typeof (val as Scorer).score === "function", 48 + ); 49 + 50 + export const PhaseLatenessBlock: ScoringBlock<PhaseLatenessInput> = { 51 + id: "PhaseLateness", 52 + schema: { input: PhaseLatenessInputSchema, output: ScorerOutputSchema }, 53 + apply: (input): Scorer => { 54 + const { phases } = PhaseLatenessInputSchema.parse(input); 55 + return { 56 + name: "PhaseLateness", 57 + direction: "min", 58 + reads: { taskUniqueIds: phaseTaskIds(phases) }, 59 + score: (schedule: Schedule): number => phaseDeviation(schedule, phases, "finish"), 60 + }; 61 + }, 62 + toMiniZinc: (input): MiniZincFragment => { 63 + const { phases } = PhaseLatenessInputSchema.parse(input); 64 + return { text: phaseDeviationMiniZinc("phase_lateness", phases, "finish") }; 65 + }, 66 + doc: { 67 + nl: "Completion-priority penalty: for each phase, the working days its latest task finishes past the phase's target day, weighted by the phase's completion-priority weight and summed. Finishing on or before target costs nothing.", 68 + pseudocode: 69 + "sum_p weight_p * workingDays(target_p, max(finish(t) for t in phase_p)) when that finish is late", 70 + }, 71 + };
+120
src/level-blocks/phaseScoring.ts
··· 1 + // Shared phase-window scoring for the completion-priority scorer blocks 2 + // (PhaseLateness / PhaseEarliness). 3 + // 4 + // A *phase* is a named group of tasks with a target day in the resolved 5 + // calendar frame. Its realized boundary is derived from the schedule: 6 + // • finish boundary = the latest finish across the phase's tasks (max) — 7 + // the day the phase actually completes; 8 + // • start boundary = the earliest start across the phase's tasks (min) — 9 + // the day the phase actually begins. 10 + // Deviation from the target is measured in WORKING days (the same 11 + // prefix-sum count the rest of level-core uses), then weighted per phase and 12 + // summed. `weight` is the phase's completion-priority weight — the 13 + // generalization of an earlier `completionWeight` (earlier/more-critical 14 + // phases carry a heavier weight so their slippage dominates). It is NOT a 15 + // dynamic fraction of tasks complete; it is a static per-phase priority the 16 + // caller sets. A phase with no scheduled tasks contributes nothing. 17 + 18 + import { z } from "zod"; 19 + 20 + import { countWorkingDays, resolveWorkingCalendar } from "../level-core/calendarDays.ts"; 21 + import { scheduleProfiles } from "../level-core/profiles.ts"; 22 + import type { Schedule } from "../level-core/types.ts"; 23 + 24 + export const PhaseWindowSchema = z.object({ 25 + /** Optional label, purely for readability of a config. */ 26 + name: z.string().optional(), 27 + /** Tasks comprising the phase; the realized boundary is derived from these. */ 28 + taskUniqueIds: z.array(z.number().int()), 29 + /** Target day (calendar-frame index): a deadline for lateness, a planned 30 + * earliest start for earliness. */ 31 + targetDay: z.number().int().nonnegative(), 32 + /** Completion-priority weight (an earlier `completionWeight`). Higher = 33 + * this phase's deviation costs more. */ 34 + weight: z.number().positive().default(1), 35 + }); 36 + 37 + export type PhaseWindow = z.infer<typeof PhaseWindowSchema>; 38 + 39 + /** The distinct task ids referenced across all phases — for `Scorer.reads` 40 + * so `analyzeRun` can flag a phase pointing at a missing task. */ 41 + export function phaseTaskIds(phases: ReadonlyArray<PhaseWindow>): number[] { 42 + return [...new Set(phases.flatMap((p) => p.taskUniqueIds))]; 43 + } 44 + 45 + /** Working-day deviation summed over phases, weighted per phase. 46 + * 47 + * `edge = "finish"` aggregates the phase's latest task finish (max) and 48 + * charges the working days AFTER `targetDay` — lateness. `edge = "start"` 49 + * aggregates the earliest task start (min) and charges the working days 50 + * BEFORE `targetDay` — earliness. `countWorkingDays` returns 0 when the 51 + * realized boundary is on the good side of the target, so a phase that 52 + * finishes early (or starts late) simply contributes nothing. */ 53 + export function phaseDeviation( 54 + schedule: Schedule, 55 + phases: ReadonlyArray<PhaseWindow>, 56 + edge: "finish" | "start", 57 + ): number { 58 + if (phases.length === 0) return 0; 59 + const cal = resolveWorkingCalendar(schedule.resolved, null); 60 + const { taskById } = scheduleProfiles(schedule); 61 + 62 + let total = 0; 63 + for (const phase of phases) { 64 + let boundary = edge === "finish" ? -Infinity : Infinity; 65 + let seen = false; 66 + for (const id of phase.taskUniqueIds) { 67 + const t = taskById.get(id); 68 + if (!t) continue; 69 + seen = true; 70 + if (edge === "finish") { 71 + if (t.finishDay > boundary) boundary = t.finishDay; 72 + } else if (t.startDay < boundary) { 73 + boundary = t.startDay; 74 + } 75 + } 76 + if (!seen) continue; 77 + // Working days strictly between target and the realized boundary; the 78 + // half-open [from, to) count is 0 whenever the phase is on time. 79 + const deviation = 80 + edge === "finish" 81 + ? countWorkingDays(cal, phase.targetDay, boundary) 82 + : countWorkingDays(cal, boundary, phase.targetDay); 83 + total += deviation * phase.weight; 84 + } 85 + return total; 86 + } 87 + 88 + /** MiniZinc fragment shared by both blocks: one boundary var per non-empty 89 + * phase, then a weighted sum of working-day counts. `DAYS` is the harness's 90 + * working-day set, so summing `bool2int` over it mirrors `countWorkingDays`. 91 + * `edge` picks the aggregate (`max`/`min` over `finish_day`/`start_day`) and 92 + * the half-open day predicate. */ 93 + export function phaseDeviationMiniZinc( 94 + varName: string, 95 + phases: ReadonlyArray<PhaseWindow>, 96 + edge: "finish" | "start", 97 + ): string { 98 + const dayVar = edge === "finish" ? "finish_day" : "start_day"; 99 + const aggregate = edge === "finish" ? "max" : "min"; 100 + const boundaryPrefix = `${varName}_boundary`; 101 + 102 + const decls: string[] = []; 103 + const terms: string[] = []; 104 + phases.forEach((phase, i) => { 105 + if (phase.taskUniqueIds.length === 0) return; // max/min over {} is undefined 106 + const ids = phase.taskUniqueIds.map(String).join(","); 107 + const boundary = `${boundaryPrefix}_${String(i)}`; 108 + decls.push(`var int: ${boundary} = ${aggregate}(t in {${ids}})(${dayVar}[t]);`); 109 + // Working days on the wrong side of the target: after it for lateness, 110 + // before it for earliness. 111 + const predicate = 112 + edge === "finish" 113 + ? `d >= ${String(phase.targetDay)} /\\ d < ${boundary}` 114 + : `d >= ${boundary} /\\ d < ${String(phase.targetDay)}`; 115 + terms.push(`${String(phase.weight)} * sum(d in DAYS)(bool2int(${predicate}))`); 116 + }); 117 + 118 + const rhs = terms.length === 0 ? "0.0" : terms.join(" + "); 119 + return [...decls, `var float: ${varName} = ${rhs};`].join("\n"); 120 + }
+18
src/level-blocks/standard.ts
··· 17 17 import { MaxConcurrentResourceBlock } from "./maxConcurrentResource.ts"; 18 18 import { OpenUnitPenaltyBlock } from "./openUnitPenalty.ts"; 19 19 import { PeakCapBlock } from "./peakCap.ts"; 20 + import { PhaseEarlinessBlock } from "./phaseEarliness.ts"; 21 + import { PhaseLatenessBlock } from "./phaseLateness.ts"; 20 22 import { createRegistry, type BlockDef, type BlockRegistry } from "./registry.ts"; 21 23 import { ReleaseBlock } from "./release.ts"; 22 24 import { SerialSGSBlock } from "./serialSgsSearch.ts"; ··· 32 34 concurrentResourceCost: "levelset.ConcurrentResourceCost.v1", 33 35 openUnitPenalty: "levelset.OpenUnitPenalty.v1", 34 36 hiringLagPenalty: "levelset.HiringLagPenalty.v1", 37 + phaseLateness: "levelset.PhaseLateness.v1", 38 + phaseEarliness: "levelset.PhaseEarliness.v1", 35 39 unimodalDeviation: "levelset.UnimodalDeviation.v1", 36 40 makespan: "levelset.Makespan.v1", 37 41 serialSGS: "levelset.SerialSGS.v1", ··· 99 103 tier: "estimator", 100 104 block: HiringLagPenaltyBlock as never, 101 105 defaultShadow: { resourceUniqueId: 0, trainingWeeks: 2, costPerCrewWeek: 1000 }, 106 + }, 107 + { 108 + code: STANDARD_CODES.phaseLateness, 109 + family: "scorer", 110 + tier: "estimator", 111 + block: PhaseLatenessBlock as never, 112 + defaultShadow: { phases: [] }, 113 + }, 114 + { 115 + code: STANDARD_CODES.phaseEarliness, 116 + family: "scorer", 117 + tier: "estimator", 118 + block: PhaseEarlinessBlock as never, 119 + defaultShadow: { phases: [] }, 102 120 }, 103 121 { 104 122 code: STANDARD_CODES.unimodalDeviation,
+3
src/leveling.ts
··· 83 83 export * from "./level-blocks/concurrentUnitsLimit.ts"; 84 84 export * from "./level-blocks/hiringLagPenalty.ts"; 85 85 export * from "./level-blocks/openUnitPenalty.ts"; 86 + export * from "./level-blocks/phaseScoring.ts"; 87 + export * from "./level-blocks/phaseLateness.ts"; 88 + export * from "./level-blocks/phaseEarliness.ts"; 86 89 export * from "./level-blocks/unimodalDeviation.ts"; 87 90 export * from "./level-blocks/makespan.ts"; 88 91 export * from "./level-blocks/serialSgsSearch.ts";
+231
test/level-blocks/phaseScorers.test.ts
··· 1 + // PhaseLateness / PhaseEarliness scorer blocks — schema, hand-computed 2 + // scoring on the Mon–Fri fixture calendar, and one RunConfig wiring test. 3 + // 4 + // Calendar frame (epoch Mon Jan 5 2026, from fixtures): 5 + // day 0 Mon, 1 Tue, 2 Wed, 3 Thu, 4 Fri, 5 Sat*, 6 Sun*, 7 Mon, 8 Tue, 6 + // 9 Wed, 10 Thu, 11 Fri, ... (* = non-working) 7 + // All deviations are counted in WORKING days over the half-open span. 8 + 9 + import { describe, expect, test } from "bun:test"; 10 + 11 + import { 12 + PhaseEarlinessBlock, 13 + PhaseEarlinessInputSchema, 14 + } from "../../src/level-blocks/phaseEarliness.ts"; 15 + import { 16 + PhaseLatenessBlock, 17 + PhaseLatenessInputSchema, 18 + } from "../../src/level-blocks/phaseLateness.ts"; 19 + import { PhaseWindowSchema } from "../../src/level-blocks/phaseScoring.ts"; 20 + import { fillRunConfig, runFromConfig } from "../../src/level-blocks/runConfig.ts"; 21 + import { STANDARD_CODES } from "../../src/level-blocks/standard.ts"; 22 + import { resolveCalendar } from "../../src/level-core/resolveCalendar.ts"; 23 + import type { Schedule, ScheduledTask } from "../../src/level-core/types.ts"; 24 + 25 + import { MON_JAN_5, SAT_JAN_10, makeProject, makeTask } from "../level-core/fixtures.ts"; 26 + 27 + /** Wrap chosen scheduled day indices in a Schedule. The ProjectFile's task 28 + * dates only seed the calendar/horizon; scoring reads the ScheduledTask day 29 + * indices supplied here. */ 30 + function scheduleWith(scheduledTasks: ScheduledTask[]): Schedule { 31 + const project = makeProject( 32 + scheduledTasks.map((t) => 33 + makeTask({ uniqueId: t.uniqueId, start: MON_JAN_5, finish: SAT_JAN_10 }), 34 + ), 35 + ); 36 + const resolved = resolveCalendar(project); 37 + const makespan = scheduledTasks.reduce((m, t) => Math.max(m, t.finishDay), 0); 38 + return { resolved, tasks: scheduledTasks, makespan, annotations: new Map() }; 39 + } 40 + 41 + describe("PhaseWindow schema", () => { 42 + test("defaults weight to 1 and keeps name optional", () => { 43 + const parsed = PhaseWindowSchema.parse({ taskUniqueIds: [1], targetDay: 5 }); 44 + expect(parsed.weight).toBe(1); 45 + expect(parsed.name).toBeUndefined(); 46 + }); 47 + 48 + test("rejects a non-positive weight and a negative targetDay", () => { 49 + expect(() => 50 + PhaseWindowSchema.parse({ taskUniqueIds: [1], targetDay: 5, weight: 0 }), 51 + ).toThrow(); 52 + expect(() => PhaseWindowSchema.parse({ taskUniqueIds: [1], targetDay: -1 })).toThrow(); 53 + }); 54 + 55 + test("empty phases arrays parse (schema-permissive, like OpenUnitPenalty)", () => { 56 + expect(PhaseLatenessInputSchema.parse({ phases: [] }).phases).toEqual([]); 57 + expect(PhaseEarlinessInputSchema.parse({ phases: [] }).phases).toEqual([]); 58 + }); 59 + }); 60 + 61 + describe("PhaseLateness — apply", () => { 62 + test("charges working days the latest finish overruns the target", () => { 63 + // One phase, tasks finishing day 4 and day 8 → phase finish = max = 8. 64 + // target 2 → working days in [2,8) = {2,3,4,7} = 4, × weight 1. 65 + const schedule = scheduleWith([ 66 + { uniqueId: 1, startDay: 0, finishDay: 4, modeId: null }, 67 + { uniqueId: 2, startDay: 0, finishDay: 8, modeId: null }, 68 + ]); 69 + const scorer = PhaseLatenessBlock.apply({ 70 + phases: [{ taskUniqueIds: [1, 2], targetDay: 2, weight: 1 }], 71 + }); 72 + expect(scorer.score(schedule)).toBe(4); 73 + }); 74 + 75 + test("weight scales the penalty", () => { 76 + // finish = 5, target 2 → [2,5) = {2,3,4} = 3, × weight 2 = 6. 77 + const schedule = scheduleWith([{ uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }]); 78 + const scorer = PhaseLatenessBlock.apply({ 79 + phases: [{ taskUniqueIds: [1], targetDay: 2, weight: 2 }], 80 + }); 81 + expect(scorer.score(schedule)).toBe(6); 82 + }); 83 + 84 + test("a phase finishing on or before target costs nothing", () => { 85 + const schedule = scheduleWith([{ uniqueId: 1, startDay: 0, finishDay: 2, modeId: null }]); 86 + // finish 2 == target 2 → 0; finish before target → also 0. 87 + expect( 88 + PhaseLatenessBlock.apply({ 89 + phases: [{ taskUniqueIds: [1], targetDay: 2, weight: 1 }], 90 + }).score(schedule), 91 + ).toBe(0); 92 + expect( 93 + PhaseLatenessBlock.apply({ 94 + phases: [{ taskUniqueIds: [1], targetDay: 5, weight: 1 }], 95 + }).score(schedule), 96 + ).toBe(0); 97 + }); 98 + 99 + test("a phase with no scheduled tasks contributes nothing", () => { 100 + const schedule = scheduleWith([{ uniqueId: 1, startDay: 0, finishDay: 8, modeId: null }]); 101 + // Phase references task 99, absent from the schedule → skipped. 102 + const scorer = PhaseLatenessBlock.apply({ 103 + phases: [{ taskUniqueIds: [99], targetDay: 0, weight: 5 }], 104 + }); 105 + expect(scorer.score(schedule)).toBe(0); 106 + }); 107 + 108 + test("multiple phases sum independently and reads declares all task ids", () => { 109 + const schedule = scheduleWith([ 110 + { uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }, 111 + { uniqueId: 2, startDay: 0, finishDay: 9, modeId: null }, 112 + ]); 113 + const scorer = PhaseLatenessBlock.apply({ 114 + phases: [ 115 + { name: "P1", taskUniqueIds: [1], targetDay: 2, weight: 1 }, // [2,5) = 3 116 + { name: "P2", taskUniqueIds: [2], targetDay: 4, weight: 2 }, // [4,9) = {4,7,8} = 3 × 2 = 6 117 + ], 118 + }); 119 + expect(scorer.score(schedule)).toBe(9); 120 + expect(scorer.reads?.taskUniqueIds).toEqual([1, 2]); 121 + }); 122 + 123 + test("realistic-shaped: a phase partly done at the target, last task late", () => { 124 + // Three tasks: two finish on/before the target (2 and 4), the last runs 125 + // to day 9. The phase's completion is its LAST task, so lateness is the 126 + // overrun of that finish, × the phase's completion-priority weight 3. 127 + // finish = 9, target 4 → [4,9) = {4,7,8} = 3, × 3 = 9. 128 + const schedule = scheduleWith([ 129 + { uniqueId: 10, startDay: 0, finishDay: 2, modeId: null }, 130 + { uniqueId: 11, startDay: 1, finishDay: 4, modeId: null }, 131 + { uniqueId: 12, startDay: 4, finishDay: 9, modeId: null }, 132 + ]); 133 + const scorer = PhaseLatenessBlock.apply({ 134 + phases: [{ name: "assembly", taskUniqueIds: [10, 11, 12], targetDay: 4, weight: 3 }], 135 + }); 136 + expect(scorer.score(schedule)).toBe(9); 137 + }); 138 + }); 139 + 140 + describe("PhaseEarliness — apply", () => { 141 + test("charges working days the earliest start precedes the target", () => { 142 + // Tasks starting day 2 and day 0 → phase start = min = 0. 143 + // target 4 → working days in [0,4) = {0,1,2,3} = 4, × weight 1. 144 + const schedule = scheduleWith([ 145 + { uniqueId: 1, startDay: 2, finishDay: 6, modeId: null }, 146 + { uniqueId: 2, startDay: 0, finishDay: 6, modeId: null }, 147 + ]); 148 + const scorer = PhaseEarlinessBlock.apply({ 149 + phases: [{ taskUniqueIds: [1, 2], targetDay: 4, weight: 1 }], 150 + }); 151 + expect(scorer.score(schedule)).toBe(4); 152 + }); 153 + 154 + test("a phase starting on or after target costs nothing", () => { 155 + const schedule = scheduleWith([{ uniqueId: 1, startDay: 3, finishDay: 8, modeId: null }]); 156 + expect( 157 + PhaseEarlinessBlock.apply({ 158 + phases: [{ taskUniqueIds: [1], targetDay: 3, weight: 1 }], 159 + }).score(schedule), 160 + ).toBe(0); 161 + expect( 162 + PhaseEarlinessBlock.apply({ 163 + phases: [{ taskUniqueIds: [1], targetDay: 0, weight: 1 }], 164 + }).score(schedule), 165 + ).toBe(0); 166 + }); 167 + 168 + test("weight scales and reads declares task ids", () => { 169 + const schedule = scheduleWith([{ uniqueId: 7, startDay: 0, finishDay: 6, modeId: null }]); 170 + // start 0, target 3 → [0,3) = 3, × weight 4 = 12. 171 + const scorer = PhaseEarlinessBlock.apply({ 172 + phases: [{ taskUniqueIds: [7], targetDay: 3, weight: 4 }], 173 + }); 174 + expect(scorer.score(schedule)).toBe(12); 175 + expect(scorer.reads?.taskUniqueIds).toEqual([7]); 176 + }); 177 + }); 178 + 179 + describe("Phase scorers — toMiniZinc", () => { 180 + test("PhaseLateness emits a max-finish boundary and a working-day sum", () => { 181 + const { text } = PhaseLatenessBlock.toMiniZinc({ 182 + phases: [{ taskUniqueIds: [1, 2], targetDay: 4, weight: 3 }], 183 + }); 184 + expect(text).toContain("max(t in {1,2})(finish_day[t])"); 185 + expect(text).toContain("var float: phase_lateness ="); 186 + expect(text).toContain("3 * sum(d in DAYS)"); 187 + expect(text).toContain("d >= 4"); 188 + }); 189 + 190 + test("PhaseEarliness emits a min-start boundary; empty phases give 0", () => { 191 + const { text } = PhaseEarlinessBlock.toMiniZinc({ 192 + phases: [{ taskUniqueIds: [5], targetDay: 6, weight: 1 }], 193 + }); 194 + expect(text).toContain("min(t in {5})(start_day[t])"); 195 + expect(text).toContain("d < 6"); 196 + expect(PhaseEarlinessBlock.toMiniZinc({ phases: [] }).text).toBe( 197 + "var float: phase_earliness = 0.0;", 198 + ); 199 + }); 200 + }); 201 + 202 + describe("Phase scorers — RunConfig wiring", () => { 203 + test("PhaseLateness scores through a weighted run", async () => { 204 + // One unconstrained task Mon Jan5 → Sat Jan10 schedules at day 0 and runs 205 + // 5 working days → finishDay 5. A phase targeting day 2 is late by the 206 + // working days in [2,5) = {2,3,4} = 3. 207 + const project = makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]); 208 + const config = fillRunConfig({ 209 + formatVersion: 1, 210 + pipeline: { 211 + formatVersion: 1, 212 + blocks: [ 213 + { 214 + instanceId: "phase-late", 215 + code: STANDARD_CODES.phaseLateness, 216 + params: { phases: [{ name: "P1", taskUniqueIds: [1], targetDay: 2, weight: 1 }] }, 217 + }, 218 + { instanceId: "makespan", code: STANDARD_CODES.makespan, params: {} }, 219 + { instanceId: "search", code: STANDARD_CODES.serialSGS, params: {} }, 220 + ], 221 + }, 222 + weights: { "phase-late": 10, makespan: 1 }, 223 + }); 224 + const run = runFromConfig(project, config); 225 + expect(run.analysis.ok).toBe(true); 226 + const best = await run.best(); 227 + expect(best).not.toBeNull(); 228 + expect(best!.tasks[0]!.finishDay).toBe(5); 229 + expect(run.scores(best!)["phase-late"]).toBe(3); 230 + }); 231 + });