07 — Données réelles & externe : Ciqual × RecipeML (couche de données, 0 Z3)

Bloc meal-planner — notebook « Données & externe » (Epic #4677). Il porte à l’échelle réelle — Ciqual complet × plusieurs milliers de recettes RecipeML — la couche données du planificateur. La modélisation Z3 vit ailleurs : 06 la pose sur un corpus jouet, 09 la résout à l’échelle sur le cache produit ici.

Avant qu’un solveur SMT puisse planifier un menu, il faut charger et préparer des données réelles. Ce notebook ne résout rien (0 Z3) : son livrable est la couche de données propre que les notebooks de modélisation et de capstone (08, 09) consommeront.

Deux sources, une jointure de data fusion

Source Rôle Format Ce qu’elle apporte
Ciqual (ANSES 2025) référentiel nutrition XML composition de 3484 aliments par 100 g ; noms anglais (alim_nom_eng)
RecipeML (archive culinaire) corpus recettes XML auto-décrit <ing> = <amt><qty> + <unit> + <item> — avec les quantités

Le corpus RecipeML porte quoi (les ingrédients), Ciqual porte combien (la nutrition). Aucune clé commune ne les relie : seul un appariement lexical approximatif (item anglais ↔︎ alim_nom_eng) fait le pont. C’est de la fusion de données réelle — pas un join jouet.

Le fil conducteur : trois obstacles que les corpus jouets masquaient

  1. Pas de clé commune → appariement lexical flou (sac-de-mots + index inversé). Section 3.
  2. Les ingrédients ont des quantités en unités culinaires (2 cups, 1 teaspoon) → il faut normaliser en grammes avant toute agrégation nutritionnelle. Section 5. C’est le point que les premières ébauches du planificateur à l’échelle escamotaient en sommant les teneurs per-100g comme si chaque ingrédient pesait 100 g.
  3. La couverture d’appariement est inégale → on gate les recettes par qualité de match (100 % / ≥80 % / ≥50 %) pour ne livrer au solveur qu’un sous-ensemble propre. Sections 6-7.
flowchart LR
    Q["Ciqual ANSES 2025<br/>(XML, per-100g, EN)"]
    R["Archive RecipeML<br/>(XML, qty/unit/item)"]
    Q -->|"XmlReader"| QL["alimCompo + alim_nom_eng"]
    R -->|"XDocument"| RL["recettes (item, qty, unit)"]
    RL -->|"appariement flou"| J["item ↔ code Ciqual"]
    RL -->|"qty×unité→grammes"| G["masse par ingrédient"]
    QL --> A
    J --> A
    G --> A["agrégation PONDÉRÉE par la masse"]
    A -->|"gate ≥80%"| C["cache JSON propre"]
    C -.->|"consommé par"| M["08 capstone · 09 échelle"]
    %% color: explicite -- sans lui, libelle clair sur fond clair en mode sombre GitHub (#15022) ; ton parfois plus fonce que le stroke (le stroke en couleur de texte rendrait infer illisible) : ne pas harmoniser
    style Q fill:#f7e3c5,color:#7a5300
    style R fill:#f7e3c5,color:#7a5300
    style C fill:#c8e6f7,color:#0c5460
    style M fill:#eeeeee,color:#333333

Objectifs d’apprentissage

  • Réaliser une jointure de data fusion entre un corpus de recettes (RecipeML, ingrédients anglais) et une base nutritionnelle (Ciqual alim_nom_eng), via rapprochement lexical flou.
  • Parser une structure XML réelle avec quantités (<amt><qty><unit>), et comprendre pourquoi la normalisation en grammes est un préalable non négociable à l’agrégation nutritionnelle.
  • Mesurer la couverture d’appariement et gater le corpus par qualité — le levier de montée en charge piloté par la donnée.

Prérequis

  • Notions LINQ-to-objects et LINQ-to-XML.
  • Aucune dépendance Z3 : ce notebook est purement data-engineering (0 solveur).

1. Données : Ciqual 2025 + archive RecipeML

Les données sont volumineuses et hors dépôt (data/meals/ est gitignore). Si le dossier est absent, exécutez d’abord le téléchargeur :

python download_meal_data.py --areas Ciqual RecipeML

Ciqual ANSES 2025 (DOI 10.57745/RDMHWY, licence etalab 2.0) est une base normalisée en 4 fichiers : const (constituants), alim (aliments), compo (compositions, ~66 Mo) et alim_grp (groupes). On la lit en flux (XmlReader) pour ne garder que les constituants contraints. L’archive RecipeML est un ensemble de fichiers XML <recipe> auto-décrits.

// ---- dependances (0 Z3 : purement data-engineering) ----
using System;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using System.Xml;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Globalization;
using System.Diagnostics;

var BASE = "data/meals";
if (!Directory.Exists(Path.Combine(BASE, "Ciqual")) || !Directory.Exists(Path.Combine(BASE, "RecipeML")))
    Console.WriteLine("Donnees absentes. Lancez d'abord : python download_meal_data.py --areas Ciqual RecipeML");
else
    Console.WriteLine("Donnees presentes : " + string.Join(", ", Directory.GetDirectories(BASE).Select(Path.GetFileName)));
Donnees presentes : Ciqual, Recettes, RecipeML

2. Ciqual : le référentiel nutritionnel (per-100g)

On lit const (pour repérer les 5 constituants qu’on contraindra : énergie, protéines, glucides, lipides, sel), alim (nom anglais de chaque aliment) et compo (teneurs). Les teneurs Ciqual sont toutes par 100 g — c’est ce qui rendra la normalisation en grammes (section 5) indispensable.

// ---- Ciqual : lecture en flux, sous-ensemble des constituants contraints ----
var sw = Stopwatch.StartNew();
string[] WANTED = { "Energie, Règlement", "Protéines, N x", "Glucides (g", "Lipides (g", "Sel chlorure" };

var consts = new Dictionary<string, string>();               // const_code -> nom FR
using (var rd = XmlReader.Create(Path.Combine(BASE, "Ciqual/const_2025_11_03.xml")))
{
    string code = null, fr = null, field = null;
    while (rd.Read())
    {
        if (rd.NodeType == XmlNodeType.Element) { if (rd.Name == "CONST") { code = fr = null; } field = rd.Name; }
        else if (rd.NodeType == XmlNodeType.Text) { if (field == "const_code") code = rd.Value.Trim(); else if (field == "const_nom_fr") fr = rd.Value.Trim(); }
        else if (rd.NodeType == XmlNodeType.EndElement && rd.Name == "CONST") { if (code != null) consts[code] = fr ?? ""; }
    }
}
var chosenCodes = WANTED.Select(w => consts.First(c => c.Value.StartsWith(w.Substring(0, Math.Min(18, w.Length)))).Key).ToList();
var codeIdx = chosenCodes.Select((c, i) => (c, i)).ToDictionary(x => x.c, x => x.i);
int C = chosenCodes.Count;

var alimEng = new Dictionary<string, string>();              // alim_code -> nom anglais
using (var rd = XmlReader.Create(Path.Combine(BASE, "Ciqual/alim_2025_11_03.xml")))
{
    string code = null, eng = null, field = null;
    while (rd.Read())
    {
        if (rd.NodeType == XmlNodeType.Element) { if (rd.Name == "ALIM") { code = eng = null; } field = rd.Name; }
        else if (rd.NodeType == XmlNodeType.Text) { if (field == "alim_code") code = rd.Value.Trim(); else if (field == "alim_nom_eng") eng = rd.Value.Trim(); }
        else if (rd.NodeType == XmlNodeType.EndElement && rd.Name == "ALIM") { if (code != null) alimEng[code] = eng ?? ""; }
    }
}

decimal ParseTeneur(string s)
{
    if (s == null) return 0m;
    s = s.Replace("traces", "0").Replace("<", " ").Replace("-", "0").Replace(",", ".").Trim();   // Ciqual : decimale FR + qualificatifs
    return decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : 0m;
}
var alimCompo = new Dictionary<string, decimal[]>();          // alim_code -> teneurs sur les constituants choisis
using (var rd = XmlReader.Create(Path.Combine(BASE, "Ciqual/compo_2025_11_03.xml")))
{
    string ac = null, cc = null, te = null, field = null;
    while (rd.Read())
    {
        if (rd.NodeType == XmlNodeType.Element) { if (rd.Name == "COMPO") { ac = cc = te = null; } field = rd.Name; }
        else if (rd.NodeType == XmlNodeType.Text)
        {
            if (field == "alim_code") ac = rd.Value.Trim();
            else if (field == "const_code") cc = rd.Value.Trim();
            else if (field == "teneur") te = rd.Value;
        }
        else if (rd.NodeType == XmlNodeType.EndElement && rd.Name == "COMPO")
        {
            if (cc != null && ac != null && codeIdx.ContainsKey(cc))
            {
                if (!alimCompo.TryGetValue(ac, out var v)) { v = new decimal[C]; alimCompo[ac] = v; }
                v[codeIdx[cc]] = ParseTeneur(te);
            }
        }
    }
}
Console.WriteLine($"Ciqual charge en {sw.Elapsed.TotalSeconds:F1}s : {consts.Count} constituants, {alimEng.Count} aliments, {alimCompo.Count} avec composition.");
Console.WriteLine($"Constituants contraints (C={C}) :");
foreach (var cc in chosenCodes) Console.WriteLine($"   [{cc}] {consts[cc].Substring(0, Math.Min(48, consts[cc].Length))}");
Ciqual charge en 1,5s : 74 constituants, 3484 aliments, 3484 avec composition.
Constituants contraints (C=5) :
   [327] Energie, Règlement UE N° 1169/2011 (kJ/100 g)
   [25000] Protéines, N x facteur de Jones (g/100 g)
   [31000] Glucides (g/100 g)
   [40000] Lipides (g/100 g)
   [10004] Sel chlorure de sodium (g/100 g)

3. La jointure de data fusion : ingrédient anglais → aliment Ciqual

RecipeML est en anglais ; on matche donc contre alim_nom_eng (100 % renseigné en 2025), pas alim_nom_fr — même langue des deux côtés, la leçon de rapprochement survit sans pénalité de traduction. Un index inversé mot → aliments accélère la recherche : chaque ingrédient n’est comparé qu’aux aliments partageant au moins un mot (au lieu des ~3500). Sans cet index, la jointure serait quadratique.

La normalisation étagée est la leçon — et la curation tue les faux positifs. Un sac-de-mots positionnel naïf apparie « White sugar » → « White pudding » (les deux commencent par « white ») : faux positif classique. La correction, ci-dessous, est une petite pile de règles métier explicables :

  1. déaccentuation + singularisation grossière + suppression des mots de préparation/qualificatifs (chopped, large, fresh…) ;
  2. aliment primaire = ce qui précède la 1re virgule côté Ciqual (Sugar, white → tête = sugar) ;
  3. garde sur la tête-de-nom : l’aliment candidat doit partager le dernier mot de l’ingrédient (le nom, pas l’adjectif) — c’est ce qui écarte « White pudding » pour « white sugar » ;
  4. similarité de Jaccard (recouvrement d’ensembles, seuil 0,5), départage par nom Ciqual le plus court (le plus primaire) ;
  5. petites tables de synonymes (catsup→ketchup, baking soda→sodium bicarbonate), de composés (salt pepper→ deux aliments) et de modificateurs de forme (chili powder→chili).

Ce qui subsiste (rappel manquant, synonymes non couverts) est exactement la matière des exercices. Mesuré sur l’archive : couverture par occurrence ~72 % sans faux positif (contre ~79 % naïf mais truffé de faux positifs).

// ---- rapprochement lexical CURÉ : normalisation étagée + index inversé + garde tête-de-nom ----
// Un sac-de-mots positionnel naïf matcherait « White sugar » -> « White pudding » (les deux
// commencent par « white ») : faux positif. La CURATION ci-dessous -- déaccentuation, aliment
// primaire (avant la 1re virgule côté Ciqual), garde sur la tête-de-nom, préférence au nom le
// plus court, petites tables de synonymes / composés / modificateurs -- élimine ces faux positifs.

// mots à retirer (préparation, qualificatifs, mots vides) : bruit qui masque l'aliment
var DROP = new HashSet<string>(
    ("chopped ground grated minced sliced diced melted softened packed sifted beaten cooked raw " +
     "peeled seeded finely coarsely halved quartered cubed shredded crushed crumbled drained rinsed " +
     "lightly thinly thickly cut divided fresh dried frozen canned chilled warmed uncooked prepared " +
     "unbaked mashed toasted roasted boiled steamed blanched julienned trimmed cored pitted stemmed deveined " +
     "large small medium extra whole ripe boneless skinless unsalted salted lean heavy light dark " +
     "granulated powdered confectioners all purpose self rising active instant fine coarse " +
     "of to for the with and or into pieces piece plus taste needed garnish optional about each more " +
     "as in at room temperature degrees inch thick thin long your favorite good quality such other any some few several")
    .Split(' ', StringSplitOptions.RemoveEmptyEntries));

// synonymes US/culinaire -> vocabulaire Ciqual (clé = item normalisé complet) : la couche « métier »
var SYN = new Dictionary<string, string> {
    ["catsup"] = "ketchup", ["scallion"] = "green onion", ["cilantro"] = "coriander",
    ["cornstarch"] = "corn starch", ["shortening"] = "vegetable fat", ["baking soda"] = "sodium bicarbonate",
    ["confectioner sugar"] = "icing sugar", ["powdered sugar"] = "icing sugar", ["bell pepper"] = "sweet pepper",
    ["heavy cream"] = "cream", ["whipping cream"] = "cream", ["vanilla extract"] = "vanilla",
    ["lemon rind"] = "lemon zest", ["lemon peel"] = "lemon zest",
};
// composés = deux aliments joints par un « and/or » tombé (chacun matché séparément, puis on garde le 1er)
var COMPOUND = new Dictionary<string, string[]> {
    ["salt pepper"] = new[] { "salt", "pepper" }, ["butter margarine"] = new[] { "butter", "margarine" },
    ["salt black pepper"] = new[] { "salt", "black pepper" }, ["oil butter"] = new[] { "oil", "butter" },
};

string Deaccent(string s) {
    var n = s.Normalize(NormalizationForm.FormD);
    var sb = new StringBuilder();
    foreach (var ch in n) if (CharUnicodeInfo.GetUnicodeCategory(ch) != UnicodeCategory.NonSpacingMark) sb.Append(ch);
    return sb.ToString();
}
string Singular(string t) {
    if (t.EndsWith("ies") && t.Length > 4) return t.Substring(0, t.Length - 3) + "y";
    if (t.EndsWith("oes") && t.Length > 4) return t.Substring(0, t.Length - 2);
    if (t.EndsWith("s") && !t.EndsWith("ss") && t.Length > 3) return t.Substring(0, t.Length - 1);
    return t;
}
List<string> Norm(string s) {
    s = Deaccent((s ?? "").ToLowerInvariant());
    s = Regex.Replace(s, @"\([^)]*\)", " ");                 // retire les parenthèses : (19-oz)
    s = s.Split(',')[0];                                     // aliment primaire = avant la 1re virgule
    s = Regex.Replace(s, "[^a-z\\s-]", " ").Replace("-", " ");
    var o = new List<string>();
    foreach (var w in s.Split(' ', StringSplitOptions.RemoveEmptyEntries)) {
        if (w.Length <= 1 || DROP.Contains(w)) continue;
        var t = Singular(w);
        if (t.Length > 0) o.Add(t);
    }
    return o;
}

// index Ciqual : (code, jeu de tokens, tête-de-nom, premier mot) sur le nom EN
var ents = alimCompo.Keys.Where(k => alimEng.ContainsKey(k) && alimEng[k].Length > 0)
    .Select(k => { var t = Norm(alimEng[k]); return (code: k, toks: new HashSet<string>(t),
                   head: t.Count > 0 ? t[t.Count - 1] : "", first: t.Count > 0 ? t[0] : ""); })
    .Where(e => e.toks.Count > 0).ToList();
var byTok = new Dictionary<string, List<int>>();             // mot -> indices d'aliments (index inversé)
for (int e = 0; e < ents.Count; e++)
    foreach (var t in ents[e].toks) {
        if (!byTok.TryGetValue(t, out var l)) { l = new List<int>(); byTok[t] = l; }
        l.Add(e);
    }
double Jacc(HashSet<string> a, HashSet<string> b) {
    if (a.Count == 0 || b.Count == 0) return 0;
    int inter = a.Count(x => b.Contains(x));
    return (double)inter / (a.Count + b.Count - inter);
}

// coeur : clé normalisée -> code Ciqual (garde tête-de-nom, rang jaccard puis nom le plus court, seuil 0.5)
string MatchKey(string key) {
    if (SYN.TryGetValue(key, out var syn)) key = string.Join(" ", Norm(syn));
    var toks = key.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList();
    if (toks.Count > 1 && (toks[toks.Count - 1] == "powder" || toks[toks.Count - 1] == "extract"
        || toks[toks.Count - 1] == "root") && key != "baking powder")
        toks = toks.Take(toks.Count - 1).ToList();           // retire un modificateur de forme final
    if (toks.Count == 0) return null;
    var it = new HashSet<string>(toks);
    var head = toks[toks.Count - 1];
    var cand = new HashSet<int>();
    foreach (var t in it) if (byTok.TryGetValue(t, out var l)) foreach (var e in l) cand.Add(e);
    string best = null; double bj = 0; int bfb = 0, bnl = 0; bool has = false;
    foreach (var e in cand) {
        if (!ents[e].toks.Contains(head)) continue;          // garde : l'aliment partage la tête-de-nom
        double j = Jacc(it, ents[e].toks);
        if (j < 0.5) continue;
        int fb = ents[e].first == head ? 1 : 0;              // bonus : l'aliment Ciqual COMMENCE par la tête
        int nl = -ents[e].toks.Count;                        // préférer le nom le plus court (plus primaire)
        if (!has || j > bj + 1e-9 || (Math.Abs(j - bj) < 1e-9 && (fb > bfb || (fb == bfb && nl > bnl)))) {
            bj = j; bfb = fb; bnl = nl; best = ents[e].code; has = true;
        }
    }
    return best;
}
var matchCache = new Dictionary<string, string>();
string Match(string item) {                                  // surface publique : item brut -> code Ciqual
    var key = string.Join(" ", Norm(item.Split(';')[0]));
    if (key.Length == 0) return null;
    if (matchCache.TryGetValue(key, out var c)) return c;
    string code;
    if (COMPOUND.TryGetValue(key, out var parts)) code = MatchKey(string.Join(" ", Norm(parts[0]))); // composé : 1er aliment
    else code = MatchKey(key);
    matchCache[key] = code;
    return code;
}

Console.WriteLine("Demonstration du matcheur cure sur quelques items RecipeML :");
foreach (var t in new[] { "White sugar", "All-purpose flour", "Baking soda", "Eggs", "Butter", "Olive oil", "Salt pepper" })
{
    var mcode = Match(t);
    Console.WriteLine($"   '{t,-18}' -> {(mcode != null ? alimEng[mcode] : "(aucun)")}");
}
Demonstration du matcheur cure sur quelques items RecipeML :
   'White sugar       ' -> Sugar, white
   'All-purpose flour ' -> Wheat flour, type 110
   'Baking soda       ' -> Sodium bicarbonate
   'Eggs              ' -> Egg, raw
   'Butter            ' -> Butter, 80% fat minimum, unsalted
   'Olive oil         ' -> Olive oil, extra virgin
   'Salt pepper       ' -> Salt, white (sea, igneous or rock), no fortification

4. RecipeML : parsing structuré avec quantités

Chaque <recipe> porte des <ing> de forme <amt><qty>…</qty><unit>…</unit></amt><item>…</item>. On extrait le triplet (item, quantité, unité) — c’est la différence décisive avec l’ébauche 09, qui ne lisait que <item> et ignorait la masse.

ParseQty gère les entiers, les fractions (1/2), les quantités mixtes (1 1/2), les fractions Unicode (½) et prend la borne basse d’une fourchette (2-3 → 2). Le parsing XML est tolérant (certains fichiers ont des & nus) : on retombe sur un remplacement & → &amp; avant d’abandonner un fichier.

// ---- ParseQty : quantite textuelle -> nombre (fractions, mixtes, unicode, fourchettes) ----
double ParseQty(string s)
{
    if (string.IsNullOrWhiteSpace(s)) return 0;
    s = s.Trim().Replace("½", "1/2").Replace("¼", "1/4").Replace("¾", "3/4")
         .Replace("⅓", "1/3").Replace("⅔", "2/3");
    int dash = s.IndexOf('-'); if (dash > 0) s = s.Substring(0, dash);           // fourchette -> borne basse
    double total = 0; bool any = false;
    foreach (var tok in s.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries))
    {
        if (tok.Contains('/'))
        {
            var pr = tok.Split('/');
            if (pr.Length == 2 && double.TryParse(pr[0], NumberStyles.Any, CultureInfo.InvariantCulture, out var a)
                && double.TryParse(pr[1], NumberStyles.Any, CultureInfo.InvariantCulture, out var b) && b != 0)
            { total += a / b; any = true; }
        }
        else if (double.TryParse(tok, NumberStyles.Any, CultureInfo.InvariantCulture, out var d)) { total += d; any = true; }
    }
    return any ? total : 0;
}

// ---- parsing de l'archive : liste (item, qty, unit) par recette ----
var recipes = new List<(string title, List<string> cats, List<(string item, double qty, string unit)> ings)>();
int skipped = 0, nIng = 0, nQty = 0, nUnit = 0;
foreach (var f in Directory.GetFiles(Path.Combine(BASE, "RecipeML"), "*.xml", SearchOption.AllDirectories))
{
    System.Xml.Linq.XDocument doc;
    try { doc = System.Xml.Linq.XDocument.Load(f); }
    catch { try { doc = System.Xml.Linq.XDocument.Parse(File.ReadAllText(f).Replace("&", "&amp;")); } catch { skipped++; continue; } }
    var title = doc.Descendants("title").FirstOrDefault(x => x.Value.Trim().Length > 0)?.Value.Trim()
                ?? Path.GetFileNameWithoutExtension(f);
    var cats = doc.Descendants("cat").Select(x => x.Value.Trim()).Where(x => x.Length > 0).ToList();
    var ings = new List<(string, double, string)>();
    foreach (var ing in doc.Descendants("ing"))
    {
        var item = ing.Element("item")?.Value.Trim();
        if (string.IsNullOrEmpty(item)) continue;
        nIng++;
        double qty = 0; string unit = "";
        var amt = ing.Element("amt");
        if (amt != null)
        {
            qty = ParseQty(amt.Element("qty")?.Value);
            unit = (amt.Element("unit")?.Value ?? "").Trim().ToLowerInvariant();
        }
        if (qty > 0) nQty++;
        if (unit.Length > 0) nUnit++;
        ings.Add((item, qty, unit));
    }
    if (ings.Count > 0) recipes.Add((title.Length > 44 ? title.Substring(0, 44) : title, cats, ings));
}
Console.WriteLine($"RecipeML : {recipes.Count} recettes, {nIng} ingredients " +
    $"({100.0 * nQty / Math.Max(1, nIng):F0}% avec quantite, {100.0 * nUnit / Math.Max(1, nIng):F0}% avec unite), " +
    $"{skipped} fichiers XML irrecuperables ignores.");
Console.WriteLine("Exemple de recette (item | qty | unite) :");
foreach (var (it, q, u) in recipes[0].ings.Take(5)) Console.WriteLine($"   {it,-38} | {q,5} | {u}");
RecipeML : 8286 recettes, 76289 ingredients (90% avec quantite, 77% avec unite), 27 fichiers XML irrecuperables ignores.
Exemple de recette (item | qty | unite) :
   (19-oz) crushed pineapple with juice   |     1 | can
   White sugar                            |     2 | cups
   Eggs                                   |     2 | 
   Flour                                  |     2 | cups
   Baking soda                            |     2 | teaspoons

5. Normalisation quantité → grammes (le show-stopper résolu)

Ciqual ne fournit pas de densité (74 constituants = tous des teneurs par 100 g). Le passage (qté, unité, ingrédient) → grammes se fait en deux couches honnêtes :

  1. unité → millilitres (volumes) ou grammes (masses) : une petite table fixe (cup = 236,6 mL, tablespoon = 14,8 mL, teaspoon = 4,93 mL, ounce = 28,35 g, pound = 453,6 g…).
  2. millilitres → grammes : une petite table de densités curée par mot-clé d’ingrédient (farine ≈ 0,53, sucre ≈ 0,85, huile ≈ 0,92, miel ≈ 1,42, eau/aqueux = 1,0 par défaut).
  3. items comptés (unité vide : 2 Eggs, 3 Bananas) → une table de poids moyen par pièce par mot-clé (œuf ≈ 50 g, banane ≈ 120 g, gousse ≈ 5 g). Indispensable sur un corpus pâtissier où les œufs abondent.

Les unités de conditionnement restantes (can, package, slice…) n’ont pas de conversion fiable → masse non calculée. C’est une étape de préprocessing — et une bonne leçon de data-engineering — pas un blocage : un ingrédient apparié mais non pesable contribue 0 à la nutrition, honnêtement et visiblement dans les métriques (cf. exercice 2 pour élargir la couverture).

// ---- unite -> facteur (mL pour volumes, g pour masses) ; absente => non convertible ----
var UNIT = new Dictionary<string, (double factor, bool isMass)>
{
    ["cup"] = (236.588, false), ["cups"] = (236.588, false),
    ["tablespoon"] = (14.7868, false), ["tablespoons"] = (14.7868, false), ["tbsp"] = (14.7868, false), ["tbs"] = (14.7868, false),
    ["teaspoon"] = (4.92892, false), ["teaspoons"] = (4.92892, false), ["tsp"] = (4.92892, false),
    ["ounce"] = (28.3495, true), ["ounces"] = (28.3495, true), ["oz"] = (28.3495, true),
    ["pound"] = (453.592, true), ["pounds"] = (453.592, true), ["lb"] = (453.592, true), ["lbs"] = (453.592, true),
    ["gram"] = (1, true), ["grams"] = (1, true), ["g"] = (1, true), ["kg"] = (1000, true), ["kilogram"] = (1000, true),
    ["ml"] = (1, false), ["milliliter"] = (1, false), ["millilitre"] = (1, false),
    ["l"] = (1000, false), ["liter"] = (1000, false), ["litre"] = (1000, false),
    ["pint"] = (473.176, false), ["pt"] = (473.176, false), ["quart"] = (946.353, false), ["qt"] = (946.353, false),
    ["gallon"] = (3785.41, false), ["gal"] = (3785.41, false),
    ["pinch"] = (0.36, true), ["dash"] = (0.6, false), ["stick"] = (113.0, true), ["sticks"] = (113.0, true),
};
// densite (g/mL) par mot-cle present dans l'item ; defaut 1.0 (eau/aqueux)
var DENS = new (string kw, double d)[]
{
    ("flour", 0.53), ("sugar", 0.85), ("oil", 0.92), ("butter", 0.911), ("honey", 1.42), ("syrup", 1.33),
    ("milk", 1.03), ("cream", 1.01), ("water", 1.0), ("salt", 1.22), ("rice", 0.85), ("cocoa", 0.52),
    ("cornstarch", 0.54), ("oats", 0.41),
};
double Density(string itemLower) { foreach (var (kw, d) in DENS) if (itemLower.Contains(kw)) return d; return 1.0; }
// items COMPTES (unite vide : "2 Eggs", "3 Bananas") -> poids moyen par piece, par mot-cle de tete
var COUNT = new (string kw, double g)[]
{
    ("egg", 50), ("banana", 120), ("apple", 180), ("onion", 110), ("clove", 5), ("carrot", 60),
    ("potato", 150), ("tomato", 120), ("lemon", 60), ("lime", 45), ("orange", 130), ("garlic", 5),
    ("shallot", 30), ("scallion", 15), ("pepper", 120),
};
double CountWeight(string itemLower) { foreach (var (kw, g) in COUNT) if (itemLower.Contains(kw)) return g; return 0; }

// (qty, unite, item) -> grammes ; unite vide => item compte (table COUNT) ; unite inconnue => 0
double ToGrams(double qty, string unit, string itemLower)
{
    if (qty <= 0) return 0;
    if (unit.Length == 0) { double cw = CountWeight(itemLower); return cw > 0 ? qty * cw : 0; }  // "2 Eggs" -> 100 g
    if (!UNIT.TryGetValue(unit, out var u)) return 0;                                             // "1 can" -> non convertible
    return u.isMass ? qty * u.factor : qty * u.factor * Density(itemLower);
}

// demonstration
foreach (var (q, u, it) in new[] { (2.0, "cups", "white sugar"), (2.0, "cups", "flour"),
    (1.0, "teaspoon", "vanilla"), (4.0, "ounces", "butter"), (2.0, "", "eggs"), (1.0, "can", "crushed pineapple") })
    Console.WriteLine($"   {q} {(u.Length == 0 ? "(compte)" : u),-10} {it,-18} -> {ToGrams(q, u, it):F1} g" +
        (ToGrams(q, u, it) == 0 ? "   (non convertible)" : ""));
   2 cups       white sugar        -> 402,2 g
   2 cups       flour              -> 250,8 g
   1 teaspoon   vanilla            -> 4,9 g
   4 ounces     butter             -> 113,4 g
   2 (compte)   eggs               -> 100,0 g
   1 can        crushed pineapple  -> 0,0 g   (non convertible)

6. Agrégation nutritionnelle pondérée par la masse + couverture par recette

Pour chaque recette : on apparie chaque item à Ciqual (section 3), on convertit sa quantité en grammes (section 5), et on ajoute sa contribution teneur_per_100g × grammes / 100. C’est la correction du biais quantity-blind : un gâteau avec 2 tasses de farine (~250 g) ne compte plus comme 100 g.

On mesure aussi la couverture d’appariement de chaque recette = fraction de ses ingrédients appariés à Ciqual (indépendante des quantités : c’est la qualité des noms). Cette couverture est le critère de gating (section 7).

// ---- agregation ponderee + couverture par recette ----
var built = new List<(string title, List<string> cats, decimal[] vec, double cover, int nMatch, int nItems)>();
var mc = new Dictionary<string, string>();   // item -> code Ciqual (cache d'appariement)
foreach (var (title, cats, ings) in recipes)
{
    var vec = new decimal[C];
    int nMatch = 0;
    foreach (var (item, qty, unit) in ings)
    {
        if (!mc.TryGetValue(item, out var acode)) { acode = Match(item); mc[item] = acode; }
        if (acode == null) continue;
        nMatch++;
        if (!alimCompo.TryGetValue(acode, out var cv)) continue;
        double grams = ToGrams(qty, unit, item.ToLowerInvariant());
        if (grams <= 0) continue;                                  // apparie mais non pesable -> 0 nutrition
        for (int k = 0; k < C; k++) vec[k] += cv[k] * (decimal)(grams / 100.0);
    }
    double cover = ings.Count > 0 ? (double)nMatch / ings.Count : 0;
    built.Add((title, cats, vec, cover, nMatch, ings.Count));
}
int N = built.Count;
int b100 = 0, b90 = 0, b80 = 0, b50 = 0, blo = 0; double covSum = 0;
foreach (var r in built)
{
    covSum += r.cover;
    if (r.cover >= 1.0) b100++; else if (r.cover >= 0.9) b90++; else if (r.cover >= 0.8) b80++;
    else if (r.cover >= 0.5) b50++; else blo++;
}
Console.WriteLine($"Agregation : {N} recettes, couverture moyenne d'appariement = {100.0 * covSum / N:F1}%");
Console.WriteLine($"   couverture 100%           : {b100,5} recettes");
Console.WriteLine($"   couverture >=80% (solveur): {b100 + b90 + b80,5} recettes");
Console.WriteLine($"   couverture >=50%          : {b100 + b90 + b80 + b50,5} recettes");
Console.WriteLine($"   couverture  <50%          : {blo,5} recettes");
Console.WriteLine("Echantillon 100% apparie (energie kJ, prot g, gluc g, lip g, sel g -- PONDERE par la masse) :");
foreach (var r in built.Where(x => x.cover >= 1.0 && x.vec.Any(v => v > 0)).Take(4))
    Console.WriteLine($"   {r.title,-46} [{string.Join(", ", r.vec.Select(x => Math.Round(x, 1)))}]");
Agregation : 8286 recettes, couverture moyenne d'appariement = 73,3%
   couverture 100%           :  1133 recettes
   couverture >=80% (solveur):  3756 recettes
   couverture >=50%          :  7528 recettes
   couverture  <50%          :   758 recettes
Echantillon 100% apparie (energie kJ, prot g, gluc g, lip g, sel g -- PONDERE par la masse) :
   #10 Cake                                       [25886,9, 69,1, 857,5, 262,3, 1,0]
   #1 Lemon Bars                                  [11420,5, 39,7, 581,1, 18,1, 0,8]
   1,2,3,4 Cake                                   [14159,5, 69,8, 673,2, 34,2, 1,4]
   1-1-1 Cookies                                  [9464,4, 54,2, 237,8, 115,7, 2,5]

7. Sous-ensembles gatés par qualité + cache JSON

Le levier de montée en charge piloté par la donnée : plutôt qu’un plafond arbitraire (10 → 100 → 1000), on livre au solveur des sous-ensembles gatés par la qualité d’appariement. Le sous-ensemble solveur-usable (≥ 80 % des ingrédients appariés) est sérialisé en cache mealplan_cache.json — la matière première propre que les notebooks de modélisation (08 capstone, 09 échelle) consommeront, sans re-parser Ciqual ni ré-apparier.

Le cache vit sous data/meals/ (gitignore) : il est régénéré à l’exécution, jamais commité.

// ---- cache JSON du sous-ensemble solveur-usable (>=80% apparie) ----
var usable = built.Where(r => r.cover >= 0.8 && r.vec.Any(v => v > 0))
                  .Select(r => new Dictionary<string, object>
                  {
                      ["title"] = r.title,
                      ["cats"] = r.cats,
                      ["cover"] = Math.Round(r.cover, 3),
                      ["vec"] = r.vec.Select(x => Math.Round(x, 2)).ToArray()
                  }).ToList();
var cachePath = Path.Combine(BASE, "mealplan_cache.json");
var payload = new Dictionary<string, object>
{
    ["constituants"] = chosenCodes.Select(c => consts[c]).ToArray(),
    ["n_total"] = N,
    ["n_usable"] = usable.Count,
    ["recipes"] = usable
};
File.WriteAllText(cachePath, JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = false }));
Console.WriteLine($"Cache ecrit : {Path.GetFileName(cachePath)} -- {usable.Count} recettes solveur-usables, " +
    $"{new FileInfo(cachePath).Length / 1024} Ko.");
