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-core): weekly demand-profile binning

Shared weekly demand binning lifted into level-core so every scorer uses
one rule: Sunday-snapped, working-day-only weekly peaks. Exposes
buildResourceLoad, weeklyProfile, and scheduleSpan, which the hiring-lag
and unimodal-deviation scorers (and future smoothness/idle scorers) build
on.

Russ T. Fugal (May 7, 2026, 9:00 AM -0600) 27082c5a 5a3309ab

+430
+132
src/level-core/weeklyProfile.ts
··· 1 + // Weekly-binned demand profiles derived from a Schedule. 2 + // 3 + // Several scoring blocks (`UnimodalDeviation`, `IdlePercent`, `Smoothness`, 4 + // `HiringLagPenalty`, `LayoffHysteresis`) need the same weekly-peak view of 5 + // per-resource demand. Lift it to level-core so every consumer agrees on 6 + // binning rules (Sunday-snapped, working-day-only, max-per-week). 7 + // 8 + // Weeks bin from the Sunday on-or-before each day so adjacent runs share 9 + // boundaries even if their range starts differ by a few days. 10 + 11 + import { dayToDate, resolveWorkingCalendar } from "./calendarDays.ts"; 12 + import type { DayIndex, ResolvedProject, Schedule } from "./types.ts"; 13 + 14 + export interface WeeklyBucket { 15 + readonly weekStart: DayIndex; 16 + readonly value: number; 17 + } 18 + 19 + /** Per-resource per-working-day load. O(|assignments| × avg-span). */ 20 + export type ResourceLoad = Map<number, Int16Array>; 21 + 22 + export function buildResourceLoad(schedule: Schedule): ResourceLoad { 23 + const cal = resolveWorkingCalendar(schedule.resolved, null); 24 + const out: ResourceLoad = new Map(); 25 + 26 + for (const a of schedule.resolved.assignments) { 27 + if (!out.has(a.resourceUniqueId)) { 28 + out.set(a.resourceUniqueId, new Int16Array(cal.horizonDays)); 29 + } 30 + } 31 + 32 + const taskById = new Map(schedule.tasks.map((t) => [t.uniqueId, t])); 33 + for (const a of schedule.resolved.assignments) { 34 + const t = taskById.get(a.taskUniqueId); 35 + if (!t) continue; 36 + const arr = out.get(a.resourceUniqueId)!; 37 + for (let d = t.startDay; d < t.finishDay; d++) { 38 + if (cal.bits[d] === 1) arr[d]! += a.units; 39 + } 40 + } 41 + return out; 42 + } 43 + 44 + export function peakLoad(load: ResourceLoad, resourceId: number): number { 45 + const arr = load.get(resourceId); 46 + if (!arr) return 0; 47 + let m = 0; 48 + for (let i = 0; i < arr.length; i++) if (arr[i]! > m) m = arr[i]!; 49 + return m; 50 + } 51 + 52 + export function combinedPeakLoad(load: ResourceLoad, resourceIds: ReadonlyArray<number>): number { 53 + const arrs = resourceIds.map((id) => load.get(id)).filter((a): a is Int16Array => !!a); 54 + if (arrs.length === 0) return 0; 55 + let m = 0; 56 + const n = arrs[0]!.length; 57 + for (let i = 0; i < n; i++) { 58 + let s = 0; 59 + for (const a of arrs) s += a[i]!; 60 + if (s > m) m = s; 61 + } 62 + return m; 63 + } 64 + 65 + /** Snap `day` back to the Sunday on-or-before in the resolved calendar's epoch frame. */ 66 + export function snapToSunday(resolved: ResolvedProject, day: DayIndex): DayIndex { 67 + const cal = resolveWorkingCalendar(resolved, null); 68 + const d = dayToDate(cal, day); 69 + return day - d.getDay(); 70 + } 71 + 72 + /** Per-resource weekly peak — max working-day units in each 7-day window. */ 73 + export function weeklyProfile( 74 + resolved: ResolvedProject, 75 + arr: Int16Array | undefined, 76 + rangeStart: DayIndex, 77 + rangeEnd: DayIndex, 78 + ): WeeklyBucket[] { 79 + if (!arr) return []; 80 + const cal = resolveWorkingCalendar(resolved, null); 81 + const result: WeeklyBucket[] = []; 82 + let ws = snapToSunday(resolved, rangeStart); 83 + while (ws <= rangeEnd) { 84 + let max = 0; 85 + const dStart = Math.max(0, ws); 86 + const dEnd = Math.min(ws + 6, rangeEnd, cal.horizonDays - 1); 87 + for (let d = dStart; d <= dEnd; d++) { 88 + if (cal.bits[d] === 1 && arr[d]! > max) max = arr[d]!; 89 + } 90 + result.push({ weekStart: ws, value: max }); 91 + ws += 7; 92 + } 93 + return result; 94 + } 95 + 96 + /** Combined weekly peak across multiple resources — for "install pool" views. */ 97 + export function weeklyProfileCombined( 98 + resolved: ResolvedProject, 99 + arrs: ReadonlyArray<Int16Array>, 100 + rangeStart: DayIndex, 101 + rangeEnd: DayIndex, 102 + ): WeeklyBucket[] { 103 + const cal = resolveWorkingCalendar(resolved, null); 104 + const result: WeeklyBucket[] = []; 105 + let ws = snapToSunday(resolved, rangeStart); 106 + while (ws <= rangeEnd) { 107 + let max = 0; 108 + const dStart = Math.max(0, ws); 109 + const dEnd = Math.min(ws + 6, rangeEnd, cal.horizonDays - 1); 110 + for (let d = dStart; d <= dEnd; d++) { 111 + if (cal.bits[d] !== 1) continue; 112 + let s = 0; 113 + for (const a of arrs) s += a[d]!; 114 + if (s > max) max = s; 115 + } 116 + result.push({ weekStart: ws, value: max }); 117 + ws += 7; 118 + } 119 + return result; 120 + } 121 + 122 + /** Derive a [start, end] day-index range from a Schedule's actual placements. */ 123 + export function scheduleSpan(schedule: Schedule): { startDay: DayIndex; endDay: DayIndex } { 124 + if (schedule.tasks.length === 0) return { startDay: 0, endDay: 0 }; 125 + let s = Infinity; 126 + let e = 0; 127 + for (const t of schedule.tasks) { 128 + if (t.startDay < s) s = t.startDay; 129 + if (t.finishDay > e) e = t.finishDay; 130 + } 131 + return { startDay: s === Infinity ? 0 : s, endDay: e }; 132 + }
+298
test/level-core/weeklyProfile.test.ts
··· 1 + import { test, expect, describe } from "bun:test"; 2 + 3 + import { resolveCalendar } from "../../src/level-core/resolveCalendar.ts"; 4 + import { 5 + buildResourceLoad, 6 + combinedPeakLoad, 7 + peakLoad, 8 + scheduleSpan, 9 + snapToSunday, 10 + weeklyProfile, 11 + weeklyProfileCombined, 12 + } from "../../src/level-core/weeklyProfile.ts"; 13 + import { ResourceType } from "../../src/model/types.ts"; 14 + import type { Schedule, ScheduledTask } from "../../src/level-core/types.ts"; 15 + import type { Calendar } from "../../src/schema/calendar.ts"; 16 + import type { ProjectFile } from "../../src/schema/project.ts"; 17 + 18 + function monFriCalendar(uniqueId = 1): Calendar { 19 + return { 20 + uniqueId, 21 + name: "Standard", 22 + weekDays: [ 23 + { dayType: 1, working: false, workingTimes: [] }, // Sun 24 + { dayType: 2, working: true, workingTimes: [] }, // Mon 25 + { dayType: 3, working: true, workingTimes: [] }, // Tue 26 + { dayType: 4, working: true, workingTimes: [] }, // Wed 27 + { dayType: 5, working: true, workingTimes: [] }, // Thu 28 + { dayType: 6, working: true, workingTimes: [] }, // Fri 29 + { dayType: 7, working: false, workingTimes: [] }, // Sat 30 + ], 31 + exceptions: [], 32 + }; 33 + } 34 + 35 + function makeSchedule(opts: { 36 + scheduledTasks: ScheduledTask[]; 37 + assignments: Array<{ taskUniqueId: number; resourceUniqueId: number; units: number }>; 38 + resourceUniqueIds: ReadonlyArray<number>; 39 + epoch?: Date; 40 + }): Schedule { 41 + const calendars = [monFriCalendar()]; 42 + const epoch = opts.epoch ?? new Date(2026, 0, 5); // Mon Jan 5 2026 43 + const project: ProjectFile = { 44 + properties: { 45 + title: null, 46 + author: null, 47 + startDate: epoch, 48 + finishDate: null, 49 + statusDate: epoch, 50 + defaultCalendarUniqueId: calendars[0]!.uniqueId, 51 + minutesPerDay: 480, 52 + minutesPerWeek: 2400, 53 + daysPerMonth: 20, 54 + saveVersion: null, 55 + }, 56 + tasks: opts.scheduledTasks.map((s) => ({ 57 + id: null, 58 + uniqueId: s.uniqueId, 59 + name: `Task ${String(s.uniqueId)}`, 60 + wbs: null, 61 + outlineLevel: 0, 62 + start: epoch, 63 + finish: epoch, 64 + duration: null, 65 + percentComplete: null, 66 + summary: false, 67 + milestone: false, 68 + critical: null, 69 + notes: null, 70 + priority: null, 71 + cost: null, 72 + work: null, 73 + actualStart: null, 74 + actualFinish: null, 75 + baselineStart: null, 76 + baselineFinish: null, 77 + baselineDuration: null, 78 + actualWork: null, 79 + constraintType: null, 80 + freeSlack: null, 81 + totalSlack: null, 82 + earlyStart: null, 83 + earlyFinish: null, 84 + lateStart: null, 85 + lateFinish: null, 86 + levelingDelay: null, 87 + deadline: null, 88 + splits: null, 89 + predecessors: [], 90 + })), 91 + resources: opts.resourceUniqueIds.map((uniqueId) => ({ 92 + id: uniqueId, 93 + uniqueId, 94 + name: `R${String(uniqueId)}`, 95 + type: ResourceType.Work, 96 + email: null, 97 + group: null, 98 + maxUnits: null, 99 + cost: null, 100 + work: null, 101 + resourcePool: null, 102 + })), 103 + assignments: opts.assignments.map((a) => ({ 104 + taskUniqueId: a.taskUniqueId, 105 + resourceUniqueId: a.resourceUniqueId, 106 + work: null, 107 + units: a.units, 108 + start: null, 109 + finish: null, 110 + actualWork: null, 111 + remainingWork: null, 112 + })), 113 + calendars, 114 + }; 115 + const resolved = resolveCalendar(project); 116 + const makespan = opts.scheduledTasks.reduce((m, t) => Math.max(m, t.finishDay), 0); 117 + return { 118 + resolved, 119 + tasks: opts.scheduledTasks, 120 + makespan, 121 + annotations: new Map(), 122 + }; 123 + } 124 + 125 + describe("weeklyProfile — buildResourceLoad", () => { 126 + test("two tasks on disjoint days accumulate per-day demand", () => { 127 + const schedule = makeSchedule({ 128 + resourceUniqueIds: [100], 129 + assignments: [ 130 + { taskUniqueId: 1, resourceUniqueId: 100, units: 2 }, 131 + { taskUniqueId: 2, resourceUniqueId: 100, units: 1 }, 132 + ], 133 + scheduledTasks: [ 134 + { uniqueId: 1, startDay: 0, finishDay: 3, modeId: null }, // Mon-Wed = 2 each 135 + { uniqueId: 2, startDay: 3, finishDay: 5, modeId: null }, // Thu-Fri = 1 each 136 + ], 137 + }); 138 + const load = buildResourceLoad(schedule); 139 + const arr = load.get(100)!; 140 + expect(arr[0]).toBe(2); 141 + expect(arr[2]).toBe(2); 142 + expect(arr[3]).toBe(1); 143 + expect(arr[4]).toBe(1); 144 + }); 145 + 146 + test("skips non-working days", () => { 147 + const schedule = makeSchedule({ 148 + resourceUniqueIds: [100], 149 + assignments: [{ taskUniqueId: 1, resourceUniqueId: 100, units: 3 }], 150 + scheduledTasks: [{ uniqueId: 1, startDay: 0, finishDay: 7, modeId: null }], 151 + }); 152 + const load = buildResourceLoad(schedule); 153 + const arr = load.get(100)!; 154 + // Sat (day 5) and Sun (day 6) should be 0; Mon Jan 5 is day 0 in this epoch. 155 + expect(arr[5]).toBe(0); 156 + expect(arr[6]).toBe(0); 157 + }); 158 + 159 + test("returns an entry for each assigned resource even with zero placement", () => { 160 + const schedule = makeSchedule({ 161 + resourceUniqueIds: [100, 200], 162 + assignments: [{ taskUniqueId: 1, resourceUniqueId: 200, units: 1 }], 163 + scheduledTasks: [{ uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }], 164 + }); 165 + const load = buildResourceLoad(schedule); 166 + expect(load.has(200)).toBe(true); 167 + // 100 has no assignments at all, so it's absent. 168 + expect(load.has(100)).toBe(false); 169 + }); 170 + }); 171 + 172 + describe("weeklyProfile — peakLoad / combinedPeakLoad", () => { 173 + test("peakLoad returns the highest single-day demand", () => { 174 + const schedule = makeSchedule({ 175 + resourceUniqueIds: [100], 176 + assignments: [ 177 + { taskUniqueId: 1, resourceUniqueId: 100, units: 2 }, 178 + { taskUniqueId: 2, resourceUniqueId: 100, units: 3 }, 179 + ], 180 + scheduledTasks: [ 181 + { uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }, 182 + { uniqueId: 2, startDay: 2, finishDay: 5, modeId: null }, 183 + ], 184 + }); 185 + const load = buildResourceLoad(schedule); 186 + // Days 2-4 overlap → 2+3 = 5. 187 + expect(peakLoad(load, 100)).toBe(5); 188 + }); 189 + 190 + test("combinedPeakLoad sums across resources before maxing", () => { 191 + const schedule = makeSchedule({ 192 + resourceUniqueIds: [100, 200], 193 + assignments: [ 194 + { taskUniqueId: 1, resourceUniqueId: 100, units: 2 }, 195 + { taskUniqueId: 2, resourceUniqueId: 200, units: 3 }, 196 + ], 197 + scheduledTasks: [ 198 + { uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }, 199 + { uniqueId: 2, startDay: 0, finishDay: 5, modeId: null }, 200 + ], 201 + }); 202 + const load = buildResourceLoad(schedule); 203 + expect(combinedPeakLoad(load, [100, 200])).toBe(5); 204 + }); 205 + 206 + test("peakLoad returns 0 for unknown resource", () => { 207 + const schedule = makeSchedule({ 208 + resourceUniqueIds: [100], 209 + assignments: [{ taskUniqueId: 1, resourceUniqueId: 100, units: 1 }], 210 + scheduledTasks: [{ uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }], 211 + }); 212 + const load = buildResourceLoad(schedule); 213 + expect(peakLoad(load, 999)).toBe(0); 214 + }); 215 + }); 216 + 217 + describe("weeklyProfile — snapToSunday & weeklyProfile", () => { 218 + test("snapToSunday on Mon Jan 5 2026 (day 0) returns day -1 (Sun Jan 4)", () => { 219 + const schedule = makeSchedule({ 220 + resourceUniqueIds: [100], 221 + assignments: [], 222 + scheduledTasks: [], 223 + }); 224 + // Mon Jan 5 2026 → snap back to Sun Jan 4 = day -1. 225 + expect(snapToSunday(schedule.resolved, 0)).toBe(-1); 226 + // Wed Jan 7 = day 2 → snap back to Sun Jan 4 = day -1. 227 + expect(snapToSunday(schedule.resolved, 2)).toBe(-1); 228 + // Sun Jan 11 = day 6 → snap to itself. 229 + expect(snapToSunday(schedule.resolved, 6)).toBe(6); 230 + }); 231 + 232 + test("weeklyProfile bins working-day peaks into 7-day windows", () => { 233 + const schedule = makeSchedule({ 234 + resourceUniqueIds: [100], 235 + assignments: [{ taskUniqueId: 1, resourceUniqueId: 100, units: 2 }], 236 + scheduledTasks: [ 237 + // Mon-Fri week 1: 5 working days at 2 each. 238 + { uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }, 239 + ], 240 + }); 241 + const load = buildResourceLoad(schedule); 242 + const profile = weeklyProfile(schedule.resolved, load.get(100), 0, 4); 243 + // Window starting Sun Jan 4 (day -1) captures Mon Jan 5 → peak 2. 244 + expect(profile.length).toBeGreaterThan(0); 245 + expect(profile[0]!.weekStart).toBe(-1); 246 + expect(profile[0]!.value).toBe(2); 247 + }); 248 + 249 + test("weeklyProfileCombined sums across resources within each week", () => { 250 + const schedule = makeSchedule({ 251 + resourceUniqueIds: [100, 200], 252 + assignments: [ 253 + { taskUniqueId: 1, resourceUniqueId: 100, units: 1 }, 254 + { taskUniqueId: 2, resourceUniqueId: 200, units: 2 }, 255 + ], 256 + scheduledTasks: [ 257 + { uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }, 258 + { uniqueId: 2, startDay: 0, finishDay: 5, modeId: null }, 259 + ], 260 + }); 261 + const load = buildResourceLoad(schedule); 262 + const arrs = [load.get(100)!, load.get(200)!]; 263 + const profile = weeklyProfileCombined(schedule.resolved, arrs, 0, 4); 264 + expect(profile[0]!.value).toBe(3); 265 + }); 266 + 267 + test("weeklyProfile returns empty when arr is undefined", () => { 268 + const schedule = makeSchedule({ 269 + resourceUniqueIds: [100], 270 + assignments: [], 271 + scheduledTasks: [], 272 + }); 273 + expect(weeklyProfile(schedule.resolved, undefined, 0, 10)).toEqual([]); 274 + }); 275 + }); 276 + 277 + describe("weeklyProfile — scheduleSpan", () => { 278 + test("returns earliest start and latest finish", () => { 279 + const schedule = makeSchedule({ 280 + resourceUniqueIds: [100], 281 + assignments: [{ taskUniqueId: 1, resourceUniqueId: 100, units: 1 }], 282 + scheduledTasks: [ 283 + { uniqueId: 1, startDay: 2, finishDay: 5, modeId: null }, 284 + { uniqueId: 2, startDay: 0, finishDay: 12, modeId: null }, 285 + ], 286 + }); 287 + expect(scheduleSpan(schedule)).toEqual({ startDay: 0, endDay: 12 }); 288 + }); 289 + 290 + test("empty schedule returns zero range", () => { 291 + const schedule = makeSchedule({ 292 + resourceUniqueIds: [100], 293 + assignments: [], 294 + scheduledTasks: [], 295 + }); 296 + expect(scheduleSpan(schedule)).toEqual({ startDay: 0, endDay: 0 }); 297 + }); 298 + });