diff --git a/docs/benchmark/issue-302.md b/docs/benchmark/issue-302.md index 9159870..b1f877c 100644 --- a/docs/benchmark/issue-302.md +++ b/docs/benchmark/issue-302.md @@ -35,3 +35,27 @@ the placements in the same order with the same yields. | loaded year | 58.26 | 57.75 | | two years | 2.27 | 1.82 | | three years | 7.45 | 5.52 | + +### Retain the best valid plan more cheaply + +Retaining a better valid plan for cancellation reconciled its optimization +provenance. For every placement credited by an optimization rule, that +evaluated all active objectives with the placement released. Only the credited +objectives are compared, so the released plan now evaluates just those, and +the calendar observations of the other objectives are no longer computed. +Retention also no longer computes quality metrics that nothing read. An +instrumented three-year run spent 1.16 of its 1.22 retention seconds in that +reconciliation; afterwards retention took 0.26 seconds. + +| Scenario | Before (s) | After (s) | +| --------------------------- | ---------: | --------: | +| representative year | 0.45 | 0.37 | +| typed obligations | 4.76 | 5.03 | +| chained earned entitlements | 0.02 | 0.02 | +| deliberate conflict | 0.01 | 0.01 | +| loaded year | 55.59 | 53.58 | +| two years | 1.68 | 1.22 | +| three years | 5.29 | 3.85 | + +The typed-obligations difference is run-to-run noise; its retention cost was +22 milliseconds. diff --git a/src/app/services/leave-planner.core.ts b/src/app/services/leave-planner.core.ts index 04036f0..4337ffb 100644 --- a/src/app/services/leave-planner.core.ts +++ b/src/app/services/leave-planner.core.ts @@ -544,12 +544,8 @@ interface SearchContext { observerTime: number; completedPreferenceStates: Map; completedGenericState?: string; - /** Supplied metrics were evaluated after reconciling earned entitlement dates. */ - retain?: ( - state: State, - finalizedMetrics?: PlanningMetrics, - vector?: PlanningObjectiveVector, - ) => void; + /** A supplied vector was evaluated after reconciling earned entitlement dates. */ + retain?: (state: State, vector?: PlanningObjectiveVector) => void; } type PlanningCalendar = Pick< @@ -1869,7 +1865,15 @@ function reconcileOptimizationProvenance( ); if (!credits.length) continue; const withoutState = releasePlacement(state, placement.date); - const withoutPlacement = activeObjectiveVector(withoutState, context); + // Only credited objectives are compared, so skip observing the others. + 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}`), @@ -2468,7 +2472,7 @@ function evaluateRuleCandidate( const vector = objectiveVector(state, context, active); // Preserve the best valid candidate even if the active prefix rejects it. if (compareObjectiveLists(vector.objectives, incumbentVector.objectives) < 0) - context.retain?.(state, undefined, vector); + context.retain?.(state, vector); // shortcut: single additions cannot cross equal-value plateaus; add coordinated balance bundles when a measured configuration needs them. if (addition && comparison === 0) return undefined; return { state, vector }; @@ -2676,7 +2680,7 @@ function* optimizeRulePlacements( 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 && @@ -3395,6 +3399,7 @@ function* activeObjectives( state: State, context: SearchContext, throughIndex = Number.POSITIVE_INFINITY, + only?: ReadonlySet, ): Generator { let preferences: PreferenceResult[] | undefined; let balances: OptimizationObservations | undefined; @@ -3403,6 +3408,11 @@ function* activeObjectives( for (const rule of context.rules) { if (!rule.enabled || rule.force !== 'preference') 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' @@ -4257,19 +4267,18 @@ function* calculatePlanningSteps( let checkpoint: PlanOutcome | undefined; let checkpointState: State | undefined; let retained: - | (Pick & { + | (Pick & { vector: PlanningObjectiveVector; }) | undefined; if (retainIncumbents) - context.retain = (candidate, finalizedMetrics, candidateVector) => { + context.retain = (candidate, candidateVector) => { const check = context.check; context.check = () => undefined; try { - const finalized = - finalizedMetrics || candidateVector - ? candidate - : finalizeEarnedState(candidate, context); + const finalized = candidateVector + ? candidate + : finalizeEarnedState(candidate, context); if (!finalized) return; if (unsatisfiedObligationCount(finalized, context)) return; if ( @@ -4314,14 +4323,6 @@ function* calculatePlanningSteps( ), vector, preferences: candidatePreferences, - metrics: finalizedMetrics - ? { - ...finalizedMetrics, - preferenceSatisfaction: candidatePreferences.map( - ({ satisfied }) => satisfied, - ), - } - : qualityMetrics(finalized, context, candidatePreferences), }; if (firstValidCandidate && checkpointCallback) observeCallback(context, () =>