Console.WriteLine("Palier de montee en charge (pilote par la qualite, non par un plafond arbitraire) :");
Console.WriteLine($"   subset propre 100%   : {built.Count(r => r.cover >= 1.0 && r.vec.Any(v => v > 0)),5} recettes");
Console.WriteLine($"   subset >=80% (cache) : {usable.Count,5} recettes  <- livre au solveur");
Console.WriteLine($"   subset >=50%         : {built.Count(r => r.cover >= 0.5 && r.vec.Any(v => v > 0)),5} recettes");
Console.WriteLine("Ce cache est la couche de donnees consommee par les notebooks 08 (capstone) et 09 (echelle).");
Cache ecrit : mealplan_cache.json -- 3717 recettes solveur-usables, 418 Ko.
Palier de montee en charge (pilote par la qualite, non par un plafond arbitraire) :
   subset propre 100%   :  1107 recettes
   subset >=80% (cache) :  3717 recettes  <- livre au solveur
   subset >=50%         :  7405 recettes
Ce cache est la couche de donnees consommee par les notebooks 08 (capstone) et 09 (echelle).

8. Exercices

Trois exercices de data-engineering (toujours 0 Z3). Ils prolongent directement les limites mesurées ci-dessus : rappel du matcheur, couverture des unités, agrégation par catégorie.

