Something went wrong. Try again.
A functional toolkit for authoring resource-leveling algorithms — compose constraint and scoring blocks, enumerate feasible schedules
Something went wrong. Try again.
12 kB · 310 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311// 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); });});