Analyse approfondie : Mise à l'Échelle (Scaling)
Mettre une recette à l'échelle (scaling) ressemble à une simple multiplication, mais Gram prend en charge en réalité trois mécanismes distincts qui finissent tous par produire un seul nombre — un facteur d'échelle — et l'appliquent ensuite uniformément partout : la liste de courses, chaque mention d'ingrédient dans le texte des étapes, et les métadonnées de portions de la recette.
- Mise à l'Échelle Globale — un facteur fixe (
--scale 2). - Mise à l'Échelle Inversée — une quantité cible pour un ingrédient (
--scale farine=400g), à partir de laquelle le facteur est dérivé. - Pourcentage du Boulanger — ce n'est pas vraiment de la "mise à l'échelle", mais plutôt une transformation d'affichage qui montre chaque ingrédient en tant que pourcentage de la masse d'un ingrédient de référence.
Ces trois éléments sont résolus et appliqués via un petit moteur centralisé ScaleEngine dans @gram/kitchen, plutôt que de laisser chaque consommateur (CLI, Playground) réinventer ses propres règles.
Pourquoi un moteur dédié
La mise à l'échelle inversée semble simple — diviser la quantité cible par la quantité actuelle de l'ingrédient — mais une implémentation naïve se trompe silencieusement sur un nombre surprenant de points :
- Mettre à l'échelle par rapport à
farine=1kgquand la recette est écrite avec500gcalcule un facteur de0.002au lieu de2si on ne réconcilie pas les unités d'abord. - Mettre à l'échelle par rapport à un ingrédient marqué comme fixe (
@=, ne se met jamais à l'échelle) produit un facteur qui ne décrit pas réellement ce qui arrive à la recette. - Mettre à l'échelle par rapport à une quantité relative (
@eau{70% @&farine}) est circulaire : sa valeur est dérivée d'un autre ingrédient, elle ne peut donc pas être la référence en même temps. - Un ingrédient réparti sur deux unités incompatibles dans la même recette n'a qu'une quantité "primaire" dans la liste de courses agrégée — en dériver un facteur ignorerait silencieusement le reste.
resolveScaleFactor() (exportée depuis @gram/kitchen) valide tout cela en amont et lève une erreur spécifique et typée au lieu de renvoyer un nombre erroné. Chaque consommateur — l'option --scale du CLI aujourd'hui, le Playground demain — appelle la même fonction et obtient les mêmes garanties gratuitement.
Le contrat requête/résolution
type ScaleRequest =
| { type: 'factor'; value: number }
| { type: 'target'; id: string; qty: number; unit: string | null };
function resolveScaleFactor(
compiled: CompilationResult | null,
request: ScaleRequest,
convertUnit?: (value: number, fromUnit: string, toUnit: string) => number | null,
): { factor: number; resolvedFrom: 'factor' | 'target'; targetId?: string; unitConverted?: boolean };Le mode facteur (factor) valide simplement que le nombre est positif et fini —
compiledn'est même pas nécessaire, les appelants peuvent donc valider un facteur brut (ex : venant d'un curseur dans le Playground) sans exécuter de pipeline.Le mode cible (target) recherche l'ingrédient dans la
compiled.shopping_list, le fait passer par les règles de rejet ci-dessous, réconcilie les unités, et renvoie le facteur dérivé.convertUnitest optionnel et injecté par l'appelant.@gram/kitchenne connaît rien aux densités, bases de données d'ingrédients, ou à la tableUNIT_CONVERSIONS— tout cela vit entièrement dans@gram/analyzer(qui en a de toute façon besoin pour la standardisation des masses). Le moteur l'appelle toujours sous la formeconvertUnit(valeur, de_unite, vers_unite); sans cela, seules les correspondances d'unités exactes (après normalisation des alias, ex : "gramme" → "g") réussissent.La fonction
convertUnit(valeur, de_unite, vers_unite, densite?)propre à@gram/analyzerprend une densité (g/ml) optionnelle en 4ème argument pour faire le pont entre masse ↔ volume (ex :farine=1Lface à une recette eng) — sans elle, elle ne convertit qu'au sein de la même famille (masse↔masse, volume↔volume), comme le fait la table brute. L'appelant résout cette densité une fois, viaresolveIngredientDensity(id, database, overrides)(en vérifiant une surchargedensities:dans le frontmatter avant de chercher en base), et l'attache dans une fermeture (closure) avant de la passer àresolveScaleFactor— le moteur lui-même ne voit jamais un id d'ingrédient, une base de données, ou un nombre de densité, seulement une fonction à 3 arguments. Le CLI et le Playground câblent tous deux cela de la même façon.
Règles de rejet (mode cible)
| Code d'erreur | Pourquoi c'est rejeté | Ce que le message suggère |
|---|---|---|
INGREDIENT_NOT_FOUND | Aucun ingrédient avec cet id dans la liste de courses | Une suggestion "vouliez-vous dire" en comparant avec les id réels de la recette |
NESTED_ONLY_TARGET | L'ingrédient n'existe qu'à l'intérieur du usage[] d'un composite/sous-recette, jamais au premier niveau | Mettez le composite parent à l'échelle — son propre total (ex : "2 citrons") est lui-même une cible valide |
ALTERNATIVE_TARGET | L'id est une option au sein d'un groupe d'ingrédients alternatifs | Choisissez un ingrédient différent et non ambigu — l'erreur liste les autres options |
FIXED_INGREDIENT | Marqué @=, ou une TextQuantity comme "une pincée" (qui est fixe par définition) | Ne se met jamais à l'échelle, ne peut donc pas décrire de facteur d'échelle |
RELATIVE_TARGET | La quantité est dérivée par formule (70% @&farine) | Mettez plutôt la cible à l'échelle (farine) — voir Quantités Relatives |
AMBIGUOUS_MULTI_UNIT | Le même ingrédient apparaît dans deux unités incompatibles dans la recette | Le total de la liste de courses ne peut pas être réduit à un seul nombre |
UNIT_MISMATCH | Les unités appartiennent à différentes familles (masse vs volume) et aucune densité n'a pu être résolue | Ajoutez une densité via gram db enrich, déclarez-en une dans le bloc densities: de la recette, ou faites correspondre les unités |
INVALID_FACTOR | Référence non-positive, non-finie, avec une quantité nulle, etc. | — |
Immuabilité
applyScale(result, factor) — la fonction qui multiplie réellement chaque quantité — est une fonction pure : elle fait un clone (structuredClone) de l'entrée avant de modifier quoi que ce soit, et renvoie un nouveau CompilationResult. Deux avantages en découlent gratuitement :
- Pas de cumul. Réappliquer un facteur différent repart toujours du même original non modifié, il n'y a donc aucun risque d'appliquer un facteur deux fois sur une valeur
portionsdéjà mise à l'échelle. - Pas de corruption par référence partagée.
compile()lui-même clone défensivement l'objetmetade l'AST avant de le passer au résultat, donc mettre à l'échelle une recette compilée ne modifie jamais l'AST de l'analyseur syntaxique — c'est sans danger même si un futur appelant (ex : un Playground qui met en cache l'AST analysé et ne recompile que sur un mouvement du curseur d'échelle, au lieu de re-parser à chaque mouvement) réutilise le même AST à travers plusieurs appels.
Le CompilationResult obtenu porte un champ honnête scaleFactor (par défaut 1) enregistrant comment il se rapporte à l'original non mis à l'échelle — c'est ce que lisent les interfaces interactives (comme le widget de portions HTML du renderer) pour calculer une base, plutôt qu'un champ caché/préfixé par un tiret bas.
Validation du Pourcentage du Boulanger
Le pourcentage du boulanger n'est pas une mise à l'échelle, mais il partage la même philosophie "ne pas calculer un nombre faux silencieusement". @gram/analyzer le calcule sous la forme d'un champ bakersPercentage par article de la liste de courses et par mention d'ingrédient en ligne dans les étapes — @gram/renderer se contente d'afficher cette valeur précalculée, il ne la recalcule pas.
L'ingrédient de référence (marqué avec @*, ou forcé via --bakers-reference) doit être une ancre physique : si sa propre masse a elle-même été dérivée d'une quantité relative (conversionMethod === 'relative'), l'analyseur refuse de l'utiliser comme base de 100 % — cela serait circulaire — et émet à la place un avertissement INVALID_BAKERS_REFERENCE sur le résultat, laissant les calculs du boulanger désactivés pour cette exécution au lieu d'afficher des pourcentages par rapport à une cible mouvante.
Si les calculs du boulanger ont été demandés (--bakers-math ou --bakers-reference) mais qu'aucun modificateur @* ni aucun id correspondant n'ont été trouvés, un avertissement NO_BAKERS_REFERENCE est émis de la même manière — vérifiez result.warnings plutôt qu'un console.log.
L'agrégation reste cohérente avec la masse. Lorsque le même ingrédient est utilisé plus d'une fois dans une section (ex : du sucre ajouté à deux étapes différentes), aggregateSectionIngredients de @gram/kitchen somme à la fois la masse normalisée (normalizedMass) et le bakersPercentage à travers les occurrences répétées — puisque le pourcentage est linéaire à la masse pour une référence fixe, les deux concordent toujours (ex : deux occurrences de 100 g/10 % s'agrègent en 200 g/20 %, jamais en un incohérent 200 g/10 %). Chaque moteur de rendu (les formateurs HTML, Markdown, et impression de @gram/renderer, ainsi que le TUI gram cook du CLI) lit cette même valeur précalculée et déjà agrégée — aucun ne la recalcule de son côté.
Consommer ceci depuis un nouveau frontend
- Compilez normalement (
compile(), optionnellementanalyze()si vous voulez la masse/les données du Boulanger). - Construisez une
ScaleRequestà partir des entrées de l'utilisateur (un nombre brut, ou un id + quantité + unité). - Appelez
resolveScaleFactor(compiled, request, convertUnit?). Attrapez l'erreurScaleErroret faites un switch sur.codepour des messages spécifiques à l'interface utilisateur — chaque erreur porte déjà un message complet.messagedestiné à l'utilisateur. - Appelez
applyScale(compiled, resolution.factor)pour obtenir le résultat mis à l'échelle. Gardez lecompiledd'origine de côté si vous devez remettre à l'échelle plus tard (ex : un curseur en direct) — ne mettez jamais à l'échelle un résultat déjà mis à l'échelle.