Exercice 1 — pousser le matcheur plus loin (synonymes non couverts)

Le matcheur curé embarque déjà une table de synonymes de départ (SYN : catsup→ketchup, baking soda→sodium bicarbonate…). Mais la liste des items non appariés fréquents (à afficher : trier mc / recipes par occurrence et lister ceux sans code) révèle d’autres trous : buttermilk, sherry, molasses, crisco, half and half, graham cracker… Étendre la table SYN (les rediriger vers un aliment Ciqual proche), re-appliquer Match, et re-mesurer la couverture moyenne d’appariement (section 6) pour quantifier le gain.

// Exercice 1 : pousser le matcheur plus loin en couvrant des synonymes qui manquent.
// Indice : reperer d'abord les items non apparies frequents, p.ex.
//          recipes.SelectMany(r => r.ings.Select(i => i.item)).GroupBy(x => x)
//                 .Where(g => Match(g.Key) == null).OrderByDescending(g => g.Count()).Take(20)
//          puis ajouter des entrees a une COPIE de SYN et recomparer la couverture.
// TODO etudiant : completer la table et re-mesurer la couverture avant/apres.
var synonymesSup = new Dictionary<string, string>(); // ex : { ["buttermilk"]="fermented milk", ["molasses"]="cane syrup" }

