Run the unchanged benchmark as a separate advisory job while #78 is open. Keep functional coverage and other checks required, and retain the 2 GB heap and 300-second protocol limit. Record #78's obligation to restore the gate. Refs: #75, #78
287 lines
21 KiB
Markdown
287 lines
21 KiB
Markdown
# 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:<rule-id>` and `preference:<rule-id>`
|
||
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.
|