// Shared derived data over a Schedule — spaCy's listener pattern. // // Several scoring blocks derive from the same expensive intermediates: // per-day resource histograms and per-unit open spans. Computed // independently by each scorer that's O(scorers × days × tasks) per // candidate schedule; computed once here it's O(days × tasks) plus cheap // reads. The provider (this module) owns the derivation; scorer blocks are // listeners that read the slot. Ownership sits with the runner, not the // blocks, because blocks can't see each other. // // Memoization is per-Schedule via WeakMap, which is safe because Schedule // is immutable by contract: a search emits a new Schedule per candidate // rather than mutating one in place. import { resolveWorkingCalendar } from "./calendarDays.ts"; import type { DayIndex, Schedule, ScheduledTask } from "./types.ts"; import { buildResourceLoad, effectiveAssignments, type ResourceLoad, } from "./weeklyProfile.ts"; export interface UnitSpan { readonly startDay: DayIndex; readonly finishDay: DayIndex; } export interface ScheduleProfiles { /** Per-resource per-working-day demand (realized-mode aware). */ readonly resourceLoad: ResourceLoad; /** Scheduled task lookup by uniqueId. */ readonly taskById: ReadonlyMap; /** Open span (first start → last finish) of every work unit, optionally * counting only tasks assigned to `discipline` (a resourceUniqueId). * Units with no scheduled (matching) tasks, or an empty span, are absent. */ unitSpans(discipline?: number): ReadonlyMap; } function computeUnitSpans( schedule: Schedule, discipline: number | null, ): ReadonlyMap { const taskById = new Map(schedule.tasks.map((t) => [t.uniqueId, t])); const disciplineTasks = discipline === null ? null : new Set( effectiveAssignments(schedule) .filter((a) => a.resourceUniqueId === discipline) .map((a) => a.taskUniqueId), ); const spans = new Map(); for (const [unitId, unit] of schedule.resolved.workUnits) { let start = Infinity; let finish = -Infinity; for (const taskId of unit.taskUniqueIds) { if (disciplineTasks && !disciplineTasks.has(taskId)) continue; const t = taskById.get(taskId); if (!t) continue; if (t.startDay < start) start = t.startDay; if (t.finishDay > finish) finish = t.finishDay; } if (start === Infinity || finish <= start) continue; spans.set(unitId, { startDay: start, finishDay: finish }); } return spans; } class ProfilesImpl implements ScheduleProfiles { private resourceLoadMemo: ResourceLoad | null = null; private taskByIdMemo: ReadonlyMap | null = null; private readonly unitSpansMemo = new Map< number | null, ReadonlyMap >(); private readonly schedule: Schedule; constructor(schedule: Schedule) { this.schedule = schedule; } get resourceLoad(): ResourceLoad { this.resourceLoadMemo ??= buildResourceLoad(this.schedule); return this.resourceLoadMemo; } get taskById(): ReadonlyMap { this.taskByIdMemo ??= new Map( this.schedule.tasks.map((t) => [t.uniqueId, t]), ); return this.taskByIdMemo; } unitSpans(discipline?: number): ReadonlyMap { const key = discipline ?? null; let spans = this.unitSpansMemo.get(key); if (!spans) { spans = computeUnitSpans(this.schedule, key); this.unitSpansMemo.set(key, spans); } return spans; } } const cache = new WeakMap(); /** The shared read slot for a schedule's derived data. Same Schedule object * → same profiles instance; each derivation runs at most once. */ export function scheduleProfiles(schedule: Schedule): ScheduleProfiles { let profiles = cache.get(schedule); if (!profiles) { profiles = new ProfilesImpl(schedule); cache.set(schedule, profiles); } return profiles; } /** Count of open units per working day across the given unit ids, using the * shared span slot. Shared by OpenUnitPenalty and unit-WIP reporting. */ export function openUnitCount( schedule: Schedule, unitIds: readonly number[], discipline?: number, ): Int32Array { const cal = resolveWorkingCalendar(schedule.resolved, null); const spans = scheduleProfiles(schedule).unitSpans(discipline); const counts = new Int32Array(cal.horizonDays); for (const unitId of new Set(unitIds)) { const span = spans.get(unitId); if (!span) continue; const end = Math.min(span.finishDay, cal.horizonDays); for (let d = span.startDay; d < end; d++) { if (cal.bits[d] === 1) counts[d]! += 1; } } return counts; }