Skip to content

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.

  1. Mise à l'Échelle Globale — un facteur fixe (--scale 2).
  2. Mise à l'Échelle Inversée — une quantité cible pour un ingrédient (--scale farine=400g), à partir de laquelle le facteur est dérivé.
  3. 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=1kg quand la recette est écrite avec 500g calcule un facteur de 0.002 au lieu de 2 si 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

ts
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 — compiled n'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é.

  • convertUnit est optionnel et injecté par l'appelant. @gram/kitchen ne connaît rien aux densités, bases de données d'ingrédients, ou à la table UNIT_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 forme convertUnit(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/analyzer prend une densité (g/ml) optionnelle en 4ème argument pour faire le pont entre masse ↔ volume (ex : farine=1L face à une recette en g) — 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, via resolveIngredientDensity(id, database, overrides) (en vérifiant une surcharge densities: 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'erreurPourquoi c'est rejetéCe que le message suggère
INGREDIENT_NOT_FOUNDAucun ingrédient avec cet id dans la liste de coursesUne suggestion "vouliez-vous dire" en comparant avec les id réels de la recette
NESTED_ONLY_TARGETL'ingrédient n'existe qu'à l'intérieur du usage[] d'un composite/sous-recette, jamais au premier niveauMettez le composite parent à l'échelle — son propre total (ex : "2 citrons") est lui-même une cible valide
ALTERNATIVE_TARGETL'id est une option au sein d'un groupe d'ingrédients alternatifsChoisissez un ingrédient différent et non ambigu — l'erreur liste les autres options
FIXED_INGREDIENTMarqué @=, 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_TARGETLa quantité est dérivée par formule (70% @&farine)Mettez plutôt la cible à l'échelle (farine) — voir Quantités Relatives
AMBIGUOUS_MULTI_UNITLe même ingrédient apparaît dans deux unités incompatibles dans la recetteLe total de la liste de courses ne peut pas être réduit à un seul nombre
UNIT_MISMATCHLes unités appartiennent à différentes familles (masse vs volume) et aucune densité n'a pu être résolueAjoutez une densité via gram db enrich, déclarez-en une dans le bloc densities: de la recette, ou faites correspondre les unités
INVALID_FACTORRé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 portions déjà mise à l'échelle.
  • Pas de corruption par référence partagée. compile() lui-même clone défensivement l'objet meta de 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

  1. Compilez normalement (compile(), optionnellement analyze() si vous voulez la masse/les données du Boulanger).
  2. Construisez une ScaleRequest à partir des entrées de l'utilisateur (un nombre brut, ou un id + quantité + unité).
  3. Appelez resolveScaleFactor(compiled, request, convertUnit?). Attrapez l'erreur ScaleError et faites un switch sur .code pour des messages spécifiques à l'interface utilisateur — chaque erreur porte déjà un message complet .message destiné à l'utilisateur.
  4. Appelez applyScale(compiled, resolution.factor) pour obtenir le résultat mis à l'échelle. Gardez le compiled d'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.