// Shared phase-window scoring for the completion-priority scorer blocks // (PhaseLateness / PhaseEarliness). // // A *phase* is a named group of tasks with a target day in the resolved // calendar frame. Its realized boundary is derived from the schedule: // • finish boundary = the latest finish across the phase's tasks (max) — // the day the phase actually completes; // • start boundary = the earliest start across the phase's tasks (min) — // the day the phase actually begins. // Deviation from the target is measured in WORKING days (the same // prefix-sum count the rest of level-core uses), then weighted per phase and // summed. `weight` is the phase's completion-priority weight — the // generalization of an earlier `completionWeight` (earlier/more-critical // phases carry a heavier weight so their slippage dominates). It is NOT a // dynamic fraction of tasks complete; it is a static per-phase priority the // caller sets. A phase with no scheduled tasks contributes nothing. import { z } from "zod"; import { countWorkingDays, resolveWorkingCalendar, } from "../level-core/calendarDays.ts"; import { scheduleProfiles } from "../level-core/profiles.ts"; import type { Schedule } from "../level-core/types.ts"; export const PhaseWindowSchema = z.object({ /** Optional label, purely for readability of a config. */ name: z.string().optional(), /** Tasks comprising the phase; the realized boundary is derived from these. */ taskUniqueIds: z.array(z.number().int()), /** Target day (calendar-frame index): a deadline for lateness, a planned * earliest start for earliness. It lives in the same exclusive-finish day * frame as `ScheduledTask.finishDay` (a task doing its last work on day d * has `finishDay === d + 1`). Lateness is `countWorkingDays(cal, targetDay, * boundary)`, so a finish boundary costs 0 iff `boundary <= targetDay`; to * let a phase's last task work ON a deadline date for free, set `targetDay` * to the day AFTER that date (its exclusive-finish index). */ targetDay: z.number().int().nonnegative(), /** Completion-priority weight (an earlier `completionWeight`). Higher = * this phase's deviation costs more. */ weight: z.number().positive().default(1), }); export type PhaseWindow = z.infer; /** The distinct task ids referenced across all phases — for `Scorer.reads` * so `analyzeRun` can flag a phase pointing at a missing task. */ export function phaseTaskIds(phases: readonly PhaseWindow[]): number[] { return [...new Set(phases.flatMap((p) => p.taskUniqueIds))]; } /** Working-day deviation summed over phases, weighted per phase. * * `edge = "finish"` aggregates the phase's latest task finish (max) and * charges the working days AFTER `targetDay` — lateness. `edge = "start"` * aggregates the earliest task start (min) and charges the working days * BEFORE `targetDay` — earliness. `countWorkingDays` returns 0 when the * realized boundary is on the good side of the target, so a phase that * finishes early (or starts late) simply contributes nothing. */ export function phaseDeviation( schedule: Schedule, phases: readonly PhaseWindow[], edge: "finish" | "start", ): number { if (phases.length === 0) return 0; const cal = resolveWorkingCalendar(schedule.resolved, null); const { taskById } = scheduleProfiles(schedule); let total = 0; for (const phase of phases) { let boundary = edge === "finish" ? -Infinity : Infinity; let seen = false; for (const id of phase.taskUniqueIds) { const t = taskById.get(id); if (!t) continue; seen = true; if (edge === "finish") { if (t.finishDay > boundary) boundary = t.finishDay; } else if (t.startDay < boundary) { boundary = t.startDay; } } if (!seen) continue; // Working days strictly between target and the realized boundary; the // half-open [from, to) count is 0 whenever the phase is on time. const deviation = edge === "finish" ? countWorkingDays(cal, phase.targetDay, boundary) : countWorkingDays(cal, boundary, phase.targetDay); total += deviation * phase.weight; } return total; } /** MiniZinc fragment shared by both blocks: one boundary var per non-empty * phase, then a weighted sum of working-day counts. `DAYS` is the harness's * working-day set, so summing `bool2int` over it mirrors `countWorkingDays`. * `edge` picks the aggregate (`max`/`min` over `finish_day`/`start_day`) and * the half-open day predicate. * * Collision caveat: the two blocks pass fixed `varName`s (`phase_lateness` / * `phase_earliness`), so two instances of the *same* block in one graph would * emit duplicate `var float: ` and `_boundary_` declarations — * a MiniZinc model error. This is latent (no compiler consumes fragments yet) * and openUnitPenalty.ts shares the flaw. Threading a per-instance `varName` * through from the caller is future work for when a MiniZinc compiler lands. */ export function phaseDeviationMiniZinc( varName: string, phases: readonly PhaseWindow[], edge: "finish" | "start", ): string { const dayVar = edge === "finish" ? "finish_day" : "start_day"; const aggregate = edge === "finish" ? "max" : "min"; const boundaryPrefix = `${varName}_boundary`; const decls: string[] = []; const terms: string[] = []; phases.forEach((phase, i) => { if (phase.taskUniqueIds.length === 0) return; // max/min over {} is undefined const ids = phase.taskUniqueIds.map(String).join(","); const boundary = `${boundaryPrefix}_${String(i)}`; decls.push( `var int: ${boundary} = ${aggregate}(t in {${ids}})(${dayVar}[t]);`, ); // Working days on the wrong side of the target: after it for lateness, // before it for earliness. const predicate = edge === "finish" ? `d >= ${String(phase.targetDay)} /\\ d < ${boundary}` : `d >= ${boundary} /\\ d < ${String(phase.targetDay)}`; terms.push( `${String(phase.weight)} * sum(d in DAYS)(bool2int(${predicate}))`, ); }); const rhs = terms.length === 0 ? "0.0" : terms.join(" + "); return [...decls, `var float: ${varName} = ${rhs};`].join("\n"); }