Aller au contenu

Analyse approfondie : ajustement des proportions (Scaling)

Ajuster les proportions d’une recette (scaling) s’apparente à une simple multiplication, mais Gram gère en réalité trois mécanismes distincts. Ces mécanismes aboutissent tous à la production d’un nombre unique — le facteur d’ajustement — qui est ensuite appliqué uniformément partout : liste de courses, mentions d’ingrédients dans les étapes, et métadonnées des portions.

  1. Ajustement Global — un facteur fixe (--scale 2).
  2. Ajustement par Ingrédient Repère (Inversé) — 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 un ajustement de proportions, 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-lang/kitchen, plutôt que de laisser chaque consommateur (CLI, Playground) réinventer ses propres règles.

L’ajustement par ingrédient repère paraît simple sur le papier — il suffit de diviser la quantité cible par la quantité actuelle de l’ingrédient. Pourtant, une implémentation naïve se trompe silencieusement sur un nombre surprenant de cas :

  • Ajuster les proportions 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.
  • Ajuster les proportions par rapport à un ingrédient marqué comme fixe (@=, ne s’ajuste jamais) produit un facteur qui ne décrit pas réellement ce qui arrive à la recette.
  • Ajuster les proportions 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.
  • Si un ingrédient est utilisé avec deux unités incompatibles dans la même recette, seule sa quantité “primaire” figure dans la liste de courses agrégée. En déduire un facteur d’ajustement reviendrait à ignorer le reste silencieusement.

Exportée depuis @gram-lang/kitchen, la fonction resolveScaleFactor() valide tout cela en amont et lève une erreur fortement typée au lieu de renvoyer une valeur fantaisiste. Tous les consommateurs (l’option --scale du CLI aujourd’hui, le Playground demain) appellent cette même fonction et bénéficient de ces garanties gratuitement.

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-lang/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-lang/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-lang/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.

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 niveauAjustez les proportions du composite parent — 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 s’ajuste jamais, ne peut donc pas décrire de facteur d’ajustement
RELATIVE_TARGETLa quantité est dérivée par formule (70% @&farine)Ajustez plutôt les proportions de la cible (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.

La fonction qui applique réellement la multiplication sur chaque quantité, applyScale(result, factor), est une fonction pure : elle clone l’entrée (structuredClone) avant toute modification, puis renvoie un nouveau CompilationResult. Cela offre deux avantages majeurs :

  • 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à ajustée.
  • 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 ajuster les proportions d’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, au lieu de re-parser à chaque mouvement) réutilise le même AST à travers plusieurs appels.

Le CompilationResult final porte un champ explicite scaleFactor (défini à 1 par défaut) qui consigne son ratio par rapport à l’original. C’est cette valeur que lisent les interfaces interactives (comme le widget des portions HTML du renderer) pour calculer leur base, évitant ainsi de s’appuyer sur des champs masqués.

Le pourcentage du boulanger n’est pas un ajustement de proportions, mais il partage la même philosophie “ne pas calculer un nombre faux silencieusement”. @gram-lang/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-lang/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. Si un même ingrédient est utilisé plusieurs fois dans une section (ex : du sucre ajouté à deux étapes distinctes), la méthode aggregateSectionIngredients de @gram-lang/kitchen additionne à la fois sa normalizedMass et son bakersPercentage. Puisque le pourcentage est linéaire par rapport à la masse de référence, les deux valeurs concordent systématiquement (ex: 100 g/10 % + 100 g/10 % donne bien 200 g/20 %, et non un aberrant 200 g/10 %). Tous les moteurs de rendu lisent cette valeur précalculée et agrégée sans jamais la recalculer de leur côté.

Ajustement des imports modulaires (@gram-lang/modules)

Section intitulée « Ajustement des imports modulaires (@gram-lang/modules) »

Lorsqu’une recette importe une sous-base via @use (ex : @use "./bases/pate.gram" as &pate), le scaling suit une évaluation basée sur le rendement :

  1. Mesure du Rendement : @gram-lang/modules pré-analyse le sous-module pour déterminer sa masse totale (resolveYield), en sommant récursivement toutes ses sections contributrices.
  2. Calcul du Ratio : Lorsque la recette hôte précise une quantité (ex : &pate{300g} pour une base qui produit 400g), computeScaleFactor() calcule un facteur de 300 / 400 = 0.75.
  3. Pré-Scaling de l’AST : L’AST du sous-module est ajusté en amont (scaleAst) avant d’être greffé dans la recette hôte. Si plusieurs étapes demandent des quantités d’un même export, elles sont sommées ; si plusieurs exports sont importés, le ratio maximal est retenu.
  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 recalculé. Gardez le compiled d’origine de côté si vous devez réajuster les proportions plus tard (ex : un curseur en direct) — n’ajustez jamais un résultat déjà recalculé.