// PhaseLateness — Scoring block (completion-priority, generalized from an // earlier phase-lateness term). // // A schedule is carved into phases (groups of tasks). Each phase has a // target completion day (its deadline / window end). A phase's realized // finish is the LATEST finish across its tasks; the penalty is the working // days that finish overruns the target, weighted by the phase's // completion-priority `weight` and summed over phases: // // penalty = sum_p weight_p × workingDays(target_p, phaseFinish_p) [finish > target] // // This is the hard-priority "finish the phases on time" pressure. Earlier or // more-critical phases get a heavier `weight`, so their slippage dominates — // the original formulation used weights 3/2/1 across three phases and ranked // this term first in a lexicographic objective. The per-day scale // (`phaseLatenessPerDay` in the original formulation) is supplied by the run // config's scorer weight, not baked in here. `weight` here is a static // per-phase priority, NOT a dynamic fraction of tasks complete. // // MiniZinc compile contract: harness supplies `finish_day[t]` and the // working-day set `DAYS`. import { z } from "zod"; import type { Schedule, Scorer } from "../level-core/types.ts"; import { PhaseWindowSchema, phaseDeviation, phaseDeviationMiniZinc, phaseTaskIds, } from "./phaseScoring.ts"; import type { MiniZincFragment, ScoringBlock } from "./types.ts"; export const PhaseLatenessInputSchema = z.object({ phases: z.array(PhaseWindowSchema), }); export type PhaseLatenessInput = z.infer; const ScorerOutputSchema: z.ZodType = z.custom( (val): val is Scorer => typeof val === "object" && val !== null && typeof (val as Scorer).name === "string" && ((val as Scorer).direction === "min" || (val as Scorer).direction === "max") && typeof (val as Scorer).score === "function", ); export const PhaseLatenessBlock: ScoringBlock = { id: "PhaseLateness", schema: { input: PhaseLatenessInputSchema, output: ScorerOutputSchema }, apply: (input): Scorer => { const { phases } = PhaseLatenessInputSchema.parse(input); return { name: "PhaseLateness", direction: "min", reads: { taskUniqueIds: phaseTaskIds(phases) }, score: (schedule: Schedule): number => phaseDeviation(schedule, phases, "finish"), }; }, toMiniZinc: (input): MiniZincFragment => { const { phases } = PhaseLatenessInputSchema.parse(input); return { text: phaseDeviationMiniZinc("phase_lateness", phases, "finish") }; }, doc: { nl: "Completion-priority penalty: for each phase, the working days its latest task finishes past the phase's target day, weighted by the phase's completion-priority weight and summed. A finish boundary at or before the target costs nothing — note the boundary is the exclusive finishDay index (finishDay d+1 for last work on day d), not the calendar date of that last work.", pseudocode: "sum_p weight_p * workingDays(target_p, max(finish(t) for t in phase_p)) when that finish is late", }, };