# Composable planning objective contract The common implementation for tickets #70–#75 is on `feature/optimization-rules-70-75`. `src/app/services/planning-objectives.ts` evaluates supported configurations; rule names and identifiers never enter the evaluator. Supplied and personal optimization rules share this model, editor, persistence and ordered calculation pipeline. ## Configuration `OptimizationConfiguration` contains `elements`, `conditions`, `measure`, `aggregation`, `direction`, and, only for `work-sequence-deficit`, `thresholdDays`. Conditions are a list of distinct selectors, combined with AND; an empty list selects every observed element. Directions are `minimize` and `maximize`. The finite `OPTIMIZATION_CATALOG` contains the following compatible combinations. Each row accepts any subset of its listed conditions. | Elements | Applicable conditions | Measure | Aggregations | Measure unit | Parameters | | ---------------------- | ----------------------- | --------------------------- | ------------------------- | --------------------- | ------------------------------------------------------ | | `leave-balances` | `at-risk` | `unplaced-quantity` | `sum`, `count`, `maximum` | working days | none | | `public-holiday-weeks` | `complete` | `public-holiday-week-value` | `sum`, `count`, `maximum` | calendar-day distance | none | | `time-off-periods` | none | `non-working-day-gain` | `sum`, `count`, `maximum` | calendar days | none | | `work-sequences` | `internal`, `non-empty` | `work-sequence-length` | `sum`, `count`, `maximum` | working days | none | | `work-sequences` | `internal`, `non-empty` | `work-sequence-deficit` | `sum`, `count`, `maximum` | working days | `thresholdDays`: positive safe integer in working days | | `leave-placements` | `supporting` | `placement-count` | `sum`, `count`, `maximum` | count | none | `count` counts selected elements, including selected elements whose measured value is zero; its unit is always count. `sum` and `maximum` aggregate the selected measured values. All three return **zero on an empty collection**. An empty work sequence has zero deficit, even if a configuration selects it. The maximum of an empty work-sequence collection is zero; the existing optional average statistics keep their separate null semantics. `validateOptimizationConfiguration(unknown)` returns `{ valid: true, configuration }` or `{ valid: false, code, message }`. Malformed values use `invalid-optimization-configuration`; unsupported elements/measure/condition/aggregation/parameter combinations use `unsupported-optimization-configuration`. Unknown fields, duplicate conditions, missing, fractional, non-positive, non-finite and unsafe thresholds are rejected. Other measures reject `thresholdDays`. Direct and worker calculations attach each configuration error to its rule, including malformed sparse selector lists; they never replace it with a preset. For example, a personal rule can select internal non-empty work sequences, sum their deficits at a threshold of three, and minimize the result. It uses the same measure as the five-day preset. Another can maximize the longest sequence or count complete holiday weeks; composition never dispatches by a preset identifier. ## Six supplied recipes `OPTIMIZATION_PRESETS` provides configurations, without identities, display labels or enabled states. `RuleService` wraps copies in named planning rules and persists the full configuration. | Export key | Elements and conditions | Measure and aggregation | Direction | | ---------------------- | --------------------------------------- | ---------------------------------- | --------- | | `unplacedAtRisk` | leave balances, at risk | unplaced quantity, sum | minimize | | `holidayWeekValue` | public-holiday weeks, complete | public-holiday week value, sum | maximize | | `nonWorkingDayGain` | time-off periods | non-working-day gain, sum | maximize | | `longestWorkSequence` | work sequences, including horizon edges | work-sequence length, maximum | minimize | | `shortSequenceDeficit` | internal non-empty work sequences | deficit at `thresholdDays: 5`, sum | minimize | | `supportingPlacements` | leave placements, supporting | placement count, count | minimize | ## Concrete observations and boundaries `OptimizationObservations` carries collections from one concrete valid plan: - Leave balances expose unplaced working-day quantity and the at-risk flag. Expiry on either the horizon start or its exclusive end is at risk, preserving the existing classification; placement dates themselves stay within the half-open horizon. - Public-holiday weeks expose their weekday holiday value and whether every other working date is a placement or declared non-working day. Monday through Friday contributions are **1, 2, 3, 2, 1**; several holidays in one week are summed once in that week's observation. Ordinary fully declared weeks never become holiday weeks. A partial horizon only admits a week when every required date outside it is already declared, preserving the existing rule. - Time-off periods carry distinct natural non-working dates; periods are disjoint and include adjacent weekends, holidays and declared days at either horizon edge. Placements and declared working days anchor periods but do not themselves contribute non-working-day gain. - Work sequences carry a working-day length and an `internal` flag. Weekends and holidays neither count nor split them. Both horizon-edge sequences contribute to the maximum and are excluded by the `internal` condition. Internal zero-length observations contribute zero deficit and are excluded by `non-empty`. - Leave placements expose their supporting classification. Declared days are fixed observations and do not consume balances or count as supporting placements. `evaluateOptimization(configuration, observations)` validates the configuration and evaluates these collections. The core's `optimizationObservations(state, context, periods?)` builds the collections once; `evaluateStateOptimization(configuration, state, context)` is the shared core seam for a personal rule. The preset metrics call the same evaluator, with no second engine. ## Ordered rule execution Named `type: 'optimization'` rules contain a full `configuration` and preference-only force. Storage, editor buffers, direct calculation snapshots and worker transport copy the nested configuration. The core executes only explicitly submitted active rules; it never adds presets to a request. Obligations are solved jointly. Each active preference then runs in the displayed rule order. Disabled rules are excluded before validation and search. A request with no active rules preserves declared days and adds no placements, including when balances are at risk of expiry. Statistics may still report preset metrics; only the active configured objectives affect selection. The shared core seams are: - `activeObjectiveVector(state, context, throughIndex?)` evaluates active preferences in display order, with unique `optimization:` and `preference:` identities. Optimization measurements use their full configurations; calendar preferences measure actual matching dates/patterns. - `evaluateRuleCandidate(before, candidate, context, throughIndex, dates, sourceDate?, addition?, baseline?, beforeVector?, incumbentVector?)` validates mandatory type/date/pattern memberships and earned entitlement availability and compares the active prefix. Its comparison preserves the source state; technical selection facts compare with the actual current best candidate. It returns the valid state, its full vector and optional trace evaluation. Additions must improve an active prefix objective. - `optimizeRulePlacements(state, context, throughIndex, allowAdd, rule?)` generates single additions/removals/moves, atomic balance reassignments and balance swaps. `relocateForAddition` handles augmenting chains when a scarce balance needs an occupied date. Complete typed calendar patterns are reassigned atomically too. Coordinated family bundles can pass through `evaluateRuleCandidate` and rank through `compareObligationPreviews(candidate, incumbent, context)` without fixed metric guards. - `applyPreferenceStep` dispatches every optimization and calendar window through the same kernel. Earlier preferences replay when a later rule changes their state or creates an earned entitlement. Earned entitlements follow actual completed active producer patterns, including patterns already fulfilled by another rule or declared days. Shared finalization reconciles those rights before candidate comparisons, cancellation retention and unchanged-state caching. Availability starts after the complete merged producing period; its own reward cannot finance a placement inside that period. Earning is a validity consequence, rather than an extra objective that an active configuration can suppress. `compareObjectiveValues`, `compareObjectiveLists` and `firstDifferentObjective` serve both selection and explanation. Earlier balance expiry and placement dates are final tie-breaks after all active objectives compare equally; they only reshape existing placements. Equal expiry values are compared independently of arbitrary balance identifiers, so earlier placement dates remain the next tie-break. Captured rule identities, names and configurations label the result independently of subsequent editor changes. Loaded plans retain the established sampling strategy for flexible move destinations above 24 placements, including non-working-adjacent, weekly and calendar-window dates. Excluded move destinations are recorded as `not-examined`, never as proven rejected. There is no automatic elapsed-time cutoff. Complete holiday weeks, joined time-off periods and work-sequence changes are compared as coordinated valid bundles. Moves that preserve every active objective can be pruned when their final tie-breaks cannot improve. Per-iteration vectors are reused; preset statistics are materialized only for selected incumbents and published plans. For large non-earned window obligation searches above the existing exact-branch threshold, the joint feasibility search retains its existing limit. Feasible solutions are ranked by actual active objectives and final ties; the first eight receive full optimization previews. Other feasible solutions are recorded as `not-examined`. Small searches and earned-entitlement chains keep all previews. This deterministic sampling has no elapsed-time cutoff or user-facing setting. A deferred optimization addition is supporting only when it provides one of the calendar benefits in the domain definition, rather than merely extending a period. Holiday occurrences and eligible patterns are immutable per request and cached independently of mutable placement states. ## Saved rules and initialization `vacationRules` uses one versioned envelope: `{ version: 1, rules: [...] }`. `RuleService` restores it synchronously before the first duration estimate or calculation. A legacy array, including `[]`, receives loss prevention first, every original record in its original relative order, then the other five presets. Each original raw record is preserved, including names, IDs, dates, parameters, disabled state and a missing legacy force. Getters interpret missing force as preference without rewriting that record. Preset IDs use collision-free suffixes when a personal rule already uses a proposed ID. New users receive the six active presets around the existing Samedi Malin and Christmas calendar defaults. The migration and its version are written by one synchronous `setItem`; no separate marker can succeed while the rules fail to save. A failed write leaves the original key intact and permits a later complete save or a fresh reload to retry. Failed reads, malformed saved data and unsupported versions disable writes for that service instance so later editing cannot overwrite unknown persisted user data. Storage unavailable in the runtime leaves an in-memory configuration usable. Every later edit, toggle, reorder or deletion saves the full versioned list. Versioned empty lists stay empty; disabled and deleted presets stay that way. With no active rules, eligible unused leave remains visible with its original expiry classification and a translated explanation that the active planning rules justified no addition. Physical availability, occupation, expiry and incomplete-calculation reasons keep precedence where they apply. Declared days remain fixed and consume no leave. ## Search and trace limits These are deterministic search limits, rather than claims of global optimality: - Above 24 placements, flexible moves examine the existing non-working-adjacent, declared-adjacent, weekly and calendar-window destinations. Other destinations retain `not-examined` trace records. - Non-earned obligation searches whose estimated branch count exceeds 10,000 retain at most 4,096 feasible solutions. Large window searches rank them by the actual active vector and final ties, then fully preview the first eight; the other feasible solutions are marked `not-examined`. Earned chains and small searches retain all previews. - Calendar bundles rank original donors once by the actual active vector of the source plan with each donor released, then retain that order during recursive allocation. They keep the first prefix-valid allocation and collapse equivalent donor tokens. Work-sequence bundles use the nearest center per balance and the first accepted allocation. These choices can miss another coordinated allocation. - When a completed specialized bundle strictly improves the current active prefix, the kernel commits its full-vector best before scanning generic alternatives for the old state. The old generic neighborhood is marked `not-examined`, then bundles and generic candidates continue from the improved state. Earlier preferences replay and the final expiry/date pass still runs. This scheduling can make a better unseen old-state generic branch unreachable; it is an explicit heuristic rather than an exhaustive comparison of all bundles and moves. - Additions require a strict improvement through the current rule. A custom maximum/minimize unplaced-quantity rule with two tied at-risk CP1 balances can therefore keep both unused: either single addition leaves the maximum at one, while a coordinated addition of both would reduce it to zero. This balance plateau has no specialized bundle generator; the supplied sum/minimize loss preset does consume both. A completely enumerated single obligation solution has no alternative to rank, so its discarded full preference preview is skipped; the real ordered execution still runs in full. Earlier rules reuse their completed evaluation only when the complete ordered incoming state matches their last output, including balance quantities and validity, earned activations, placement insertion order, supporting status and rule provenance. The trace records that unchanged state as `not-examined` with the earlier decisions retained. A candidate unable to beat a valid local incumbent already offered to cancellation retention keeps its primary comparison but avoids a duplicate global incumbent comparison. These are exact reuse decisions, not additional sampling. At the first explicit sum/minimize quantity preference, complete single additions can reuse an evaluated funding score when eligible at-risk balances have the same expiry and matching signature across every enabled time-window rule, including obligations. The first original-order funder is preserved; later equivalent alternatives are recorded as `not-examined` without fabricated comparisons. This applies only without active IF_THEN rules or earned activations, without a balance maximum objective, and with a safe combined remaining quantity. Recursive bundles and augmenting allocation retain every funding choice: equal current scores do not prove equivalent future capacity. A first-position sum/minimize unplaced-quantity rule finishes its preference stage when its reconciled value reaches zero. The same bound applies to a prefix consisting of that zero quantity followed only by fully satisfied time-window preferences or maximizing public-holiday-week preferences at their configured upper bound. Windows are capped at their requested quantity. A holiday maximum is evaluated from the same immutable request weeks, marking every week complete and using its configured sum, count or maximum; partial horizon weeks retain the existing declaration and eligibility semantics. The whole bounded stage is recorded as `not-examined`; later preferences, replay after actual state changes and the final full-vector expiry/date pass still run. Other configurations and preference orders retain their search. This schedules lower priorities later; it does not claim an exhaustive full-vector search in the bounded stage. When every balance is exhausted and there is no active IF_THEN rule, a complete generic iteration that finds no full-vector improvement can be reused for the identical ordered state at a later preference. Explicit balance assignments use the same dates, validity, provenance and configured move sampling regardless of the current rule. Rule-specific bundles still run, and any balance, placement, supporting, provenance or activation change invalidates reuse. Bound returns and interrupted iterations never admit a state. Reuse is recorded as `not-examined` with the earlier sampled generic decisions retained; the final expiry/date pass still examines its neighborhood. Captured trace vectors, scalar facts, placements, ordered date lists and deltas are immutable detached evidence. Bounded request-local indexes share only identical complete values and metadata; the vector index uses recorded date keys and holds at most 65,536 entries, including variants in one bucket. Eviction changes allocation only, with every examined candidate and its ordered factual fields retained. Mutable candidate shells and emitted replacement snapshots remain independent. Search never reads these sharing indexes. Calendar facts also reuse a bounded 4,096-entry request-local cache keyed by the complete occupied date set; balance quantities, earned metadata, provenance and supporting classifications are refreshed separately. Removing a placement copies the placement map without editing surviving entries; later assignment and metadata edits still detach them. Candidate locations are resolved from the latest recorded decision only when a later update or selection needs them, avoiding a permanent index for observation-only candidates. Cancellation retains the best valid examined plan under the captured active vector and its original trace. Counterfactual and explanation comparisons use that same snapshot; later editor changes and delayed artifact delivery do not alter it. An explicit caller-owned planning-work budget remains supported; the application introduces no automatic duration-based stop. ## Confirmed decisions and technical assumptions Confirmed by ADR 0008: one composable model for supplied and personal optimization rules; preference-only force; the six recipes' existing business meanings; declared-day and horizon semantics; user-controlled active preference order; expiry then date tie-breaks; preserving saved user data. The integrated implementation replaces the former implicit policy with explicit active rules and performs the one-time saved-data addition. The supported configuration boundary is only sum/count/maximum and both directions; empty aggregate zero; deficit threshold is a positive working-day integer, initially five; every listed compatible condition subset and aggregation is supported; no other filters or measures. Public-holiday weights retain the existing business definition rather than becoming a parameter. ## Verification scope Ticket #75 covers saved-rule migration and functional integration of #70–#75. Performance acceptance and any further optimization are tracked in #78, after original-decision-trace removal in #76 and functional verification in #77. The seven-scenario benchmark, its warm-up and two measured repetitions, the 2 GB heap limit and the 300-second timeout remain unchanged. CI runs the benchmark as a separate non-blocking job at the user's request while #78 is open; functional tests and other checks remain required. #78 must restore the required benchmark gate after the protocol passes. Completing the functional migration or passing CI during this exception does not establish a passing benchmark.