if (synonymesSup.Count == 0)
    Console.WriteLine("Exercice 1 a completer : couvrir des synonymes manquants puis re-mesurer la couverture d'appariement.");
else
    Console.WriteLine($"Table de {synonymesSup.Count} synonymes supplementaires prete -- reappliquer Match() et comparer.");
Exercice 1 a completer : couvrir des synonymes manquants puis re-mesurer la couverture d'appariement.

Exercice 2 — étendre la couverture des unités

Beaucoup d’ingrédients restent non pesables parce que leur unité (can, package, clove, slice, bunch) n’est pas dans la table UNIT. Ajouter des conversions plausibles (clove d’ail ≈ 5 g, slice de pain ≈ 30 g, stick de beurre = 113 g déjà présent), puis compter combien d’ingrédients gagnent une masse non nulle. Attention : certaines conversions dépendent de l’ingrédient (une « tranche » de pain ≠ une « tranche » de fromage) — documenter l’hypothèse.

// Exercice 2 : etendre la table d'unites pour rendre pesables plus d'ingredients.
// Indice : ajouter des entrees a une copie de UNIT (clove ~5 g, slice ~30 g...) ;
//          re-parcourir `recipes` et compter les ToGrams(...) > 0 avant/apres.
// TODO etudiant : completer les unites supplementaires et recompter.
var unitesSup = new Dictionary<string, (double factor, bool isMass)>(); // a completer

