feat(planning): make optimization rules composable
Evaluate explicit, ordered optimization configurations through one shared planner model. Add six editable presets, durable one-time legacy migration, and consistent completed and cancelled plan provenance. Include functional regressions and the agreed trace-removal and benchmark follow-up specs. Performance acceptance remains in #78 under unchanged gates. Refs: #70, #71, #72, #73, #74, #75, #76, #77, #78
This commit is contained in:
1 parent
209bf05f36
commit
6811142299
45 files changed
+8795
-3075
No files matched your search
@@ -0,0 +1,283 @@
|
||||
# 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. Completing the functional
|
||||
migration does not establish that these performance gates pass.
|
||||
Reference in new issue
Block a user