Compagnon « capstone » de la jambe Python-large #1206 : 16 (modélisation jouet) → 16b (couche de données réelles) → 16c (ce notebook) : le patient → 16d (convergence à l’échelle).
Le capstone : un patient impose des restrictions nutritionnelles (énergie bornée, protéines minimum, lipides maximum) et le solveur doit composer un menu multi-jours qui les satisfait. Port fidèle du C# 08_Meal_Planner_Patient_Capstone (Z3-Linq2Z3).
Deux mouvements : (1) un squelette structural à grande échelle (semaine 7×5, sans nutrition) ; (2) le capstone patient curé (nutrition contrainte).
0. Dépendances : le cache produit par 16b
Ce notebook consomme le cache solveur-usable régénéré par 16b (data/meals/mealplan_cache.json). Pas de solveur ici tant que le cache est absent — échec bruyant (règle SOTA : pas de corpus de substitution).
# Chargement du cache 16b + derivation du creneau (Ordre 1..5) depuis les cats RecipeML.import json, timefrom z3 import Solver, Int, Or, Distinct, sat, Impliesfrom pathlib import Pathdef _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 _craiseFileNotFoundError("Serie Z3-API introuvable depuis "+str(cwd))CACHE = _meal_base() /"mealplan_cache.json"ifnot CACHE.exists():print(f"[CACHE ABSENT] {CACHE}")print("Executez d'abord Z3-Python-16b-Meal-Planner-Data-External (couche de donnees).")raiseFileNotFoundError(CACHE)print(f"Cache present : {CACHE.relative_to(_meal_base().parents[1])} ({CACHE.stat().st_size/1024:.0f} Ko)")root = json.loads(CACHE.read_text(encoding="utf-8"))constituants = root["constituants"]; C =len(constituants)class Plat:__slots__= ("nom", "ordre", "comp")def__init__(self, nom, ordre, comp): self.nom, self.ordre, self.comp = nom, ordre, compdef ordre_from_cats(cats): j =" ".join(cats).lower()ifany(k in j for k in ("appetizer","starter","soup")): return1ifany(k in j for k in ("main dish","beef","pork","chicken","meat","fish")): return2ifany(k in j for k in ("vegetable","salad","rice","pasta","potato","bread","side")): return3ifany(k in j for k in ("cheese","dairy","yogurt","cream")): return4ifany(k in j for k in ("dessert","cake","cookie","pie","fruit","sweet","tart")): return5return0t0 = time.perf_counter()all_plats = []for r in root["recipes"]: t = r["title"].strip()[:44] # tronque a 44 (miroir C#) o = ordre_from_cats(r["cats"])if o <1: continue# orphelin ecarte all_plats.append(Plat(t, o, [float(x) for x in r["vec"]]))print(f"Cache charge en {time.perf_counter()-t0:.2f}s : {len(all_plats)} recettes exploitables "f"(sur {root['n_total']} brutes), {C} constituants.")print("Constituants : "+" | ".join(f"[{i}] {n.split(',')[0].strip()}"for i,n inenumerate(constituants)))
Mouvement 1 — squelette structural à grande échelle (7 jours × 5 créneaux)
Avant la nutrition, le squelette : une semaine de 7 jours × 5 créneaux, où chaque créneau choisit un plat dans sa catégorie. C’est l’occasion d’examiner l’idiome « tableau imbriqué » (int[][]) en SMT — et pourquoi le binding C# Z3.Linq le réifie via CollectionHandling.Array tandis qu’en z3-py on déclare simplement une grille plate de Int.
Divergence de port (honnête) : le C# pose DefaultCollectionHandling = CollectionHandling.Array pour traduire int[][] en un SMT Array (théorie des tableaux de McCarthy, Select/Store). z3-py n’impose pas cette couche : on déclare une list[list[Int]] — chaque cellule est une variable Int indépendante. Fonctionnellement équivalent pour ce squelette ; la vraie théorie des Array est l’objet du notebook 15.
1.1 Corpus + créneaux : pourquoi trier par Ordre
# Semaine 7x5 : on groupe les plats par creneau, ranges contigus [lo,hi] par categorie.JOURS, CRENEAUX =7, 5# tri par (ordre, nom) pour des ranges contigus par creneau (le tri n'affecte que lo/hi, pas le modele M2).plats =sorted([p for p in all_plats], key=lambda p: (p.ordre, p.nom))lo, hi = [], []for o inrange(1, CRENEAUX +1): idxs = [i for i, p inenumerate(plats) if p.ordre == o] lo.append(idxs[0] if idxs else0) hi.append(idxs[-1] if idxs else-1)print(f"{len(plats)} plats ranges en 5 creneaux :")for o inrange(CRENEAUX): n =0if hi[o] < lo[o] else hi[o] - lo[o] +1print(f" creneau {o+1} (ordre {o+1}) : [{lo[o]}..{hi[o]}] ({n} candidats)")
with_bounds encadre chaque créneau dans sa range contigue. Variante A : montée en gamme (chaque jour fait mieux que le précédent, même créneau). Variante B : variété totale (Distinct croisé sur la colonne d’un même créneau, tous jours confondus).
# M1 : squelette 7x5 + Variante A (montee en gamme) + Variante B (Distinct cross-row).def with_bounds(plan): cs = []for jj inrange(JOURS):for cc inrange(CRENEAUX): cs.append(plan[jj][cc] >= lo[cc]); cs.append(plan[jj][cc] <= hi[cc])return csdef print_plan(plan, mdl, titre):print(titre)for jj inrange(JOURS): row = []for cc inrange(CRENEAUX): v = mdl.eval(plan[jj][cc]).as_long() row.append(f"{plats[v].nom[:14]:14}")print(f" J{jj+1}: "+" | ".join(row))t0 = time.perf_counter()# Variante A : montee en gamme, chaque jour > precedent (meme creneau)sA = Solver()planA = [[Int(f"a_j{jj}_c{cc}") for cc inrange(CRENEAUX)] for jj inrange(JOURS)]for c in with_bounds(planA): sA.add(c)for jj inrange(JOURS -1):for cc inrange(CRENEAUX): sA.add(planA[jj][cc] < planA[jj+1][cc])satA = (sA.check() == sat); mA = sA.model() if satA elseNoneprint(f"[A] Montee en gamme : {'resolue'if satA else'UNSAT'} en {(time.perf_counter()-t0)*1000:.0f} ms")# Variante B : Distinct cross-row par creneau (tous jours differents dans la meme categorie)t1 = time.perf_counter()sB = Solver()planB = [[Int(f"b_j{jj}_c{cc}") for cc inrange(CRENEAUX)] for jj inrange(JOURS)]for c in with_bounds(planB): sB.add(c)for cc inrange(CRENEAUX):if hi[cc] >= lo[cc] and (hi[cc] - lo[cc] +1) >= JOURS: sB.add(Distinct(*[planB[jj][cc] for jj inrange(JOURS)]))satB = (sB.check() == sat); mB = sB.model() if satB elseNoneprint(f"[B] Permutation totale (Distinct cross-row) : {'resolue'if satB else'UNSAT'} en {(time.perf_counter()-t1)*1000:.0f} ms")if satA: print_plan(planA, mA, "\n== Variante A (montee en gamme) ==")
[A] Montee en gamme : resolue en 44 ms
[B] Permutation totale (Distinct cross-row) : resolue en 10 ms
== Variante A (montee en gamme) ==
J1: 'ncapriata Di | (Sort-Of) Swee | 'sense and Sen | 3-Step Cheesec | #1 Lemon Bars
J2: 4 B's Restaura | 1-Pot Pastitsi | ( From Bread M | Acapulco Rice | #10 Cake
J3: 5-Minute Brocc | 1-Pot: Pastits | ( From Bread M | Almond Cheesec | $100 Chocolate
J4: 7 Layer Dip | 10 Minute Szec | 1-2-3 Meurbete | Almond Fried I | 'gimme Both' P
J5: 90-Minute Soft | 16th-Street St | 1-Pot Creamy C | Almond Ice Cre | (Sort of Light
J6: Abadoo's Walnu | 19-Alarm Chili | 1-Pot: Creamy | Almond Poppyse | 1 Egg Butter S
J7: Abalone Soup # | 2 Pungent Bbq | 10-Minute Lasa | Almond, Lemon | 1,000 Calorie-
Mouvement 2 — le capstone patient : la nutrition force la curation
Le squelette M1 ignore la nutrition. Lui ajouter des restrictions patient à l’échelle pleine (7×5×R×C) ferait exploser le modèle. D’où la curation : on réduit à 2 menus × 3 créneaux × 5 candidats × 3 constituants — un sous-ensemble à taille humaine où le capstone reste lisible.
Note de fidélité : le C# 08 référence dans sa prose une classe upstream Patient { Restriction[] Restrictions } (chaque restriction porte un Min/Max par constituant, -1 = pas de borne), qui n’existe pas dans ce dépôt (fork externe Z3.LinqBinding). Le notebook 08 lui-même inline le patient comme quatre entiers et n’a aucune classe Patient/ Restriction. Ce port Python reste fidèle à 08 (patient inline) plutôt que de reconstruire la classe upstream absente — ne pas lui attribuer une structure que 08 n’a pas.
2.2 Trois matrices d’entiers : PlatId, Comp, MenuNut
Matrice
Forme
Rôle
PlatId[m][p]
2×3
index pool du plat choisi par (menu, créneau)
Comp[slot][k]
6×3
composition liée du slot aplati (variable auxiliaire)
MenuNut[m][k]
2×3
somme nutritionnelle par menu
Comp est la variable auxiliaire qui résout l’impossibilité d’indexer un tableau hôte par une variable Z3 (pool[PlatId[m][p]] est interdit). Total : 30 variables Int.
# Variables de decision : 30 Int (PlatId 6 + Comp 18 + MenuNut 6).platid = [[Int(f"pid_{m}_{p}") for p inrange(NB_PLATS)] for m inrange(NB_MENUS)]comp = [[Int(f"comp_{slot(m,p)}_{k}") for k inrange(K)] for m inrange(NB_MENUS) for p inrange(NB_PLATS)]menunut = [[Int(f"nut_{m}_{k}") for k inrange(K)] for m inrange(NB_MENUS)]print(f"{NB_MENUS*NB_PLATS} PlatId + {NB_MENUS*NB_PLATS*K} Comp + {NB_MENUS*K} MenuNut = "f"{NB_MENUS*NB_PLATS + NB_MENUS*NB_PLATS*K + NB_MENUS*K} variables Int.")
2.3 Le patient et ses restrictions (bornes par MENU)
Fidèle au C# 08 : un seul patient, inline comme quatre bornes entières, par menu (la somme des 3 plats du menu doit respecter chaque borne). Bornes rescalées kcal→kJ (×4,184) au moment du rewiring sur le cache Ciqual (kJ/100 g).
ATTENTION millésime (#8901) : ces bornes ont été réglées contre un cache C# aujourd’hui désuet (08 tourne sur 121 Ko / 736 recettes ; 07+09 s’accordent sur 270 Ko / 2387). Ici on consomme le cache Python 16b régénéré frais — les valeurs du menu résolu différeront des sorties C# (qu’on ne reproduit pas). On vérifie que le patient reste SAT sur ce pool (sinon = finding à reporter, pas de retouche silencieuse des bornes).
# Restrictions patient (fidele a Restriction{Min,Max}, -1 = pas de borne), par MENU.energieMin =3347# ~800 kcal cumules minimum par menuenergieMax =10878# ~2600 kcal cumules maximum par menuprotMin =30# proteines minimum par menu (g)lipMax =600# lipides maximum par menu (g)print(f"Patient : energie in [{energieMin}, {energieMax}] kJ, proteines >= {protMin} g, "f"lipides <= {lipMax} g (par menu)")
Patient : energie in [3347, 10878] kJ, proteines >= 30 g, lipides <= 600 g (par menu)
2.4 Les cinq familles de contraintes
Bornes d’ordre : PlatId[m][p] dans la fenêtre des 5 candidats de son créneau.
Variété : Distinct global sur les 6 slots (pas deux fois le même plat).
Linking composition : Or(PlatId[m][p] ≠ cand, Comp[slot][k] == teneur(cand, k)) — l’idiome index + disjonction (90 assertions) qui relie l’index choisi à sa composition. C’est l’idiom que la jambe Python n’avait aucun exemple avant ce notebook.
Somme par menu : MenuNut[m][k] == Σ Comp[slot][k] sur les 3 créneaux.
Restrictions patient : MenuNut[m][·] dans les bornes (énergie, protéines, lipides).
# THE MODEL : 5 familles de contraintes + Solve (pure satisfiabilite, pas d'objectif).t0 = time.perf_counter()s = Solver()# (1) bornes d'ordrefor m inrange(NB_MENUS):for p inrange(NB_PLATS): s.add(platid[m][p] >= p*CAND, platid[m][p] <= p*CAND + CAND -1)# (2) variete : Distinct global sur les 6 slotss.add(Distinct(*[platid[m][p] for m inrange(NB_MENUS) for p inrange(NB_PLATS)]))# (3) linking composition : PlatId != cand || Comp[slot][k] == teneur(cand, k)for m inrange(NB_MENUS):for p inrange(NB_PLATS): sl = slot(m, p)for cand inrange(p*CAND, p*CAND + CAND):for k inrange(K): s.add(Or(platid[m][p] != cand, comp[sl][k] == teneur(cand, CONST[k])))# (4) somme par menufor m inrange(NB_MENUS):for k inrange(K): s.add(menunut[m][k] == comp[slot(m,0)][k] + comp[slot(m,1)][k] + comp[slot(m,2)][k])# (5) restrictions patient (par menu)for m inrange(NB_MENUS): s.add(menunut[m][0] >= energieMin, menunut[m][0] <= energieMax) s.add(menunut[m][1] >= protMin) s.add(menunut[m][2] <= lipMax)sat_cap = (s.check() == sat)mdl = s.model() if sat_cap elseNoneprint(f"Modele resolu en {(time.perf_counter()-t0)*1000:.0f} ms")ifnot sat_cap:print("UNSAT : aucun plan ne satisfait les restrictions du patient sur cette pool (finding #8901).")else:for m inrange(NB_MENUS): e = mdl.eval(menunut[m][0]).as_long(); pr = mdl.eval(menunut[m][1]).as_long(); li = mdl.eval(menunut[m][2]).as_long()print(f"== Menu {m+1} == energie={e} kJ | proteines={pr} g | lipides={li} g")for p inrange(NB_PLATS): v = mdl.eval(platid[m][p]).as_long()print(f" creneau {p+1} : {pool[v].nom.strip():<30} "f"(kJ={teneur(v,cEnergie)}, prot={teneur(v,cProt)}, lip={teneur(v,cLip)})")
Modele resolu en 19 ms
== Menu 1 == energie=8848 kJ | proteines=45 g | lipides=168 g
creneau 1 : 4 B's Restaurant Tomato Soup (kJ=3409, prot=16, lip=63)
creneau 2 : (Sort-Of) Sweet and Sour Chicken (Also Good (kJ=1018, prot=3, lip=0)
creneau 3 : ( From Bread Mix ) Big Soft Pretzels (kJ=4421, prot=26, lip=105)
== Menu 2 == energie=10571 kJ | proteines=111 g | lipides=119 g
creneau 1 : 'ncapriata Di Fave (Fava Bean Puree with Gre (kJ=5922, prot=58, lip=73)
creneau 2 : 10 Minute Szechuan Chicken (kJ=1178, prot=11, lip=15)
creneau 3 : 1-Pot: Creamy Chicken Noodle Casserole (kJ=3471, prot=42, lip=31)
2.5 Vérification hors-Z3 (croire vs prouver)
Un solveur garantit les contraintes par construction — mais on vérifie indépendamment (re-calcul hors Z3). Deux prongs : (a) les sommes recomputées depuis la pool satisfont les bornes patient ; (b) ces sommes égalent les MenuNut du solveur — ce qui valide la couche de linking elle-même, pas seulement les bornes.
# Re-verification hors Z3 : re-somme depuis la pool, compare aux MenuNut du solveur.ifnot sat_cap:print("Pas de solution a verifier (UNSAT).")else: ok =Truefor m inrange(NB_MENUS): e = pr = li =0for p inrange(NB_PLATS): v = mdl.eval(platid[m][p]).as_long() e += teneur(v, cEnergie); pr += teneur(v, cProt); li += teneur(v, cLip) eOk = energieMin <= e <= energieMax; pOk = pr >= protMin; lOk = li <= lipMax Me = mdl.eval(menunut[m][0]).as_long(); Mpr = mdl.eval(menunut[m][1]).as_long(); Mli = mdl.eval(menunut[m][2]).as_long() match = (e == Me and pr == Mpr and li == Mli) ok = ok and eOk and pOk and lOk and matchprint(f"Menu {m+1} : recalc energie={e}({'OK'if eOk else'HORS'}), prot={pr}({'OK'if pOk else'HORS'}), "f"lip={li}({'OK'if lOk else'HORS'}) ; coherent avec MenuNut={'oui'if match else'NON'}")print("\nVERIFIE : toutes les restrictions patient sont respectees."if okelse"\nINCOHERENCE detectee (a investiguer).")
Menu 1 : recalc energie=8848(OK), prot=45(OK), lip=168(OK) ; coherent avec MenuNut=oui
Menu 2 : recalc energie=10571(OK), prot=111(OK), lip=119(OK) ; coherent avec MenuNut=oui
VERIFIE : toutes les restrictions patient sont respectees.
3. Exercices
Quatre prolongements (stubs : ils s’exécutent tels quels, n’échouent pas, à compléter).
Exercice 1 — Diversité d’entrée sur jours consécutifs : forcer que le créneau 1 diffère entre Menu 1 et Menu 2 (déjà couvert par le Distinct global, mais l’exprimer comme une contrainte dédiée Or(PlatId[0][0] != PlatId[1][0], ...) et mesurer l’impact).
# Exercice 1 (stub) : diversite entree jours consecutifs.# TODO: ajouter s.add(Or(...)) forçant créneau 1 different entre menus, re-solve, comparer.print("[Ex.1] Stub : a completer (diversite entree jours consecutifs).")def exercice_diversite_entree():# ... ajouter la contrainte, re-solve, retourner le nb de plans ...return-1
[Ex.1] Stub : a completer (diversite entree jours consecutifs).
Exercice 2 — Seuil d’énergie strict → UNSAT : resserrer energieMax par dichotomie jusqu’à rendre le modèle insatisfiable. Quel créneau porte l’essentiel de l’énergie kJ ?
# Exercice 2 (stub) : seuil UNSAT par dichotomie sur energieMax.# TODO: boucle resserrant energieMax, re-solve, retourner le seuil ou ca casse.print("[Ex.2] Stub : a completer (seuil UNSAT energie).")def exercice_unsat_threshold():# ... dichotomie sur energieMax ...return-1
[Ex.2] Stub : a completer (seuil UNSAT energie).
Exercice 3 — Passage à 3 menus : adapter les dimensions des matrices (PlatId 3×3, Comp 9×3, MenuNut 3×3) et re-solve. Note : les formes sont hardcoded à NB_MENUS=2 dans la déclaration des variables — les paramétrer est le cœur de l’exercice.
# Exercice 3 (stub) : passer a 3 menus (adapter les formes de matrices).# TODO: parametriser NB_MENUS=3, redeclarer les matrices, re-solve, retourner le temps.print("[Ex.3] Stub : a completer (3 menus).")def exercice_trois_menus():# ... NB_MENUS=3, redeclarer vars, re-solve ...return-1.0
[Ex.3] Stub : a completer (3 menus).
Exercice 4 — Ajouter le sel (Sel chlorure de sodium, cache idx 4) comme 4e constituant contraint (borne max selMax), et re-solve.
# Exercice 4 (stub) : ajouter le constituant Sel (cache idx 4).# TODO: CONST.append(4), ajouter selMax, re-declarer Comp/MenuNut sur K=4, re-solve.print("[Ex.4] Stub : a completer (constituant sel).")def exercice_constituant_sel(): cSel =next(i for i, n inenumerate(constituants) if"sel chlorure"in n.lower())# ... CONST.append(cSel) ...return cSel
[Ex.4] Stub : a completer (constituant sel).
Synthèse — le capstone patient, en Python
Mouvement
Ce qu’il montre
Idiome Z3
M1 squelette
semaine 7×5 sans nutrition
int[][] plate de Int (vs CollectionHandling.Array C#)
M2 capstone
patient SAT avec restrictions nutritionnelles
index + linking disjonction (Or(pid≠cand, comp==val))
Le capstone patient est le point où la nutrition rencontre la combinatoire : on ne peut pas forcer « 30 g de protéines minimum » sur un jouet de 24 plats — il faut un corpus réel (le cache 16b) pour que la contrainte morde. La re-vérification hors-Z3 clôt l’arc : on ne croit pas le solveur, on le prouve. C’est ce qui distingue ce capstone d’une simple démonstration.
Prochain grain (G3) : la convergence à l’échelle — comparer les encodages (index naïf vs one-hot pseudo-Boolean PbEq/PbLe) quand R et C augmentent, là où le naïf explose.