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.
- Ajustement Global — un facteur fixe (
--scale 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é. - 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.
Pourquoi un moteur dédié
Section intitulée « Pourquoi un moteur dédié »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=1kgquand la recette est écrite avec500gcalcule un facteur de0.002au lieu de2si 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.
Le contrat requête/résolution
Section intitulée « Le contrat requête/résolution »-
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-lang/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-lang/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-lang/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)
Section intitulée « 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 | Ajustez les proportions du composite parent — 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 s’ajuste jamais, ne peut donc pas décrire de facteur d’ajustement |
RELATIVE_TARGET | La quantité est dérivée par formule (70% @&farine) | Ajustez plutôt les proportions de la cible (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é
Section intitulée « Immuabilité »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
portionsdéjà ajustée. - 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 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.
Validation du pourcentage du boulanger
Section intitulée « Validation du pourcentage du boulanger »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 :
- Mesure du Rendement :
@gram-lang/modulespré-analyse le sous-module pour déterminer sa masse totale (resolveYield), en sommant récursivement toutes ses sections contributrices. - Calcul du Ratio : Lorsque la recette hôte précise une quantité (ex :
&pate{300g}pour une base qui produit400g),computeScaleFactor()calcule un facteur de300 / 400 = 0.75. - 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.
Consommer ceci depuis un nouveau frontend
Section intitulée « 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 recalculé. Gardez lecompiledd’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é.