perf(planning): plan from the best-ranked obligation solution
With several obligation solutions, the planner ran the whole preference chain on candidate solutions as previews: every solution after a complete enumeration, or the eight best-ranked after a truncated window search. It now orders all solutions by the existing pre-preference ranking (active objectives, then tie-breaks, of each obligation-only plan; equal ranks keep enumeration order) and runs the preference stages once, on the first. If that run ends with earned leave available only after its final merged time-off period, the stages restart on the next solution, so a request that planned before never becomes a conflict. The loaded year drops from 43 to 5.5 seconds and typed obligations from 3.3 to 0.1 seconds, with byte-identical benchmark plans. Over the 5,000 seeded requests, 23 plans become worse (9 on a real objective) and 1 better; over 50 loaded-year-sized requests, 4 become worse on a real objective. ADR 0010 records the decision; docs/benchmark/issue-302.md records the comparison and the opt-in runner that reproduces it. The frozen-engine check fails until the frozen engine is rebuilt from this commit in the next one. Part of #302. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aa6a0c6f1b
commit
8168137cd5
13 files changed
+579
-278
No files matched your search
@@ -0,0 +1,80 @@
|
||||
// Opt-in before/after record, outside the functional suite and CI: compares the
|
||||
// current engine with the calibration engine (a871f8f) over seeded requests.
|
||||
// bun src/app/services/frozen-engine/compare-engines.ts [small|loaded-year] [first seed] [count]
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import type { PlanningArtifacts, PlanningObjective, PlanningOutcome } from '../leave-planner.core';
|
||||
import { compareObjectiveValues } from '../planning-objectives';
|
||||
|
||||
// The preset rules come from an Angular service file, which needs the JIT compiler outside tests.
|
||||
await import('@angular/compiler');
|
||||
const { generateLoadedYearRequest, generateRequest } = await import('./random-planning-requests');
|
||||
const current = await import('../leave-planner.core');
|
||||
const calibration = await import('./leave-planner.core.calibration.mjs');
|
||||
|
||||
const [kind = 'small', first = '280000', count = '5000'] = process.argv.slice(2);
|
||||
const generate = kind === 'loaded-year' ? generateLoadedYearRequest : generateRequest;
|
||||
type Engine = (
|
||||
request: unknown,
|
||||
checkpoint?: undefined,
|
||||
artifacts?: (artifacts: PlanningArtifacts) => void,
|
||||
) => PlanningOutcome;
|
||||
|
||||
function run(calculate: Engine, request: unknown) {
|
||||
let artifacts: PlanningArtifacts | undefined;
|
||||
const started = performance.now();
|
||||
const outcome = calculate(structuredClone(request), undefined, (value) => (artifacts = value));
|
||||
return {
|
||||
ms: performance.now() - started,
|
||||
result: {
|
||||
outcome,
|
||||
explanation: artifacts?.explanation,
|
||||
finalState: artifacts?.finalState,
|
||||
explanationError: artifacts?.explanationError,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The final plan's objective vector, which every placement explanation repeats. */
|
||||
function objectives(result: ReturnType<typeof run>['result']): readonly PlanningObjective[] {
|
||||
return result.explanation?.kind === 'PlanExplanation'
|
||||
? (result.explanation.placements[0]?.objectiveVector.objectives ?? [])
|
||||
: [];
|
||||
}
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
const time = { before: 0, after: 0 };
|
||||
for (let seed = Number(first); seed < Number(first) + Number(count); seed++) {
|
||||
const request = generate(seed);
|
||||
const before = run(calibration.calculatePlanningInternal, request);
|
||||
const after = run(current.calculatePlanningInternal, request);
|
||||
time.before += before.ms;
|
||||
time.after += after.ms;
|
||||
let category: string;
|
||||
let detail = '';
|
||||
if (isDeepStrictEqual(before.result, after.result)) category = 'identical';
|
||||
else if (before.result.outcome.kind !== after.result.outcome.kind)
|
||||
category = 'changed outcome kind';
|
||||
else if (after.result.outcome.kind !== 'Plan') category = 'different non-plan outcome';
|
||||
else {
|
||||
const left = objectives(before.result);
|
||||
const right = objectives(after.result);
|
||||
const index = right.findIndex((objective, i) => objective.value !== left[i]?.value);
|
||||
if (index < 0 && left.length === right.length) category = 'same objective values';
|
||||
else if (index < 0 || left[index]?.id !== right[index].id) category = 'not comparable';
|
||||
else {
|
||||
const objective = right[index];
|
||||
const better =
|
||||
compareObjectiveValues(objective.direction, objective.value, left[index].value) < 0;
|
||||
const tieBreak = ['balance-expiry', 'placement-date'].includes(objective.id);
|
||||
category = `${better ? 'better' : 'worse'}${tieBreak ? ' (tie-break only)' : ''}`;
|
||||
detail = `\t${objective.id}\t${left[index].value} -> ${objective.value}`;
|
||||
}
|
||||
}
|
||||
if (category !== 'identical') console.log(`${seed}\t${category}${detail}`);
|
||||
counts.set(category, (counts.get(category) ?? 0) + 1);
|
||||
}
|
||||
console.log(`${kind} requests, seeds ${first}..${Number(first) + Number(count) - 1}`);
|
||||
for (const [category, total] of [...counts].sort()) console.log(`${category}\t${total}`);
|
||||
console.log(
|
||||
`calibration engine ${(time.before / 1000).toFixed(1)} s, current engine ${(time.after / 1000).toFixed(1)} s`,
|
||||
);
|
||||
@@ -0,0 +1,179 @@
|
||||
// Seeded planning requests shared by the frozen-engine check and the engine comparison.
|
||||
import { frenchPublicHolidaysForYear, isWorkingPlanningDate } from '../declared-non-working-days';
|
||||
import { OPTIMIZATION_CATALOG } from '../planning-objectives';
|
||||
import { createOptimizationPresetRules } from '../rule-service';
|
||||
|
||||
export function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export const DAY = 86_400_000;
|
||||
export const weekday = (ms: number) => ![0, 6].includes(new Date(ms).getUTCDay());
|
||||
export const iso = (ms: number) => new Date(ms).toISOString().slice(0, 10);
|
||||
|
||||
export type Req = Record<string, unknown> & { rules: Record<string, unknown>[] };
|
||||
|
||||
export function generateRequest(seed: number): Req {
|
||||
const rnd = mulberry32(seed);
|
||||
const int = (min: number, max: number) => min + Math.floor(rnd() * (max - min + 1));
|
||||
const pick = <T>(items: readonly T[]): T => items[int(0, items.length - 1)];
|
||||
const chance = (p: number) => rnd() < p;
|
||||
const shuffle = <T>(items: T[]): T[] => {
|
||||
for (let i = items.length - 1; i > 0; i--) {
|
||||
const j = int(0, i);
|
||||
[items[i], items[j]] = [items[j], items[i]];
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
const long = chance(0.1);
|
||||
const mode = rnd();
|
||||
let startMs = Date.UTC(2025, 0, 1) + int(0, 1094) * DAY;
|
||||
if (mode < 0.15) startMs = Date.UTC(pick([2025, 2026]), 11, int(8, 31));
|
||||
else if (mode < 0.3) {
|
||||
const year = pick([2025, 2026, 2027]);
|
||||
const holidays = [...frenchPublicHolidaysForYear(year)].filter((d) =>
|
||||
weekday(Date.parse(d)),
|
||||
);
|
||||
startMs = Date.parse(pick(holidays)) - int(1, 10) * DAY;
|
||||
}
|
||||
const days = long ? int(29, 70) : int(7, 28);
|
||||
const start = iso(startMs);
|
||||
const date = (offset: number) => iso(startMs + offset * DAY);
|
||||
const types = ['cp', 'rtt', 'other'] as const;
|
||||
|
||||
const balances = Array.from({ length: int(1, 3) }, (_, i) => ({
|
||||
id: `b${i}`,
|
||||
type: pick(types),
|
||||
quantity: int(0, long ? 4 : 3),
|
||||
...(chance(0.3) ? { availableFrom: date(int(1, days)) } : {}),
|
||||
expiryDate: date(int(-3, days + 40)),
|
||||
})).map((b) =>
|
||||
b.availableFrom && b.availableFrom > b.expiryDate
|
||||
? { ...b, expiryDate: b.availableFrom }
|
||||
: b,
|
||||
);
|
||||
|
||||
const declaredDays: { date: string; type: string }[] = [];
|
||||
for (let n = int(0, 4); n > 0; n--) {
|
||||
const ms = startMs + int(0, days) * DAY;
|
||||
const d = iso(ms);
|
||||
if (isWorkingPlanningDate(d) && !declaredDays.some((x) => x.date === d))
|
||||
declaredDays.push({ date: d, type: pick(types) });
|
||||
}
|
||||
|
||||
const selectors = ['cp', 'rtt', 'other', 'any'] as const;
|
||||
const rules: Record<string, unknown>[] = [];
|
||||
for (let n = long ? int(0, 1) : int(0, 2); n > 0; n--) {
|
||||
const from = int(0, days - 1);
|
||||
rules.push({
|
||||
id: `tw${rules.length}`,
|
||||
type: chance(0.5) ? 'time_window' : 'TIME_WINDOW',
|
||||
force: pick(['obligation', 'preference']),
|
||||
startDate: date(from),
|
||||
endDate: date(from + int(0, 10)),
|
||||
placeDays: int(1, 4),
|
||||
placeType: pick(selectors),
|
||||
});
|
||||
}
|
||||
for (let n = long && rules.length ? 0 : long ? int(0, 1) : int(0, 2); n > 0; n--) {
|
||||
rules.push({
|
||||
id: `it${rules.length}`,
|
||||
type: chance(0.5) ? 'if_then' : 'IF_THEN',
|
||||
force: pick(['obligation', 'preference']),
|
||||
dayOfWeek: int(1, 5),
|
||||
placeDays: int(1, 3),
|
||||
placeType: pick(selectors),
|
||||
placement: pick(['before', 'after', 'around', 'auto']),
|
||||
rewardDays: int(0, 2),
|
||||
rewardType: pick(types),
|
||||
});
|
||||
}
|
||||
const presets: Record<string, unknown>[] = shuffle(
|
||||
createOptimizationPresetRules() as unknown as Record<string, unknown>[],
|
||||
).slice(0, long ? int(1, 4) : int(0, 3));
|
||||
rules.push(...presets);
|
||||
if (!long && chance(0.3)) {
|
||||
const row = pick(OPTIMIZATION_CATALOG);
|
||||
const conditions = (row.conditions as readonly string[]).filter(() => chance(0.5));
|
||||
rules.push({
|
||||
id: 'composed',
|
||||
type: 'optimization',
|
||||
force: 'preference',
|
||||
configuration: {
|
||||
elements: row.elements,
|
||||
conditions,
|
||||
measure: row.measure,
|
||||
aggregation: pick(row.aggregations),
|
||||
direction: pick(['minimize', 'maximize']),
|
||||
...(row.measure === 'work-sequence-deficit' ? { thresholdDays: int(1, 8) } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const rule of shuffle(rules)) if (chance(0.08)) rule['enabled'] = false;
|
||||
|
||||
const request: Req = {
|
||||
calculationDate: start,
|
||||
horizon: { start, end: date(days) },
|
||||
balances,
|
||||
declaredDays,
|
||||
rules,
|
||||
};
|
||||
if (chance(0.04)) {
|
||||
const breakIt = int(0, 3);
|
||||
if (breakIt === 0) balances[0].quantity = -1;
|
||||
else if (breakIt === 1) request['horizon'] = { start: date(days), end: start };
|
||||
else if (breakIt === 2) rules.push({ id: 'bad', type: 'unknown_type' });
|
||||
else declaredDays.push({ date: start, type: 'nope' });
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded-year-sized requests: one year, four to eight balances and two to four
|
||||
* obligation windows of two or three days. Their obligation search always stops
|
||||
* at its solution limit, so the calibration engine previews at most eight solutions.
|
||||
*/
|
||||
export function generateLoadedYearRequest(seed: number): Req {
|
||||
const rnd = mulberry32(seed);
|
||||
const int = (min: number, max: number) => min + Math.floor(rnd() * (max - min + 1));
|
||||
const pick = <T>(items: readonly T[]): T => items[int(0, items.length - 1)];
|
||||
const year = int(2024, 2026);
|
||||
const start = `${year}-01-01`;
|
||||
const months = Array.from({ length: 12 }, (_, index) => index + 1);
|
||||
for (let i = months.length - 1; i > 0; i--) {
|
||||
const j = int(0, i);
|
||||
[months[i], months[j]] = [months[j], months[i]];
|
||||
}
|
||||
const obligations = int(2, 4);
|
||||
const windows = months.slice(0, obligations + int(0, 2)).map((month, index) => ({
|
||||
id: `window-${index}`,
|
||||
type: 'time_window',
|
||||
force: index < obligations ? 'obligation' : 'preference',
|
||||
startDate: `${year}-${String(month).padStart(2, '0')}-01`,
|
||||
endDate: `${year}-${String(month).padStart(2, '0')}-28`,
|
||||
placeDays: index < obligations ? int(2, 3) : int(1, 4),
|
||||
placeType: pick(['any', 'cp', 'rtt']),
|
||||
}));
|
||||
const presets = createOptimizationPresetRules() as unknown as Record<string, unknown>[];
|
||||
return {
|
||||
calculationDate: start,
|
||||
horizon: { start, end: `${year + 1}-01-01` },
|
||||
balances: Array.from({ length: int(4, 8) }, (_, index) => ({
|
||||
id: `b${index}`,
|
||||
type: pick(['cp', 'rtt']),
|
||||
quantity: int(6, 14),
|
||||
availableFrom: start,
|
||||
expiryDate: `${year}-12-31`,
|
||||
})),
|
||||
declaredDays: [],
|
||||
rules: [presets[0], ...windows, ...presets.slice(1)],
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user