// 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 & { rules: Record[] }; 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 = (items: readonly T[]): T => items[int(0, items.length - 1)]; const chance = (p: number) => rnd() < p; const shuffle = (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[] = []; 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[] = shuffle( createOptimizationPresetRules() as unknown as Record[], ).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 = (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[]; 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)], }; }