From 6c9ff4ae347d87e4282129b56a2d5ffeb6a0599b Mon Sep 17 00:00:00 2001 From: Nessar Date: Sun, 18 Oct 2026 21:18:40 +0200 Subject: [PATCH] test(planning): rebuild the frozen engine from the obligation ranking The frozen engine of the differential check is rebuilt from 8168137, which plans from the best-ranked obligation solution, with the documented build command. The check passes again, including its cancelled asynchronous comparisons and generator-coverage assertions. The calibration engine stays at a871f8f. Part of #302. Co-Authored-By: Claude Opus 5.5 --- docs/benchmark/README.md | 7 +- .../leave-planner.core.frozen.mjs | 952 +++++++++++------- 2 files changed, 621 insertions(+), 338 deletions(-) diff --git a/docs/benchmark/README.md b/docs/benchmark/README.md index 499893b..29f998a 100644 --- a/docs/benchmark/README.md +++ b/docs/benchmark/README.md @@ -120,8 +120,11 @@ trace counters and timings are historical; current evidence is in `src/app/services/frozen-engine.spec.ts` runs 5,000 seeded random planning requests (seeds 280000 to 284999, one test per 200 seeds) through the current -engine and through a frozen copy of the engine at `a871f8f`, develop after -PR #278. For each request, the planning outcome, stable plan explanation, +engine and through a frozen copy of the engine. The copy was rebuilt for #302 +from `8168137`, which plans from the best-ranked obligation solution +([comparison record](issue-302.md#comparison-record)). Until then it was the +engine at `a871f8f`, develop after PR #278, which remains the calibration +engine. For each request, the planning outcome, stable plan explanation, explanation error and final-state snapshot must be strictly deep-equal, including `PlanningConflict` and `InvalidInput` outcomes. Timings are not compared. diff --git a/src/app/services/frozen-engine/leave-planner.core.frozen.mjs b/src/app/services/frozen-engine/leave-planner.core.frozen.mjs index 54ef602..7ac350d 100644 --- a/src/app/services/frozen-engine/leave-planner.core.frozen.mjs +++ b/src/app/services/frozen-engine/leave-planner.core.frozen.mjs @@ -195,10 +195,7 @@ var OPTIMIZATION_PRESETS = { } }; var nonWorkingDateCounts = new WeakMap; -function evaluateOptimization(configuration, observations) { - const validation = validateOptimizationConfiguration(configuration); - if (!validation.valid) - throw new Error(`${validation.code}: ${validation.message}`); +function evaluateValidatedOptimization(configuration, observations) { const selected = (condition) => configuration.conditions.includes(condition); let values; switch (configuration.elements) { @@ -248,14 +245,445 @@ function compareObjectiveLists(left, right) { return index < 0 ? 0 : compareObjectiveValues(left[index].direction, left[index].value, right[index].value); } +// src/app/services/plan-observations.ts +var DAY_MS = 86400000; +function utcDate(key) { + return new Date(`${key}T00:00:00Z`); +} +function addDays(key, days) { + return new Date(utcDate(key).getTime() + days * DAY_MS).toISOString().slice(0, 10); +} +function compareDate(a, b) { + return a < b ? -1 : a > b ? 1 : 0; +} +function dateRange(start, endExclusive) { + const result = []; + for (let date = start;compareDate(date, endExclusive) < 0; date = addDays(date, 1)) + result.push(date); + return result; +} +var calendarPeriodCache = new WeakMap; +var occupancies = new WeakMap; +var derivations = new WeakMap; +var shadowMode = globalThis.process?.env?.["VAC_OBSERVATION_SHADOW"] === "1"; +function note(parent, child, op, date) { + occupancies.delete(child); + if (parent === child) + return; + derivations.set(child, { parent, op, date }); +} +function derivePlacements(parent, child) { + note(parent, child, "same"); +} +function notePlacementAdded(parent, child, date) { + note(parent, child, "add", date); +} +function notePlacementRemoved(parent, child, date) { + note(parent, child, "remove", date); +} +function insertionPoint(sorted, date) { + let low = 0; + let high = sorted.length; + while (low < high) { + const mid = low + high >> 1; + if (sorted[mid] < date) + low = mid + 1; + else + high = mid; + } + return low; +} +function occupancyFromScratch(placements, context) { + const dates = [...placements.keys()].sort(); + return { calendar: context, dates, groups: groupsFromScratch(dates, context), observed: {} }; +} +function deriveOccupancy(parent, { op, date }, context) { + if (op === "same") + return parent; + const at = insertionPoint(parent.dates, date); + const present = parent.dates[at] === date; + if (present === (op === "add")) + return; + const dates = parent.dates.slice(); + if (op === "add") + dates.splice(at, 0, date); + else + dates.splice(at, 1); + const touchesGroups = date >= context.horizon.start && date < context.horizon.end && !context.declaredDates.has(date); + if (touchesGroups && !context.workingCalendar.has(date)) + return; + const groups = !touchesGroups ? parent.groups : op === "add" ? groupsWithDate(parent.groups, date, context) : groupsWithoutDate(parent.groups, date, context); + if (!groups) + return; + const observed = {}; + if (parent.observed.publicHolidayWeeks) + observed.publicHolidayWeeks = derivedHolidayWeeks(parent.observed.publicHolidayWeeks, dates, date, context); + return { calendar: context, dates, groups, observed }; +} +var weeksByDate = new WeakMap; +function derivedHolidayWeeks(weeks, dates, date, context) { + let index = weeksByDate.get(context); + if (!index) { + index = new Map; + context.holidayWeeks.forEach((week, i) => week.dates.forEach((d) => { + const list = index.get(d); + if (list) + list.push(i); + else + index.set(d, [i]); + })); + weeksByDate.set(context, index); + } + const affected = index.get(date); + if (!affected) + return weeks; + const next = weeks.slice(); + for (const i of affected) { + const week = context.holidayWeeks[i]; + const complete = week.dates.every((d) => { + const at = insertionPoint(dates, d); + return dates[at] === d || context.declaredDates.has(d); + }); + if (complete !== weeks[i].complete) + next[i] = Object.freeze({ value: week.value, complete }); + } + return Object.freeze(next); +} +function groupsWithDate(groups, date, context) { + let low = 0; + let high = groups.length; + while (low < high) { + const mid = low + high >> 1; + if (groups[mid].anchors[0] < date) + low = mid + 1; + else + high = mid; + } + const previous = low > 0 ? groups[low - 1] : undefined; + const next = low < groups.length ? groups[low] : undefined; + const index = context.calendarIndex.get(date); + const joinsPrevious = !!previous && context.workingPrefix[index] - context.workingPrefix[context.calendarIndex.get(previous.anchors[previous.anchors.length - 1]) + 1] === 0; + const joinsNext = !!next && context.workingPrefix[context.calendarIndex.get(next.anchors[0])] - context.workingPrefix[index + 1] === 0; + const merged = { + anchors: [ + ...joinsPrevious ? previous.anchors : [], + date, + ...joinsNext ? next.anchors : [] + ], + startIndex: joinsPrevious ? previous.startIndex : extendStart(index, context), + endIndex: joinsNext ? next.endIndex : extendEnd(index, context) + }; + const from = joinsPrevious ? low - 1 : low; + return [...groups.slice(0, from), merged, ...groups.slice(joinsNext ? low + 1 : low)]; +} +function groupsWithoutDate(groups, date, context) { + let low = 0; + let high = groups.length; + while (low < high) { + const mid = low + high >> 1; + if (groups[mid].anchors[0] <= date) + low = mid + 1; + else + high = mid; + } + const group = groups[low - 1]; + if (!group) + return; + const cut = insertionPoint(group.anchors, date); + if (group.anchors[cut] !== date) + return; + const leftAnchors = group.anchors.slice(0, cut); + const rightAnchors = group.anchors.slice(cut + 1); + const split = []; + if (leftAnchors.length) + split.push({ + anchors: leftAnchors, + startIndex: group.startIndex, + endIndex: extendEnd(context.calendarIndex.get(leftAnchors[leftAnchors.length - 1]), context) + }); + if (rightAnchors.length) + split.push({ + anchors: rightAnchors, + startIndex: extendStart(context.calendarIndex.get(rightAnchors[0]), context), + endIndex: group.endIndex + }); + return [...groups.slice(0, low - 1), ...split, ...groups.slice(low)]; +} +function occupancyOf(placements, context) { + const chain = []; + let current = placements; + let occupancy; + for (;; ) { + const known = occupancies.get(current); + if (known?.calendar === context) { + occupancy = known; + break; + } + const derivation = derivations.get(current); + if (!derivation) + break; + chain.push([current, derivation]); + current = derivation.parent; + } + if (!occupancy) { + occupancy = occupancyFromScratch(current, context); + occupancies.set(current, occupancy); + } + for (let i = chain.length - 1;i >= 0; i--) { + const [map, derivation] = chain[i]; + derivations.delete(map); + occupancy = deriveOccupancy(occupancy, derivation, context) ?? occupancyFromScratch(map, context); + occupancies.set(map, occupancy); + } + return occupancy; +} +function tieKeyOf(occupancy) { + return occupancy.tieKey ??= occupancy.dates.join("|"); +} +function placementTieKey(state, context) { + const occupancy = occupancyOf(state.placements, context); + if (shadowMode) + verifyOccupancy(state, context, occupancy); + return tieKeyOf(occupancy); +} +function expiryTieKey(state) { + return [...state.placements.values()].map((placement) => state.balances.find((item) => item.id === placement.balanceId)?.expiryDate ?? "").sort(compareDate).join("|"); +} +function isAtRisk(balance, context) { + return compareDate(balance.expiryDate, context.horizon.start) >= 0 && compareDate(balance.expiryDate, context.horizon.end) <= 0; +} +function isNaturalNonWorkingDate(date, context) { + return context.calendarIndex.has(date) ? !context.workingCalendar.has(date) : !isWorkingPlanningDate(date); +} +function calendarDateAt(index, context) { + return context.calendarDays[index] ?? addDays(context.calendarDays[0], index); +} +function extendStart(index, context) { + while (context.declaredDates.has(calendarDateAt(index - 1, context)) || isNaturalNonWorkingDate(calendarDateAt(index - 1, context), context)) { + context.check(); + index--; + } + return index; +} +function extendEnd(index, context) { + while (context.declaredDates.has(calendarDateAt(index + 1, context)) || isNaturalNonWorkingDate(calendarDateAt(index + 1, context), context)) { + context.check(); + index++; + } + return index; +} +function groupsFromScratch(placementDates, context) { + const dates = [...new Set([...placementDates, ...context.declaredDates])].filter((date) => date >= context.horizon.start && date < context.horizon.end).sort(compareDate); + if (!dates.length) + return []; + const groups = []; + let group = [dates[0]]; + for (let i = 1;i < dates.length; i++) { + context.check(); + const previousIndex = context.calendarIndex.get(dates[i - 1]); + const currentIndex = context.calendarIndex.get(dates[i]); + const workingInGap = context.workingPrefix[currentIndex] - context.workingPrefix[previousIndex + 1]; + if (workingInGap === 0) + group.push(dates[i]); + else { + groups.push(group); + group = [dates[i]]; + } + } + groups.push(group); + return groups.map((anchors) => { + context.check(); + return { + anchors, + startIndex: extendStart(context.calendarIndex.get(anchors[0]), context), + endIndex: extendEnd(context.calendarIndex.get(anchors[anchors.length - 1]), context) + }; + }); +} +function calendarPeriod(startIndex, endIndex, context) { + let periods = calendarPeriodCache.get(context); + if (!periods) { + periods = new Map; + calendarPeriodCache.set(context, periods); + } + const key = startIndex * 1048576 + endIndex; + let period = periods.get(key); + if (period) + periods.delete(key); + else { + const start = calendarDateAt(startIndex, context); + const end = calendarDateAt(endIndex, context); + const dates = startIndex >= 0 && endIndex < context.calendarDays.length ? context.calendarDays.slice(startIndex, endIndex + 1) : dateRange(start, addDays(end, 1)); + period = Object.freeze({ + start, + end, + duration: dates.length, + nonWorkingDates: Object.freeze(dates.filter((date) => isNaturalNonWorkingDate(date, context))), + declaredDates: Object.freeze(dates.filter((date) => context.declaredDates.has(date))) + }); + } + periods.set(key, period); + if (periods.size > 4096) + periods.delete(periods.keys().next().value); + return period; +} +function periodsOf(state, groups, context) { + return groups.map((group) => { + const period = group.period ??= calendarPeriod(group.startIndex, group.endIndex, context); + return { + start: period.start, + end: period.end, + duration: period.duration, + placementDates: group.anchors.filter((date) => state.placements.has(date)), + ...period.declaredDates.length ? { declaredDates: [...period.declaredDates] } : {}, + nonWorkingDates: [...period.nonWorkingDates], + nonWorkingDayGain: period.nonWorkingDates.length + }; + }); +} +function periodData(state, context) { + const occupancy = occupancyOf(state.placements, context); + if (shadowMode) + verifyOccupancy(state, context, occupancy); + return periodsOf(state, occupancy.groups, context); +} +function observationsFromScratch(state, context) { + const dates = [...state.placements.keys()].sort(); + const groups = groupsFromScratch(dates, context); + return { + tieKey: dates.join("|"), + groups: groups.map(({ anchors, startIndex, endIndex }) => ({ + anchors, + startIndex, + endIndex + })), + periods: periodsOf(state, groups, context), + ...calendarObservations(state, groups, context) + }; +} +function holidayWeeksFor(state, context) { + return Object.freeze(context.holidayWeeks.map((week) => Object.freeze({ + value: week.value, + complete: week.dates.every((date) => state.placements.has(date) || context.declaredDates.has(date)) + }))); +} +function timeOffPeriodsFor(groups, context, periods) { + return Object.freeze(periods ? periods.map((period) => Object.freeze({ nonWorkingDates: Object.freeze([...period.nonWorkingDates]) })) : groups.map((group) => group.timeOff ??= Object.freeze({ + nonWorkingDates: (group.period ??= calendarPeriod(group.startIndex, group.endIndex, context)).nonWorkingDates + }))); +} +function workSequencesFor(groups, context) { + return Object.freeze(workSequenceLengths(groups, context).map((sequence) => Object.freeze(sequence))); +} +function calendarObservations(state, groups, context) { + return { + publicHolidayWeeks: holidayWeeksFor(state, context), + timeOffPeriods: timeOffPeriodsFor(groups, context), + workSequences: workSequencesFor(groups, context) + }; +} +function verifyOccupancy(state, context, occupancy) { + const expected = observationsFromScratch(state, context); + const fail = (field, got, want) => { + throw new Error(`Observation shadow mismatch on ${field} for placements [${[...state.placements.keys()].sort().join(", ")}]: got ${JSON.stringify(got)}, expected ${JSON.stringify(want)}`); + }; + if (tieKeyOf(occupancy) !== expected.tieKey) + fail("tieKey", tieKeyOf(occupancy), expected.tieKey); + const groups = occupancy.groups.map(({ anchors, startIndex, endIndex }) => ({ + anchors, + startIndex, + endIndex + })); + if (JSON.stringify(groups) !== JSON.stringify(expected.groups)) + fail("groups", groups, expected.groups); + for (const field of ["publicHolidayWeeks", "timeOffPeriods", "workSequences"]) { + const cached = occupancy.observed[field]; + if (cached && JSON.stringify(cached) !== JSON.stringify(expected[field])) + fail(field, cached, expected[field]); + } +} +function optimizationBalanceObservations(state, context) { + return state.balances.map((balance) => ({ + unplacedQuantity: balance.remaining, + atRisk: isAtRisk(balance, context) + })); +} +function optimizationObservations(state, context, periods) { + const occupancy = occupancyOf(state.placements, context); + const calendar = occupancy.observed; + if (shadowMode) { + verifyOccupancy(state, context, occupancy); + calendar.publicHolidayWeeks ??= holidayWeeksFor(state, context); + calendar.workSequences ??= workSequencesFor(occupancy.groups, context); + if (!periods) + calendar.timeOffPeriods ??= timeOffPeriodsFor(occupancy.groups, context); + verifyOccupancy(state, context, occupancy); + } + return { + leaveBalances: optimizationBalanceObservations(state, context), + get publicHolidayWeeks() { + return calendar.publicHolidayWeeks ??= holidayWeeksFor(state, context); + }, + get timeOffPeriods() { + return calendar.timeOffPeriods ??= timeOffPeriodsFor(occupancy.groups, context, periods); + }, + get workSequences() { + return calendar.workSequences ??= workSequencesFor(occupancy.groups, context); + }, + leavePlacements: [...state.placements.values()] + }; +} +function workSequenceLengths(bounds, context) { + if (!context.workingDays.length) + return []; + let nextIndex = context.calendarIndex.get(context.horizon.start); + const sequences = bounds.map(({ anchors }, index) => { + const previousIndex = nextIndex; + nextIndex = context.calendarIndex.get(anchors[anchors.length - 1]) + 1; + return { + length: context.workingPrefix[context.calendarIndex.get(anchors[0])] - context.workingPrefix[previousIndex], + internal: index > 0 + }; + }); + sequences.push({ + length: context.workingPrefix[context.calendarIndex.get(context.horizon.end)] - context.workingPrefix[nextIndex], + internal: false + }); + return sequences; +} +function workSequenceDates(state, context) { + const sequences = []; + let current = []; + let inTimeOffPeriod = false; + for (const date of context.workingDays) { + context.check(); + if (state.placements.has(date) || context.declaredDates.has(date)) { + if (!inTimeOffPeriod) + sequences.push(current); + current = []; + inTimeOffPeriod = true; + } else { + current.push(date); + inTimeOffPeriod = false; + } + } + if (context.workingDays.length) + sequences.push(current); + return sequences.map((dates, index) => ({ + dates, + internal: index > 0 && index < sequences.length - 1 + })); +} +function evaluateStateOptimization(configuration, state, context, observations = optimizationObservations(state, context)) { + return evaluateValidatedOptimization(configuration, observations); +} + // src/app/services/leave-planner.core.ts var obligationRequirementCache = new WeakMap; var occurrenceCache = new WeakMap; var calendarPatternCache = new WeakMap; var preferencePatternCache = new WeakMap; var declaredWindowCache = new WeakMap; -var calendarObservationCache = new WeakMap; -var calendarPeriodCache = new WeakMap; function now() { return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); } @@ -273,10 +701,8 @@ class DeadlineReached extends Error { class UserCancelled extends Error { } -var DAY_MS = 86400000; var OBLIGATION_SOLUTION_LIMIT = 4096; var EXACT_OBLIGATION_BRANCH_LIMIT = 1e4; -var WINDOW_OBLIGATION_PREVIEW_LIMIT = 8; var VALID_TYPES = new Set(["cp", "rtt", "other"]); var VALID_SELECTORS = new Set(["cp", "rtt", "other", "any"]); var VALID_MODES = new Set(["before", "after", "around", "auto"]); @@ -309,24 +735,9 @@ function runtimeRequest(value, calculationDate) { function recordValue(value) { return typeof value === "object" && value !== null ? value : undefined; } -function utcDate(key) { - return new Date(`${key}T00:00:00Z`); -} -function addDays(key, days) { - return new Date(utcDate(key).getTime() + days * DAY_MS).toISOString().slice(0, 10); -} -function compareDate(a, b) { - return a < b ? -1 : a > b ? 1 : 0; -} function compareText(a, b) { return a < b ? -1 : a > b ? 1 : 0; } -function dateRange(start, endExclusive) { - const result = []; - for (let date = start;compareDate(date, endExclusive) < 0; date = addDays(date, 1)) - result.push(date); - return result; -} function defaultHorizon(start) { return { start, end: addPlanningYears(start, 1) }; } @@ -367,22 +778,30 @@ function holidayWeekCandidates(context) { function reason(code, message, ruleIds) { return ruleIds?.length ? { code, message, ruleIds } : { code, message }; } -function cloneState(state, placements) { +function copyPlacements(state) { + return new Map([...state.placements.entries()].map(([date, placement]) => [ + date, + { + ...placement, + ruleIds: new Set(placement.ruleIds) + } + ])); +} +function cloneState(state) { + const placements = copyPlacements(state); + derivePlacements(state.placements, placements); return { balances: state.balances.map((balance) => ({ ...balance })), - placements: placements ?? new Map([...state.placements.entries()].map(([date, placement]) => [ - date, - { - ...placement, - ruleIds: new Set(placement.ruleIds) - } - ])), + placements, earnedActivations: state.earnedActivations.map((activation) => ({ ...activation, dates: [...activation.dates] })) }; } +function debitBalance(state, balanceId) { + return state.balances.map((balance) => balance.id === balanceId ? { ...balance, remaining: balance.remaining - 1 } : balance); +} function planningStateSnapshot(state, context) { return { calculationDate: context.request.calculationDate, @@ -855,17 +1274,20 @@ function assignDateVariants(state, date, selector, ruleId, context) { return compareDate(a.expiryDate, b.expiryDate) || compareText(a.id, b.id); }); return balances.map((balance) => { - const result = cloneState(state); - const target = result.balances.find((item) => item.id === balance.id); - target.remaining--; - result.placements.set(date, { + const placements = copyPlacements(state); + notePlacementAdded(state.placements, placements, date); + placements.set(date, { date, - balanceId: target.id, - type: target.type, + balanceId: balance.id, + type: balance.type, ruleIds: ruleId ? new Set([ruleId]) : new Set, supporting: false }); - return result; + return { + balances: debitBalance(state, balance.id), + placements, + earnedActivations: state.earnedActivations + }; }); } function assignDateToBalance(state, date, balanceId, context, ruleIds = new Set) { @@ -881,17 +1303,20 @@ function assignDateToBalance(state, date, balanceId, context, ruleIds = new Set) } if (!mandatoryRelocationDate(date, balance, ruleIds, context)) return null; - const result = cloneState(state, new Map(state.placements)); - const target = result.balances.find((item) => item.id === balanceId); - target.remaining--; - result.placements.set(date, { + const placements = new Map(state.placements); + notePlacementAdded(state.placements, placements, date); + placements.set(date, { date, balanceId, type: balance.type, ruleIds: new Set(ruleIds), supporting: false }); - return result; + return { + balances: debitBalance(state, balanceId), + placements, + earnedActivations: state.earnedActivations + }; } function addEarnedBalance(state, rule, occurrence, dates, context) { if (!rule.rewardDays || !rule.rewardType) @@ -955,12 +1380,12 @@ function finalizeEarnedState(state, context) { if (bounds.availableFrom <= context.request.calculationDate) { if ([...result.placements.values()].some((placement) => placement.balanceId === activation.id)) return null; - result.balances = result.balances.filter((balance2) => balance2.id !== activation.id); + result.balances = result.balances.filter((balance) => balance.id !== activation.id); continue; } let balance = result.balances.find((item) => item.id === activation.id); if (!balance) { - const rule = context.rules.find((rule2) => rule2.type === "if_then" && rule2.id === activation.ruleId); + const rule = context.rules.find((rule) => rule.type === "if_then" && rule.id === activation.ruleId); if (!rule) return null; result = addEarnedBalance(result, rule, activation.occurrence, activation.dates, context); @@ -1093,6 +1518,7 @@ function releasePlacement(state, date) { })) }; const placement = result.placements.get(date); + notePlacementRemoved(state.placements, result.placements, date); result.placements.delete(date); result.balances.find((balance) => balance.id === placement.balanceId).remaining++; return result; @@ -1110,19 +1536,21 @@ function reconcileOptimizationProvenance(state, context, before, objectives = ac if (!credits.length) continue; const withoutState = releasePlacement(state, placement.date); - const withoutPlacement = activeObjectiveVector(withoutState, context); + const withoutPlacement = [ + ...activeObjectives(withoutState, context, Number.POSITIVE_INFINITY, new Set(credits.map(({ id }) => id))) + ]; const ruleIds = new Set([...placement.ruleIds].filter((id) => !credits.some((objective) => objective.id === `optimization:${id}`))); for (const objective of credits) { - const without = withoutPlacement.find(({ id: id2 }) => id2 === objective.id); + const without = withoutPlacement.find(({ id }) => id === objective.id); const id = objective.id.slice("optimization:".length); - const rule = context.rules.find((rule2) => rule2.id === id && rule2.type === "optimization"); + const rule = context.rules.find((rule) => rule.id === id && rule.type === "optimization"); const compared = compareObjectiveValues(objective.direction, objective.value, without.value); let contributes = compared < 0; if (compared === 0 && rule.configuration.aggregation === "maximum") { observations ??= optimizationObservations(state, context); const withoutObservations = optimizationObservations(withoutState, context); const summed = { ...rule.configuration, aggregation: "sum" }; - const summedComparison = compareObjectiveValues(objective.direction, evaluateOptimization(summed, observations), evaluateOptimization(summed, withoutObservations)); + const summedComparison = compareObjectiveValues(objective.direction, evaluateValidatedOptimization(summed, observations), evaluateValidatedOptimization(summed, withoutObservations)); contributes = summedComparison < 0 || summedComparison === 0 && rule.configuration.elements === "time-off-periods" && objective.direction === "maximize" && observations.timeOffPeriods.length < withoutObservations.timeOffPeriods.length; } if (contributes) @@ -1138,7 +1566,7 @@ function reconcileOptimizationProvenance(state, context, before, objectives = ac } function supportingCalendarBenefit(before, after, context) { const observations = optimizationObservations(after, context); - return evaluateOptimization(OPTIMIZATION_PRESETS.holidayWeekValue, observations) > evaluateOptimization(OPTIMIZATION_PRESETS.holidayWeekValue, before) || evaluateOptimization(OPTIMIZATION_PRESETS.nonWorkingDayGain, observations) > evaluateOptimization(OPTIMIZATION_PRESETS.nonWorkingDayGain, before) || evaluateOptimization(OPTIMIZATION_PRESETS.longestWorkSequence, observations) < evaluateOptimization(OPTIMIZATION_PRESETS.longestWorkSequence, before) || evaluateOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, observations) < evaluateOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, before); + return evaluateValidatedOptimization(OPTIMIZATION_PRESETS.holidayWeekValue, observations) > evaluateValidatedOptimization(OPTIMIZATION_PRESETS.holidayWeekValue, before) || evaluateValidatedOptimization(OPTIMIZATION_PRESETS.nonWorkingDayGain, observations) > evaluateValidatedOptimization(OPTIMIZATION_PRESETS.nonWorkingDayGain, before) || evaluateValidatedOptimization(OPTIMIZATION_PRESETS.longestWorkSequence, observations) < evaluateValidatedOptimization(OPTIMIZATION_PRESETS.longestWorkSequence, before) || evaluateValidatedOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, observations) < evaluateValidatedOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, before); } function mandatoryRelocationDate(date, balance, ruleIds, context) { return [...ruleIds].every((id) => { @@ -1300,7 +1728,7 @@ function typedCalendarDates(context) { } return typedDates; } -function* calendarPlacementCandidates(state, targetDates, ruleId, context, accept) { +function* calendarPlacementCandidates(state, targetDates, ruleId, context, accept, donorObjectives = new Map) { const missing = targetDates.filter((date) => !state.placements.has(date) && !context.declaredDates.has(date)); if (!missing.length) return false; @@ -1313,10 +1741,12 @@ function* calendarPlacementCandidates(state, targetDates, ruleId, context, accep context.check(); if (targetDates.includes(placement.date)) continue; - rankedDonors.push({ - date: placement.date, - objectives: activeObjectiveVector(releasePlacement(state, placement.date), context) - }); + let objectives = donorObjectives.get(placement.date); + if (!objectives) { + objectives = activeObjectiveVector(releasePlacement(state, placement.date), context); + donorObjectives.set(placement.date, objectives); + } + rankedDonors.push({ date: placement.date, objectives }); } rankedDonors.sort((a, b) => compareObjectiveLists(a.objectives, b.objectives) || compareDate(b.date, a.date)); const fill = function* (index, current, additions = []) { @@ -1338,9 +1768,9 @@ function* calendarPlacementCandidates(state, targetDates, ruleId, context, accep return false; if (index === missing.length) { const benefit = supportingCalendarBenefit(before, current, context); - for (const date2 of additions) { - const placement = { ...current.placements.get(date2) }; - current.placements.set(date2, placement); + for (const date of additions) { + const placement = { ...current.placements.get(date) }; + current.placements.set(date, placement); const balance = current.balances.find((item) => item.id === placement.balanceId); placement.supporting = benefit && !isAtRisk(balance, context); } @@ -1391,8 +1821,9 @@ function* calendarPlacementCandidates(state, targetDates, ruleId, context, accep return yield* fill(0, state); } function* holidayWeekPlacementCandidates(state, ruleId, context, accept) { + const donorObjectives = new Map; for (const week of context.holidayWeeks) - yield* calendarPlacementCandidates(state, week.dates, ruleId, context, accept); + yield* calendarPlacementCandidates(state, week.dates, ruleId, context, accept, donorObjectives); } function* timeOffPeriodPlacementCandidates(state, ruleId, context, accept) { const periods = periodData(state, context); @@ -1402,9 +1833,48 @@ function* timeOffPeriodPlacementCandidates(state, ruleId, context, accept) { yield* calendarPlacementCandidates(state, dates, ruleId, context, accept); } } +var finalizeInvarianceCache = new WeakMap; +function finalizeInvariance(context) { + let entries = finalizeInvarianceCache.get(context); + if (!entries) { + const earning = context.rules.some((rule) => rule.enabled && rule.type === "if_then" && rule.rewardDays); + entries = context.rules.filter((rule) => rule.enabled && rule.force === "preference").map((rule) => { + if (rule.type !== "optimization") + return "exact"; + switch (rule.configuration.elements) { + case "public-holiday-weeks": + case "time-off-periods": + case "work-sequences": + return "exact"; + case "leave-balances": + return earning ? "unknown" : "without-earning"; + default: + return "unknown"; + } + }); + finalizeInvarianceCache.set(context, entries); + } + return entries; +} +function provablyWorseThanProtectedPrefix(candidate, context, throughIndex, baseline) { + const invariance = finalizeInvariance(context); + let index = 0; + for (const objective of activeObjectives(candidate, context, throughIndex)) { + const kind = invariance[index]; + if (kind === "unknown" || kind === "without-earning" && candidate.earnedActivations.length) + return false; + const comparison = compareObjectiveValues(objective.direction, objective.value, baseline[index].value); + if (comparison !== 0) + return comparison > 0; + index++; + } + return false; +} function evaluateRuleCandidate(before, candidate, context, throughIndex, addition = false, baseline = activeObjectiveVector(before, context, throughIndex), incumbentVector = objectiveVector(before, context)) { if (!candidate) return; + if (provablyWorseThanProtectedPrefix(candidate, context, throughIndex, baseline)) + return; const state = finalizeRuleCandidate(candidate, context, before); if (!state) return; @@ -1426,7 +1896,7 @@ function evaluateRuleCandidate(before, candidate, context, throughIndex, additio } const vector = objectiveVector(state, context, active); if (compareObjectiveLists(vector.objectives, incumbentVector.objectives) < 0) - context.retain?.(state, undefined, vector); + context.retain?.(state, vector); if (addition && comparison === 0) return; return { state, vector }; @@ -1531,7 +2001,7 @@ function* optimizeRulePlacements(state, context, throughIndex, allowAdd, rule) { if (item.type === "time_window") return item.placeDays; if (item.type === "optimization" && item.configuration.elements === "public-holiday-weeks" && item.configuration.measure === "public-holiday-week-value" && item.configuration.direction === "maximize") - return evaluateOptimization(item.configuration, { + return evaluateValidatedOptimization(item.configuration, { leaveBalances: [], publicHolidayWeeks: context.holidayWeeks.map(({ value }) => ({ value, @@ -1550,7 +2020,7 @@ function* optimizeRulePlacements(state, context, throughIndex, allowAdd, rule) { const active = activeObjectiveVector(result, context); const baseline = active.slice(0, throughIndex + 1); const beforeVector = objectiveVector(result, context, active); - context.retain?.(result, undefined, beforeVector); + context.retain?.(result, beforeVector); if (allowAdd && prefixBounds.length > 0 && prefixBounds.every((bound, index) => bound !== undefined && active[index].value === bound)) { return result; } @@ -1646,7 +2116,7 @@ function* optimizeRulePlacements(state, context, throughIndex, allowAdd, rule) { const moved = assignDateToBalance(without, destination, existing.balanceId, context, existing.ruleIds); if (moved) moved.placements.get(destination).supporting = existing.supporting; - if (moved && dateInvariantObjectives && compareObjectiveLists(tieBreakObjectives(moved), beforeVector.objectives.slice(-2)) >= 0) { + if (moved && dateInvariantObjectives && compareObjectiveLists(tieBreakObjectives(moved, context), beforeVector.objectives.slice(-2)) >= 0) { continue; } consider(moved); @@ -1848,15 +2318,6 @@ function preferenceReason(rule, count, context, state) { code = "capacity"; return reason(code, `Preference "${rule.name}" could not be completely satisfied.`, [rule.id]); } -function* applyPreferenceRules(state, preferenceRules, context) { - let result = state; - const preferences = []; - for (let preferenceIndex = 0;preferenceIndex < preferenceRules.length; preferenceIndex++) { - yield; - result = yield* applyPreferenceStep(result, preferenceRules, preferenceIndex, preferences, context); - } - return { state: result, preferences }; -} function* applyPreferenceStep(state, preferenceRules, preferenceIndex, preferences, context) { let result = state; const apply = function* (index) { @@ -1901,16 +2362,10 @@ function preferenceStateKey(state) { state.earnedActivations ]); } -function placementTieKey(state) { - return [...state.placements.keys()].sort().join("|"); -} -function expiryTieKey(state) { - return [...state.placements.values()].map((placement) => state.balances.find((item) => item.id === placement.balanceId)?.expiryDate ?? "").sort(compareDate).join("|"); -} function activeObjectiveVector(state, context, throughIndex = Number.POSITIVE_INFINITY) { return [...activeObjectives(state, context, throughIndex)]; } -function* activeObjectives(state, context, throughIndex = Number.POSITIVE_INFINITY) { +function* activeObjectives(state, context, throughIndex = Number.POSITIVE_INFINITY, only) { let preferences; let balances; let calendar; @@ -1920,6 +2375,8 @@ function* activeObjectives(state, context, throughIndex = Number.POSITIVE_INFINI continue; if (index++ > throughIndex) return; + if (only && !only.has(`${rule.type === "optimization" ? "optimization" : "preference"}:${rule.id}`)) + continue; if (rule.type === "optimization") { const observations = rule.configuration.elements === "leave-balances" ? balances ??= { leaveBalances: optimizationBalanceObservations(state, context), @@ -1945,7 +2402,7 @@ function* activeObjectives(state, context, throughIndex = Number.POSITIVE_INFINI } } } -function tieBreakObjectives(state) { +function tieBreakObjectives(state, context) { return [ { id: "balance-expiry", @@ -1956,186 +2413,20 @@ function tieBreakObjectives(state) { { id: "placement-date", direction: "minimize", - value: placementTieKey(state), + value: placementTieKey(state, context), label: "Placement date tie-break" } ]; } function obligationPreviewObjectives(preview, context) { - return [...activeObjectiveVector(preview.state, context), ...tieBreakObjectives(preview.state)]; + return [ + ...activeObjectiveVector(preview.state, context), + ...tieBreakObjectives(preview.state, context) + ]; } function compareObligationPreviews(a, b, context) { return compareObjectiveLists(obligationPreviewObjectives(a, context), obligationPreviewObjectives(b, context)); } -function isAtRisk(balance, context) { - return compareDate(balance.expiryDate, context.horizon.start) >= 0 && compareDate(balance.expiryDate, context.horizon.end) <= 0; -} -function isNaturalNonWorkingDate(date, context) { - return context.calendarIndex.has(date) ? !context.workingCalendar.has(date) : !isWorkingPlanningDate(date); -} -function calendarDateAt(index, context) { - return context.calendarDays[index] ?? addDays(context.calendarDays[0], index); -} -function periodBounds(state, context) { - const dates = [...new Set([...state.placements.keys(), ...context.declaredDates])].filter((date) => date >= context.horizon.start && date < context.horizon.end).sort(compareDate); - if (!dates.length) - return []; - const groups = []; - let group = [dates[0]]; - for (let i = 1;i < dates.length; i++) { - context.check(); - const previousIndex = context.calendarIndex.get(dates[i - 1]); - const currentIndex = context.calendarIndex.get(dates[i]); - const workingInGap = context.workingPrefix[currentIndex] - context.workingPrefix[previousIndex + 1]; - if (workingInGap === 0) - group.push(dates[i]); - else { - groups.push(group); - group = [dates[i]]; - } - } - groups.push(group); - return groups.map((anchors) => { - context.check(); - let startIndex = context.calendarIndex.get(anchors[0]); - let endIndex = context.calendarIndex.get(anchors[anchors.length - 1]); - while (context.declaredDates.has(calendarDateAt(startIndex - 1, context)) || isNaturalNonWorkingDate(calendarDateAt(startIndex - 1, context), context)) { - context.check(); - startIndex--; - } - while (context.declaredDates.has(calendarDateAt(endIndex + 1, context)) || isNaturalNonWorkingDate(calendarDateAt(endIndex + 1, context), context)) { - context.check(); - endIndex++; - } - return { anchors, startIndex, endIndex }; - }); -} -function calendarPeriod(startIndex, endIndex, context) { - let periods = calendarPeriodCache.get(context); - if (!periods) { - periods = new Map; - calendarPeriodCache.set(context, periods); - } - const key = `${startIndex}:${endIndex}`; - let period = periods.get(key); - if (period) - periods.delete(key); - else { - const start = calendarDateAt(startIndex, context); - const end = calendarDateAt(endIndex, context); - const dates = startIndex >= 0 && endIndex < context.calendarDays.length ? context.calendarDays.slice(startIndex, endIndex + 1) : dateRange(start, addDays(end, 1)); - period = Object.freeze({ - start, - end, - duration: dates.length, - nonWorkingDates: Object.freeze(dates.filter((date) => isNaturalNonWorkingDate(date, context))), - declaredDates: Object.freeze(dates.filter((date) => context.declaredDates.has(date))) - }); - } - periods.set(key, period); - if (periods.size > 4096) - periods.delete(periods.keys().next().value); - return period; -} -function periodData(state, context) { - return periodBounds(state, context).map(({ anchors, startIndex, endIndex }) => { - const period = calendarPeriod(startIndex, endIndex, context); - return { - start: period.start, - end: period.end, - duration: period.duration, - placementDates: anchors.filter((date) => state.placements.has(date)), - ...period.declaredDates.length ? { declaredDates: [...period.declaredDates] } : {}, - nonWorkingDates: [...period.nonWorkingDates], - nonWorkingDayGain: period.nonWorkingDates.length - }; - }); -} -function optimizationBalanceObservations(state, context) { - return state.balances.map((balance) => ({ - unplacedQuantity: balance.remaining, - atRisk: isAtRisk(balance, context) - })); -} -function optimizationObservations(state, context, periods) { - const key = placementTieKey(state); - let byDates = calendarObservationCache.get(context); - if (!byDates) { - byDates = new Map; - calendarObservationCache.set(context, byDates); - } - const calendar = byDates.get(key) ?? {}; - if (byDates.has(key)) - byDates.delete(key); - else if (byDates.size >= 4096) - byDates.delete(byDates.keys().next().value); - byDates.set(key, calendar); - let bounds; - return { - leaveBalances: optimizationBalanceObservations(state, context), - get publicHolidayWeeks() { - return calendar.publicHolidayWeeks ??= Object.freeze(context.holidayWeeks.map((week) => Object.freeze({ - value: week.value, - complete: week.dates.every((date) => state.placements.has(date) || context.declaredDates.has(date)) - }))); - }, - get timeOffPeriods() { - return calendar.timeOffPeriods ??= Object.freeze(periods ? periods.map((period) => Object.freeze({ - nonWorkingDates: Object.freeze([...period.nonWorkingDates]) - })) : (bounds ??= periodBounds(state, context)).map(({ startIndex, endIndex }) => Object.freeze({ - nonWorkingDates: calendarPeriod(startIndex, endIndex, context).nonWorkingDates - }))); - }, - get workSequences() { - return calendar.workSequences ??= Object.freeze(workSequenceLengths(bounds ??= periodBounds(state, context), context).map((sequence) => Object.freeze(sequence))); - }, - leavePlacements: [...state.placements.values()] - }; -} -function workSequenceLengths(bounds, context) { - if (!context.workingDays.length) - return []; - let nextIndex = context.calendarIndex.get(context.horizon.start); - const sequences = bounds.map(({ anchors }, index) => { - const previousIndex = nextIndex; - nextIndex = context.calendarIndex.get(anchors[anchors.length - 1]) + 1; - return { - length: context.workingPrefix[context.calendarIndex.get(anchors[0])] - context.workingPrefix[previousIndex], - internal: index > 0 - }; - }); - sequences.push({ - length: context.workingPrefix[context.calendarIndex.get(context.horizon.end)] - context.workingPrefix[nextIndex], - internal: false - }); - return sequences; -} -function workSequenceDates(state, context) { - const sequences = []; - let current = []; - let inTimeOffPeriod = false; - for (const date of context.workingDays) { - context.check(); - if (state.placements.has(date) || context.declaredDates.has(date)) { - if (!inTimeOffPeriod) - sequences.push(current); - current = []; - inTimeOffPeriod = true; - } else { - current.push(date); - inTimeOffPeriod = false; - } - } - if (context.workingDays.length) - sequences.push(current); - return sequences.map((dates, index) => ({ - dates, - internal: index > 0 && index < sequences.length - 1 - })); -} -function evaluateStateOptimization(configuration, state, context, observations = optimizationObservations(state, context)) { - return evaluateOptimization(configuration, observations); -} function qualityMetrics(state, context, preferences) { const periods = periodData(state, context); const observations = optimizationObservations(state, context, periods); @@ -2219,7 +2510,7 @@ function objectiveVector(state, context, active = activeObjectiveVector(state, c label: "Unsatisfied obligations" }, ...active, - ...tieBreakObjectives(state) + ...tieBreakObjectives(state, context) ] }; } @@ -2309,6 +2600,7 @@ function planExplanation(state, context, placements, periods, preferences, remai }); const atRisk = normalizedBalance ? compareDate(normalizedBalance.expiryDate, context.horizon.start) >= 0 && compareDate(normalizedBalance.expiryDate, context.horizon.end) <= 0 : false; const withoutPlacement = cloneState(state); + notePlacementRemoved(state.placements, withoutPlacement.placements, placement.date); withoutPlacement.placements.delete(placement.date); const released = withoutPlacement.balances.find((candidate) => candidate.id === placement.balanceId); if (released) @@ -2502,16 +2794,30 @@ function calculatePlanningInternal(request, checkpointCallback, artifactsCallbac } async function calculatePlanningAsyncInternal(request, checkpointCallback, artifactsCallback, outcomeCallback, explanationCallback, isCancelled = () => false) { const steps = calculatePlanningSteps(request, checkpointCallback, artifactsCallback, outcomeCallback, explanationCallback, true); - let next = steps.next(); - let pauseAt = now() + 8; - while (!next.done) { - if (next.value || now() >= pauseAt) { - await new Promise((resolve) => setTimeout(resolve, 0)); - pauseAt = now() + 8; + const channel = typeof setImmediate === "function" ? undefined : new MessageChannel; + let resume = () => {}; + if (channel) + channel.port1.onmessage = () => resume(); + const pause = () => new Promise((resolve) => { + if (!channel) + return void setImmediate(resolve); + resume = resolve; + channel.port2.postMessage(undefined); + }); + try { + let next = steps.next(); + let pauseAt = now() + 8; + while (!next.done) { + if (next.value || now() >= pauseAt) { + await pause(); + pauseAt = now() + 8; + } + next = isCancelled() ? steps.throw(new UserCancelled) : steps.next(); } - next = isCancelled() ? steps.throw(new UserCancelled) : steps.next(); + return next.value; + } finally { + channel?.port1.close(); } - return next.value; } function* prepareCalendar(horizon, progress) { const start = addDays(horizon.start, -400); @@ -2639,13 +2945,13 @@ function* calculatePlanningSteps(request, checkpointCallback, artifactsCallback, let checkpointState; let retained; if (retainIncumbents) - context.retain = (candidate, finalizedMetrics, candidateVector) => { + context.retain = (candidate, candidateVector) => { const check = context.check; context.check = () => { return; }; try { - const finalized = finalizedMetrics || candidateVector ? candidate : finalizeEarnedState(candidate, context); + const finalized = candidateVector ? candidate : finalizeEarnedState(candidate, context); if (!finalized) return; if (unsatisfiedObligationCount(finalized, context)) @@ -2665,11 +2971,7 @@ function* calculatePlanningSteps(request, checkpointCallback, artifactsCallback, retained = { state: cloneState(reconcileOptimizationProvenance(finalized, context, retained?.state ?? initialState, vector.objectives)), vector, - preferences: candidatePreferences, - metrics: finalizedMetrics ? { - ...finalizedMetrics, - preferenceSatisfaction: candidatePreferences.map(({ satisfied }) => satisfied) - } : qualityMetrics(finalized, context, candidatePreferences) + preferences: candidatePreferences }; if (firstValidCandidate && checkpointCallback) observeCallback(context, () => checkpointCallback(makePlan(retained.state, context, retained.preferences, stages), planningStateSnapshot(retained.state, context))); @@ -2789,8 +3091,8 @@ function* calculatePlanningSteps(request, checkpointCallback, artifactsCallback, return finish(obligationConflict(obligationRequirements, context)); } const preferenceRules = normalized.rules.filter((item) => item.enabled && item.force === "preference"); - let previewSolutions = obligationSolutions; - if (obligationSolutionLimit > 0 && obligationRequirements.every((requirement) => requirement.rule.type === "time_window") && obligationSolutions.length > WINDOW_OBLIGATION_PREVIEW_LIMIT) { + let rankedSolutions = obligationSolutions; + if (obligationSolutions.length > 1) { const ranked = []; for (const candidate of obligationSolutions) { yield; @@ -2801,72 +3103,48 @@ function* calculatePlanningSteps(request, checkpointCallback, artifactsCallback, }); } ranked.sort((a, b) => compareObjectiveLists(a.objectives, b.objectives)); - previewSolutions = ranked.slice(0, WINDOW_OBLIGATION_PREVIEW_LIMIT).map(({ state: state2 }) => state2); + rankedSolutions = ranked.map(({ state }) => state); } - const soleSolution = obligationSolutions.length === 1 && (obligationSolutionLimit === 0 || obligationSolutions.length < obligationSolutionLimit) ? obligationSolutions[0] : undefined; - const solePreferences = soleSolution ? preferenceResultsForState(soleSolution, context) : []; - let bestPreview = soleSolution ? { - obligationState: soleSolution, - state: soleSolution, - preferences: solePreferences, - metrics: qualityMetrics(soleSolution, context, solePreferences) - } : null; - for (const obligationSolution of soleSolution ? [] : previewSolutions) { - yield; - context.check(); - const preview = yield* applyPreferenceRules(cloneState(obligationSolution), preferenceRules, context); - const previewState = finalizeEarnedState(preview.state, context); - if (!previewState) - continue; - const candidate = { - obligationState: obligationSolution, - state: previewState, - preferences: preview.preferences, - metrics: qualityMetrics(previewState, context, preview.preferences) - }; - const compared = bestPreview ? compareObligationPreviews(candidate, bestPreview, context) : undefined; - if (!bestPreview || compared < 0) - bestPreview = candidate; - } - if (!bestPreview) { + for (const obligationSolution of rankedSolutions) { + state = obligationSolution; + stages.length = 1; + preferences.length = 0; + checkpointState = undefined; stages.push("obligations"); - return finish(obligationConflict(obligationRequirements, context)); - } - state = bestPreview.obligationState; - stages.push("obligations"); - publishCheckpoint(); - for (let preferenceIndex = 0;preferenceIndex < preferenceRules.length; preferenceIndex++) { - yield; - const rule = preferenceRules[preferenceIndex]; - markProgress(context, ruleStage(rule)); - yield true; - state = yield* applyPreferenceStep(state, preferenceRules, preferenceIndex, preferences, context); - stages.push(ruleStage(rule)); publishCheckpoint(); + for (let preferenceIndex = 0;preferenceIndex < preferenceRules.length; preferenceIndex++) { + yield; + const rule = preferenceRules[preferenceIndex]; + markProgress(context, ruleStage(rule)); + yield true; + state = yield* applyPreferenceStep(state, preferenceRules, preferenceIndex, preferences, context); + stages.push(ruleStage(rule)); + publishCheckpoint(); + } + markProgress(context, "expiry"); + yield true; + state = yield* optimizeRulePlacements(state, context, preferenceRules.length - 1, false); + stages.push("expiry"); + publishCheckpoint(); + markProgress(context, "dates"); + yield true; + stages.push("dates"); + const finalized = finalizeEarnedState(state, context); + if (!finalized) + continue; + state = finalized; + publishCheckpoint(); + markProgress(context, "complete"); + yield true; + return finish(checkpoint, state); } - markProgress(context, "expiry"); - yield true; - state = yield* optimizeRulePlacements(state, context, preferenceRules.length - 1, false); - stages.push("expiry"); - publishCheckpoint(); - markProgress(context, "dates"); - yield true; - stages.push("dates"); - const finalized = finalizeEarnedState(state, context); - if (!finalized) { - return finish({ - kind: "PlanningConflict", - conflictingRuleIds: state.earnedActivations.map((activation) => activation.ruleId), - reasons: [ - reason("earned-entitlement-order", "Generated leave became available only after its final merged time-off period.") - ] - }); - } - state = finalized; - publishCheckpoint(); - markProgress(context, "complete"); - yield true; - return finish(checkpoint, state); + return finish({ + kind: "PlanningConflict", + conflictingRuleIds: state.earnedActivations.map((activation) => activation.ruleId), + reasons: [ + reason("earned-entitlement-order", "Generated leave became available only after its final merged time-off period.") + ] + }); } catch (error) { if (error instanceof UserCancelled && retained) { context.check = () => { @@ -3137,8 +3415,10 @@ function analyzeCounterfactualInternal(request, plan, selected, finalStateSnapsh const baseline = cloneState(finalState); const removeDate = selected.date; const removed = removeDate ? baseline.placements.get(removeDate) : undefined; - if (removeDate) + if (removeDate) { + notePlacementRemoved(finalState.placements, baseline.placements, removeDate); baseline.placements.delete(removeDate); + } const released = baseline.balances.find((balance) => balance.id === selected.balanceId); if (released && removed?.balanceId === selected.balanceId) released.remaining++; @@ -3214,9 +3494,9 @@ function analyzeCounterfactualInternal(request, plan, selected, finalStateSnapsh }; } export { - invalidInputFor, - explainIncompleteCheckpoint, - calculatePlanningInternal, + analyzeCounterfactualInternal, calculatePlanningAsyncInternal, - analyzeCounterfactualInternal + calculatePlanningInternal, + explainIncompleteCheckpoint, + invalidInputFor };