Z3-Python-16b — Meal-Planner : couche de données réelles (Ciqual × RecipeML)

Compagnon « data layer » du module Z3-Python-16 — Meal-Planner (strate SMT, jambe Python-large du planificateur #1206).

Le notebook 16 posait la modélisation (3 encodages, Optimize, matrice jours × plats) sur un corpus jouet (24 plats saisis à la main, la nutrition = un scalaire par plat). Ce notebook 16b est la couche de données qui manquait : il charge un corpus réel (Ciqual ANSES 2025 × archive RecipeML), le fusionne par appariement lexical flou, normalise les quantités en grammes, et agrège la nutrition pondérée par la masse — puis sérialise un cache propre que les notebooks de capstone consommeront. 0 solveur : pur data-engineering.

Port fidèle du C# 07_Meal_Planner_Data_External (Z3-Linq2Z3). La logique (normalisation étagée, garde tête-de-nom, densités, gating) est transposée byte-pour-byte ; seule la plomberie change : XmlReader → xml.etree.ElementTree.iterparse, LINQ → Python natif. Les valeurs committées sont recomputées à l’exécution (anti-drift).

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

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.

Échelle démonstrative. Ce notebook charge un sous-ensemble du corpus (Ciqual complet + 5 lots RecipeML ≈ 500 recettes) pour valider le pipeline bout-en-bout en quelques secondes. Le taux de couverture d’appariement (74,1 %) est reproductible et constitue la métrique cross-stack (il matche le port C# à 73,1 %) ; le compte absolu d’utilisables (712) dépend de la taille du sous-ensemble. Le passage à l’échelle sur le corpus plein (~11 000 recettes, ~2 400 utilisables) est l’objet du grain convergence-scale (G3) : augmenter --recipeml-limit dans la cellule de setup.

Suite de la couche de données : → 16c — Meal Planner Patient Capstone

# Setup : imports + données (téléchargées si absentes, via le downloader partagé).
import sys, json, re, unicodedata
from pathlib import Path
from xml.etree import ElementTree as ET

def _meal_base():
    """Ancrage CWD-independant : le corpus vit dans Z3-API/data/meals/ (mirror Python du pin C# #8901)."""
    cwd = Path.cwd().resolve()
    if cwd.name == "Z3-API":
        return cwd / "data" / "meals"
    for _anc in (cwd, *cwd.parents):
        _c = _anc / "MyIA.AI.Notebooks" / "SymbolicAI" / "SMT" / "Z3-API" / "data" / "meals"
        if _c.exists():
            return _c
    raise FileNotFoundError("Serie Z3-API introuvable depuis " + str(cwd))

BASE = _meal_base()
# --- truststore : cert store OS natif (proxy SSL corporate SOTA, on ne DESACTIVE pas la vérif) ---
try:
    import truststore; truststore.inject_into_ssl()
except ImportError:
    pass  # env sans MITM : urllib utilise déjà le default context

RML_BATCHES_PIN = 15  # millesime du pin (--recipeml-limit 15)

def _rml_batches(dest):
    """Batches RecipeML portant des XML : un dossier present ne prouve pas sa completude
    (passe Wayback interrompue = 10/15 batches -- 977 recettes au lieu de 1465)."""
    rml = dest / "RecipeML"
    return sum(1 for d in rml.glob("RecipeMLArchive*") if any(d.glob("*.xml"))) if rml.exists() else 0

if not (BASE / "Ciqual").exists() or _rml_batches(BASE) < RML_BATCHES_PIN:
    print("Donnees absentes ou RecipeML incomplet : appel du downloader partage (subset 15 batches = millesime du pin)...")
    # reuse the shared downloader in-process (avoids touching po-2025's file)
    import importlib.util
    _p = (BASE.parents[2] / "Z3-Linq2Z3" / "download_meal_data.py").resolve()
    _spec = importlib.util.spec_from_file_location("dl", _p)
    _dl = importlib.util.module_from_spec(_spec); _spec.loader.exec_module(_dl)
    _dl.main(["--dest", str(BASE), "--areas", "Ciqual", "RecipeML", "--recipeml-limit", "15"])

nRml = _rml_batches(BASE)
dirs = sorted(p.name for p in BASE.iterdir() if p.is_dir())
print("Donnees presentes :", ", ".join(dirs) if dirs else "(aucune)",
      f"-- RecipeML {nRml}/{RML_BATCHES_PIN} batches")
assert (BASE / "Ciqual").exists(), "Corpus Ciqual absent."
assert nRml >= RML_BATCHES_PIN, (
    f"Corpus RecipeML incomplet : {nRml}/{RML_BATCHES_PIN} batches avec XML -- "
    "Wayback refuse parfois des batches (reprise : relancer, les batches presents sont sautes).")
Donnees presentes : Ciqual, RecipeML -- RecipeML 15/15 batches

1. Ciqual : le référentiel nutritionnel (per-100g), lu en flux

On lit const (5 constituants contraints : énergie, protéines, glucides, lipides, sel), alim (nom anglais) et compo (teneurs) — le gros fichier compo (~66 Mo) est lu en flux (iterparse + clear) pour ne pas tout charger en mémoire. Les teneurs Ciqual sont toutes par 100 g — c’est ce qui rendra la normalisation en grammes (§4) indispensable.

# Ciqual : lecture en flux du sous-ensemble des constituants contraints.
import time
WANTED = ["Energie, Règlement", "Protéines, N x", "Glucides (g", "Lipides (g", "Sel chlorure"]

def _text_stream(path, record_tag, fields):
    """Stream an XML file yielding dicts for each <record_tag> with wanted text fields."""
    fields = set(fields)
    for ev, el in ET.iterparse(path, events=("start", "end")):
        if ev == "start" and el.tag == record_tag:
            cur = {f: None for f in fields}
        elif ev == "end":
            if el.tag in fields:
                cur[el.tag] = (el.text or "").strip()
            elif el.tag == record_tag:
                yield cur
            el.clear()  # free memory

# const_code -> nom FR
consts = {}
for r in _text_stream(BASE / "Ciqual/const_2025_11_03.xml", "CONST", ["const_code", "const_nom_fr"]):
    if r["const_code"]:
        consts[r["const_code"]] = r["const_nom_fr"] or ""
chosen = []
for w in WANTED:
    pref = w[:18]
    code = next(c for c, n in consts.items() if n.startswith(pref))
    chosen.append(code)
codeIdx = {c: i for i, c in enumerate(chosen)}
C = len(chosen)

# alim_code -> nom anglais
alimEng = {}
for r in _text_stream(BASE / "Ciqual/alim_2025_11_03.xml", "ALIM", ["alim_code", "alim_nom_eng"]):
    if r["alim_code"]:
        alimEng[r["alim_code"]] = r["alim_nom_eng"] or ""

def parse_teneur(s):
    if not s:
        return 0.0
    s = s.replace("traces", "0").replace("<", " ").replace("-", "0").replace(",", ".").strip()
    try:
        return float(s)
    except ValueError:
        return 0.0

t0 = time.time()
alimCompo = {}  # alim_code -> [teneurs sur C constituants]
for r in _text_stream(BASE / "Ciqual/compo_2025_11_03.xml", "COMPO", ["alim_code", "const_code", "teneur"]):
    cc, ac = r["const_code"], r["alim_code"]
    if cc in codeIdx and ac:
        alimCompo.setdefault(ac, [0.0] * C)[codeIdx[cc]] = parse_teneur(r["teneur"])
print(f"Ciqual charge en {time.time()-t0:.1f}s : {len(consts)} constituants, "
      f"{len(alimEng)} aliments, {len(alimCompo)} avec composition.")
print(f"Constituants contraints (C={C}) :")
for cc in chosen:
    print(f"   [{cc}] {consts[cc][:48]}")
Ciqual charge en 9.0s : 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)

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

RecipeML est en anglais ; on matche contre alim_nom_eng. Un index inversé mot → aliments accélère la recherche : chaque ingrédient n’est comparé qu’aux aliments partageant au moins un mot. La normalisation étagée est la leçon — et la curation tue les faux positifs :

  1. déaccentuation + singularisation grossière + suppression des mots de préparation/qualificatifs ;
  2. aliment primaire = avant 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 (seuil 0,5), départage par nom Ciqual le plus court ;
  5. petites tables de synonymes / composés / modificateurs de forme.
# Rapprochement lexical CURÉ : normalisation étagée + index inversé + garde tête-de-nom.
# (port verbatim du C# 07 : DROP / SYN / COMPOUND + Deaccent / Singular / Norm / Jacc / MatchKey)
DROP = set(("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())

SYN = {"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"}
COMPOUND = {"salt pepper": ["salt", "pepper"], "butter margarine": ["butter", "margarine"],
    "salt black pepper": ["salt", "black pepper"], "oil butter": ["oil", "butter"]}

def deaccent(s):
    return "".join(ch for ch in unicodedata.normalize("NFKD", s) if not unicodedata.combining(ch))

def singular(t):
    if t.endswith("ies") and len(t) > 4: return t[:-3] + "y"
    if t.endswith("oes") and len(t) > 4: return t[:-2]
    if t.endswith("s") and not t.endswith("ss") and len(t) > 3: return t[:-1]
    return t

def norm(s):
    s = deaccent((s or "").lower())
    s = re.sub(r"\([^)]*\)", " ", s)            # retire les parenthèses
    s = s.split(",")[0]                            # aliment primaire = avant la 1re virgule
    s = re.sub(r"[^a-z\s-]", " ", s).replace("-", " ")
    out = []
    for w in s.split():
        if len(w) <= 1 or w in DROP: continue
        t = singular(w)
        if t: out.append(t)
    return out

# index Ciqual : (code, toks set, head, first)
ents = []
for k in alimCompo:
    if k in alimEng and alimEng[k]:
        t = norm(alimEng[k])
        if t:
            ents.append((k, set(t), t[-1], t[0]))
byTok = {}
for e, (code, toks, head, first) in enumerate(ents):
    for tok in toks:
        byTok.setdefault(tok, []).append(e)

def jacc(a, b):
    if not a or not b: return 0.0
    inter = len(a & b)
    return inter / (len(a) + len(b) - inter)

def match_key(key):
    if key in SYN: key = " ".join(norm(SYN[key]))
    toks = key.split()
    if len(toks) > 1 and toks[-1] in ("powder", "extract", "root") and key != "baking powder":
        toks = toks[:-1]                           # retire un modificateur de forme final
    if not toks: return None
    it = set(toks); head = toks[-1]
    cand = set()
    for tok in it:
        for e in byTok.get(tok, ()): cand.add(e)
    best = None; bj = 0.0; bfb = 0; bnl = 0; has = False
    # ordre deterministe : cand est un set, dont l'iteration depend de PYTHONHASHSEED.
    # Sans tri, le tie-break "premier gagnant" fait matcher 'Bell pepper' sur deux
    # aliments Ciqual differents selon le run (38 items sur 6158 mesures, #17869).
    for e in sorted(cand):
        code, etoks, ehead, efirst = ents[e]
        if head not in etoks: continue             # garde : partage la tête-de-nom
        j = jacc(it, etoks)
        if j < 0.5: continue
        fb = 1 if efirst == head else 0            # bonus : Ciqual COMMENCE par la tête
        nl = -len(etoks)                           # préférer le nom le plus court
        if not has or j > bj + 1e-9 or (abs(j - bj) < 1e-9 and (fb > bfb or (fb == bfb and nl > bnl))):
            bj, bfb, bnl, best, has = j, fb, nl, code, True
    return best

_matchCache = {}
def match(item):
    key = " ".join(norm(item.split(";")[0]))
    if not key: return None
    if key in _matchCache: return _matchCache[key]
    if key in COMPOUND: code = match_key(" ".join(norm(COMPOUND[key][0])))
    else: code = match_key(key)
    _matchCache[key] = code
    return code

print("Démonstration du matcheur curé sur quelques items RecipeML :")
for t in ["White sugar", "All-purpose flour", "Baking soda", "Eggs", "Butter", "Olive oil", "Salt pepper"]:
    mcode = match(t)
    print(f"   {t!r:24} -> {alimEng.get(mcode) if mcode else '(aucun)'}")
Démonstration du matcheur curé 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

3. 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é). ParseQty gère entiers, fractions (1/2), quantités mixtes (1 1/2), fractions Unicode (½) et prend la borne basse d’une fourchette (2-3 → 2). Le parsing XML est tolérant (& nus → &amp;).

# ParseQty + parsing de l'archive RecipeML -> (item, qty, unit) par recette.
FRAC = {"½": "1/2", "¼": "1/4", "¾": "3/4", "⅓": "1/3", "⅔": "2/3"}

def parse_qty(s):
    if not s or not s.strip(): return 0.0
    s = s.strip()
    for u, v in FRAC.items(): s = s.replace(u, v)
    dash = s.find("-")
    if dash > 0: s = s[:dash]                     # fourchette -> borne basse
    total = 0.0; any_ = False
    for tok in s.split():
        if "/" in tok:
            pr = tok.split("/")
            if len(pr) == 2:
                try:
                    a, b = float(pr[0]), float(pr[1])
                    if b != 0: total += a / b; any_ = True
                except ValueError: pass
        else:
            try: total += float(tok); any_ = True
            except ValueError: pass
    return total if any_ else 0.0

recipes = []
skipped = nIng = nQty = nUnit = 0
for f in sorted((BASE / "RecipeML").rglob("*.xml")):
    try:
        doc = ET.parse(f)
    except ET.ParseError:
        try:
            txt = f.read_text(encoding="utf-8", errors="replace").replace("&", "&amp;")
            doc = ET.fromstring(txt)
        except Exception:
            skipped += 1; continue
    root = doc.getroot() if not isinstance(doc, ET.Element) else doc
    title_el = next((e for e in root.iter("title") if (e.text or "").strip()), None)
    title = (title_el.text.strip()[:44] if title_el is not None else f.stem)
    cats = [e.text.strip() for e in root.iter("cat") if (e.text or "").strip()]
    ings = []
    for ing in root.iter("ing"):
        item_el = ing.find("item")
        item = (item_el.text or "").strip() if item_el is not None else ""
        if not item: continue
        nIng += 1
        qty = 0.0; unit = ""
        amt = ing.find("amt")
        if amt is not None:
            qty_el = amt.find("qty")
            qty = parse_qty(qty_el.text) if qty_el is not None else 0.0
            unit_el = amt.find("unit")
            unit = (unit_el.text or "").strip().lower() if unit_el is not None else ""
        if qty > 0: nQty += 1
        if unit: nUnit += 1
        ings.append((item, qty, unit))
    if ings: recipes.append((title, cats, ings))
print(f"RecipeML : {len(recipes)} recettes, {nIng} ingrédients "
      f"({100.0*nQty/max(1,nIng):.0f}% avec quantité, {100.0*nUnit/max(1,nIng):.0f}% avec unité), "
      f"{skipped} fichiers XML irrécupérables ignorés.")
print("Exemple de recette (item | qty | unité) :")
for it, q, u in recipes[0][2][:5]:
    print(f"   {it[:38]:38} | {q:5} | {u}")
RecipeML : 1465 recettes, 13666 ingrédients (91% avec quantité, 78% avec unité), 5 fichiers XML irrécupérables ignorés.
Exemple de recette (item | qty | unité) :
   (19-oz) crushed pineapple with juice   |   1.0 | can
   White sugar                            |   2.0 | cups
   Eggs                                   |   2.0 | 
   Flour                                  |   2.0 | cups
   Baking soda                            |   2.0 | teaspoons

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

Ciqual ne fournit pas de densité. Le passage (qté, unité, ingrédient) → grammes se fait en deux couches honnêtes : (1) unité → mL (volumes) ou g (masses) via une table fixe ; (2) mL → g via une table de densités curée par mot-clé ; (3) items comptés (unité vide : 2 Eggs) → poids moyen par pièce. Les unités de conditionnement (can, package…) restent non convertibles → masse 0 (honnête et visible dans les métriques).

# unite -> mL/g ; densites ; items comptes -> grammes.
UNIT = {"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)}
DENS = [("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)]
COUNT = [("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)]

def density(item_lower):
    for kw, d in DENS:
        if kw in item_lower: return d
    return 1.0
def count_weight(item_lower):
    for kw, g in COUNT:
        if kw in item_lower: return g
    return 0.0
def to_grams(qty, unit, item_lower):
    if qty <= 0: return 0.0
    if not unit:
        cw = count_weight(item_lower)
        return qty * cw if cw > 0 else 0.0
    u = UNIT.get(unit)
    if u is None: return 0.0
    factor, is_mass = u
    return qty * factor if is_mass else qty * factor * density(item_lower)

for q, u, it in [(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")]:
    g = to_grams(q, u, it)
    print(f"   {q} {(u or '(compte)'):10} {it:18} -> {g:.1f} g" + ("   (non convertible)" if g == 0 else ""))
   2.0 cups       white sugar        -> 402.2 g
   2.0 cups       flour              -> 250.8 g
   1.0 teaspoon   vanilla            -> 4.9 g
   4.0 ounces     butter             -> 113.4 g
   2.0 (compte)   eggs               -> 100.0 g
   1.0 can        crushed pineapple  -> 0.0 g   (non convertible)

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

Pour chaque recette : on apparie chaque item à Ciqual, on convertit sa quantité en grammes, 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 (fraction des ingrédients appariés) — le critère de gating.

# Agrégation pondérée par la masse + couverture par recette.
built = []
mc = {}  # item -> code Ciqual (cache d'appariement)
for title, cats, ings in recipes:
    vec = [0.0] * C
    nMatch = 0
    for item, qty, unit in ings:
        acode = mc.get(item)
        if acode is None:
            acode = match(item); mc[item] = acode
        if acode is None: continue
        nMatch += 1
        cv = alimCompo.get(acode)
        if cv is None: continue
        grams = to_grams(qty, unit, item.lower())
        if grams <= 0: continue
        for k in range(C):
            vec[k] += cv[k] * (grams / 100.0)
    cover = nMatch / len(ings) if ings else 0.0
    built.append((title, cats, vec, cover, nMatch, len(ings)))

N = len(built)
b100 = b90 = b80 = b50 = blo = 0; covSum = 0.0
for r in built:
    c = r[3]; covSum += c
    if c >= 1.0: b100 += 1
    elif c >= 0.9: b90 += 1
    elif c >= 0.8: b80 += 1
    elif c >= 0.5: b50 += 1
    else: blo += 1
print(f"Agrégation : {N} recettes, couverture moyenne d'appariement = {100.0*covSum/max(1,N):.1f}%")
print(f"   couverture 100%           : {b100:5} recettes")
print(f"   couverture >=80% (solveur): {b100+b90+b80:5} recettes")
print(f"   couverture >=50%          : {b100+b90+b80+b50:5} recettes")
print(f"   couverture  <50%          : {blo:5} recettes")
print("Échantillon 100% apparié (énergie kJ, prot g, gluc g, lip g, sel g — PONDÉRÉ par la masse) :")
for r in [x for x in built if x[3] >= 1.0 and any(v > 0 for v in x[2])][:4]:
    vec = ", ".join(f"{v:.1f}" for v in r[2])
    print(f"   {r[0][:46]:46} [{vec}]")
Agrégation : 1465 recettes, couverture moyenne d'appariement = 74.1%
   couverture 100%           :   226 recettes
   couverture >=80% (solveur):   716 recettes
   couverture >=50%          :  1338 recettes
   couverture  <50%          :   127 recettes
Échantillon 100% apparié (énergie kJ, prot g, gluc g, lip g, sel g — PONDÉRÉ 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]

6. Sous-ensembles gâtés par qualité + cache JSON (contract assertif)

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 capstone consommeront. Le cache contract (constituants, n_total, n_usable, recipes[].{title,cats,cover,vec}) est asserté : c’est un test différentiel qui garantit que le port Python respecte le même schéma que le cache C# consommé par 08.

Le cache dérivé mealplan_cache.json est épinglé dans git (exception .gitignore, mirror Python du pin C# #8901) : les notebooks 16c/16d/16e le consomment sans réseau ni ré-exécution de la couche de données — re-exécution autonome garantie. Ré-exécuter 16b le régénère (nouveau millésime à committer avec les outputs des consommateurs) ; le corpus brut (~68 Mo) reste gitignoré.

# Cache JSON du sous-ensemble solveur-usable (>=80% apparié) + contract assertif.
usable = [{"title": r[0], "cats": r[1], "cover": round(r[3], 3),
           "vec": [round(v, 2) for v in r[2]]}
          for r in built if r[3] >= 0.8 and any(v > 0 for v in r[2])]
payload = {"constituants": [consts[c] for c in chosen],
           "n_total": N, "n_usable": len(usable), "recipes": usable}
cache_path = BASE / "mealplan_cache.json"
cache_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
size_kb = cache_path.stat().st_size / 1024

# --- contract assertif : le cache respecte le schema attendu par les notebooks C# 08/09 ---
assert isinstance(payload["constituants"], list) and len(payload["constituants"]) == C
assert isinstance(payload["n_total"], int) and payload["n_total"] == N
assert isinstance(payload["n_usable"], int) and payload["n_usable"] == len(usable)
for rec in payload["recipes"][:50]:
    assert set(rec.keys()) == {"title", "cats", "cover", "vec"}
    assert isinstance(rec["cats"], list) and isinstance(rec["vec"], list) and len(rec["vec"]) == C
print(f"Cache écrit : {cache_path.name} -- {len(usable)} recettes solveur-usables, {size_kb:.0f} Ko.")
print("Contract assertif PASS : constituants(C), n_total, n_usable, recipes[].{title,cats,cover,vec} OK.")
print("Palier de montée en charge (piloté par la qualité, non par un plafond arbitraire) :")
print(f"   subset propre 100%   : {sum(1 for r in built if r[3] >= 1.0 and any(v>0 for v in r[2])):5} recettes")
print(f"   subset >=80% (cache) : {len(usable):5} recettes  <- livré au solveur")
print(f"   subset >=50%         : {sum(1 for r in built if r[3] >= 0.5 and any(v>0 for v in r[2])):5} recettes")
Cache écrit : mealplan_cache.json -- 712 recettes solveur-usables, 89 Ko.
Contract assertif PASS : constituants(C), n_total, n_usable, recipes[].{title,cats,cover,vec} OK.
Palier de montée en charge (piloté par la qualité, non par un plafond arbitraire) :
   subset propre 100%   :   223 recettes
   subset >=80% (cache) :   712 recettes  <- livré au solveur
   subset >=50%         :  1325 recettes

Synthèse — la couche de données du planificateur, en Python

Verdict Ce qu’il montre Contrôle falsifiant
appariement curé White sugar → Sugar white (pas White pudding) garde tête-de-nom écarte le faux positif positionnel
agrégation massique 2 cups flour (~250 g) ≠ 100 g sans ToGrams, chaque ingrédient = 100 g fictif
gating par qualité seules les recettes ≥80% appariées entrent au cache cover < 0.8 → exclu du cache

Ce notebook 16b comble le gap de la couche données du planificateur Python : là où 16 modélisait sur un corpus jouet, ici le corpus est réel (Ciqual × RecipeML), fusionné (appariement lexical flou), normalisé (unités → grammes), et agrégé (pondéré par la masse). C’est le socle que les prochains grains Python consommeront : capstone patient (restrictions Min/Max) et convergence à l’échelle (comparaison d’encodages Optimize / PbEq / Array theory).

7. Exercices

Trois exercices de data-engineering (0 solveur), prolongeant les limites mesurées.

# Exercice 1 — pousser le matcheur (synonymes non couverts).
# TODO: lister les items non appariés fréquents, étendre SYN, re-mesurer la couverture.
from collections import Counter
miss = Counter(it for _, _, ings in recipes for it, _, _ in ings if mc.get(it) is None)
print("Top 15 items non appariés (à couvrir dans SYN) :")
for it, n in miss.most_common(15):
    print(f"   {n:4}x  {it}")
Top 15 items non appariés (à couvrir dans SYN) :
     27x  Margarine
     25x  Shortening
     22x  Bay leaf
     21x  Buttermilk
     21x  Bread flour
     17x  Poppy seeds
     16x  Chicken stock
     15x  Cream of tartar
     14x  Gluten
     13x  Chicken broth
     13x  Allspice
     12x  Cornmeal
     12x  Celery seed
     11x  Chocolate chips
     11x  Soda
# Exercice 2 — étendre la couverture des unités (rendre pesables plus d'ingrédients).
# TODO: ajouter à une copie de UNIT (clove ~5g, slice ~30g, bunch...) et recompter ToGrams > 0.
unites_sup = {}  # ex: {"clove": (5, True), "slice": (30, True)}
n_pesable = sum(1 for _, _, ings in recipes for _, q, u in ings if to_grams(q, u, "x") > 0 or not u)
print(f"Ingrédients pesables (table UNIT courante) : à compléter après extension.")
Ingrédients pesables (table UNIT courante) : à compléter après extension.
# Exercice 3 — profil nutritionnel moyen par catégorie RecipeML.
# TODO: built filtré cover>=0.8, grouper par cats, moyenne composant par composant.
from collections import defaultdict
prof = defaultdict(list)
for title, cats, vec, cover, _, _ in built:
    if cover >= 0.8:
        for c in cats: prof[c].append(vec)
print("Profil par catégorie : à compléter (moyenne de vec par catégorie).")
Profil par catégorie : à compléter (moyenne de vec par catégorie).
Retour au sommet