if (unitesSup.Count == 0)
    Console.WriteLine("Exercice 2 a completer : ajouter des unites (clove, slice, bunch...) et recompter les ingredients pesables.");
else
    Console.WriteLine($"{unitesSup.Count} unites supplementaires definies -- recompter les ingredients pesables.");
Exercice 2 a completer : ajouter des unites (clove, slice, bunch...) et recompter les ingredients pesables.

Exercice 3 — profil nutritionnel moyen par catégorie

Sur le sous-ensemble solveur-usable (usable ou built filtré à cover ≥ 0.8), regrouper par catégorie RecipeML (cats, ex. Cake, Bread, Soup) et calculer le vecteur nutritionnel moyen de chaque catégorie. Cela révèle la structure du corpus (les desserts ressortent-ils comme attendu ?) — utile pour équilibrer un menu au notebook de modélisation.

// Exercice 3 : profil nutritionnel moyen par categorie RecipeML.
// Indice : built.Where(r => r.cover >= 0.8).SelectMany(r => r.cats.Select(c => (c, r.vec)))
//          .GroupBy(x => x.c) puis moyenne composant par composant.
// TODO etudiant : completer l'agregation par categorie.
System.Collections.IEnumerable profilParCategorie = null; // remplacer par le GroupBy/Select

