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.
10 kB · 338 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339import { test, expect, describe } from "bun:test";
import { ConcurrentResourceCostBlock, ConcurrentResourceCostInputSchema, dayCost,} from "../../src/level-blocks/concurrentResourceCost.ts";import { resolveCalendar } from "../../src/level-core/resolveCalendar.ts";import { ResourceType } from "../../src/model/types.ts";import type { Schedule, ScheduledTask } from "../../src/level-core/types.ts";import type { Calendar } from "../../src/schema/calendar.ts";import type { ProjectFile } from "../../src/schema/project.ts";
function monFriCalendar(uniqueId = 1): Calendar { return { uniqueId, name: "Standard", weekDays: [ { dayType: 1, working: false, workingTimes: [] }, { dayType: 2, working: true, workingTimes: [] }, { dayType: 3, working: true, workingTimes: [] }, { dayType: 4, working: true, workingTimes: [] }, { dayType: 5, working: true, workingTimes: [] }, { dayType: 6, working: true, workingTimes: [] }, { dayType: 7, working: false, workingTimes: [] }, ], exceptions: [], };}
function makeSchedule(opts: { scheduledTasks: ScheduledTask[]; assignments: { taskUniqueId: number; units: number }[]; resourceUniqueId: number;}): Schedule { const calendars = [monFriCalendar()]; const epoch = new Date(2026, 0, 5); const project: ProjectFile = { properties: { title: null, author: null, startDate: epoch, finishDate: null, statusDate: epoch, defaultCalendarUniqueId: calendars[0]!.uniqueId, minutesPerDay: 480, minutesPerWeek: 2400, daysPerMonth: 20, saveVersion: null, }, tasks: opts.scheduledTasks.map((s) => ({ id: null, uniqueId: s.uniqueId, name: `Task ${String(s.uniqueId)}`, wbs: null, outlineLevel: 0, start: epoch, finish: epoch, duration: null, percentComplete: null, summary: false, milestone: false, critical: null, notes: null, priority: null, cost: null, work: null, actualStart: null, actualFinish: null, baselineStart: null, baselineFinish: null, baselineDuration: null, actualWork: null, constraintType: null, freeSlack: null, totalSlack: null, earlyStart: null, earlyFinish: null, lateStart: null, lateFinish: null, levelingDelay: null, deadline: null, splits: null, predecessors: [], })), resources: [ { id: 1, uniqueId: opts.resourceUniqueId, name: "Crew", type: ResourceType.Work, email: null, group: null, maxUnits: null, cost: null, work: null, resourcePool: null, }, ], assignments: opts.assignments.map((a) => ({ taskUniqueId: a.taskUniqueId, resourceUniqueId: opts.resourceUniqueId, work: null, units: a.units, start: null, finish: null, actualWork: null, remainingWork: null, })), calendars, }; const resolved = resolveCalendar(project); const makespan = opts.scheduledTasks.reduce( (m, t) => Math.max(m, t.finishDay), 0, ); return { resolved, tasks: opts.scheduledTasks, makespan, annotations: new Map(), };}
describe("ConcurrentResourceCostBlock — input schema", () => { test("accepts valid params with default threshold", () => { const parsed = ConcurrentResourceCostInputSchema.parse({ resourceUniqueId: 7, basePrice: 100, growthBase: 2, }); expect(parsed.threshold).toBe(0); expect(parsed.growthBase).toBe(2); });
test("rejects growthBase ≤ 1", () => { expect(() => ConcurrentResourceCostInputSchema.parse({ resourceUniqueId: 7, basePrice: 100, growthBase: 1, }), ).toThrow(); });
test("rejects non-positive basePrice", () => { expect(() => ConcurrentResourceCostInputSchema.parse({ resourceUniqueId: 7, basePrice: 0, growthBase: 2, }), ).toThrow(); });});
describe("ConcurrentResourceCostBlock — dayCost", () => { test("returns 0 at or below threshold", () => { expect(dayCost(0, 100, 2, 1)).toBe(0); expect(dayCost(1, 100, 2, 1)).toBe(0); });
test("growthBase=2 doubles marginal cost per worker", () => { // basePrice=100, growthBase=2, threshold=0: // load=1 → 100*(2-1)=100, load=2 → 100*(4-1)=300, load=3 → 100*(8-1)=700. expect(dayCost(1, 100, 2, 0)).toBe(100); expect(dayCost(2, 100, 2, 0)).toBe(300); expect(dayCost(3, 100, 2, 0)).toBe(700); });
test("threshold shifts the cost curve right", () => { // basePrice=100, growthBase=2, threshold=2: // load=2 → 0, load=3 → 100*(2-1)=100, load=4 → 100*(4-1)=300. expect(dayCost(2, 100, 2, 2)).toBe(0); expect(dayCost(3, 100, 2, 2)).toBe(100); expect(dayCost(4, 100, 2, 2)).toBe(300); });});
describe("ConcurrentResourceCostBlock — apply", () => { test("zero cost when no demand", () => { const schedule = makeSchedule({ resourceUniqueId: 100, assignments: [], scheduledTasks: [], }); const scorer = ConcurrentResourceCostBlock.apply({ resourceUniqueId: 100, basePrice: 100, growthBase: 2, threshold: 0, }); expect(scorer.score(schedule)).toBe(0); });
test("single-crew workload accrues linear basePrice per working day", () => { // 5 working days, load=1 each → 5 × 100 × (2-1) = 500. const schedule = makeSchedule({ resourceUniqueId: 100, assignments: [{ taskUniqueId: 1, units: 1 }], scheduledTasks: [ { uniqueId: 1, startDay: 0, finishDay: 7, modeId: null }, ], }); const scorer = ConcurrentResourceCostBlock.apply({ resourceUniqueId: 100, basePrice: 100, growthBase: 2, threshold: 0, }); expect(scorer.score(schedule)).toBe(500); });
test("overlapping tasks compound by load, not by task count", () => { // Both tasks days 0-4, units 1 each → daily load=2. 5 days × 100 × (4-1) = 1500. const schedule = makeSchedule({ resourceUniqueId: 100, assignments: [ { taskUniqueId: 1, units: 1 }, { taskUniqueId: 2, units: 1 }, ], scheduledTasks: [ { uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }, { uniqueId: 2, startDay: 0, finishDay: 5, modeId: null }, ], }); const scorer = ConcurrentResourceCostBlock.apply({ resourceUniqueId: 100, basePrice: 100, growthBase: 2, threshold: 0, }); expect(scorer.score(schedule)).toBe(1500); });
test("threshold makes the first N workers free", () => { // load=2 days 0-4, threshold=1 → effective load excess = 1 per day, 5 days × 100×1 = 500. const schedule = makeSchedule({ resourceUniqueId: 100, assignments: [ { taskUniqueId: 1, units: 1 }, { taskUniqueId: 2, units: 1 }, ], scheduledTasks: [ { uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }, { uniqueId: 2, startDay: 0, finishDay: 5, modeId: null }, ], }); const scorer = ConcurrentResourceCostBlock.apply({ resourceUniqueId: 100, basePrice: 100, growthBase: 2, threshold: 1, }); expect(scorer.score(schedule)).toBe(500); });
test("non-working days do not accrue cost", () => { // Task spans Sat+Sun within its 7-day extent — only working days bill. const schedule = makeSchedule({ resourceUniqueId: 100, assignments: [{ taskUniqueId: 1, units: 1 }], scheduledTasks: [ { uniqueId: 1, startDay: 0, finishDay: 7, modeId: null }, ], }); const scorer = ConcurrentResourceCostBlock.apply({ resourceUniqueId: 100, basePrice: 100, growthBase: 2, threshold: 0, }); // 5 working days × 100 = 500 (Sat day 5 and Sun day 6 are not billed). expect(scorer.score(schedule)).toBe(500); });
test("super-linear shape — going from 2 to 3 costs more than 0 to 2", () => { const sched2 = makeSchedule({ resourceUniqueId: 100, assignments: [ { taskUniqueId: 1, units: 1 }, { taskUniqueId: 2, units: 1 }, ], scheduledTasks: [ { uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }, { uniqueId: 2, startDay: 0, finishDay: 5, modeId: null }, ], }); const sched3 = makeSchedule({ resourceUniqueId: 100, assignments: [ { taskUniqueId: 1, units: 1 }, { taskUniqueId: 2, units: 1 }, { taskUniqueId: 3, units: 1 }, ], scheduledTasks: [ { uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }, { uniqueId: 2, startDay: 0, finishDay: 5, modeId: null }, { uniqueId: 3, startDay: 0, finishDay: 5, modeId: null }, ], }); const scorer = ConcurrentResourceCostBlock.apply({ resourceUniqueId: 100, basePrice: 100, growthBase: 2, threshold: 0, }); const cost2 = scorer.score(sched2); const cost3 = scorer.score(sched3); // load=2 cost = 100*3 = 300 per day; load=3 cost = 100*7 = 700 per day. // Marginal cost of the 3rd worker (700-300=400) exceeds adding 2nd (300-100=200). expect(cost3 - cost2).toBeGreaterThan(cost2 - 500); // 500 = baseline 1-worker cost });});
describe("ConcurrentResourceCostBlock — toMiniZinc", () => { test("emits a per-day exponential cost expression", () => { const fragment = ConcurrentResourceCostBlock.toMiniZinc({ resourceUniqueId: 42, basePrice: 100, growthBase: 2, threshold: 1, }); expect(fragment.text).toContain("concurrent_resource_cost_42"); expect(fragment.text).toContain("tasks_demanding[42]"); expect(fragment.text).toContain("pow(2, load - 1)"); });});
describe("ConcurrentResourceCostBlock — metadata", () => { test("has stable id and non-empty doc", () => { expect(ConcurrentResourceCostBlock.id).toBe("ConcurrentResourceCost"); expect(ConcurrentResourceCostBlock.doc.nl.length).toBeGreaterThan(0); expect(ConcurrentResourceCostBlock.doc.pseudocode.length).toBeGreaterThan( 0, ); });});