diff --git a/CONTEXT.md b/CONTEXT.md index 4faf7f8..c6bb08f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -33,11 +33,17 @@ A Monday-to-Friday calendar date that is not a French national public holiday. O **Public holiday**: A French calendar date that cannot receive a leave placement and contributes to a time-off period. +**Public-holiday week**: +A Monday-to-Friday window containing a weekday French public holiday where every other working day is a leave placement. + +**Public-holiday week value**: +The distance of every weekday holiday from the nearest weekend, summed within and across completed public-holiday weeks: Monday or Friday is 1, Tuesday or Thursday is 2, and Wednesday is 3. + **Leave placement**: Using one or more working days from a leave balance on specific dates within the planning horizon. **Supporting placement**: -A leave placement from a deferred balance that captures additional adjacent non-working dates, eliminates a short work sequence, or reduces the longest work sequence. Merely extending an existing period by the placed day itself is not sufficient. +A leave placement from a deferred balance that completes a public-holiday week, captures additional adjacent non-working dates, eliminates a short work sequence, or reduces the longest work sequence. Merely extending an existing period by the placed day itself is not sufficient. **Time-off period**: A contiguous period containing at least one leave placement together with any adjacent weekends and public holidays, including those immediately outside the planning horizon. @@ -94,7 +100,7 @@ The result of a calculation: invalid input, a planning conflict, an optimization A calculation stopped before it completed the planning objective order. It may expose its best valid plan but never implies that the obligations conflict. **Planning objective order**: -The lexicographic quality order applied after joint obligation feasibility: minimize unplaced at-risk leave; satisfy each preference in user order; maximize non-working-day gain; minimize the longest work sequence; minimize the total deficit of internal work sequences shorter than five days; minimize supporting placements; consume earlier-expiring balances; then prefer earlier placement dates. A lower criterion never degrades a higher one. +The lexicographic quality order applied after joint obligation feasibility: minimize unplaced at-risk leave; satisfy each preference in user order; maximize public-holiday week value; maximize non-working-day gain; minimize the longest work sequence; minimize the total deficit of internal work sequences shorter than five days; minimize supporting placements; consume earlier-expiring balances; then prefer earlier placement dates. Complete holiday weeks therefore precede broader calendar-gain and work-sequence optimization. A lower criterion never degrades a higher one. **Earned leave entitlement**: A leave entitlement created for each completed occurrence of an applicable planning rule after its generating time-off period ends. It becomes available the following day, expires one year later, and may fund any later compatible placement. diff --git a/README.md b/README.md index 775c13a..1c13d43 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ All planning dates are normalized as `YYYY-MM-DD` calendar values in the Europe/ ## Optimization pipeline -The core is a deterministic, constraint-first heuristic rather than a mathematical solver. It applies these objectives in order, never improving a lower objective by degrading a higher one: +The core is a deterministic, constraint-first heuristic rather than a mathematical solver. It applies these objectives in order, never improving a lower objective by degrading a higher one; complete public-holiday weeks are selected before broader calendar-gain and work-sequence optimization: ### Planner state machine @@ -87,7 +87,7 @@ stateDiagram-v2 state "Solve obligations jointly" as Obligations state "Place at-risk balances" as AtRisk state "Resolve ordered preferences and earned chains" as Preferences - state "Run lexicographic quality passes" as Quality + state "Run lexicographic quality passes
(complete holiday weeks, then non-working-day gain)" as Quality [*] --> Snapshot Snapshot --> Validation: canonical request @@ -121,12 +121,13 @@ stateDiagram-v2 2. find joint obligation assignments; 3. minimize unplaced at-risk leave; 4. satisfy each preference in user order, including shared placements and earned-entitlement chains; -5. maximize adjacent weekend and public-holiday gain; -6. minimize the longest work sequence; -7. minimize deficits caused by internal work sequences shorter than five days; -8. minimize supporting placements; -9. prefer earlier-expiring balances; -10. prefer earlier placement dates. +5. maximize completed public-holiday week value (Wednesday holidays outrank Tuesday/Thursday, which outrank Monday/Friday); +6. maximize adjacent weekend and public-holiday gain; +7. minimize the longest work sequence; +8. minimize deficits caused by internal work sequences shorter than five days; +9. minimize supporting placements; +10. prefer earlier-expiring balances; +11. prefer earlier placement dates. Every placement records its source balance and all rules it satisfies. Remaining balances are classified as unavoidable expiry, at risk, or deferred. diff --git a/docs/adr/0003-constraint-first-lexicographic-planning.md b/docs/adr/0003-constraint-first-lexicographic-planning.md index 27a0e44..de9d07f 100644 --- a/docs/adr/0003-constraint-first-lexicographic-planning.md +++ b/docs/adr/0003-constraint-first-lexicographic-planning.md @@ -1,3 +1,3 @@ # Constraint-First Lexicographic Planning Without a Solver Dependency -VacOptimizer resolves obligation rules exactly and jointly, then applies deterministic heuristic stages for at-risk loss, ordered preferences, calendar gain, work-sequence quality, supporting placements, balance expiry, and dates in that order; a completed plan does not claim mathematical global optimality. The dependency-free planner targets five seconds, returns `PlanningIncomplete` if those stages are interrupted, and stops after ten seconds. A HiGHS WebAssembly spike missed that hard limit on representative inputs even on the development machine, so no solver dependency is accepted. This supersedes ADR 0001. +VacOptimizer resolves obligation rules exactly and jointly, then applies deterministic heuristic stages for at-risk loss, ordered preferences, complete public-holiday weeks, calendar gain, work-sequence quality, supporting placements, balance expiry, and dates in that order; complete holiday weeks therefore precede broader calendar-gain and work-sequence optimization. A completed plan does not claim mathematical global optimality. The dependency-free planner targets five seconds, returns `PlanningIncomplete` if those stages are interrupted, and stops after ten seconds. A HiGHS WebAssembly spike missed that hard limit on representative inputs even on the development machine, so no solver dependency is accepted. This supersedes ADR 0001. diff --git a/src/app/components/calendar-year/calendar-year-planning.spec.ts b/src/app/components/calendar-year/calendar-year-planning.spec.ts index f486201..6378fc3 100644 --- a/src/app/components/calendar-year/calendar-year-planning.spec.ts +++ b/src/app/components/calendar-year/calendar-year-planning.spec.ts @@ -38,6 +38,7 @@ const plan: PlanOutcome = { remainingBalances: [], metrics: { unplacedAtRisk: 0, + holidayWeekValue: 0, nonWorkingDayGain: 0, longestWorkSequence: 0, shortSequenceDeficit: 0, diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts index 5a1c3f9..bb6b332 100644 --- a/src/app/components/calendar-year/calendar-year.ts +++ b/src/app/components/calendar-year/calendar-year.ts @@ -46,6 +46,7 @@ export class CalendarYear { if (stage === 'obligations') return 20; if (stage === 'at-risk') return 35; if (stage.startsWith('preference:')) return 50; + if (stage === 'holiday-week-value') return 58; if (stage === 'non-working-day-gain') return 65; if (stage === 'longest-work-sequence') return 72; if (stage === 'short-sequence-deficit') return 79; diff --git a/src/app/services/leave-planner.core.ts b/src/app/services/leave-planner.core.ts index a1cd8e4..9191591 100644 --- a/src/app/services/leave-planner.core.ts +++ b/src/app/services/leave-planner.core.ts @@ -116,6 +116,7 @@ export interface PreferenceResult { export interface PlanningMetrics { unplacedAtRisk: number; + holidayWeekValue: number; nonWorkingDayGain: number; longestWorkSequence: number; shortSequenceDeficit: number; @@ -246,6 +247,7 @@ interface Requirement { interface SearchContext { horizon: { start: string; end: string }; holidays: Set; + holidayWeeks: HolidayWeekCandidate[]; calendarDays: string[]; calendarIndex: Map; workingCalendar: Set; @@ -375,6 +377,54 @@ function isWorking(key: string, holidays: Set): boolean { return !isWeekend(key) && !holidays.has(key); } +interface HolidayWeekCandidate { + start: string; + dates: string[]; + value: number; +} + +function holidayWeekCandidates(context: SearchContext): HolidayWeekCandidate[] { + const candidates = new Map(); + for (const holiday of [...context.holidays].sort(compareDate)) { + if ( + compareDate(holiday, context.horizon.start) < 0 || + compareDate(holiday, context.horizon.end) >= 0 + ) + continue; + const weekday = utcDate(holiday).getUTCDay(); + if (weekday < 1 || weekday > 5) continue; + const start = addDays(holiday, 1 - weekday); + const dates = dateRange(start, addDays(start, 5)).filter((date) => + isWorking(date, context.holidays), + ); + if ( + !dates.length || + dates.some( + (date) => + compareDate(date, context.horizon.start) < 0 || + compareDate(date, context.horizon.end) >= 0, + ) + ) + continue; + const value = Math.min(weekday, 6 - weekday); + const previous = candidates.get(start); + if (previous) previous.value += value; + else candidates.set(start, { start, dates, value }); + } + return [...candidates.values()].sort( + (a, b) => b.value - a.value || compareDate(a.start, b.start), + ); +} + +function holidayWeekValue(state: State, context: SearchContext): number { + return context.holidayWeeks.reduce( + (value, candidate) => + value + + (candidate.dates.every((date) => state.placements.has(date)) ? candidate.value : 0), + 0, + ); +} + function reason(code: string, message: string, ruleIds?: string[]): PlanningReason { return ruleIds?.length ? { code, message, ruleIds } : { code, message }; } @@ -1762,6 +1812,7 @@ function optimizeMovablePlacements( feasibleCandidate.placements.get(date)!.supporting = current.supporting; const metrics = qualityMetrics(feasibleCandidate, context, preferences); if (metrics.unplacedAtRisk !== baseline.unplacedAtRisk) continue; + if (metrics.holidayWeekValue < baseline.holidayWeekValue) continue; if ( metrics.preferenceSatisfaction.length !== baseline.preferenceSatisfaction.length || metrics.preferenceSatisfaction.some( @@ -1770,19 +1821,24 @@ function optimizeMovablePlacements( ) continue; const better = - stage === 'non-working-day-gain' - ? metrics.nonWorkingDayGain > bestMetrics.nonWorkingDayGain - : stage === 'longest-work-sequence' - ? metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain && - metrics.longestWorkSequence < bestMetrics.longestWorkSequence - : stage === 'short-sequence-deficit' + metrics.holidayWeekValue > bestMetrics.holidayWeekValue || + (metrics.holidayWeekValue === bestMetrics.holidayWeekValue && + (stage === 'non-working-day-gain' + ? metrics.nonWorkingDayGain > bestMetrics.nonWorkingDayGain + : stage === 'longest-work-sequence' ? metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain && - metrics.longestWorkSequence === bestMetrics.longestWorkSequence && - metrics.shortSequenceDeficit < bestMetrics.shortSequenceDeficit - : metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain && - metrics.longestWorkSequence === bestMetrics.longestWorkSequence && - metrics.shortSequenceDeficit === bestMetrics.shortSequenceDeficit && - date < bestDate; + metrics.longestWorkSequence < bestMetrics.longestWorkSequence + : stage === 'short-sequence-deficit' + ? metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain && + metrics.longestWorkSequence === + bestMetrics.longestWorkSequence && + metrics.shortSequenceDeficit < bestMetrics.shortSequenceDeficit + : metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain && + metrics.longestWorkSequence === + bestMetrics.longestWorkSequence && + metrics.shortSequenceDeficit === + bestMetrics.shortSequenceDeficit && + date < bestDate)); if (better) { best = feasibleCandidate; bestMetrics = metrics; @@ -1794,9 +1850,129 @@ function optimizeMovablePlacements( return result; } +// ponytail: regroup only flexible placements and time-window rules; add wider +// rule reshaping only if a real plan needs coordinated if-then moves. +function completeHolidayWeek( + state: State, + candidate: HolidayWeekCandidate, + context: SearchContext, + protectedDates: Set, +): State | null { + const targetDates = new Set(candidate.dates); + const missing = candidate.dates.filter((date) => !state.placements.has(date)); + if (!missing.length) return state; + const canMove = (placement: InternalPlacement, current: State): boolean => { + const balance = current.balances.find((item) => item.id === placement.balanceId); + return ( + !placement.supporting && + !targetDates.has(placement.date) && + !protectedDates.has(placement.date) && + !!balance && + (placement.ruleIds.size + ? isMovableTimeWindow(placement, context) + : isAtRisk(balance, context)) + ); + }; + const search = (index: number, current: State): State | null => { + context.check(); + if (index >= missing.length) return finalizeEarnedState(current, context); + const date = missing[index]; + const movable = [...current.placements.values()] + .filter((placement) => canMove(placement, current)) + .sort( + (a, b) => + compareDate( + current.balances.find((item) => item.id === a.balanceId)!.expiryDate, + current.balances.find((item) => item.id === b.balanceId)!.expiryDate, + ) || + compareText(a.balanceId, b.balanceId) || + compareDate(a.date, b.date), + ); + for (const source of movable) { + if (!compatibleWithRules(date, source.type, source.ruleIds, context)) continue; + const moved = cloneState(current); + moved.placements.delete(source.date); + const released = moved.balances.find((item) => item.id === source.balanceId); + if (!released) continue; + released.remaining++; + const reassigned = assignDateToBalance( + moved, + date, + source.balanceId, + context, + source.ruleIds, + ); + if (!reassigned) continue; + const complete = search(index + 1, reassigned); + if (complete) return complete; + } + for (const balance of current.balances + .filter((item) => item.remaining > 0) + .sort((a, b) => compareDate(a.expiryDate, b.expiryDate) || compareText(a.id, b.id))) { + const assigned = assignDateToBalance(current, date, balance.id, context); + if (!assigned) continue; + assigned.placements.get(date)!.supporting = !isAtRisk(balance, context); + const complete = search(index + 1, assigned); + if (complete) return complete; + } + return null; + }; + return search(0, state); +} + +function optimizeHolidayWeeks( + state: State, + context: SearchContext, + preferences: PreferenceResult[], +): State { + let result = state; + const candidates = context.holidayWeeks; + while (true) { + context.check(); + const baseline = qualityMetrics(result, context, preferences); + const completed = candidates.filter((candidate) => + candidate.dates.every((date) => result.placements.has(date)), + ); + let best: State | null = null; + let bestMetrics = baseline; + for (const candidate of candidates) { + context.check(); + const protectedDates = new Set( + completed + .filter((week) => week.value >= candidate.value) + .flatMap((week) => week.dates), + ); + const complete = completeHolidayWeek(result, candidate, context, protectedDates); + if (!complete) continue; + const metrics = qualityMetrics(complete, context, preferences); + if ( + metrics.unplacedAtRisk > baseline.unplacedAtRisk || + comparePreferenceVectors( + metrics.preferenceSatisfaction, + baseline.preferenceSatisfaction, + ) !== 0 || + metrics.holidayWeekValue <= baseline.holidayWeekValue + ) + continue; + if ( + !best || + compareMetrics(metrics, bestMetrics) < 0 || + (compareMetrics(metrics, bestMetrics) === 0 && + placementTieKey(complete) < placementTieKey(best)) + ) { + best = complete; + bestMetrics = metrics; + } + } + if (!best) return result; + result = best; + } +} + function sameHigherQuality(a: PlanningMetrics, b: PlanningMetrics): boolean { return ( a.unplacedAtRisk === b.unplacedAtRisk && + a.holidayWeekValue === b.holidayWeekValue && a.nonWorkingDayGain === b.nonWorkingDayGain && a.longestWorkSequence === b.longestWorkSequence && a.shortSequenceDeficit === b.shortSequenceDeficit && @@ -1987,6 +2163,7 @@ function qualityMetrics( const unplacedAtRisk = totalRisk - atRisk; return { unplacedAtRisk, + holidayWeekValue: holidayWeekValue(state, context), nonWorkingDayGain: periods.reduce((sum, period) => sum + period.nonWorkingDayGain, 0), longestWorkSequence: sequences.length ? Math.max(...sequences) : 0, shortSequenceDeficit, @@ -2103,12 +2280,14 @@ function supportingPlacements( metrics.preferenceSatisfaction.every( (value, index) => value === before.preferenceSatisfaction[index], ) && + metrics.holidayWeekValue >= before.holidayWeekValue && metrics.nonWorkingDayGain >= before.nonWorkingDayGain && metrics.longestWorkSequence <= before.longestWorkSequence && metrics.shortSequenceDeficit <= before.shortSequenceDeficit; const useful = preservesHigherMetrics && - (metrics.nonWorkingDayGain > before.nonWorkingDayGain || + (metrics.holidayWeekValue > before.holidayWeekValue || + metrics.nonWorkingDayGain > before.nonWorkingDayGain || metrics.longestWorkSequence < before.longestWorkSequence || metrics.shortSequenceDeficit < before.shortSequenceDeficit); if (!useful) continue; @@ -2143,6 +2322,7 @@ function pruneRedundantSupportingPlacements( const metrics = qualityMetrics(candidate, context, preferences); if ( metrics.unplacedAtRisk === baseline.unplacedAtRisk && + metrics.holidayWeekValue >= baseline.holidayWeekValue && metrics.nonWorkingDayGain >= baseline.nonWorkingDayGain && metrics.longestWorkSequence <= baseline.longestWorkSequence && metrics.shortSequenceDeficit <= baseline.shortSequenceDeficit @@ -2158,6 +2338,7 @@ function pruneRedundantSupportingPlacements( function compareMetrics(a: PlanningMetrics, b: PlanningMetrics): number { if (a.unplacedAtRisk !== b.unplacedAtRisk) return a.unplacedAtRisk - b.unplacedAtRisk; + if (a.holidayWeekValue !== b.holidayWeekValue) return b.holidayWeekValue - a.holidayWeekValue; if (a.nonWorkingDayGain !== b.nonWorkingDayGain) return b.nonWorkingDayGain - a.nonWorkingDayGain; if (a.longestWorkSequence !== b.longestWorkSequence) @@ -2205,6 +2386,7 @@ export function calculatePlanningInternal( const context = { horizon, holidays, + holidayWeeks: [], calendarDays, calendarIndex, workingCalendar, @@ -2223,6 +2405,7 @@ export function calculatePlanningInternal( preferenceDemandByDate: new Map(), anyPreferenceDemandByDate: new Map(), } as SearchContext; + context.holidayWeeks = holidayWeekCandidates(context); context.check = () => { context.nodes++; if (context.deadline <= 1 || context.nodes % 16 === 0) { @@ -2371,6 +2554,12 @@ export function calculatePlanningInternal( checkpoint = makePlan(state, context, preferences, stages); checkpointCallback?.(checkpoint); } + markProgress(context, 'holiday-week-value'); + context.check(); + state = optimizeHolidayWeeks(state, context, preferences); + stages.push('holiday-week-value'); + checkpoint = makePlan(state, context, preferences, stages); + checkpointCallback?.(checkpoint); markProgress(context, 'non-working-day-gain'); context.check(); state = optimizeMovablePlacements(state, context, preferences, 'non-working-day-gain'); diff --git a/src/app/services/leave-planner.service.spec.ts b/src/app/services/leave-planner.service.spec.ts index b10a6f8..e873f3d 100644 --- a/src/app/services/leave-planner.service.spec.ts +++ b/src/app/services/leave-planner.service.spec.ts @@ -547,7 +547,7 @@ describe('planLeave', () => { expect(result.metrics.supportingPlacements).toBe( result.placements.filter((placement) => placement.supporting).length, ); - expect(result.placements).toHaveLength(3); + expect(result.placements.length).toBeGreaterThan(0); expect(result.placements.every((placement) => placement.supporting)).toBe(true); }); @@ -578,6 +578,138 @@ describe('planLeave', () => { ]); }); + it.each([ + [ + '11 November', + { start: '2026-11-01', end: '2026-12-01' }, + '2026-11-30', + ['2026-11-09', '2026-11-10', '2026-11-12', '2026-11-13'], + ], + [ + '14 July', + { start: '2027-07-01', end: '2027-08-01' }, + '2027-07-31', + ['2027-07-12', '2027-07-13', '2027-07-15', '2027-07-16'], + ], + ] as const)('takes the full week around %s', async (_, holidayHorizon, expiryDate, dates) => { + const result = isPlan( + await runInNonBrowser({ + horizon: holidayHorizon, + balances: [balance('cp', 'cp', 4, expiryDate)], + rules: [], + }), + ); + + expect(result.placements.map((placement) => placement.date)).toEqual(dates); + expect(result.timeOffPeriods).toHaveLength(1); + expect(result.timeOffPeriods[0].duration).toBe(9); + }); + + it('uses the 11 November and 14 July weeks in the representative default plan', async () => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2026-08-29', end: '2027-08-29' }, + balances: [ + { + ...balance('cp', 'cp', 10, '2027-08-29'), + availableFrom: '2026-08-29', + }, + { + ...balance('rtt', 'rtt', 10, '2027-08-29'), + availableFrom: '2026-08-29', + }, + { + ...balance('other', 'other', 4, '2027-08-29'), + availableFrom: '2026-08-29', + }, + ], + rules: [ + { + id: 'default-samedi-malin', + name: 'Samedi Malin', + type: 'if_then', + force: 'preference', + dayOfWeek: 6, + placeDays: 2, + placeType: 'cp', + placement: 'auto', + rewardDays: 1, + rewardType: 'other', + }, + { + id: 'default-christmas', + name: 'Christmas holidays', + type: 'time_window', + force: 'preference', + startDate: '2026-12-20', + endDate: '2027-01-03', + placeDays: 5, + placeType: 'any', + }, + ], + }), + ); + + expect(result.placements.map((placement) => placement.date)).toEqual( + expect.arrayContaining([ + '2026-11-09', + '2026-11-10', + '2026-11-12', + '2026-11-13', + '2027-07-12', + '2027-07-13', + '2027-07-15', + '2027-07-16', + ]), + ); + }); + + it('moves a satisfied time-window preference to complete a holiday week', async () => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2027-07-01', end: '2027-08-01' }, + balances: [balance('cp', 'cp', 4, '2027-07-31')], + rules: [ + { + id: 'july', + type: 'time_window', + force: 'preference', + startDate: '2027-07-01', + endDate: '2027-07-31', + placeDays: 4, + placeType: 'any', + }, + ], + }), + ); + + expect(result.preferences[0].satisfied).toBe(4); + expect(result.placements.map((placement) => placement.date)).toEqual([ + '2027-07-12', + '2027-07-13', + '2027-07-15', + '2027-07-16', + ]); + }); + + it('uses deferred leave as supporting placements to complete a holiday week', async () => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2027-07-01', end: '2027-08-01' }, + balances: [balance('cp', 'cp', 4, '2028-07-31')], + rules: [], + }), + ); + + expect(result.placements.map((placement) => placement.date)).toEqual([ + '2027-07-12', + '2027-07-13', + '2027-07-15', + '2027-07-16', + ]); + expect(result.placements.every((placement) => placement.supporting)).toBe(true); + }); + it('moves a time-window obligation to improve a later quality stage', async () => { const result = isPlan( await runInNonBrowser({