if (profilParCategorie is null)
    Console.WriteLine("Exercice 3 a completer : vecteur nutritionnel moyen par categorie.");
else
    foreach (var row in profilParCategorie) Console.WriteLine(row);
Exercice 3 a completer : vecteur nutritionnel moyen par categorie.

Conclusion — la couche de données du bloc meal-planner

Ce notebook construit la couche de données propre du planificateur de repas, sans jamais appeler de solveur (0 Z3) :

  • Ciqual × RecipeML fusionnés par appariement lexical flou (aucune clé commune) — la normalisation étagée est la leçon, ses limites sont les exercices.
  • Quantités normalisées en grammes (unités culinaires → mL → g via densités) — ce qui rend l’agrégation nutritionnelle pondérée par la masse enfin honnête, là où sommer les teneurs per-100g brutes était fictif.
  • Sous-ensembles gatés par qualité d’appariement (100 % / ≥80 % / ≥50 %) sérialisés en cache — le levier de montée en charge piloté par la donnée (des données propres de taille croissante, pas un plafond arbitraire).

La leçon transverse est celle du data-engineering : les données réelles arrivent hétérogènes (référentiel per-100g, corpus culinaire impérial) et ne se joignent pas proprement ; il faut apparier, normaliser, et mesurer la qualité avant de livrer quoi que ce soit à un solveur. Ces structures typées sont la matière première des notebooks suivants du bloc meal-planner :

  • Modélisation déclarative (06) — index/booléens, MkITE, exclusion d’allergène, théorème hiérarchique ;
  • Capstone patient (08) — théorème 4 niveaux + restrictions Min/Max ;
  • Convergence à l’échelle (09) — garder Z3 tractable quand le sous-ensemble grandit.
Retour au sommet