Coeur pur du module "Calcul batch-cooking" (specs/batch-cooking-architecture.md, jusqu'ici TODO) : optimizeCookingPlan() prend les recettes planifiees d'une semaine et les reorganise en phases ordonnees. - Mutualisation de la mise en place : une meme technique de decoupe (chop, peel, mince...) appliquee au meme ingredient par >= 2 recettes est regroupee en une seule tache "merged-prep" (les oignons de plusieurs recettes = une decoupe). - Parallelisme : les cuissons passives (simmer, braise, bake, marinate...) sont poussees en tache de fond des phases suivantes pendant qu'une autre recette avance en actif. - Fonction pure sans base (meme split matchXxx pur / loadXxx DB-backed que ingredient-matcher / tech-step-matcher), testable en isolation. Types partages : OptimizedCookingPlanView + schema getCookingSessionSchema. Tests Mocha purs (6 cas) : merge, non-merge d'une decoupe solo, tache de fond, mise a l'echelle par portions, plan vide, legende. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
511 lines
19 KiB
TypeScript
511 lines
19 KiB
TypeScript
import type {
|
||
CookingBackgroundTaskView,
|
||
CookingPhaseKind,
|
||
CookingPhaseView,
|
||
CookingSessionRecipeRef,
|
||
CookingTaskIngredientView,
|
||
CookingTaskView,
|
||
TechStepView,
|
||
UtensilView,
|
||
} from "@batch-cooking/shared";
|
||
|
||
/**
|
||
* The pure core of the "Calcul batch-cooking" module (`specs/batch-cooking-architecture.md`):
|
||
* takes the week's planned recipes — already resolved to reference views by
|
||
* `cooking-session.service.ts` — and reorganizes their steps into an ordered
|
||
* sequence of {@link CookingPhaseView}s that pools shared preparation and
|
||
* interleaves the recipes so passive cooks (simmer, braise, bake…) run in
|
||
* the background while the cook does active work from another recipe.
|
||
*
|
||
* Pure and synchronous, no database access — same `matchXxx()` pure /
|
||
* `loadXxx()` DB-backed split as `ingredient-matcher.ts` /
|
||
* `tech-step-matcher.ts` / `shopping-list.service.ts`'s
|
||
* `aggregateShoppingList`, so the whole optimization is unit-testable
|
||
* without a Postgres round-trip.
|
||
*
|
||
* v1 scope (see the plan / spec): preparation is the only thing *merged*
|
||
* across recipes — a `chop`/`peel`/… technique applied to the same
|
||
* ingredient by two or more recipes, in a step that does nothing but prep,
|
||
* collapses into a single {@link CookingTaskView} of `kind: "merged-prep"`.
|
||
* Cooking steps themselves are never merged (no "same oven, same
|
||
* temperature" reasoning yet); they're only *reordered* for parallelism.
|
||
*/
|
||
|
||
/**
|
||
* Technique keys (`TechStep.key`, see `reference-seed-data.ts`'s
|
||
* `TECH_STEPS`) that are pure knife/prep work on an ingredient — the only
|
||
* techniques v1 pools across recipes. A *step* counts as prep only when
|
||
* **every** technique it mentions is in here (see {@link isPurePrepStep}):
|
||
* "émincer les oignons" merges, "faire revenir les oignons émincés" does
|
||
* not (its `panFry` keeps it a cooking step).
|
||
*/
|
||
const PREP_TECHNIQUES: ReadonlySet<string> = new Set([
|
||
"chop",
|
||
"peel",
|
||
"mince",
|
||
"julienne",
|
||
"brunoise",
|
||
"concasse",
|
||
"paysanne",
|
||
"mirepoix",
|
||
"zest",
|
||
"score",
|
||
"pod",
|
||
"shellEgg",
|
||
"hollowOut",
|
||
"filet",
|
||
"disgorge",
|
||
"sift",
|
||
"dustWithFlour",
|
||
"peelBlanch",
|
||
]);
|
||
|
||
/**
|
||
* How much of the cook's attention a technique needs once it's under way —
|
||
* the axis that makes parallelism possible.
|
||
*
|
||
* - `"SETUP"` — a short active trigger, then it looks after itself: preheat
|
||
* the oven, bring a pot of water to the boil. Pooled into the first
|
||
* ("mise en place") phase so it's running before it's needed.
|
||
* - `"PASSIVE"` — unattended once started (simmer, braise, bake, marinate,
|
||
* rest…). Scheduled, then floated into every following phase's
|
||
* `background` until the step that consumes it comes up.
|
||
* - anything not listed here, or a step with no detected technique at all,
|
||
* is treated as `"ACTIVE"` — hands-on, occupies the cook.
|
||
*/
|
||
const SETUP_TECHNIQUES: ReadonlySet<string> = new Set(["preheat", "boil", "bainMarie"]);
|
||
|
||
/** See {@link SETUP_TECHNIQUES}. */
|
||
const PASSIVE_TECHNIQUES: ReadonlySet<string> = new Set([
|
||
"simmer",
|
||
"bake",
|
||
"roast",
|
||
"braise",
|
||
"marinate",
|
||
"rest",
|
||
"proof",
|
||
"confit",
|
||
"reduce",
|
||
"blindBake",
|
||
"compote",
|
||
"smother",
|
||
"setGel",
|
||
"pasteurize",
|
||
"appertize",
|
||
"poach",
|
||
"sweat",
|
||
"glaze",
|
||
]);
|
||
|
||
/** Attention class of a single step — see {@link SETUP_TECHNIQUES}. */
|
||
type Attention = "SETUP" | "PASSIVE" | "ACTIVE";
|
||
|
||
/** One technique occurrence within a step, already scaled to the planned portions. */
|
||
interface OptimizerTechStepInput {
|
||
techStep: TechStepView;
|
||
order: number;
|
||
ingredients: CookingTaskIngredientView[];
|
||
utensils: UtensilView[];
|
||
}
|
||
|
||
/** One recipe step, as handed to {@link optimizeCookingPlan}. */
|
||
interface OptimizerStepInput {
|
||
stepId: number;
|
||
order: number;
|
||
description: string;
|
||
techSteps: OptimizerTechStepInput[];
|
||
}
|
||
|
||
/**
|
||
* One planned recipe, as handed to {@link optimizeCookingPlan}. `portions`
|
||
* is the planning slot's own count and `recipePortions` the recipe's
|
||
* as-written yield — quantities are scaled by `portions / recipePortions`
|
||
* (see {@link scaleOf}). The same recipe planned twice at different portion
|
||
* counts arrives as two entries with the same `recipeId`; that's
|
||
* intentional (two real cooking jobs), and merged-prep still pools their
|
||
* knife work back together.
|
||
*/
|
||
interface OptimizerRecipeInput {
|
||
recipeId: number;
|
||
name: string;
|
||
portions: number;
|
||
recipePortions: number;
|
||
steps: OptimizerStepInput[];
|
||
}
|
||
|
||
/** {@link optimizeCookingPlan}'s result — the date-range/legend wrapper is added by the service. */
|
||
interface OptimizeCookingPlanResult {
|
||
recipes: CookingSessionRecipeRef[];
|
||
phases: CookingPhaseView[];
|
||
}
|
||
|
||
export type {
|
||
OptimizeCookingPlanResult,
|
||
OptimizerRecipeInput,
|
||
OptimizerStepInput,
|
||
OptimizerTechStepInput,
|
||
};
|
||
|
||
/** Portion scale factor for a recipe — guards a missing/zero as-written yield (bad data) by falling back to 1× rather than dividing by zero. */
|
||
function scaleOf(recipe: OptimizerRecipeInput): number {
|
||
if (!recipe.recipePortions || recipe.recipePortions <= 0) return 1;
|
||
return recipe.portions / recipe.recipePortions;
|
||
}
|
||
|
||
/** A step normalized for scheduling — techniques scaled, attention resolved, ingredients/utensils unioned across its technique clauses. */
|
||
interface NormalizedStep {
|
||
/** Stable within one response: `step:<recipeIndex>:<stepId>` (the index disambiguates the same recipe planned twice). */
|
||
taskId: string;
|
||
recipeIndex: number;
|
||
recipe: CookingSessionRecipeRef;
|
||
stepId: number;
|
||
order: number;
|
||
description: string;
|
||
techSteps: OptimizerTechStepInput[];
|
||
attention: Attention;
|
||
isPurePrep: boolean;
|
||
dominantTechnique: TechStepView | null;
|
||
ingredients: CookingTaskIngredientView[];
|
||
utensils: UtensilView[];
|
||
/** Set once merged-prep extraction absorbs this step wholesale (all its prep pooled elsewhere) — it then produces no standalone task. */
|
||
absorbed: boolean;
|
||
}
|
||
|
||
/** Sums two ingredient lines only when it's unambiguous — same unit id and both quantities known; otherwise the pooled line carries no number (see `ShoppingListItemView`'s "don't guess a conversion" rule). */
|
||
function poolIngredient(lines: CookingTaskIngredientView[]): {
|
||
quantity: number | null;
|
||
unit: CookingTaskIngredientView["unit"];
|
||
} {
|
||
const first = lines[0];
|
||
if (!first) return { quantity: null, unit: null };
|
||
const unitId = first.unit?.id ?? null;
|
||
let total = 0;
|
||
for (const line of lines) {
|
||
if (line.quantity === null || (line.unit?.id ?? null) !== unitId) {
|
||
return { quantity: null, unit: null };
|
||
}
|
||
total += line.quantity;
|
||
}
|
||
return { quantity: total, unit: first.unit };
|
||
}
|
||
|
||
/** Unions ingredient lines by `(ingredientId, unitId)`, summing quantities within a group the same careful way as {@link poolIngredient}. */
|
||
function unionIngredients(lines: CookingTaskIngredientView[]): CookingTaskIngredientView[] {
|
||
const groups = new Map<string, CookingTaskIngredientView[]>();
|
||
for (const line of lines) {
|
||
const key = `${line.ingredient.id}:${line.unit?.id ?? "x"}`;
|
||
const group = groups.get(key);
|
||
if (group) group.push(line);
|
||
else groups.set(key, [line]);
|
||
}
|
||
const out: CookingTaskIngredientView[] = [];
|
||
for (const group of groups.values()) {
|
||
const head = group[0];
|
||
if (!head) continue;
|
||
const pooled = poolIngredient(group);
|
||
out.push({ ingredient: head.ingredient, quantity: pooled.quantity, unit: pooled.unit });
|
||
}
|
||
return out.sort((a, b) => a.ingredient.key.localeCompare(b.ingredient.key));
|
||
}
|
||
|
||
/** Unions utensils by id, keeping a stable order by key. */
|
||
function unionUtensils(utensils: UtensilView[]): UtensilView[] {
|
||
const byId = new Map<number, UtensilView>();
|
||
for (const utensil of utensils) byId.set(utensil.id, utensil);
|
||
return [...byId.values()].sort((a, b) => a.key.localeCompare(b.key));
|
||
}
|
||
|
||
/** A step is pure prep only if it has techniques and every one of them is in {@link PREP_TECHNIQUES}. */
|
||
function isPurePrepStep(techSteps: OptimizerTechStepInput[]): boolean {
|
||
return techSteps.length > 0 && techSteps.every((ts) => PREP_TECHNIQUES.has(ts.techStep.key));
|
||
}
|
||
|
||
/** Resolves a step's {@link Attention} — SETUP wins, then a *trailing* passive technique, else ACTIVE (see {@link SETUP_TECHNIQUES}). */
|
||
function attentionOf(techSteps: OptimizerTechStepInput[]): Attention {
|
||
if (techSteps.some((ts) => SETUP_TECHNIQUES.has(ts.techStep.key))) return "SETUP";
|
||
const last = techSteps[techSteps.length - 1];
|
||
if (last && PASSIVE_TECHNIQUES.has(last.techStep.key)) return "PASSIVE";
|
||
return "ACTIVE";
|
||
}
|
||
|
||
/** Turns one recipe's raw steps into {@link NormalizedStep}s — scales quantities, resolves attention, unions per-clause ingredients/utensils up to the step. */
|
||
function normalizeRecipe(recipe: OptimizerRecipeInput, recipeIndex: number): NormalizedStep[] {
|
||
const scale = scaleOf(recipe);
|
||
const recipeRef: CookingSessionRecipeRef = {
|
||
recipeId: recipe.recipeId,
|
||
name: recipe.name,
|
||
portions: recipe.portions,
|
||
};
|
||
|
||
return [...recipe.steps]
|
||
.sort((a, b) => a.order - b.order)
|
||
.map((step) => {
|
||
const techSteps: OptimizerTechStepInput[] = [...step.techSteps]
|
||
.sort((a, b) => a.order - b.order)
|
||
.map((ts) => ({
|
||
techStep: ts.techStep,
|
||
order: ts.order,
|
||
ingredients: ts.ingredients.map((line) => ({
|
||
ingredient: line.ingredient,
|
||
quantity: line.quantity === null ? null : line.quantity * scale,
|
||
unit: line.unit,
|
||
})),
|
||
utensils: ts.utensils,
|
||
}));
|
||
|
||
const lastTech = techSteps[techSteps.length - 1];
|
||
return {
|
||
taskId: `step:${recipeIndex}:${step.stepId}`,
|
||
recipeIndex,
|
||
recipe: recipeRef,
|
||
stepId: step.stepId,
|
||
order: step.order,
|
||
description: step.description,
|
||
techSteps,
|
||
attention: attentionOf(techSteps),
|
||
isPurePrep: isPurePrepStep(techSteps),
|
||
dominantTechnique: lastTech ? lastTech.techStep : null,
|
||
ingredients: unionIngredients(techSteps.flatMap((ts) => ts.ingredients)),
|
||
utensils: unionUtensils(techSteps.flatMap((ts) => ts.utensils)),
|
||
absorbed: false,
|
||
} satisfies NormalizedStep;
|
||
});
|
||
}
|
||
|
||
/** The prep signature of a pure-prep step — sorted `<techniqueKey>:<ingredientId>` pairs; two steps with the same signature do identical knife work and can be pooled. */
|
||
function prepSignature(step: NormalizedStep): string {
|
||
const pairs: string[] = [];
|
||
for (const ts of step.techSteps) {
|
||
for (const line of ts.ingredients) {
|
||
pairs.push(`${ts.techStep.key}:${line.ingredient.id}`);
|
||
}
|
||
}
|
||
return [...new Set(pairs)].sort().join("+");
|
||
}
|
||
|
||
/** Builds one {@link CookingTaskView} from a normalized step run as written. */
|
||
function stepToTask(step: NormalizedStep): CookingTaskView {
|
||
return {
|
||
id: step.taskId,
|
||
kind: "step",
|
||
technique: step.dominantTechnique,
|
||
description: step.description,
|
||
ingredients: step.ingredients,
|
||
utensils: step.utensils,
|
||
sourceRecipes: [step.recipe],
|
||
originalSteps: [
|
||
{
|
||
recipeId: step.recipe.recipeId,
|
||
recipeName: step.recipe.name,
|
||
description: step.description,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
/** Builds the running-in-the-background status line for a passive step already scheduled in an earlier phase. */
|
||
function stepToBackground(step: NormalizedStep): CookingBackgroundTaskView {
|
||
return {
|
||
id: `bg:${step.taskId}`,
|
||
technique: step.dominantTechnique,
|
||
description: step.description,
|
||
recipeId: step.recipe.recipeId,
|
||
recipeName: step.recipe.name,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Pools pure-prep steps that do the *exact same* knife work (same
|
||
* {@link prepSignature}) in two or more distinct recipes into one
|
||
* `merged-prep` {@link CookingTaskView}, and marks every contributing step
|
||
* `absorbed` so it produces no standalone task. A pure-prep step whose
|
||
* signature is unique (only one recipe needs it) is left untouched — it
|
||
* still lands in the mise-en-place phase, just as its own step task.
|
||
*
|
||
* Returns the merged tasks in a stable order (by id).
|
||
*/
|
||
function extractMergedPrep(steps: NormalizedStep[]): CookingTaskView[] {
|
||
const bySignature = new Map<string, NormalizedStep[]>();
|
||
for (const step of steps) {
|
||
if (!step.isPurePrep) continue;
|
||
const signature = prepSignature(step);
|
||
if (signature === "") continue;
|
||
const group = bySignature.get(signature);
|
||
if (group) group.push(step);
|
||
else bySignature.set(signature, [step]);
|
||
}
|
||
|
||
const merged: CookingTaskView[] = [];
|
||
for (const [signature, group] of bySignature) {
|
||
const recipeIndexes = new Set(group.map((s) => s.recipeIndex));
|
||
if (recipeIndexes.size < 2) continue;
|
||
|
||
for (const step of group) step.absorbed = true;
|
||
|
||
// Every contributing clause's ingredient lines, pooled per ingredient.
|
||
const allLines = group.flatMap((s) => s.techSteps.flatMap((ts) => ts.ingredients));
|
||
const ingredients = unionIngredients(allLines);
|
||
const utensils = unionUtensils(group.flatMap((s) => s.utensils));
|
||
|
||
// Dominant technique of the pool = the first pair's technique (v1
|
||
// signatures are almost always a single `<technique>:<ingredient>`
|
||
// pair; a multi-pair signature just takes the earliest).
|
||
const firstTech = group[0]?.techSteps[0]?.techStep ?? null;
|
||
const firstIngredientKey = ingredients[0]?.ingredient.key ?? signature;
|
||
|
||
// Distinct source recipes / original step texts, in input order.
|
||
const sourceRecipes: CookingSessionRecipeRef[] = [];
|
||
const seenRecipe = new Set<number>();
|
||
const originalSteps: CookingTaskView["originalSteps"] = [];
|
||
for (const step of [...group].sort((a, b) => a.recipeIndex - b.recipeIndex)) {
|
||
if (!seenRecipe.has(step.recipeIndex)) {
|
||
seenRecipe.add(step.recipeIndex);
|
||
sourceRecipes.push(step.recipe);
|
||
}
|
||
originalSteps.push({
|
||
recipeId: step.recipe.recipeId,
|
||
recipeName: step.recipe.name,
|
||
description: step.description,
|
||
});
|
||
}
|
||
|
||
merged.push({
|
||
id: `prep:${firstTech ? firstTech.key : "prep"}:${firstIngredientKey}`,
|
||
kind: "merged-prep",
|
||
technique: firstTech,
|
||
description: null,
|
||
ingredients,
|
||
utensils,
|
||
sourceRecipes,
|
||
originalSteps,
|
||
});
|
||
}
|
||
|
||
return merged.sort((a, b) => a.id.localeCompare(b.id));
|
||
}
|
||
|
||
/** Maps the input recipe list to its display legend, de-duplicating an exact `(recipeId, portions)` repeat. */
|
||
function toRecipeLegend(recipes: OptimizerRecipeInput[]): CookingSessionRecipeRef[] {
|
||
const seen = new Set<string>();
|
||
const out: CookingSessionRecipeRef[] = [];
|
||
for (const recipe of recipes) {
|
||
const key = `${recipe.recipeId}:${recipe.portions}`;
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
out.push({ recipeId: recipe.recipeId, name: recipe.name, portions: recipe.portions });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** A phase is `"finishing"` when everything left in it is plating; otherwise it's a normal `"cooking"` phase. */
|
||
function cookingPhaseKind(tasks: CookingTaskView[]): CookingPhaseKind {
|
||
return tasks.every((task) => task.technique?.key === "plate") ? "finishing" : "cooking";
|
||
}
|
||
|
||
/**
|
||
* See the file header. Given the week's planned recipes (already resolved
|
||
* to reference views), returns the display legend plus the ordered phases:
|
||
*
|
||
* 1. **Mise en place** (`"mise-en-place"`) — every `merged-prep` task, then
|
||
* every leftover pure-prep step, then every `SETUP` step. Omitted
|
||
* entirely if it would be empty.
|
||
* 2. **Cooking** (`"cooking"` / `"finishing"`) — the recipes interleaved:
|
||
* each phase pops the next remaining step of every recipe that still has
|
||
* one (passive-cook steps first, so long cooks start early). A passive
|
||
* step scheduled in one phase is echoed in every later phase's
|
||
* `background` until that recipe's next step is popped.
|
||
*/
|
||
export function optimizeCookingPlan(recipes: OptimizerRecipeInput[]): OptimizeCookingPlanResult {
|
||
const legend = toRecipeLegend(recipes);
|
||
const normalized = recipes.map((recipe, index) => normalizeRecipe(recipe, index));
|
||
const allSteps = normalized.flat();
|
||
|
||
const mergedPrep = extractMergedPrep(allSteps);
|
||
|
||
const phases: CookingPhaseView[] = [];
|
||
|
||
// Phase 0 — mise en place.
|
||
const miseTasks: CookingTaskView[] = [...mergedPrep];
|
||
for (const step of allSteps) {
|
||
if (step.absorbed) continue;
|
||
if (step.isPurePrep || step.attention === "SETUP") {
|
||
miseTasks.push(stepToTask(step));
|
||
step.absorbed = true; // consumed here, not again in the cooking loop
|
||
}
|
||
}
|
||
if (miseTasks.length > 0) {
|
||
phases.push({ index: 0, kind: "mise-en-place", tasks: miseTasks, background: [] });
|
||
}
|
||
|
||
// Cooking phases — one "next step of each recipe" per phase. `hold` keeps
|
||
// a recipe out of the *next* phase right after it starts a passive cook,
|
||
// so another recipe's active work fills that phase and the passive cook
|
||
// shows up as `background` there instead of being immediately followed by
|
||
// its own next step.
|
||
const queues = normalized.map((steps) => ({
|
||
remaining: steps.filter((s) => !s.absorbed),
|
||
cursor: 0,
|
||
hold: 0,
|
||
}));
|
||
/** Passive steps started in an earlier phase, keyed by recipe index, still "cooking". */
|
||
const runningPassive = new Map<number, NormalizedStep>();
|
||
|
||
while (queues.some((queue) => queue.cursor < queue.remaining.length)) {
|
||
const phaseSteps: NormalizedStep[] = [];
|
||
queues.forEach((queue, recipeIndex) => {
|
||
const next = queue.remaining[queue.cursor];
|
||
if (!next) return;
|
||
if (queue.hold > 0) {
|
||
// Still tending its passive cook this phase — leave it in
|
||
// `runningPassive` so it renders as background, don't advance.
|
||
queue.hold--;
|
||
return;
|
||
}
|
||
// This recipe is advancing — whatever passive cook it had going is
|
||
// now being tended to, so it stops showing as background.
|
||
runningPassive.delete(recipeIndex);
|
||
phaseSteps.push(next);
|
||
queue.cursor++;
|
||
});
|
||
|
||
// Every recipe with steps left is holding on a passive cook — break the
|
||
// stall by releasing all holds and letting the next iteration advance.
|
||
if (phaseSteps.length === 0) {
|
||
for (const queue of queues) queue.hold = 0;
|
||
continue;
|
||
}
|
||
|
||
// Background = passive cooks from earlier phases not yet resolved above.
|
||
const background = [...runningPassive.values()].map(stepToBackground);
|
||
|
||
// Start the long cooks first within the phase.
|
||
phaseSteps.sort((a, b) => {
|
||
const rank = (s: NormalizedStep) => (s.attention === "PASSIVE" ? 0 : 1);
|
||
return rank(a) - rank(b) || a.recipeIndex - b.recipeIndex;
|
||
});
|
||
|
||
const tasks = phaseSteps.map(stepToTask);
|
||
phases.push({
|
||
index: phases.length,
|
||
kind: cookingPhaseKind(tasks),
|
||
tasks,
|
||
background,
|
||
});
|
||
|
||
for (const step of phaseSteps) {
|
||
if (step.attention === "PASSIVE") {
|
||
runningPassive.set(step.recipeIndex, step);
|
||
const queue = queues[step.recipeIndex];
|
||
if (queue) queue.hold = 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
// `index` was set from `phases.length` as we went; re-stamp so it always
|
||
// matches the final array position even if phase 0 was skipped.
|
||
phases.forEach((phase, index) => {
|
||
phase.index = index;
|
||
});
|
||
|
||
return { recipes: legend, phases };
|
||
}
|