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.
6.3 kB · 139 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140// 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<typeof PhaseWindowSchema>;
/** 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: <name>` and `<name>_boundary_<i>` 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");}