// PhaseEarliness — Scoring block (companion to PhaseLateness, generalized // from an earlier early-start term). // // The mirror of PhaseLateness on the start side. Each phase has a target // earliest day (its planned window start). A phase's realized start is the // EARLIEST start across its tasks; the penalty is the working days that // start precedes the target, weighted by the phase's completion-priority // `weight` and summed over phases: // // penalty = sum_p weight_p × workingDays(phaseStart_p, target_p) [start < target] // // Why a phase should not start too early: pulling a phase in ahead of plan // costs factory-build / customer-readiness pressure. The original formulation // charged this term in a lower objective tier than lateness — earliness // trades off against cost, whereas lateness is the hard priority — so this is // a separate block, not a mode of PhaseLateness: the two anchor on different // target days (window start vs window end) and land in different objective // tiers, and a single phase carries both at once. The per-day scale // (`earlyStartPerDay` in the original formulation) comes from the run // config's scorer weight. // // MiniZinc compile contract: harness supplies `start_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 PhaseEarlinessInputSchema = z.object({ phases: z.array(PhaseWindowSchema), }); export type PhaseEarlinessInput = 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 PhaseEarlinessBlock: ScoringBlock = { id: "PhaseEarliness", schema: { input: PhaseEarlinessInputSchema, output: ScorerOutputSchema }, apply: (input): Scorer => { const { phases } = PhaseEarlinessInputSchema.parse(input); return { name: "PhaseEarliness", direction: "min", reads: { taskUniqueIds: phaseTaskIds(phases) }, score: (schedule: Schedule): number => phaseDeviation(schedule, phases, "start"), }; }, toMiniZinc: (input): MiniZincFragment => { const { phases } = PhaseEarlinessInputSchema.parse(input); return { text: phaseDeviationMiniZinc("phase_earliness", phases, "start") }; }, doc: { nl: "Early-start penalty: for each phase, the working days its earliest task starts before the phase's target day, weighted by the phase's completion-priority weight and summed. Starting on or after target costs nothing.", pseudocode: "sum_p weight_p * workingDays(min(start(t) for t in phase_p), target_p) when that start is early", }, };