// PhaseLateness / PhaseEarliness scorer blocks — schema, hand-computed // scoring on the Mon–Fri fixture calendar, and one RunConfig wiring test. // // Calendar frame (epoch Mon Jan 5 2026, from fixtures): // day 0 Mon, 1 Tue, 2 Wed, 3 Thu, 4 Fri, 5 Sat*, 6 Sun*, 7 Mon, 8 Tue, // 9 Wed, 10 Thu, 11 Fri, ... (* = non-working) // All deviations are counted in WORKING days over the half-open span. import { describe, expect, test } from "bun:test"; import { PhaseEarlinessBlock, PhaseEarlinessInputSchema, } from "../../src/level-blocks/phaseEarliness.ts"; import { PhaseLatenessBlock, PhaseLatenessInputSchema, } from "../../src/level-blocks/phaseLateness.ts"; import { PhaseWindowSchema } from "../../src/level-blocks/phaseScoring.ts"; import { fillRunConfig, runFromConfig, } from "../../src/level-blocks/runConfig.ts"; import { STANDARD_CODES } from "../../src/level-blocks/standard.ts"; import { resolveCalendar } from "../../src/level-core/resolveCalendar.ts"; import type { Schedule, ScheduledTask } from "../../src/level-core/types.ts"; import { MON_JAN_5, SAT_JAN_10, makeProject, makeTask, } from "../level-core/fixtures.ts"; /** Wrap chosen scheduled day indices in a Schedule. The ProjectFile's task * dates only seed the calendar/horizon; scoring reads the ScheduledTask day * indices supplied here. */ function scheduleWith(scheduledTasks: ScheduledTask[]): Schedule { const project = makeProject( scheduledTasks.map((t) => makeTask({ uniqueId: t.uniqueId, start: MON_JAN_5, finish: SAT_JAN_10 }), ), ); const resolved = resolveCalendar(project); const makespan = scheduledTasks.reduce((m, t) => Math.max(m, t.finishDay), 0); return { resolved, tasks: scheduledTasks, makespan, annotations: new Map() }; } describe("PhaseWindow schema", () => { test("defaults weight to 1 and keeps name optional", () => { const parsed = PhaseWindowSchema.parse({ taskUniqueIds: [1], targetDay: 5, }); expect(parsed.weight).toBe(1); expect(parsed.name).toBeUndefined(); }); test("rejects a non-positive weight and a negative targetDay", () => { expect(() => PhaseWindowSchema.parse({ taskUniqueIds: [1], targetDay: 5, weight: 0 }), ).toThrow(); expect(() => PhaseWindowSchema.parse({ taskUniqueIds: [1], targetDay: -1 }), ).toThrow(); }); test("empty phases arrays parse (schema-permissive, like OpenUnitPenalty)", () => { expect(PhaseLatenessInputSchema.parse({ phases: [] }).phases).toEqual([]); expect(PhaseEarlinessInputSchema.parse({ phases: [] }).phases).toEqual([]); }); }); describe("PhaseLateness — apply", () => { test("charges working days the latest finish overruns the target", () => { // One phase, tasks finishing day 4 and day 8 → phase finish = max = 8. // target 2 → working days in [2,8) = {2,3,4,7} = 4, × weight 1. const schedule = scheduleWith([ { uniqueId: 1, startDay: 0, finishDay: 4, modeId: null }, { uniqueId: 2, startDay: 0, finishDay: 8, modeId: null }, ]); const scorer = PhaseLatenessBlock.apply({ phases: [{ taskUniqueIds: [1, 2], targetDay: 2, weight: 1 }], }); expect(scorer.score(schedule)).toBe(4); }); test("weight scales the penalty", () => { // finish = 5, target 2 → [2,5) = {2,3,4} = 3, × weight 2 = 6. const schedule = scheduleWith([ { uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }, ]); const scorer = PhaseLatenessBlock.apply({ phases: [{ taskUniqueIds: [1], targetDay: 2, weight: 2 }], }); expect(scorer.score(schedule)).toBe(6); }); test("a phase finishing on or before target costs nothing", () => { const schedule = scheduleWith([ { uniqueId: 1, startDay: 0, finishDay: 2, modeId: null }, ]); // finish 2 == target 2 → 0; finish before target → also 0. expect( PhaseLatenessBlock.apply({ phases: [{ taskUniqueIds: [1], targetDay: 2, weight: 1 }], }).score(schedule), ).toBe(0); expect( PhaseLatenessBlock.apply({ phases: [{ taskUniqueIds: [1], targetDay: 5, weight: 1 }], }).score(schedule), ).toBe(0); }); test("a phase with no scheduled tasks contributes nothing", () => { const schedule = scheduleWith([ { uniqueId: 1, startDay: 0, finishDay: 8, modeId: null }, ]); // Phase references task 99, absent from the schedule → skipped. const scorer = PhaseLatenessBlock.apply({ phases: [{ taskUniqueIds: [99], targetDay: 0, weight: 5 }], }); expect(scorer.score(schedule)).toBe(0); }); test("multiple phases sum independently and reads declares all task ids", () => { const schedule = scheduleWith([ { uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }, { uniqueId: 2, startDay: 0, finishDay: 9, modeId: null }, ]); const scorer = PhaseLatenessBlock.apply({ phases: [ { name: "P1", taskUniqueIds: [1], targetDay: 2, weight: 1 }, // [2,5) = 3 { name: "P2", taskUniqueIds: [2], targetDay: 4, weight: 2 }, // [4,9) = {4,7,8} = 3 × 2 = 6 ], }); expect(scorer.score(schedule)).toBe(9); expect(scorer.reads?.taskUniqueIds).toEqual([1, 2]); }); test("completion-priority weight decides between schedules, not just scale", () => { // Two phases: P3 (weight 3, task 1) and P1 (weight 1, task 2), both // targeting day 2. Two candidate schedules trade off which phase slips. const phases = [ { name: "P3", taskUniqueIds: [1], targetDay: 2, weight: 3 }, { name: "P1", taskUniqueIds: [2], targetDay: 2, weight: 1 }, ]; const scorer = PhaseLatenessBlock.apply({ phases }); // Schedule A: the heavy phase P3 slips 3 working days, P1 on time. // P3: [2,5) = {2,3,4} = 3 × 3 = 9; P1: finish 2 == target → 0. Total 9. const scheduleA = scheduleWith([ { uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }, { uniqueId: 2, startDay: 0, finishDay: 2, modeId: null }, ]); // Schedule B: P3 on time, the light phase P1 slips MORE (5 working days). // P3: 0; P1: [2,9) = {2,3,4,7,8} = 5 × 1 = 5. Total 5. const scheduleB = scheduleWith([ { uniqueId: 1, startDay: 0, finishDay: 2, modeId: null }, { uniqueId: 2, startDay: 0, finishDay: 9, modeId: null }, ]); expect(scorer.score(scheduleA)).toBe(9); expect(scorer.score(scheduleB)).toBe(5); // B slips more total days (5 vs 3) yet A scores worse: the weight-3 phase // dominates, so the weighting — not a raw day count — ranks the schedules. expect(scorer.score(scheduleA)).toBeGreaterThan(scorer.score(scheduleB)); }); test("a partially-scheduled phase derives its boundary from present tasks only", () => { // Phase mixes scheduled ids (1, 2) with an unscheduled one (99). The // finish boundary is max over the PRESENT tasks = max(4, 8) = 8; task 99 // is skipped, not treated as a zero/absent boundary. // [2,8) = {2,3,4,7} = 4, × weight 1 = 4. const schedule = scheduleWith([ { uniqueId: 1, startDay: 0, finishDay: 4, modeId: null }, { uniqueId: 2, startDay: 0, finishDay: 8, modeId: null }, ]); const scorer = PhaseLatenessBlock.apply({ phases: [{ taskUniqueIds: [1, 2, 99], targetDay: 2, weight: 1 }], }); expect(scorer.score(schedule)).toBe(4); }); test("realistic-shaped: a phase partly done at the target, last task late", () => { // Three tasks: two finish on/before the target (2 and 4), the last runs // to day 9. The phase's completion is its LAST task, so lateness is the // overrun of that finish, × the phase's completion-priority weight 3. // finish = 9, target 4 → [4,9) = {4,7,8} = 3, × 3 = 9. const schedule = scheduleWith([ { uniqueId: 10, startDay: 0, finishDay: 2, modeId: null }, { uniqueId: 11, startDay: 1, finishDay: 4, modeId: null }, { uniqueId: 12, startDay: 4, finishDay: 9, modeId: null }, ]); const scorer = PhaseLatenessBlock.apply({ phases: [ { name: "assembly", taskUniqueIds: [10, 11, 12], targetDay: 4, weight: 3, }, ], }); expect(scorer.score(schedule)).toBe(9); }); }); describe("PhaseEarliness — apply", () => { test("charges working days the earliest start precedes the target", () => { // Tasks starting day 2 and day 0 → phase start = min = 0. // target 4 → working days in [0,4) = {0,1,2,3} = 4, × weight 1. const schedule = scheduleWith([ { uniqueId: 1, startDay: 2, finishDay: 6, modeId: null }, { uniqueId: 2, startDay: 0, finishDay: 6, modeId: null }, ]); const scorer = PhaseEarlinessBlock.apply({ phases: [{ taskUniqueIds: [1, 2], targetDay: 4, weight: 1 }], }); expect(scorer.score(schedule)).toBe(4); }); test("a phase starting on or after target costs nothing", () => { const schedule = scheduleWith([ { uniqueId: 1, startDay: 3, finishDay: 8, modeId: null }, ]); expect( PhaseEarlinessBlock.apply({ phases: [{ taskUniqueIds: [1], targetDay: 3, weight: 1 }], }).score(schedule), ).toBe(0); expect( PhaseEarlinessBlock.apply({ phases: [{ taskUniqueIds: [1], targetDay: 0, weight: 1 }], }).score(schedule), ).toBe(0); }); test("weight scales and reads declares task ids", () => { const schedule = scheduleWith([ { uniqueId: 7, startDay: 0, finishDay: 6, modeId: null }, ]); // start 0, target 3 → [0,3) = 3, × weight 4 = 12. const scorer = PhaseEarlinessBlock.apply({ phases: [{ taskUniqueIds: [7], targetDay: 3, weight: 4 }], }); expect(scorer.score(schedule)).toBe(12); expect(scorer.reads?.taskUniqueIds).toEqual([7]); }); }); describe("Phase scorers — toMiniZinc", () => { test("PhaseLateness emits a max-finish boundary and a working-day sum", () => { const { text } = PhaseLatenessBlock.toMiniZinc({ phases: [{ taskUniqueIds: [1, 2], targetDay: 4, weight: 3 }], }); expect(text).toContain("max(t in {1,2})(finish_day[t])"); expect(text).toContain("var float: phase_lateness ="); expect(text).toContain("3 * sum(d in DAYS)"); expect(text).toContain("d >= 4"); }); test("PhaseEarliness emits a min-start boundary; empty phases give 0", () => { const { text } = PhaseEarlinessBlock.toMiniZinc({ phases: [{ taskUniqueIds: [5], targetDay: 6, weight: 1 }], }); expect(text).toContain("min(t in {5})(start_day[t])"); expect(text).toContain("d < 6"); expect(PhaseEarlinessBlock.toMiniZinc({ phases: [] }).text).toBe( "var float: phase_earliness = 0.0;", ); }); }); describe("Phase scorers — RunConfig wiring", () => { test("PhaseLateness scores through a weighted run", async () => { // One unconstrained task Mon Jan5 → Sat Jan10 schedules at day 0 and runs // 5 working days → finishDay 5. A phase targeting day 2 is late by the // working days in [2,5) = {2,3,4} = 3. const project = makeProject([ makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }), ]); const config = fillRunConfig({ formatVersion: 1, pipeline: { formatVersion: 1, blocks: [ { instanceId: "phase-late", code: STANDARD_CODES.phaseLateness, params: { phases: [ { name: "P1", taskUniqueIds: [1], targetDay: 2, weight: 1 }, ], }, }, { instanceId: "makespan", code: STANDARD_CODES.makespan, params: {} }, { instanceId: "search", code: STANDARD_CODES.serialSGS, params: {} }, ], }, weights: { "phase-late": 10, makespan: 1 }, }); const run = runFromConfig(project, config); expect(run.analysis.ok).toBe(true); const best = await run.best(); expect(best).not.toBeNull(); expect(best!.tasks[0]!.finishDay).toBe(5); expect(run.scores(best!)["phase-late"]).toBe(3); }); });