feat(benchmark): enhance benchmark documentation and add emulated run details
fix(benchmark): update deterministic checks and improve measurement accuracy fix(service): refine candidate recording and enhance balance assignment logic test(service): add test for balance recording with preference assignment
This commit is contained in:
1 parent
ff86cf48b4
commit
f1f08356df
5 files changed
+252
-50
No files matched your search
@@ -16,11 +16,14 @@ stage and incumbent are retained.
|
|||||||
battery level.
|
battery level.
|
||||||
- Use a cool phone, unplugged, with other applications closed. Record whether
|
- Use a cool phone, unplugged, with other applications closed. Record whether
|
||||||
the device became warm or throttled during the run.
|
the device became warm or throttled during the run.
|
||||||
- Run the benchmark three times after one warm-up run. Do not interact with
|
- Tap **Run benchmark** once. It first runs every scenario as an unreported
|
||||||
the page while a run is active.
|
warm-up, then records three measured repetitions per scenario. Do not interact
|
||||||
- Representative and loaded scenarios should normally complete below five
|
with the page while a run is active. The first measured result is the
|
||||||
seconds; every scenario must return within ten seconds. These are release
|
determinism baseline; later repetitions report whether they match it.
|
||||||
measurements, not CI timing assertions.
|
- The original target was below five seconds for representative and loaded
|
||||||
|
scenarios, with a ten-second planning ceiling for every scenario. The owner
|
||||||
|
accepted six to seven seconds for the loaded scenario on 2026-09-25. These
|
||||||
|
are release measurements, not CI timing assertions.
|
||||||
- Keep the copied report unchanged, including any incomplete outcomes or
|
- Keep the copied report unchanged, including any incomplete outcomes or
|
||||||
hardware limitations. No mobile-device measurement is claimed by the source
|
hardware limitations. No mobile-device measurement is claimed by the source
|
||||||
tree until a human records it here.
|
tree until a human records it here.
|
||||||
@@ -29,3 +32,43 @@ The browser page uses the same `createPlanningCalculation(request)` context as
|
|||||||
the calendar and adds no solver or backend dependency. The ten-second release
|
the calendar and adds no solver or backend dependency. The ten-second release
|
||||||
ceiling applies to planning latency; trace delivery is reported separately and
|
ceiling applies to planning latency; trace delivery is reported separately and
|
||||||
cannot replace a valid planning outcome.
|
cannot replace a valid planning outcome.
|
||||||
|
|
||||||
|
## Hardware status
|
||||||
|
|
||||||
|
No reference-phone run has been recorded. The project owner elected to close
|
||||||
|
the release tickets on 2026-09-25 using the emulated run below, accepting its
|
||||||
|
six-to-seven-second loaded planning time and the observed post-result trace UI
|
||||||
|
pause. This is an explicit release exception, not a reference-phone measurement
|
||||||
|
or proof of physical-device responsiveness.
|
||||||
|
|
||||||
|
## Emulated browser run — 2026-09-25
|
||||||
|
|
||||||
|
Chromium 149.0.7827.55 on an AMD Ryzen 7 7800X3D (x86_64), headless at
|
||||||
|
412×915 pixels, device scale 2.625, touch enabled, and 4× CPU throttling.
|
||||||
|
One full warm-up was excluded, followed by three measured repetitions. All
|
||||||
|
five scenarios returned within ten seconds of planning time and complete
|
||||||
|
results matched across repetitions. The loaded year took 5.74–6.17 seconds,
|
||||||
|
above the five-second target. A 100 ms main-thread timer observed a maximum
|
||||||
|
extra delay of 5,950.9 ms. Worker event timestamps place the four loaded-year
|
||||||
|
pauses of 4.0–6.0 seconds after their planning results, around delivery of
|
||||||
|
full trace artifacts. This desktop emulation does not establish reference-phone
|
||||||
|
performance or responsiveness.
|
||||||
|
|
||||||
|
```tsv
|
||||||
|
scenario repetition preparation_ms planning_ms trace_delivery_ms wall_ms outcome last_stage placements deterministic trace_status trace_candidates
|
||||||
|
representative year 1 0.0 443.7 624.7 1069.1 Plan dates 30 baseline ready 29451
|
||||||
|
representative year 2 0.1 490.0 609.3 1099.4 Plan dates 30 yes ready 29451
|
||||||
|
representative year 3 0.1 465.5 614.2 1079.8 Plan dates 30 yes ready 29451
|
||||||
|
typed obligations 1 0.0 157.0 261.2 418.2 Plan dates 10 baseline ready 13103
|
||||||
|
typed obligations 2 0.0 162.6 255.6 418.2 Plan dates 10 yes ready 13103
|
||||||
|
typed obligations 3 0.0 163.4 258.2 421.6 Plan dates 10 yes ready 13103
|
||||||
|
chained earned entitlements 1 0.0 45.3 1.0 47.0 Plan dates 3 baseline ready 27
|
||||||
|
chained earned entitlements 2 0.1 44.5 0.9 45.5 Plan dates 3 yes ready 27
|
||||||
|
chained earned entitlements 3 0.1 44.4 0.8 45.4 Plan dates 3 yes ready 27
|
||||||
|
deliberate conflict 1 0.0 92.8 0.9 93.8 PlanningConflict 0 baseline ready 366
|
||||||
|
deliberate conflict 2 0.0 44.7 0.2 44.9 PlanningConflict 0 yes ready 366
|
||||||
|
deliberate conflict 3 0.0 43.5 0.6 44.1 PlanningConflict 0 yes ready 366
|
||||||
|
loaded year 1 0.3 5901.5 5677.7 11581.0 Plan dates 144 baseline ready 865117
|
||||||
|
loaded year 2 0.8 6167.5 7582.4 13752.4 Plan dates 144 yes ready 865117
|
||||||
|
loaded year 3 0.6 5738.3 6219.5 11962.5 Plan dates 144 yes ready 865117
|
||||||
|
```
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
|
import { createPlanningCalculation } from '../services/leave-planner.service';
|
||||||
import {
|
import {
|
||||||
benchmarkReport,
|
benchmarkReport,
|
||||||
benchmarkScenarios,
|
benchmarkScenarios,
|
||||||
@@ -9,6 +10,20 @@ describe('leave planner benchmark protocol', () => {
|
|||||||
it('keeps five deterministic scenarios and exercises a later earned consumer', async () => {
|
it('keeps five deterministic scenarios and exercises a later earned consumer', async () => {
|
||||||
const scenarios = benchmarkScenarios();
|
const scenarios = benchmarkScenarios();
|
||||||
expect(scenarios).toHaveLength(5);
|
expect(scenarios).toHaveLength(5);
|
||||||
|
const typedObligations = scenarios.find(
|
||||||
|
(scenario) => scenario.name === 'typed obligations',
|
||||||
|
);
|
||||||
|
const cpRule = typedObligations?.request.rules?.find((rule) => rule.id === 'cp-window');
|
||||||
|
const rttRule = typedObligations?.request.rules?.find((rule) => rule.id === 'rtt-window');
|
||||||
|
expect(cpRule).toMatchObject({ placeType: 'cp', placeDays: 2 });
|
||||||
|
expect(rttRule).toMatchObject({ placeType: 'rtt', placeDays: 2 });
|
||||||
|
expect(cpRule?.type).toBe('time_window');
|
||||||
|
expect(rttRule?.type).toBe('time_window');
|
||||||
|
if (cpRule && 'startDate' in cpRule && rttRule && 'startDate' in rttRule)
|
||||||
|
expect(
|
||||||
|
String(cpRule.startDate) <= String(rttRule.endDate) &&
|
||||||
|
String(rttRule.startDate) <= String(cpRule.endDate),
|
||||||
|
).toBe(true);
|
||||||
const chain = scenarios.find((scenario) => scenario.name === 'chained earned entitlements');
|
const chain = scenarios.find((scenario) => scenario.name === 'chained earned entitlements');
|
||||||
expect(chain?.request.rules?.map((rule) => rule.id)).toEqual([
|
expect(chain?.request.rules?.map((rule) => rule.id)).toEqual([
|
||||||
'wednesday-holidays',
|
'wednesday-holidays',
|
||||||
@@ -18,24 +33,44 @@ describe('leave planner benchmark protocol', () => {
|
|||||||
const browserWindow = globalThis.window;
|
const browserWindow = globalThis.window;
|
||||||
vi.stubGlobal('window', undefined);
|
vi.stubGlobal('window', undefined);
|
||||||
try {
|
try {
|
||||||
const measurements = await runLeavePlannerBenchmark(1);
|
const typedOutcome = await createPlanningCalculation(typedObligations!.request).outcome;
|
||||||
|
expect(typedOutcome.kind).toBe('Plan');
|
||||||
|
if (typedOutcome.kind === 'Plan') {
|
||||||
|
expect(
|
||||||
|
typedOutcome.placements.filter((placement) =>
|
||||||
|
placement.ruleIds.includes('cp-window'),
|
||||||
|
),
|
||||||
|
).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
typedOutcome.placements.filter((placement) =>
|
||||||
|
placement.ruleIds.includes('rtt-window'),
|
||||||
|
),
|
||||||
|
).toHaveLength(2);
|
||||||
|
}
|
||||||
|
const measurements = await runLeavePlannerBenchmark(2);
|
||||||
const chained = measurements.find(
|
const chained = measurements.find(
|
||||||
(measurement) => measurement.scenario === 'chained earned entitlements',
|
(measurement) => measurement.scenario === 'chained earned entitlements',
|
||||||
);
|
);
|
||||||
const loaded = measurements.find(
|
const loaded = measurements.filter(
|
||||||
(measurement) => measurement.scenario === 'loaded year',
|
(measurement) => measurement.scenario === 'loaded year',
|
||||||
);
|
);
|
||||||
expect(chained?.outcome).toBe('Plan');
|
expect(chained?.outcome).toBe('Plan');
|
||||||
expect(chained?.placements).toBeGreaterThanOrEqual(3);
|
expect(chained?.placements).toBeGreaterThanOrEqual(3);
|
||||||
expect(loaded?.outcome).toBe('Plan');
|
expect(loaded).toHaveLength(2);
|
||||||
expect(loaded?.deterministic).toBe(true);
|
expect(loaded[0].outcome).toBe('Plan');
|
||||||
expect(loaded?.planningMs).toBeGreaterThanOrEqual(0);
|
expect(loaded[0].deterministic).toBe('baseline');
|
||||||
expect(loaded?.traceDeliveryMs).toBeGreaterThanOrEqual(0);
|
expect(loaded[1].deterministic).toBe('yes');
|
||||||
expect(loaded?.traceStatus).toBe('ready');
|
expect(loaded[0].planningMs).toBeGreaterThanOrEqual(0);
|
||||||
expect(loaded?.traceCandidates).toBeGreaterThan(0);
|
expect(loaded[0].traceDeliveryMs).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(loaded[0].wallMs).toBeGreaterThanOrEqual(
|
||||||
|
loaded[0].preparationMs + loaded[0].planningMs + loaded[0].traceDeliveryMs,
|
||||||
|
);
|
||||||
|
expect(loaded[0].traceStatus).toBe('ready');
|
||||||
|
expect(loaded[0].traceCandidates).toBeGreaterThan(0);
|
||||||
const report = benchmarkReport(measurements);
|
const report = benchmarkReport(measurements);
|
||||||
expect(report.split('\n')).toHaveLength(6);
|
expect(report.split('\n')).toHaveLength(11);
|
||||||
expect(report).toContain('planning_ms\ttrace_delivery_ms');
|
expect(report).toContain('planning_ms\ttrace_delivery_ms');
|
||||||
|
expect(report).toContain('\tbaseline\t');
|
||||||
} finally {
|
} finally {
|
||||||
vi.stubGlobal('window', browserWindow);
|
vi.stubGlobal('window', browserWindow);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface BenchmarkMeasurement {
|
|||||||
outcome: PlanningOutcome['kind'];
|
outcome: PlanningOutcome['kind'];
|
||||||
lastCompletedStage?: string;
|
lastCompletedStage?: string;
|
||||||
placements: number;
|
placements: number;
|
||||||
deterministic: boolean;
|
deterministic: 'baseline' | 'yes' | 'no';
|
||||||
traceStatus: PlanningTrace['status'] | 'not-produced';
|
traceStatus: PlanningTrace['status'] | 'not-produced';
|
||||||
traceCandidates: number;
|
traceCandidates: number;
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ export function benchmarkScenarios(): BenchmarkScenario[] {
|
|||||||
id: 'cp-window',
|
id: 'cp-window',
|
||||||
type: 'time_window',
|
type: 'time_window',
|
||||||
force: 'obligation',
|
force: 'obligation',
|
||||||
startDate: '2024-04-01',
|
startDate: '2024-04-08',
|
||||||
endDate: '2024-04-12',
|
endDate: '2024-04-12',
|
||||||
placeDays: 2,
|
placeDays: 2,
|
||||||
placeType: 'cp',
|
placeType: 'cp',
|
||||||
@@ -68,8 +68,8 @@ export function benchmarkScenarios(): BenchmarkScenario[] {
|
|||||||
id: 'rtt-window',
|
id: 'rtt-window',
|
||||||
type: 'time_window',
|
type: 'time_window',
|
||||||
force: 'obligation',
|
force: 'obligation',
|
||||||
startDate: '2024-05-01',
|
startDate: '2024-04-08',
|
||||||
endDate: '2024-05-12',
|
endDate: '2024-04-12',
|
||||||
placeDays: 2,
|
placeDays: 2,
|
||||||
placeType: 'rtt',
|
placeType: 'rtt',
|
||||||
},
|
},
|
||||||
@@ -166,10 +166,18 @@ function resultSummary(outcome: PlanningOutcome): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runLeavePlannerBenchmark(repetitions = 1): Promise<BenchmarkMeasurement[]> {
|
export async function runLeavePlannerBenchmark(repetitions = 1): Promise<BenchmarkMeasurement[]> {
|
||||||
|
const scenarios = benchmarkScenarios();
|
||||||
|
for (const scenario of scenarios) {
|
||||||
|
const calculation = createPlanningCalculation(structuredClone(scenario.request));
|
||||||
|
await calculation.outcome;
|
||||||
|
await calculation.trace;
|
||||||
|
}
|
||||||
|
|
||||||
const measurements: BenchmarkMeasurement[] = [];
|
const measurements: BenchmarkMeasurement[] = [];
|
||||||
for (const scenario of benchmarkScenarios()) {
|
for (const scenario of scenarios) {
|
||||||
let previous: string | undefined;
|
let baseline: string | undefined;
|
||||||
for (let repetition = 1; repetition <= repetitions; repetition++) {
|
for (let repetition = 1; repetition <= repetitions; repetition++) {
|
||||||
|
const wallStart = performance.now();
|
||||||
const preparationStart = performance.now();
|
const preparationStart = performance.now();
|
||||||
const request = structuredClone(scenario.request);
|
const request = structuredClone(scenario.request);
|
||||||
const preparationMs = performance.now() - preparationStart;
|
const preparationMs = performance.now() - preparationStart;
|
||||||
@@ -182,29 +190,32 @@ export async function runLeavePlannerBenchmark(repetitions = 1): Promise<Benchma
|
|||||||
const traceDeliveryMs = performance.now() - traceStart;
|
const traceDeliveryMs = performance.now() - traceStart;
|
||||||
const summary = resultSummary(outcome);
|
const summary = resultSummary(outcome);
|
||||||
const stable = JSON.stringify(outcome);
|
const stable = JSON.stringify(outcome);
|
||||||
|
const deterministic =
|
||||||
|
baseline === undefined ? 'baseline' : stable === baseline ? 'yes' : 'no';
|
||||||
|
const traceCandidates =
|
||||||
|
trace?.phases.reduce(
|
||||||
|
(total, phase) =>
|
||||||
|
total +
|
||||||
|
phase.decisions.reduce(
|
||||||
|
(decisionTotal, decision) => decisionTotal + decision.candidates.length,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
) ?? 0;
|
||||||
|
const wallMs = performance.now() - wallStart;
|
||||||
measurements.push({
|
measurements.push({
|
||||||
scenario: scenario.name,
|
scenario: scenario.name,
|
||||||
repetition,
|
repetition,
|
||||||
preparationMs,
|
preparationMs,
|
||||||
planningMs,
|
planningMs,
|
||||||
traceDeliveryMs,
|
traceDeliveryMs,
|
||||||
wallMs: preparationMs + planningMs + traceDeliveryMs,
|
wallMs,
|
||||||
...summary,
|
...summary,
|
||||||
deterministic: previous === undefined || previous === stable,
|
deterministic,
|
||||||
traceStatus: trace?.status ?? 'not-produced',
|
traceStatus: trace?.status ?? 'not-produced',
|
||||||
traceCandidates:
|
traceCandidates,
|
||||||
trace?.phases.reduce(
|
|
||||||
(total, phase) =>
|
|
||||||
total +
|
|
||||||
phase.decisions.reduce(
|
|
||||||
(decisionTotal, decision) =>
|
|
||||||
decisionTotal + decision.candidates.length,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
0,
|
|
||||||
) ?? 0,
|
|
||||||
});
|
});
|
||||||
previous = stable;
|
baseline ??= stable;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return measurements;
|
return measurements;
|
||||||
|
|||||||
@@ -582,15 +582,19 @@ class TraceRecorder {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
recordCandidate(candidate: PlanningTraceCandidate): void {
|
recordCandidate(candidate: PlanningTraceCandidate, copy = true): void {
|
||||||
this.latestDecision().candidates.push({
|
this.latestDecision().candidates.push(
|
||||||
|
copy
|
||||||
|
? {
|
||||||
...candidate,
|
...candidate,
|
||||||
dates: candidate.dates ? [...candidate.dates] : undefined,
|
dates: candidate.dates ? [...candidate.dates] : undefined,
|
||||||
reason: candidate.reason ? { ...candidate.reason } : undefined,
|
reason: candidate.reason ? { ...candidate.reason } : undefined,
|
||||||
placement: candidate.placement
|
placement: candidate.placement
|
||||||
? { ...candidate.placement, ruleIds: [...candidate.placement.ruleIds] }
|
? { ...candidate.placement, ruleIds: [...candidate.placement.ruleIds] }
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
}
|
||||||
|
: candidate,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
recordDecision(
|
recordDecision(
|
||||||
@@ -891,7 +895,8 @@ function observeTrace(context: SearchContext, observe: (trace: TraceRecorder) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
function recordTraceCandidate(context: SearchContext, candidate: PlanningTraceCandidate): void {
|
function recordTraceCandidate(context: SearchContext, candidate: PlanningTraceCandidate): void {
|
||||||
observeTrace(context, (trace) => trace.recordCandidate(candidate));
|
// These internal callsites pass fresh literals; retain the full snapshot copy for plan data.
|
||||||
|
observeTrace(context, (trace) => trace.recordCandidate(candidate, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordTraceIneligible(
|
function recordTraceIneligible(
|
||||||
@@ -1633,7 +1638,7 @@ function assignDate(
|
|||||||
ruleId: string,
|
ruleId: string,
|
||||||
context: SearchContext,
|
context: SearchContext,
|
||||||
): State | null {
|
): State | null {
|
||||||
return assignDateVariants(state, date, selector, ruleId, context)[0] ?? null;
|
return assignDateVariants(state, date, selector, ruleId, context, true)[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function assignDateVariants(
|
function assignDateVariants(
|
||||||
@@ -1642,6 +1647,7 @@ function assignDateVariants(
|
|||||||
selector: LeaveSelector,
|
selector: LeaveSelector,
|
||||||
ruleId: string,
|
ruleId: string,
|
||||||
context: SearchContext,
|
context: SearchContext,
|
||||||
|
firstOnly = false,
|
||||||
): State[] {
|
): State[] {
|
||||||
context.check();
|
context.check();
|
||||||
const existing = state.placements.get(date);
|
const existing = state.placements.get(date);
|
||||||
@@ -1724,6 +1730,40 @@ function assignDateVariants(
|
|||||||
'No compatible leave balance can fund this candidate date.',
|
'No compatible leave balance can fund this candidate date.',
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
if (firstOnly && balances.length) {
|
||||||
|
const first = balances[0];
|
||||||
|
const result = cloneState(state);
|
||||||
|
result.balances.find((item) => item.id === first.id)!.remaining--;
|
||||||
|
result.placements.set(date, {
|
||||||
|
date,
|
||||||
|
balanceId: first.id,
|
||||||
|
type: first.type,
|
||||||
|
ruleIds: ruleId ? new Set([ruleId]) : new Set(),
|
||||||
|
supporting: false,
|
||||||
|
});
|
||||||
|
for (const [index, balance] of balances.entries()) {
|
||||||
|
recordTraceCandidate(context, {
|
||||||
|
status: index === 0 ? 'evaluated-rejected' : 'not-examined',
|
||||||
|
date,
|
||||||
|
...(index === 0
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
reason: reason(
|
||||||
|
'first-compatible-balance',
|
||||||
|
'This balance was not examined after the first compatible balance was selected.',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
placement: {
|
||||||
|
date,
|
||||||
|
balanceId: balance.id,
|
||||||
|
type: balance.type,
|
||||||
|
ruleIds: ruleId ? [ruleId] : [],
|
||||||
|
supporting: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [result];
|
||||||
|
}
|
||||||
return balances.map((balance) => {
|
return balances.map((balance) => {
|
||||||
const result = cloneState(state);
|
const result = cloneState(state);
|
||||||
const target = result.balances.find((item) => item.id === balance.id)!;
|
const target = result.balances.find((item) => item.id === balance.id)!;
|
||||||
@@ -1806,23 +1846,40 @@ function assignDateToBalanceInPlace(
|
|||||||
date: string,
|
date: string,
|
||||||
balanceId: string,
|
balanceId: string,
|
||||||
context: SearchContext,
|
context: SearchContext,
|
||||||
|
ruleIds = new Set<string>(),
|
||||||
): boolean {
|
): boolean {
|
||||||
context.check();
|
context.check();
|
||||||
if (state.placements.has(date)) return false;
|
if (state.placements.has(date)) {
|
||||||
|
recordTraceCandidate(context, {
|
||||||
|
status: 'ineligible',
|
||||||
|
date,
|
||||||
|
reason: reason('occupied', 'Another placement already uses this date.'),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const balance = state.balances.find((item) => item.id === balanceId);
|
const balance = state.balances.find((item) => item.id === balanceId);
|
||||||
if (
|
if (
|
||||||
!balance ||
|
!balance ||
|
||||||
balance.remaining <= 0 ||
|
balance.remaining <= 0 ||
|
||||||
compareDate(balance.availableFrom, date) > 0 ||
|
compareDate(balance.availableFrom, date) > 0 ||
|
||||||
compareDate(date, balance.expiryDate) > 0
|
compareDate(date, balance.expiryDate) > 0
|
||||||
)
|
) {
|
||||||
|
recordTraceCandidate(context, {
|
||||||
|
status: 'evaluated-rejected',
|
||||||
|
date,
|
||||||
|
reason: reason(
|
||||||
|
'balance-unavailable',
|
||||||
|
'The selected leave balance cannot fund this date.',
|
||||||
|
),
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
balance.remaining--;
|
balance.remaining--;
|
||||||
state.placements.set(date, {
|
state.placements.set(date, {
|
||||||
date,
|
date,
|
||||||
balanceId,
|
balanceId,
|
||||||
type: balance.type,
|
type: balance.type,
|
||||||
ruleIds: new Set(),
|
ruleIds: new Set(ruleIds),
|
||||||
supporting: false,
|
supporting: false,
|
||||||
});
|
});
|
||||||
recordTraceCandidate(context, {
|
recordTraceCandidate(context, {
|
||||||
@@ -1830,7 +1887,7 @@ function assignDateToBalanceInPlace(
|
|||||||
date,
|
date,
|
||||||
placement: {
|
placement: {
|
||||||
...state.placements.get(date)!,
|
...state.placements.get(date)!,
|
||||||
ruleIds: [],
|
ruleIds: [...state.placements.get(date)!.ruleIds],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
@@ -2637,15 +2694,18 @@ function optimizeMovablePlacements(
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const candidate = assignDateToBalance(
|
if (
|
||||||
|
!assignDateToBalanceInPlace(
|
||||||
without,
|
without,
|
||||||
date,
|
date,
|
||||||
current.balanceId,
|
current.balanceId,
|
||||||
context,
|
context,
|
||||||
current.ruleIds,
|
current.ruleIds,
|
||||||
);
|
)
|
||||||
if (!candidate) continue;
|
)
|
||||||
const feasibleCandidate = finalizeEarnedState(candidate, context);
|
continue;
|
||||||
|
try {
|
||||||
|
const feasibleCandidate = finalizeEarnedState(without, context);
|
||||||
if (!feasibleCandidate) continue;
|
if (!feasibleCandidate) continue;
|
||||||
feasibleCandidate.placements.get(date)!.supporting = current.supporting;
|
feasibleCandidate.placements.get(date)!.supporting = current.supporting;
|
||||||
const metrics = qualityMetrics(feasibleCandidate, context, preferences);
|
const metrics = qualityMetrics(feasibleCandidate, context, preferences);
|
||||||
@@ -2662,7 +2722,8 @@ function optimizeMovablePlacements(
|
|||||||
if (metrics.unplacedAtRisk !== baseline.unplacedAtRisk) continue;
|
if (metrics.unplacedAtRisk !== baseline.unplacedAtRisk) continue;
|
||||||
if (metrics.holidayWeekValue < baseline.holidayWeekValue) continue;
|
if (metrics.holidayWeekValue < baseline.holidayWeekValue) continue;
|
||||||
if (
|
if (
|
||||||
metrics.preferenceSatisfaction.length !== baseline.preferenceSatisfaction.length ||
|
metrics.preferenceSatisfaction.length !==
|
||||||
|
baseline.preferenceSatisfaction.length ||
|
||||||
metrics.preferenceSatisfaction.some(
|
metrics.preferenceSatisfaction.some(
|
||||||
(value, index) => value !== baseline.preferenceSatisfaction[index],
|
(value, index) => value !== baseline.preferenceSatisfaction[index],
|
||||||
)
|
)
|
||||||
@@ -2680,7 +2741,8 @@ function optimizeMovablePlacements(
|
|||||||
? metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain &&
|
? metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain &&
|
||||||
metrics.longestWorkSequence ===
|
metrics.longestWorkSequence ===
|
||||||
bestMetrics.longestWorkSequence &&
|
bestMetrics.longestWorkSequence &&
|
||||||
metrics.shortSequenceDeficit < bestMetrics.shortSequenceDeficit
|
metrics.shortSequenceDeficit <
|
||||||
|
bestMetrics.shortSequenceDeficit
|
||||||
: metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain &&
|
: metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain &&
|
||||||
metrics.longestWorkSequence ===
|
metrics.longestWorkSequence ===
|
||||||
bestMetrics.longestWorkSequence &&
|
bestMetrics.longestWorkSequence &&
|
||||||
@@ -2688,10 +2750,14 @@ function optimizeMovablePlacements(
|
|||||||
bestMetrics.shortSequenceDeficit &&
|
bestMetrics.shortSequenceDeficit &&
|
||||||
date < bestDate));
|
date < bestDate));
|
||||||
if (better) {
|
if (better) {
|
||||||
best = feasibleCandidate;
|
best = cloneState(feasibleCandidate);
|
||||||
bestMetrics = metrics;
|
bestMetrics = metrics;
|
||||||
bestDate = date;
|
bestDate = date;
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
without.placements.delete(date);
|
||||||
|
without.balances.find((item) => item.id === current.balanceId)!.remaining++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (best !== result) recordTraceState(context, best);
|
if (best !== result) recordTraceState(context, best);
|
||||||
result = best;
|
result = best;
|
||||||
|
|||||||
@@ -1660,6 +1660,53 @@ describe('planning calculation context', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('records every eligible balance when a preference keeps the first assignment', async () => {
|
||||||
|
const browserWindow = globalThis.window;
|
||||||
|
vi.stubGlobal('window', undefined);
|
||||||
|
try {
|
||||||
|
const calculation = createPlanningCalculation({
|
||||||
|
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||||||
|
balances: [
|
||||||
|
balance('later', 'cp', 1, '2026-01-01'),
|
||||||
|
balance('earlier', 'cp', 1, '2025-01-01'),
|
||||||
|
],
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
id: 'window',
|
||||||
|
type: 'time_window',
|
||||||
|
force: 'preference',
|
||||||
|
startDate: '2024-01-02',
|
||||||
|
endDate: '2024-01-02',
|
||||||
|
placeDays: 1,
|
||||||
|
placeType: 'cp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const outcome = await calculation.outcome;
|
||||||
|
expect(outcome.kind).toBe('Plan');
|
||||||
|
if (outcome.kind !== 'Plan') return;
|
||||||
|
expect(outcome.placements[0].balanceId).toBe('earlier');
|
||||||
|
|
||||||
|
const trace = await calculation.trace;
|
||||||
|
const candidates =
|
||||||
|
trace?.phases
|
||||||
|
.find((phase) => phase.name === 'preference:window')
|
||||||
|
?.decisions.flatMap((decision) => decision.candidates)
|
||||||
|
.filter((candidate) => candidate.date === '2024-01-02' && candidate.placement)
|
||||||
|
.map((candidate) => ({
|
||||||
|
balanceId: candidate.placement!.balanceId,
|
||||||
|
status: candidate.status,
|
||||||
|
reason: candidate.reason?.code,
|
||||||
|
})) ?? [];
|
||||||
|
expect(candidates.slice(0, 2)).toEqual([
|
||||||
|
{ balanceId: 'earlier', status: 'evaluated-rejected', reason: undefined },
|
||||||
|
{ balanceId: 'later', status: 'not-examined', reason: 'first-compatible-balance' },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
vi.stubGlobal('window', browserWindow);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('offers no explanation trace for invalid input and explains conflicts', async () => {
|
it('offers no explanation trace for invalid input and explains conflicts', async () => {
|
||||||
const browserWindow = globalThis.window;
|
const browserWindow = globalThis.window;
|
||||||
vi.stubGlobal('window', undefined);
|
vi.stubGlobal('window', undefined);
|
||||||
|
|||||||
Reference in new issue
Block a user