09 — Convergence à l’échelle : l’encodage décide de la tractabilité

Clôture du bloc meal-planner (Epic #4677). La modélisation déclarative (06) a posé les modèles du planificateur sur un corpus jouet ; la couche de données (07) a construit le corpus réel — recettes RecipeML appariées à Ciqual ANSES 2025 sans faux positifs et agrégées pondérées par la masse. Ce notebook branche enfin le solveur sur ces données réelles, et y rencontre le problème que les corpus jouets masquaient : à l’échelle, l’encodage naïf explose dès la construction, l’encodage le plus compact (théorie des tableaux) devient insoluble, et seul l’encodage one-hot pseudo-booléen se construit instantanément et se résout.

Stack : B (API Microsoft.Z3 brute) — le cœur de la leçon est l’encodage pseudo-booléen (MkPBEq / MkPBLe / MkPBGe), une famille de contraintes que le DSL Z3.Linq n’expose pas (cf. README, « Les deux stacks »).

Question de convergence. Le problème fidèle reste-t-il tractable quand on remonte à l’échelle réelle (7 menus × 5 créneaux × ~2 200 recettes × constituants Ciqual) ? La puissance brute du solveur Z3 suffit-elle, ou le choix d’encodage décide-t-il seul de la tractabilité ?

Insight clé (formulation du problème). Plus de recettes = plus de solutions possibles, pas plus de contraintes : des recettes supplémentaires sont des contraintes enablantes (elles élargissent l’espace des modèles). C’est l’encodage — pas le solveur — qui décide si cet espace est explorable.

Objectifs d’apprentissage

  • Consommer la couche de données construite par le notebook 07 (cache JSON : vecteurs nutritionnels pondérés par la masse, appariement curé sans faux positifs).
  • Comprendre pourquoi un encodage SMT naïf explose dès la construction à l’échelle.
  • Découvrir que la compacité d’écriture (théorie des tableaux) n’implique pas la résolubilité.
  • Maîtriser l’encodage one-hot pseudo-booléen (MkPBEq / MkPBLe / MkPBGe) qui passe à l’échelle.

Prérequis

  • Notebooks 06 (modélisation déclarative du menu) et 07 (couche de données Ciqual × RecipeML).
  • Notebooks 04 (théorie des tableaux Z3) et 14 (Optimize / pseudo-booléen).

1. Données : le cache solveur-usable du notebook 07

Ce notebook ne refait aucun data-engineering : il charge data/meals/mealplan_cache.json, le sous-ensemble solveur-usable (couverture d’appariement ≥ 80 %) sérialisé par le notebook 07. Chaque recette y porte un vecteur nutritionnel (énergie kJ, protéines, glucides, lipides, sel) agrégé pondéré par la masse réelle des ingrédients — le contraire du raccourci per-100g qui rendait toute somme fictive.

Si le cache est absent, exécutez d’abord le notebook 07 de bout en bout (il télécharge au besoin ses sources via python download_meal_data.py --areas Ciqual RecipeML).

#r "../Z3.Linq/.deploy/Microsoft.Z3.dll"
#r "../Z3.Linq/.deploy/ExpressionUtils.dll"
#r "../Z3.Linq/.deploy/Z3.Linq.dll"
using Microsoft.Z3;
using System;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using System.Text.Json;
using System.Diagnostics;

var CACHE = Path.Combine("data/meals", "mealplan_cache.json");
Console.WriteLine(File.Exists(CACHE)
    ? $"Cache present : {CACHE} ({new FileInfo(CACHE).Length / 1024} Ko)"
    : "Cache absent : executez d'abord le notebook 07 (couche de donnees) de bout en bout.");
Cache present : data/meals\mealplan_cache.json (418 Ko)
// ---- chargement du cache solveur-usable produit par le notebook 07 ----
var sw = Stopwatch.StartNew();
var doc = JsonDocument.Parse(File.ReadAllText(CACHE));
var root = doc.RootElement;
var constituants = root.GetProperty("constituants").EnumerateArray().Select(x => x.GetString()).ToArray();
int C = constituants.Length;
var plats = new List<(string title, decimal[] vec, List<string> cats)>();
foreach (var r in root.GetProperty("recipes").EnumerateArray())
{
    var t = r.GetProperty("title").GetString().Trim();
    plats.Add((t.Length > 40 ? t.Substring(0, 40) : t,
               r.GetProperty("vec").EnumerateArray().Select(x => x.GetDecimal()).ToArray(),
               r.GetProperty("cats").EnumerateArray().Select(x => x.GetString()).ToList()));
}
int R = plats.Count;
sw.Stop();
Console.WriteLine($"Cache charge en {sw.Elapsed.TotalSeconds:F1}s : R={R} recettes solveur-usables (sur {root.GetProperty("n_total").GetInt32()} brutes), C={C} constituants.");
Console.WriteLine("Quartiles par constituant (echelle : recette ENTIERE, agregation ponderee par la masse) :");
for (int c = 0; c < C; c++)
{
    var vals = plats.Select(p => p.vec[c]).OrderBy(v => v).ToList();
    decimal P(double q) => vals[(int)(q * (vals.Count - 1))];
    var nom = constituants[c].Length > 38 ? constituants[c].Substring(0, 38) : constituants[c];
    Console.WriteLine($"   [{c}] {nom,-38} P25={Math.Round(P(0.25), 1),8}  mediane={Math.Round(P(0.5), 1),8}  P75={Math.Round(P(0.75), 1),8}");
}
Cache charge en 0,1s : R=3717 recettes solveur-usables (sur 8286 brutes), C=5 constituants.
Quartiles par constituant (echelle : recette ENTIERE, agregation ponderee par la masse) :
   [0] Energie, Règlement UE N° 1169/2011 (kJ P25=  3350,4  mediane=  7139,1  P75= 12637,0
   [1] Protéines, N x facteur de Jones (g/100 P25=    12,0  mediane=    33,8  P75=    65,6
   [2] Glucides (g/100 g)                     P25=    35,0  mediane=   153,1  P75=   361,3
   [3] Lipides (g/100 g)                      P25=    21,6  mediane=    69,8  P75=   161,1
   [4] Sel chlorure de sodium (g/100 g)       P25=     0,8  mediane=     3,4  P75=     7,1

Interprétation : ce que la couche 07 garantit (et ce qu’elle ne garantit pas)

Le cache livre des recettes déjà appariées (matcheur curé du notebook 07 : White sugar → Sugar, white, et non White pudding — zéro faux positif toléré) et déjà pesées (quantités culinaires converties en grammes, agrégation pondérée par la masse). Les premières ébauches de ce notebook refaisaient ici un appariement naïf par sac-de-mots — avec ses faux positifs — et sommaient les teneurs per-100g comme si chaque ingrédient pesait 100 g : c’est exactement le travail que le notebook 07 a assaini, et qu’on ne duplique plus.

Les quartiles affichés ci-dessus servent à calibrer les bornes du théorème (section suivante) : les vecteurs étant désormais à l’échelle de la recette entière (pas de la portion — RecipeML porte un <yield> que la couche 07 n’exploite pas encore), les fenêtres nutritionnelles par menu doivent être posées depuis ces ordres de grandeur mesurés, pas depuis des constantes héritées d’un autre encodage des données.

// ---- parametres du theoreme partages par les trois encodages ----
int NMENUS = 7, NPLATS = 5;
// valeurs entieres par constituant (kJ, g), a l'echelle de la RECETTE ENTIERE (agregation ponderee, cf. 07).
var vint = new int[C][];
for (int c = 0; c < C; c++) vint[c] = plats.Select(p => (int)Math.Round(p.vec[c])).ToArray();
// restrictions patient CALIBREES sur les quartiles mesures ci-dessus : les vecteurs etant a l'echelle de la
// recette entiere (et non per-100g), des constantes heritees d'un autre encodage n'auraient aucun sens.
decimal Pq(int c, double q) { var v = plats.Select(p => p.vec[c]).OrderBy(x => x).ToList(); return v[(int)(q * (v.Count - 1))]; }
int loE = NPLATS * (int)Pq(0, 0.20), hiE = NPLATS * (int)Pq(0, 0.80);   // energie/menu : bande large autour de l'IQR
int loP = NPLATS * (int)Pq(1, 0.30);                                    // proteines/menu : plancher realiste
int hiS = Math.Max(1, NPLATS * (int)Pq(4, 0.70));                       // sel/menu : plafond contraignant
var restr = new (int c, int lo, int hi)[] { (0, loE, hiE), (1, loP, -1), (4, -1, hiS) };
Console.WriteLine($"Theoreme : {NMENUS} menus x {NPLATS} plats, sur R={R} recettes (vecteurs recette entiere).");
Console.WriteLine($"   energie/menu in [{loE},{hiE}] kJ, proteines/menu >= {loP} g, sel/menu <= {hiS} g.");
Theoreme : 7 menus x 5 plats, sur R=3717 recettes (vecteurs recette entiere).
   energie/menu in [13275,72520] kJ, proteines/menu >= 75 g, sel/menu <= 30 g.

3. Trois encodages du même theoreme, trois comportements

Le theoreme est fixe : 7 menus x 5 plats, chaque plat distinct sur la semaine (variete), chaque menu dans une fenêtre nutritionnelle. Seul l’encodage SMT change. On va voir trois comportements radicalement différents sur le même corpus reel.

3.1 Encodage naif (disjonction) – explose des la construction

L’encodage naif (celui du Create original) introduit une variable de nutrition par creneau et, pour chaque recette, la disjonction creneau != r OU nutrition == ligne[r]. Le nombre d’assertions croit en menus x plats x R x constituants : on ne mesure même pas la resolution, juste la construction.

// ---- naif : sonde de temps de CONSTRUCTION (la resolution ne demarre meme pas a l'echelle) ----
long BuildNaive(int rcap)
{
    using var c = new Context();
    var so = c.MkSolver();
    var pid = new IntExpr[NMENUS][];
    for (int m = 0; m < NMENUS; m++) pid[m] = Enumerable.Range(0, NPLATS).Select(p => (IntExpr)c.MkIntConst($"q_{m}_{p}")).ToArray();
    long na = 0;
    var swp = Stopwatch.StartNew();
    for (int m = 0; m < NMENUS; m++)
        for (int p = 0; p < NPLATS; p++)
        {
            so.Assert(c.MkAnd(c.MkGe(pid[m][p], c.MkInt(0)), c.MkLt(pid[m][p], c.MkInt(rcap))));
            for (int rr = 0; rr < rcap; rr++)
                foreach (var (cc, lo, hi) in restr)
                {
                    var nutr = (IntExpr)c.MkIntConst($"n_{m}_{p}_{cc}");
                    so.Assert(c.MkOr(c.MkNot(c.MkEq(pid[m][p], c.MkInt(rr))), c.MkEq(nutr, c.MkInt(vint[cc][rr]))));
                    na++;
                }
        }
    swp.Stop();
    Console.WriteLine($"  naif R={rcap,5} : {na,8} disjonctions construites en {swp.Elapsed.TotalSeconds:F2}s (CONSTRUCTION seule, sans resolution)");
    return na;
}
foreach (var rcap in new[] { 100, 300, Math.Min(R, 1000) }) BuildNaive(rcap);
Console.WriteLine("  -> le cout de construction croit lineairement en R x menus x plats x contraintes :");
Console.WriteLine("     a l'echelle reelle, le solveur n'a meme pas commence a chercher une solution.");
  naif R=  100 :    10500 disjonctions construites en 0,06s (CONSTRUCTION seule, sans resolution)
  naif R=  300 :    31500 disjonctions construites en 0,23s (CONSTRUCTION seule, sans resolution)
  naif R= 1000 :   105000 disjonctions construites en 0,67s (CONSTRUCTION seule, sans resolution)
  -> le cout de construction croit lineairement en R x menus x plats x contraintes :
     a l'echelle reelle, le solveur n'a meme pas commence a chercher une solution.

3.2 Théorie des tableaux – compacte a ecrire, mais insoluble

L’encodage par théorie des tableaux (notebook 04) est compact : un Array par constituant (chaîne de Store sur les R valeurs), un index entier par creneau, et Select(arr, index) pour lire la valeur. Le nombre de contraintes devient indépendant de R. Surprise : Z3 retourne unknown – il ne sait pas resoudre les longues chaînes de Store symboliques. Compacite d’ecriture != resolubilite.

// ---- theorie des tableaux : compact, mais Z3 -> unknown sur de longues chaines de Store symboliques ----
{
    using var c = new Context();
    var so = c.MkSolver();
    so.Set("timeout", (uint)15000);                                 // plafond 15s : on attend `unknown`
    var arrs = new ArrayExpr[restr.Length];
    for (int ci = 0; ci < restr.Length; ci++)
    {
        var (cc, lo, hi) = restr[ci];
        var a = c.MkConstArray(c.IntSort, c.MkInt(0));
        for (int r = 0; r < R; r++) a = c.MkStore(a, c.MkInt(r), c.MkInt(vint[cc][r]));    // chaine de R Store
        arrs[ci] = a;
    }
    var pid = new IntExpr[NMENUS][];
    for (int m = 0; m < NMENUS; m++) pid[m] = Enumerable.Range(0, NPLATS).Select(p => (IntExpr)c.MkIntConst($"a_{m}_{p}")).ToArray();
    var flat = (from m in Enumerable.Range(0, NMENUS) from p in Enumerable.Range(0, NPLATS) select pid[m][p]).ToArray();
    foreach (var v in flat) { so.Assert(c.MkGe(v, c.MkInt(0))); so.Assert(c.MkLt(v, c.MkInt(R))); }
    so.Assert(c.MkDistinct(flat));                                   // variete : indices distincts
    for (int m = 0; m < NMENUS; m++)
        for (int ci = 0; ci < restr.Length; ci++)
        {
            var (cc, lo, hi) = restr[ci];
            var tot = c.MkAdd(Enumerable.Range(0, NPLATS).Select(p => (ArithExpr)c.MkSelect(arrs[ci], pid[m][p])).ToArray());
            if (lo >= 0) so.Assert(c.MkGe(tot, c.MkInt(lo)));
            if (hi >= 0) so.Assert(c.MkLe(tot, c.MkInt(hi)));
        }
    var sw2 = Stopwatch.StartNew(); var rr = so.Check(); sw2.Stop();
    Console.WriteLine($"theorie des tableaux ({NMENUS * NPLATS} index, chaines de Store de longueur {R}) : {rr} en {sw2.Elapsed.TotalSeconds:F1}s");
    Console.WriteLine("  -> compact a ECRIRE, mais insoluble : Z3 ne tranche pas les Store symboliques empiles. Compacite != resolubilite.");
}
theorie des tableaux (35 index, chaines de Store de longueur 3717) : UNKNOWN en 15,0s
  -> compact a ECRIRE, mais insoluble : Z3 ne tranche pas les Store symboliques empiles. Compacite != resolubilite.

3.3 One-hot pseudo-booléen – l’encodage qui passe a l’echelle

L’encodage one-hot introduit un booléen sel[m][p][r] (“la recette r occupe le creneau p du menu m”). Trois familles de contraintes, toutes pseudo-booleennes natives de Z3 :

  • MkPBEq(.,1) par creneau : exactement une recette par creneau.
  • MkPBLe(.,1) par recette sur toute la semaine : chaque recette au plus une fois (= variete / Distinct).
  • MkPBGe / MkPBLe ponderes par menu et par constituant : la fenêtre nutritionnelle devient une somme ponderee de booléens (coefficient = valeur de la recette), traitee par le solveur pseudo-booléen de Z3 – pas par l’arithmetique lineaire générale.

C’est cet encodage qui passe a l’echelle : les recettes sont des contraintes enablantes, le solveur trouve un modèle sans enumerer. La construction est quasi instantanee même a R~1000.

// ---- one-hot pseudo-booleen : exactly-one + variete + bandes ponderees (MkPBGe / MkPBLe) ----
var ctx = new Context();
var s = ctx.MkSolver();
var swB = Stopwatch.StartNew();
var sel = new BoolExpr[NMENUS][][];
for (int m = 0; m < NMENUS; m++) { sel[m] = new BoolExpr[NPLATS][]; for (int p = 0; p < NPLATS; p++) sel[m][p] = Enumerable.Range(0, R).Select(r => ctx.MkBoolConst($"s_{m}_{p}_{r}")).ToArray(); }
var onesR = Enumerable.Repeat(1, R).ToArray();
for (int m = 0; m < NMENUS; m++) for (int p = 0; p < NPLATS; p++) s.Assert(ctx.MkPBEq(onesR, sel[m][p], 1));      // exactement 1 recette / creneau
var onesMP = Enumerable.Repeat(1, NMENUS * NPLATS).ToArray();
for (int r = 0; r < R; r++) { var col = (from m in Enumerable.Range(0, NMENUS) from p in Enumerable.Range(0, NPLATS) select sel[m][p][r]).ToArray(); s.Assert(ctx.MkPBLe(onesMP, col, 1)); }  // variete : <= 1x / semaine
// fenetre nutritionnelle = somme ponderee de booleens (PB native), coeff = valeur de la recette
for (int m = 0; m < NMENUS; m++) foreach (var (c, lo, hi) in restr)
{
    var bools = (from p in Enumerable.Range(0, NPLATS) from r in Enumerable.Range(0, R) select sel[m][p][r]).ToArray();
    var coeffs = (from p in Enumerable.Range(0, NPLATS) from r in Enumerable.Range(0, R) select vint[c][r]).ToArray();
    if (hi >= 0) s.Assert(ctx.MkPBLe(coeffs, bools, hi));
    if (lo >= 0) s.Assert(ctx.MkPBGe(coeffs, bools, lo));
}
swB.Stop();
var swS = Stopwatch.StartNew(); var res = s.Check(); swS.Stop();
Console.WriteLine($"one-hot R={R} ({NMENUS * NPLATS * R} booleens) : construction {swB.Elapsed.TotalSeconds:F1}s, resolution {swS.Elapsed.TotalSeconds:F1}s -> {res}");
if (res == Status.SATISFIABLE)
{
    var mo = s.Model;
    for (int m = 0; m < NMENUS; m++)
    {
        var names = new List<string>();
        for (int p = 0; p < NPLATS; p++) for (int r = 0; r < R; r++) if (mo.Eval(sel[m][p][r], true).IsTrue) names.Add(plats[r].title);
        Console.WriteLine($"  Menu {m + 1} : " + string.Join("  |  ", names.Select(n => n.Length > 18 ? n.Substring(0, 18) : n)));
    }
}
one-hot R=3717 (130095 booleens) : construction 1,4s, resolution 5,6s -> SATISFIABLE
  Menu 1 : 12 Hour Salad  |  125-Year-Old Walnu  |  100% Pleasure's Pu  |  1-Pot: Creamy Chic  |  1-Pot Pastitsio Go
  Menu 2 : 1-Pot: Cheesy Turk  |  1-Pot Mushroom and  |  1-Pot Creamy Chick  |  1-2-3 Sweet Dough  |  1-2-3 Meurbeteig D
  Menu 3 : 4-Hour Beef Stew  |  1-Pot Cheesy Turke  |  1-2-3 Cookies  |  1-2-3-4 Cake with   |  1-2-3-4 Cake By Ja
  Menu 4 : 21-Alarm Chile --L  |  1-2-3-4 Cake  |  1-2-3-4-5 Cake  |  1-1-1 Cookies  |  1,2,3,4 Cake
  Menu 5 : 1-Pot Fuss-Free Ca  |  1,000 Calorie-A-Bi  |  (Sort of Light) Bo  |  (Sort-Of) Sweet an  |  'sense and Sensibi
  Menu 6 : Abadoo's Granola  |  11 Minute Strawber  |  10-Minute Lasagna  |  ( From Bread Mix )  |  'ncapriata Di Fave
  Menu 7 : (Homemade Fresh) C  |  'gimme Both' Pumpk  |  $100 Chocolate Cak  |  #1 Lemon Bars  |  #10 Cake

Interpretation : la compacite ne fait pas la resolubilite

Trois encodages, un seul corpus :

Encodage Taille Comportement a R~1000
Naif (disjonction) menus x plats x R x C assertions explose a la construction
Théorie des tableaux compact (R-indépendant) unknown – Store symboliques insolubles
One-hot pseudo-booléen menus x plats x R booléens construction quasi-immediate + resolu

La lecon centrale : a l’echelle, l’encodage prime sur la compacite. L’encodage one-hot pseudo-booléen repond a “plus de données” par “plus de modèles a trouver”, pas “plus de cas a enumerer” – c’est la traduction concrete de “les recettes sont des contraintes enablantes”.

Nuance (jusqu’au choix de la contrainte). Même au sein du one-hot, le detail compte : exprimer la fenêtre nutritionnelle comme une contrainte pseudo-booleenne native (MkPBGe / MkPBLe, somme ponderee de booléens, traitee par le solveur PB) plutot que comme une somme d’ITE routee vers l’arithmetique lineaire générale change la resolution d’un facteur ~150x (mesure lors d’une itération précédente a ~1 000 recettes : ~0,7 s contre ~110 s). Le bon outil SMT pour une somme ponderee de booléens est le solveur pseudo-booléen, pas le solveur LIA.

3.4 Le DSL Z3.Linq exprime le one-hot : ExactlyOne (fork #10605)

La section 3.3 écrit l’encodage one-hot à la main (MkPBEq / MkPBLe / MkPBGe). Le fork Z3.Linq du dépôt (sous-module SMT/Z3.Linq, PRs #20/#22) expose désormais le motif sous forme de magic method DSL : Z3Methods.ExactlyOne / AtMostOne / AtLeastOne (issue #10605, dernier item du backlog #4616). Ces méthodes sont interceptées par ExpressionVisitor et traduites en contraintes pseudo-booléennes natives (MkPBGe sur les indicateurs et leurs négations) — jamais en expansion arithmétique.

C’est le test du pari #4616 : « le pseudo-booléen tombe automatiquement comme MkAdd étendu de MkIte une fois B2+B3 livrés — la magic method dédiée est optionnelle ». B2 et B3 sont livrés, donc le pari est maintenant mesurable sur l’instance réelle : si l’expansion MkIte + MkAdd fait perdre à Z3 sa propagation pseudo-booléenne native, la magic method dédiée n’est plus optionnelle. On mesure (construction + résolution + statut), on ne suppose pas.

// ---- 3.4 DSL Z3.Linq : ExactlyOne = PB natif (fork #10605), le pari #4616 se tranche par la mesure ----
// CS1701 (System.Linq.Expressions 8.0 vs 10.0) : le fork Z3.Linq cible net8, l'hote .NET Interactive est net10 --
// forward-compat verifiee, la DLL se charge et les magic methods fonctionnent. Bruit supprime.
#pragma warning disable CS1701
using Z3.Linq;

// (a) La magic method tient en une clause LINQ. Motif des tests unitaires du fork (UnweightedPbTests) :
//     un tableau de booléens, exactement un vrai. Le visitor l'intercepte et emet du MkPBGe natif
//     (somme >= 1 ET somme des niega >= 4), pas une expansion MkIte+MkAdd.
var dctx = new Z3Context();
var th = dctx.NewTheorem<Slot5>().Where(t => Z3Methods.ExactlyOne(t.S[0], t.S[1], t.S[2], t.S[3], t.S[4]));
var swD = Stopwatch.StartNew();
var pick = th.Solve();
swD.Stop();
int k = Enumerable.Range(0, 5).First(i => pick!.S[i]);
Console.WriteLine($"DSL ExactlyOne (5 indicateurs) : {swD.Elapsed.TotalSeconds:F3}s -> SAT, indicateur S[{k}] vrai (PB natif emis par le visitor, pas d'expansion).");

// (b) Pari #4616 sur l'instance reelle : un creneau, R recettes. On compare les DEUX encodages
//     -- natif MkPBEq (celui de la cellule 12) vs expansion MkIte+MkAdd que le pari supposait
//     "automatique". Mesure : construction + resolution + statut + taille smt2.
var swB = Stopwatch.StartNew();
var ctxN = new Context(); var sN = ctxN.MkSolver();
var slotN = Enumerable.Range(0, R).Select(r => ctxN.MkBoolConst($"n_{r}")).ToArray();
sN.Assert(ctxN.MkPBEq(Enumerable.Repeat(1, R).ToArray(), slotN, 1));          // PB natif
int lenN = sN.ToString().Length;
swB.Stop();
var swN = Stopwatch.StartNew(); var resN = sN.Check(); swN.Stop();

var swB2 = Stopwatch.StartNew();
var ctxX = new Context(); var sX = ctxX.MkSolver();
var slotX = Enumerable.Range(0, R).Select(r => ctxX.MkBoolConst($"x_{r}")).ToArray();
var sumX = ctxX.MkAdd(slotX.Select(b => (ArithExpr)ctxX.MkITE(b, ctxX.MkInt(1), ctxX.MkInt(0))).ToArray());
sX.Assert(ctxX.MkEq(sumX, ctxX.MkInt(1)));                                    // expansion du pari #4616
int lenX = sX.ToString().Length;
swB2.Stop();
var swX = Stopwatch.StartNew(); var resX = sX.Check(); swX.Stop();

Console.WriteLine($"Pari #4616 sur 1 creneau (R={R}) :");
Console.WriteLine($"   PB natif MkPBEq      : construction {swB.Elapsed.TotalSeconds:F2}s, resolution {swN.Elapsed.TotalSeconds:F2}s -> {resN}, smt2 {lenN} chars");
Console.WriteLine($"   expansion MkIte+MkAdd : construction {swB2.Elapsed.TotalSeconds:F2}s, resolution {swX.Elapsed.TotalSeconds:F2}s -> {resX}, smt2 {lenX} chars");
Console.WriteLine($"   -> smt2 expansion/natif = {lenX / (double)lenN:F1}x ; mais surtout RESOLUTION : natif {swN.Elapsed.TotalSeconds:F2}s vs expansion {swX.Elapsed.TotalSeconds:F2}s"
    + $" = {swX.Elapsed.TotalSeconds / (swN.Elapsed.TotalSeconds + 1e-9):F0}x plus lent.");
Console.WriteLine("   La divergence eclate DEJA a un seul creneau : l'expansion MkIte+MkAdd fait perdre au solveur Z3");
Console.WriteLine("   sa propagation pseudo-booleenne native. Le pari #4616 (la magic method serait 'optionnelle') est");
Console.WriteLine("   REFUTE par la mesure : ExactlyOne -> MkPBGe natif (DSL #10605) est necessaire, pas optionnel.");

// Theoreme parametre du DSL : un creneau de 5 indicateurs (motif des tests unitaires du fork).
// Type declare APRES les top-level statements (regle du compilateur C# top-level / .NET Interactive).
public class Slot5 { public bool[] S { get; set; } = new bool[5]; }
DSL ExactlyOne (5 indicateurs) : 0,042s -> SAT, indicateur S[1] vrai (PB natif emis par le visitor, pas d'expansion).
Pari #4616 sur 1 creneau (R=3717) :
   PB natif MkPBEq      : construction 0,05s, resolution 0,02s -> SATISFIABLE, smt2 161354 chars
   expansion MkIte+MkAdd : construction 0,11s, resolution 15,87s -> SATISFIABLE, smt2 191080 chars
   -> smt2 expansion/natif = 1,2x ; mais surtout RESOLUTION : natif 0,02s vs expansion 15,87s = 661x plus lent.
   La divergence eclate DEJA a un seul creneau : l'expansion MkIte+MkAdd fait perdre au solveur Z3
   sa propagation pseudo-booleenne native. Le pari #4616 (la magic method serait 'optionnelle') est
   REFUTE par la mesure : ExactlyOne -> MkPBGe natif (DSL #10605) est necessaire, pas optionnel.

3.5 Les bandes pondérées en DSL : WeightedAtLeast / WeightedAtMost / WeightedExactly (fork #10605)

La section 3.3 exprime les fenêtres nutritionnelles directement sur l’API Z3 : MkPBGe / MkPBLe à coefficients réels — l’énergie de chaque recette pondère son indicateur. Après le one-hot non pondéré (§3.4, ExactlyOne / AtMostOne / AtLeastOne), le fork complète la surface DSL avec les bandes pondérées : Z3Methods.WeightedAtLeast(bound, weights, ...) / WeightedAtMost / WeightedExactly, traduites par le ExpressionVisitor en MkPBGe / MkPBLe / MkPBEq natifs (fork PR #24, mesuré c.8252). Le pari #4616 se repose pour les bandes : l’expansion MkIte + MkAdd « tomberait-elle » elle aussi ? Sur une bande pondérée réelle (énergies du cache jusqu’à ~144 000 kJ), la réponse est plus tranchée que le 653x de la §3.4. La cellule mesure une échelle sur des sous-ensembles croissants de candidats — même formule, bande recalculée sur chaque sous-ensemble : l’expansion passe de l’ordre de la seconde dès quelques centaines de candidats à plusieurs secondes à 1 600, puis ne rend plus du tout à l’échelle pleine (R = 3 717 : interrompue en développement à la borne du protocole de bench, cf. la sortie de la cellule suivante — et le soft timeout Z3 posé sur le solveur n’arrive pas à interrompre la normalisation des grands coefficients). Le natif, lui, reste plat : des centisecondes à chaque échelon, jusqu’à l’échelle pleine. La propagation pseudo-booléenne de Z3 ne se perd pas dans l’expansion arithmétique — elle n’y entre jamais.

// ---- 3.5 DSL Z3.Linq : bandes ponderees -> MkPBGe/MkPBLe/MkPBEq natifs (fork #10605, PR #24) ----
// CS1701 (System.Linq.Expressions 8.0 vs 10.0) : cf. cellule 3.4.
#pragma warning disable CS1701
using Z3.Linq;

// (a) Une bande ponderee tient en clauses LINQ. MenuW : 8 recettes candidates pour 2 plats, poids =
//     energie reelle (kJ) des 8 premieres recettes du cache. La bande est calibree sur les sommes
//     atteignables (28 paires) : plancher/plafond = terciles des sommes triees -> SAT garanti, et le
//     solveur doit vraiment PESER les indicateurs, pas juste en allumer un au hasard.
var wW = Enumerable.Range(0, 8).Select(r => vint[0][r]).ToArray();
var onesW = Enumerable.Repeat(1, 8).ToArray();
var sumsW = (from i in Enumerable.Range(0, 8) from j in Enumerable.Range(0, 8) where i < j select wW[i] + wW[j]).OrderBy(s => s).ToList();
int loW = sumsW[sumsW.Count / 3], hiW = sumsW[2 * sumsW.Count / 3];
var dctxW = new Z3Context();
var thW = dctxW.NewTheorem<MenuW>()
    .Where(t => Z3Methods.WeightedExactly(2, onesW, t.S[0], t.S[1], t.S[2], t.S[3], t.S[4], t.S[5], t.S[6], t.S[7]))
    .Where(t => Z3Methods.WeightedAtLeast(loW, wW, t.S[0], t.S[1], t.S[2], t.S[3], t.S[4], t.S[5], t.S[6], t.S[7]))
    .Where(t => Z3Methods.WeightedAtMost(hiW, wW, t.S[0], t.S[1], t.S[2], t.S[3], t.S[4], t.S[5], t.S[6], t.S[7]));
var swDW = Stopwatch.StartNew();
var pickW = thW.Solve();
swDW.Stop();
if (pickW != null)
{
    var namesW = Enumerable.Range(0, 8).Where(i => pickW.S[i]).Select(i => plats[i].title).ToList();
    int eW = Enumerable.Range(0, 8).Where(i => pickW.S[i]).Sum(i => wW[i]);
    Console.WriteLine($"DSL bandes ponderees (8 recettes, exactement 2 + bande energie [{loW},{hiW}] kJ) : {swDW.Elapsed.TotalSeconds:F3}s -> SAT, {eW} kJ : " + string.Join(" | ", namesW));
}
else
{
    Console.WriteLine($"DSL bandes ponderees : UNSAT sur la bande [{loW},{hiW}] kJ (recalibrer si le cache a change).");
}

// (b) Echelle : meme formule (exactly-one + bande IQR des energies, recalculee sur le sous-ensemble),
//     deux encodages, sous-ensembles croissants. Le natif = exactement ce que le visiteur du DSL emet
//     pour WeightedAtLeast/AtMost. Guard 60s par solveur (le soft timeout Z3 borne ces tailles-la).
Console.WriteLine("Echelle bande ponderee reelle (coeffs = energie kJ des n premieres recettes) :");
foreach (var n in new[] { 200, 400, 800, 1600 })
{
    var subE = Enumerable.Range(0, n).Select(r2 => vint[0][r2]).ToArray();
    var sortedE = subE.OrderBy(x => x).ToArray();
    int loS = sortedE[(int)(0.25 * (n - 1))], hiS = sortedE[(int)(0.75 * (n - 1))];

    var ctxN = new Context(); var sN = ctxN.MkSolver(); sN.Set("timeout", 60000u);
    var bN = Enumerable.Range(0, n).Select(i => ctxN.MkBoolConst($"ln_{i}")).ToArray();
    var onesN = Enumerable.Repeat(1, n).ToArray();
    sN.Assert(ctxN.MkPBEq(onesN, bN, 1));
    sN.Assert(ctxN.MkPBGe(subE, bN, loS));
    sN.Assert(ctxN.MkPBLe(subE, bN, hiS));
    var swN = Stopwatch.StartNew(); var resN = sN.Check(); swN.Stop();
    ctxN.Dispose();

    var ctxE = new Context(); var sE = ctxE.MkSolver(); sE.Set("timeout", 60000u);
    var bE = Enumerable.Range(0, n).Select(i => ctxE.MkBoolConst($"le_{i}")).ToArray();
    var cardE = ctxE.MkAdd(bE.Select(b => (ArithExpr)ctxE.MkITE(b, ctxE.MkInt(1), ctxE.MkInt(0))).ToArray());
    sE.Assert(ctxE.MkEq(cardE, ctxE.MkInt(1)));
    var bandE = ctxE.MkAdd(bE.Select((b, ix) => (ArithExpr)ctxE.MkITE(b, ctxE.MkInt(subE[ix]), ctxE.MkInt(0))).ToArray());
    sE.Assert(ctxE.MkGe(bandE, ctxE.MkInt(loS)));
    sE.Assert(ctxE.MkLe(bandE, ctxE.MkInt(hiS)));
    var swE = Stopwatch.StartNew(); var resE = sE.Check(); swE.Stop();
    ctxE.Dispose();

    Console.WriteLine($"   n={n,4} bande [{loS},{hiS}] : natif PB {swN.Elapsed.TotalSeconds:F2}s -> {resN} | expansion MkIte {swE.Elapsed.TotalSeconds:F2}s -> {resE}");
}

// (c) L'echelle pleine, cote natif : R booleens, bande IQR de toutes les energies. L'expansion, elle,
//     ne rend PAS a cette taille (3 executions coupees a 600 s en dev ; le soft timeout pose sur le
//     solveur n'interrompt pas la normalisation des grands coefficients) -- d'ou l'echelle en (b).
int loB = (int)Pq(0, 0.25), hiB = (int)Pq(0, 0.75);
var ctxF = new Context(); var sF = ctxF.MkSolver(); sF.Set("timeout", 120000u);
var bF = Enumerable.Range(0, R).Select(r2 => ctxF.MkBoolConst($"fn_{r2}")).ToArray();
var onesF = Enumerable.Repeat(1, R).ToArray();
sF.Assert(ctxF.MkPBEq(onesF, bF, 1));
sF.Assert(ctxF.MkPBGe(vint[0], bF, loB));
sF.Assert(ctxF.MkPBLe(vint[0], bF, hiB));
var swF = Stopwatch.StartNew(); var resF = sF.Check(); swF.Stop();
Console.WriteLine($"Echelle pleine : natif PB R={R}, bande IQR [{loB},{hiB}] -> {resF} en {swF.Elapsed.TotalSeconds:F2}s ; expansion : pas de reponse sous 600 s (mur mesure entre n=1600 et R=3717).");
Console.WriteLine("   (cf. pb-bench c.8252 du fork : n=100 pondere -> 103 vs 307 noeuds AST ; la §3.4 mesurait 653x en resolution non ponderee, ici l'ecart devient qualitatif : plat vs mur)");

public class MenuW { public bool[] S { get; set; } = new bool[8]; }
DSL bandes ponderees (8 recettes, exactement 2 + bande energie [14041,26268] kJ) : 0,024s -> SAT, 19614 kJ : #1 Lemon Bars | $100 Chocolate Cake
Echelle bande ponderee reelle (coeffs = energie kJ des n premieres recettes) :
   n= 200 bande [4479,15886] : natif PB 0,01s -> SATISFIABLE | expansion MkIte 0,14s -> SATISFIABLE
   n= 400 bande [3825,14229] : natif PB 0,01s -> SATISFIABLE | expansion MkIte 0,35s -> SATISFIABLE
   n= 800 bande [3900,14452] : natif PB 0,02s -> SATISFIABLE | expansion MkIte 2,33s -> SATISFIABLE
   n=1600 bande [4123,13999] : natif PB 0,03s -> SATISFIABLE | expansion MkIte 10,01s -> SATISFIABLE
Echelle pleine : natif PB R=3717, bande IQR [3350,12637] -> SATISFIABLE en 0,07s ; expansion : pas de reponse sous 600 s (mur mesure entre n=1600 et R=3717).
   (cf. pb-bench c.8252 du fork : n=100 pondere -> 103 vs 307 noeuds AST ; la §3.4 mesurait 653x en resolution non ponderee, ici l'ecart devient qualitatif : plat vs mur)

4. Partitionnement par catégorie : reduire R par creneau

Le one-hot plat traite les cinq creneaux de chaque menu de facon identique : chacun peut tirer dans les R recettes. Or RecipeML porte <catégories><cat> (Appetizers / Main dish / Desserts / …). En affectant un pool par creneau – le creneau 1 ne tire que dans les entrees, le creneau 5 que dans les desserts – chaque creneau ne choisit plus que dans un sous-ensemble (R/creneau plus petit). Deux gains :

  1. Moins de booléens : sum_p |pool[p]| x menus au lieu de menus x plats x R – le solveur a structurellement moins de variables a poser.
  2. Fidelite a la contrainte d’ordre du notebook 06 (entree -> plat -> dessert) : la structure du menu n’est plus emergente mais imposee par construction, et les menus produits sont coherents (une entree, un plat, un accompagnement, un pain, un dessert) au lieu de cinq desserts.

On reutilise le solveur pseudo-booléen pondere de la section 3.3, applique aux pools. C’est le pont vers un planificateur a l’echelle du corpus complet (5 215 recettes brutes, ou le one-hot plat atteindrait ~183 000 booléens).

// ---- partitionnement par categorie : un pool de recettes par creneau ----
string[] COURSES = { "Entree", "Plat principal", "Accompagnement", "Pain", "Dessert" };
var COURSE_CATS = new[]
{
    new HashSet<string>(StringComparer.OrdinalIgnoreCase){ "Appetizers","Soups","Salads","Salad","Soup" },
    new HashSet<string>(StringComparer.OrdinalIgnoreCase){ "Main dish","Meats","Beef","Poultry","Seafood","Fish","Pasta","Casseroles","Chili","Pork","Chicken","Stews" },
    new HashSet<string>(StringComparer.OrdinalIgnoreCase){ "Vegetables","Vegetarian","Sauces","Sauce","Side dishes","Rice","Potatoes" },
    new HashSet<string>(StringComparer.OrdinalIgnoreCase){ "Breads","Bread","Muffins","Rolls","Biscuits" },
    new HashSet<string>(StringComparer.OrdinalIgnoreCase){ "Desserts","Cakes","Cake","Cookies","Chocolate","Fruits","Pies","Candy","Pastries" },
};
int CourseOf(List<string> cats)                                                     // 1er <cat> reconnu gagne ; defaut = plat principal
{
    foreach (var cat in cats) for (int k = 0; k < 5; k++) if (COURSE_CATS[k].Contains(cat)) return k;
    return 1;
}
var pool = new List<int>[5];
for (int k = 0; k < 5; k++) pool[k] = new List<int>();
for (int r = 0; r < R; r++) pool[CourseOf(plats[r].cats)].Add(r);                   // chaque recette -> exactement un pool
Console.WriteLine("Pools par creneau : " + string.Join(", ", Enumerable.Range(0, 5).Select(k => $"{COURSES[k]}={pool[k].Count}")));

var c3 = new Context();
var s3 = c3.MkSolver();
var swB3 = Stopwatch.StartNew();
// sel3[m][p][j] : dans le menu m, le creneau p prend la j-eme recette DE pool[p] -> |pool[p]| booleens / creneau
var sel3 = new BoolExpr[NMENUS][][];
long nb3 = 0;
for (int m = 0; m < NMENUS; m++)
{
    sel3[m] = new BoolExpr[NPLATS][];
    for (int p = 0; p < NPLATS; p++) { sel3[m][p] = Enumerable.Range(0, pool[p].Count).Select(j => c3.MkBoolConst($"c_{m}_{p}_{j}")).ToArray(); nb3 += pool[p].Count; }
}
for (int m = 0; m < NMENUS; m++) for (int p = 0; p < NPLATS; p++)                   // exactement 1 recette / creneau
    s3.Assert(c3.MkPBEq(Enumerable.Repeat(1, pool[p].Count).ToArray(), sel3[m][p], 1));
for (int p = 0; p < NPLATS; p++) for (int j = 0; j < pool[p].Count; j++)            // variete : chaque recette <= 1x / semaine
{
    var col = Enumerable.Range(0, NMENUS).Select(m => sel3[m][p][j]).ToArray();
    s3.Assert(c3.MkPBLe(Enumerable.Repeat(1, NMENUS).ToArray(), col, 1));
}
for (int m = 0; m < NMENUS; m++) foreach (var (c, lo, hi) in restr)                 // memes bandes PB ponderees qu'en 3.3
{
    var bools = (from p in Enumerable.Range(0, NPLATS) from j in Enumerable.Range(0, pool[p].Count) select sel3[m][p][j]).ToArray();
    var coeffs = (from p in Enumerable.Range(0, NPLATS) from j in Enumerable.Range(0, pool[p].Count) select vint[c][pool[p][j]]).ToArray();
    if (hi >= 0) s3.Assert(c3.MkPBLe(coeffs, bools, hi));
    if (lo >= 0) s3.Assert(c3.MkPBGe(coeffs, bools, lo));
}
swB3.Stop();
var swS3 = Stopwatch.StartNew(); var res3 = s3.Check(); swS3.Stop();
Console.WriteLine($"course-onehot : {nb3} booleens (contre {NMENUS * NPLATS * R} en one-hot plat) -- construction {swB3.Elapsed.TotalSeconds:F1}s, resolution {swS3.Elapsed.TotalSeconds:F1}s -> {res3}");
if (res3 == Status.SATISFIABLE)
{
    var mo = s3.Model;
    for (int m = 0; m < 3; m++)
    {
        var names = new List<string>();
        for (int p = 0; p < NPLATS; p++) for (int j = 0; j < pool[p].Count; j++) if (mo.Eval(sel3[m][p][j], true).IsTrue) names.Add($"{COURSES[p]} : {plats[pool[p][j]].title}");
        Console.WriteLine($"  Menu {m + 1} -> " + string.Join("  |  ", names.Select(n => n.Length > 30 ? n.Substring(0, 30) : n)));
    }
}
Pools par creneau : Entree=286, Plat principal=1927, Accompagnement=365, Pain=401, Dessert=738
course-onehot : 26019 booleens (contre 130095 en one-hot plat) -- construction 0,3s, resolution 1,2s -> SATISFIABLE
  Menu 1 -> Entree : 24 Hour Green Salad  |  Plat principal : 1-2-3 Sweet D  |  Accompagnement : Acapulco-Los   |  Pain : Banana Bread-Mom's  |  Dessert : 1-2-3-4-5 Cake
  Menu 2 -> Entree : 7 Layer Dip  |  Plat principal : Basic Pizza D  |  Accompagnement : About Braisin  |  Pain : 100% Whole Wheat Bread   |  Dessert : 1,2,3,4 Cake
  Menu 3 -> Entree : Abondigas Venezolanas  |  Plat principal : 1-2-3-4 Cake   |  Accompagnement : Aaparagus wit  |  Pain : 100% Whole Wheat  |  Dessert : 1,000 Calorie-A-Bite

Interpretation : la structure imposee aide le solveur

Le partitionnement divise le nombre de booléens (chaque creneau ne voit que son pool, pas tout le corpus) et garantit des menus structures – chaque ligne ci-dessus est bien entree / plat / accompagnement / pain / dessert, la ou le one-hot plat de la section 3.3 pouvait empiler cinq desserts. La même machinerie pseudo-booleenne (MkPBEq / MkPBLe / MkPBGe) s’applique aux pools sans changement : c’est l’espace d’indices qui retrecit, pas l’encodage. A l’echelle du corpus complet, on partitionnerait plus finement (sous-catégories, equilibrage des pools) – mais le principe “un pool par creneau” suffit a casser la croissance en plats x R en une croissance par pool.

5. Restriction patient : un menu vegetarien

L’utilisateur a observe que les recettes sont des contraintes enablantes : plus de recettes = plus de solutions, le solveur converge plus facilement. Une restriction patient (regime, allergie, intolerance) joue le rôle inverse – elle retrecit l’espace des solutions sans en changer la taille d’encodage. On le montre ici avec un menu vegetarien : il suffit d’interdire toute recette dont une <cat> RecipeML est une catégorie viande/poisson (Meats, Beef, Poultry, Seafood, Fish, Pork, Chicken, Stews), exactement comme l’exclusion d’allergene – une clause unaire MkNot(sel...) par booléen concerne. Aucune bande nutritionnelle n’est touchee : l’encodage par pools de la section 4 est reutilise tel quel, on ajoute seulement des contraintes negatives. Le corpus reste assez riche (pool plat principal a des centaines de recettes non carnees : Main dish, Pasta, Casseroles…) pour que le problème demeure SATISFIABLE.

// ---- restriction patient : menu vegetarien (exclusion categorielle sur les memes pools qu'en section 4) ----
var BANNED_VEG = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
    { "Meats", "Beef", "Poultry", "Seafood", "Fish", "Pork", "Chicken", "Stews" };
int nForbidden = Enumerable.Range(0, R).Count(r => plats[r].cats.Any(cc => BANNED_VEG.Contains(cc)));

var c5 = new Context();
var s5 = c5.MkSolver();
var sel5 = new BoolExpr[NMENUS][][];                                                // meme forme que sel3 (pools)
for (int m = 0; m < NMENUS; m++)
{
    sel5[m] = new BoolExpr[NPLATS][];
    for (int p = 0; p < NPLATS; p++) sel5[m][p] = Enumerable.Range(0, pool[p].Count).Select(j => c5.MkBoolConst($"v_{m}_{p}_{j}")).ToArray();
}
for (int m = 0; m < NMENUS; m++) for (int p = 0; p < NPLATS; p++)                   // exactement 1 recette / creneau
    s5.Assert(c5.MkPBEq(Enumerable.Repeat(1, pool[p].Count).ToArray(), sel5[m][p], 1));
for (int p = 0; p < NPLATS; p++) for (int j = 0; j < pool[p].Count; j++)            // variete : chaque recette <= 1x / semaine
{
    var col = Enumerable.Range(0, NMENUS).Select(m => sel5[m][p][j]).ToArray();
    s5.Assert(c5.MkPBLe(Enumerable.Repeat(1, NMENUS).ToArray(), col, 1));
}
for (int m = 0; m < NMENUS; m++) foreach (var (c, lo, hi) in restr)                 // memes bandes nutritionnelles PB
{
    var bools = (from p in Enumerable.Range(0, NPLATS) from j in Enumerable.Range(0, pool[p].Count) select sel5[m][p][j]).ToArray();
    var coeffs = (from p in Enumerable.Range(0, NPLATS) from j in Enumerable.Range(0, pool[p].Count) select vint[c][pool[p][j]]).ToArray();
    if (hi >= 0) s5.Assert(c5.MkPBLe(coeffs, bools, hi));
    if (lo >= 0) s5.Assert(c5.MkPBGe(coeffs, bools, lo));
}
// LA restriction : interdire toute recette viande/poisson dans chaque creneau ou elle pourrait apparaitre
for (int m = 0; m < NMENUS; m++) for (int p = 0; p < NPLATS; p++) for (int j = 0; j < pool[p].Count; j++)
    if (plats[pool[p][j]].cats.Any(cc => BANNED_VEG.Contains(cc))) s5.Assert(c5.MkNot(sel5[m][p][j]));

var swS5 = Stopwatch.StartNew(); var res5 = s5.Check(); swS5.Stop();
Console.WriteLine($"vegetarien : {nForbidden} recettes viande/poisson interdites -- resolution {swS5.Elapsed.TotalSeconds:F1}s -> {res5}");
if (res5 == Status.SATISFIABLE)
{
    var mo5 = s5.Model;
    var names = new List<string>();
    for (int p = 0; p < NPLATS; p++) for (int j = 0; j < pool[p].Count; j++) if (mo5.Eval(sel5[0][p][j], true).IsTrue) names.Add($"{COURSES[p]} : {plats[pool[p][j]].title}");
    Console.WriteLine("  Menu vegetarien 1 -> " + string.Join("  |  ", names.Select(n => n.Length > 30 ? n.Substring(0, 30) : n)));
}
vegetarien : 349 recettes viande/poisson interdites -- resolution 1,0s -> SATISFIABLE
  Menu vegetarien 1 -> Entree : Apple and Apricot Chu  |  Plat principal : 1-Pot Creamy   |  Accompagnement : Acapulco Rice  |  Pain : 100% Whole Wheat Bread   |  Dessert : 1-2-3-4-5 Cake

Exercices

Exercice 1 — seuil de non-satisfiabilité

Resserrez la borne haute d’énergie par menu (constituant 0 ; la valeur calibrée hiE est affichée par la cellule des paramètres) jusqu’au seuil où le problème 7×5 devient UNSATISFIABLE. À partir de quelle énergie maximale cumulée le planificateur ne trouve-t-il plus de semaine valide ?

Indice : reprenez l’encodage one-hot pseudo-booléen de la section 3.3 en faisant varier le hi de la bande énergie dans { hiE, 3 * hiE / 4, hiE / 2, hiE / 4 }, et observez le Status renvoyé par s.Check().

// Exercice 1 : trouver le seuil d'energie maximale ou 7x5 devient UNSATISFIABLE.
// Indice : reprenez l'encodage one-hot de la section 3.3 ; faites varier la borne 'hi'
//          de la bande energie (constituant 0, ligne (0, loE, hiE) de restr) dans
//          { hiE, 3 * hiE / 4, hiE / 2, hiE / 4 } et affichez le Status de s.Check() pour chaque valeur.
// Etape 1 : encapsuler la section 3.3 dans une fonction SolveEnergyCap(int hi) -> Status.
// Etape 2 : boucler sur les seuils, reperer la bascule SAT -> UNSAT.
Console.WriteLine("Exercice 1 a completer : seuil UNSAT de la fenetre energetique.");
Exercice 1 a completer : seuil UNSAT de la fenetre energetique.

Exercice 2 : plancher de glucides par menu

Ajoutez une borne basse sur les glucides (constituant d’index 2) de chaque menu, sur le modèle one-hot de la section 3.3.

// Exercice 2 : plancher de glucides par menu (constituant index 2).
// Indice : meme patron que la bande proteines. Avec l'encodage PB pondere :
//   var bools = (from p in Enumerable.Range(0,NPLATS) from r in Enumerable.Range(0,R) select sel[m][p][r]).ToArray();
//   var coeffs = (from p in Enumerable.Range(0,NPLATS) from r in Enumerable.Range(0,R) select vint[2][r]).ToArray();
//   s.Assert(ctx.MkPBGe(coeffs, bools, GLUCIDES_MIN));   // pour chaque menu m
// Etape 1 : choisir GLUCIDES_MIN (ex. 150).
// Etape 2 : ajouter la contrainte pour chaque menu, re-resoudre, verifier que chaque menu atteint le seuil.
Console.WriteLine("Exercice 2 a completer : plancher de glucides par menu.");
Exercice 2 a completer : plancher de glucides par menu.

Exercice 3 : exclusion d’un allergene par mot-cle

Interdisez toute recette dont le titre contient un mot-cle (ex. Peanut, Shrimp).

// Exercice 3 : exclure les recettes dont le titre contient un mot-cle allergene.
// Indice :
//   string[] bannis = { "Peanut", "Shrimp", "Crab" };
//   for (int r = 0; r < R; r++)
//       if (bannis.Any(b => plats[r].title.IndexOf(b, StringComparison.OrdinalIgnoreCase) >= 0))
//           for (int m = 0; m < NMENUS; m++) for (int p = 0; p < NPLATS; p++) s.Assert(ctx.MkNot(sel[m][p][r]));
// Etape 1 : pre-calculer l'ensemble des indices interdits. Etape 2 : forcer sel = false. Etape 3 : re-resoudre.
Console.WriteLine("Exercice 3 a completer : exclusion d'allergene par mot-cle.");
Exercice 3 a completer : exclusion d'allergene par mot-cle.

Exercice 4 : mesurer l’explosion

Faites varier R (100, 300, R complet) et comparez le temps de construction du naif au temps construction + resolution du one-hot.

// Exercice 4 : tracer construction(naif) vs construction+resolution(one-hot) en fonction de R.
// Indice : reutilisez BuildNaive(rcap) de la section 3.1 ; pour le one-hot, encapsulez la section 3.3
//          dans une fonction SolveOneHot(int rcap) qui ne garde que les rcap premieres recettes.
// Etape 1 : pour rcap in {100, 300, R}, mesurer les deux temps.
// Etape 2 : observer que le naif croit en R (construction) tandis que le one-hot reste constructible.
Console.WriteLine("Exercice 4 a completer : mesure comparative de l'explosion.");
Exercice 4 a completer : mesure comparative de l'explosion.

Conclusion

Points clés à retenir

  • La séparation données / modélisation paie : le notebook 07 livre un cache curé (appariement sans faux positifs, agrégation pondérée par la masse) ; ce notebook n’a plus qu’à encoder et résoudre. Un solveur ne rattrape jamais des données mal jointes.
  • À l’échelle, l’encodage prime sur la compacité : one-hot pseudo-booléen (construit + résolu) > théorie des tableaux (compacte mais unknown) > naïf (explose à la construction).
  • « Plus de données = contraintes enablantes » : bien encodé, un gros corpus se résout sans énumération.
  • Le partitionnement par catégorie (section 4) réduit R par créneau et impose la structure du menu (entrée → dessert) : c’est le chemin vers le corpus RecipeML complet (5 215 recettes brutes).

Clôture du bloc meal-planner (Epic #4677, capstone #4617)

Ce notebook clôt l’arc du planificateur de repas : 06 pose les modèles Z3 sur un corpus jouet, 07 construit la couche de données réelle Ciqual × RecipeML, 08 pousse le théorème patient en capstone déclaratif, et ce notebook fait converger le problème fidèle à l’échelle réelle. La leçon structurante : la puissance du solveur Z3 ne compense pas un mauvais encodage — c’est la reformulation one-hot pseudo-booléenne qui fait converger le planificateur (les fenêtres nutritionnelles patient deviennent des contraintes cardinales pondérées natives MkPBGe/MkPBLe, et l’espace de recherche rétrécit aux sélecteurs booléens plutôt qu’à l’énumération disjonctive des compositions). La même leçon d’encodage vaut pour tout problème combinatoire où le corpus grossit.

Retour au sommet