MGS-24 : SimulatedAnnealing MGS contre mealpy — deux recuits, deux architectures

Navigation : << MGS-23 (DE vs mealpy) | Index

Kernel : .NET (C#) — pont PythonNet vers mealpy dans la même exécution


Introduction

Deux paires mesurées, deux verdicts différents : sur PSO (MGS-22), mealpy dominait nettement (28,5 contre 43,5 conflits médians) ; sur DE (MGS-23), quasi-parité de qualité (21,5 contre 25,5) et MGS trois fois plus rapide par évaluation. La paire recuit simulé est la plus structurellement divergente de l’Epic #12373 : les deux moteurs ne partagent même pas la notion d’itération —

  • MGS SimulatedAnnealing est un composé géométrique population-based : population de 50 vecteurs, appariement Current/Random, croisement géométrique, réinsertion Metropolis avec température géométrique T_k = T_0·α^k (T_0 = 1,0, α = 0,95 par défaut) ;
  • mealpy OriginalSA est un marcheur single-solution : une seule solution perturbée par un pas gaussien (step_size = 0,1), acceptée selon exp(−Δ/T) avec T = temp_init/epoch (temp_init = 100 par défaut), une évaluation par epoch.

La comparaison reste loyale (même substrat, budget d’évaluations égalisé, protocole hérité), et la divergence d’architecture est déclarée d’entrée : c’est précisément ce qui rend cette paire instructive. Enfants de l’Epic #12373 (comparaison appariée MGS ↔︎ mealpy) : une paire, un notebook, une PR.


1. Le protocole apparié, pré-enregistré — hérité de MGS-22, non renégocié

Le protocole est celui de MGS-22 (#12302), repris intégralement pour que les paires de l’Epic soient comparables entre elles :

  • même substrat : grille Easy[0] de Sudoku_Easy51.txt, représentation R1 (continu + arrondi), fonction de coût = conflits totaux d’une grille pleine ;
  • budget d’évaluations égalisé — mesuré, pas supposé : les compteurs des deux moteurs sont rapportés tels quels. La structure de cette paire impose des réglages distincts : MGS consomme ~pop 50 × 160 générations ≈ 8 000 évaluations, mealpy OriginalSA consomme 1 évaluation par epoch (marcheur single-solution) → epoch = 8 000 ;
  • 4 graines nommées {0, 1, 7, 42}, médiane + min/max, jamais un run isolé ;
  • contre-vérification croisée du coût : le vainqueur mealpy est décodé et coûté côté C# — sans elle on compare deux fonctions de coût, pas deux moteurs ;
  • ms/éval séparé du temps total ;
  • graines passées explicitement aux deux moteurs : solve(prob, seed=N) côté mealpy (le paramètre du constructeur est silencieusement ignoré en 3.x), ResetSeed(N) côté MGS ;
  • déterminisme vérifié (répétition, conflits identiques exigés) avant de publier le moindre chiffre.

Paramètres par défaut de chaque bibliothèque, mesurés et déclarés (c’est le protocole MGS-22 : on compare les bibliothèques telles que leurs auteurs les livrent) :

moteur architecture température opérateur de mouvement
MGS SimulatedAnnealing population 50, composé géométrique T_0 = 1,0, α = 0,95 (géométrique par génération) croisement géométrique Current×Random, StepFraction 0,5
mealpy OriginalSA single-solution temp_init = 100, T = temp_init/epoch pas gaussien, step_size 0,1

Ici le confond n’est pas un paramètre isolé comme le F du DE : c’est l’architecture entière — recuit géométrique en population contre marche gaussienne mono-solution. Les exercices 1 et 3 alignent séparément les deux paramètres transportables (température initiale, amplitude du pas) ; la divergence de structure, elle, se mesure et se déclare — elle ne s’annule pas.

// === MGS-24 : socle commun — DLLs MGS, grille de référence, fonction de coût ===
// Même socle que MGS-22/23 : la représentation R1 (continu + arrondi) est le substrat du bench.
#r "../../MetaGeneticSharp/src/MetaGeneticSharp.Domain/bin/Debug/net9.0/GeneticSharp.Infrastructure.Framework.dll"
#r "../../MetaGeneticSharp/src/MetaGeneticSharp.Domain/bin/Debug/net9.0/MetaGeneticSharp.Infrastructure.dll"
#r "../../MetaGeneticSharp/src/MetaGeneticSharp.Domain/bin/Debug/net9.0/MetaGeneticSharp.Domain.dll"
using MetaGeneticSharp;
using GeneticSharp;
using System.Diagnostics;

// Grille facile Easy[0] de Sudoku_Easy51.txt — la MÊME que MGS-21/22/23.
public static string PuzzleLine24 = "902005403100063025508407060026309001057010290090670530240530600705200304080041950";

public static int[,] ParsePuzzle24()
{
    var g = new int[9, 9];
    for (int i = 0; i < 81; i++) g[i / 9, i % 9] = PuzzleLine24[i] - '0';
    return g;
}

// Fonction de coût du bench : conflits totaux (lignes + colonnes + blocs) sur grille PLEINE.
// Renvoie 0 ssi résolu. Le côté Python réimplémente exactement ce comptage.
public static int CountConflicts24(int[,] g)
{
    int conflicts = 0;
    for (int i = 0; i < 9; i++)
    {
        var row = new HashSet<int>(); var col = new HashSet<int>(); var blk = new HashSet<int>();
        for (int j = 0; j < 9; j++)
        {
            if (!row.Add(g[i, j])) conflicts++;
            if (!col.Add(g[j, i])) conflicts++;
            int br = 3 * (i / 3) + j / 3, bc = 3 * (i % 3) + j % 3;
            if (!blk.Add(g[br, bc])) conflicts++;
        }
    }
    return conflicts;
}

public static int CountEmpty24(int[,] p) { int n = 0; foreach (var v in p) if (v == 0) n++; return n; }

public static List<(int r, int c)> EmptyCells24(int[,] p)
{
    var l = new List<(int, int)>();
    for (int r = 0; r < 9; r++) for (int c = 0; c < 9; c++) if (p[r, c] == 0) l.Add((r, c));
    return l;
}

// Décodage R1 : arrondi + clamp vers 1..9 sur les cellules vides, ordre de lecture.
public static int[,] DecodeR1_24(double[] genes)
{
    var Puzzle = ParsePuzzle24();
    var empties = EmptyCells24(Puzzle);
    var g = (int[,])Puzzle.Clone();
    for (int k = 0; k < empties.Count; k++)
        g[empties[k].r, empties[k].c] = Math.Max(1, Math.Min(9, (int)Math.Round(genes[k])));
    return g;
}

var Puzzle24 = ParsePuzzle24();
Console.WriteLine($"Grille de référence : {CountEmpty24(Puzzle24)} cellules vides, " +
                  $"{81 - CountEmpty24(Puzzle24)} indices fixes, {EmptyCells24(Puzzle24).Count} gènes R1.");
Grille de référence : 36 cellules vides, 45 indices fixes, 36 gènes R1.

Lecture. Le socle est posé, identique à MGS-22/23 au nom près — c’est voulu : la comparabilité de l’Epic #12373 tient à ce que chaque paire courre sur exactement le même substrat. 36 cellules vides = 36 gènes continus dans [1, 10), la fonction de coût compte les doublons ligne/colonne/bloc d’une grille pleine et vaut 0 ssi résolue.

// === Moteur MGS : chromosome R1, fitness instrumentée, composé SimulatedAnnealing ===
// Recuit simulé GÉOMÉTRIQUE MGS : composé population-based (MatchMetaHeuristic Current×Random,
// croisement géométrique, réinsertion Metropolis), T_0 = 1,0, alpha = 0,95 par défaut.
public class SudokuR1Chromosome24 : ChromosomeBase
{
    private const double LO = 1.0, HI = 10.0;
    public SudokuR1Chromosome24() : base(EmptyCells24(ParsePuzzle24()).Count) { CreateGenes(); }
    public override Gene GenerateGene(int index)
        => new Gene(RandomizationProvider.Current.GetDouble(LO, HI));
    public override IChromosome CreateNew() => new SudokuR1Chromosome24();
    public double[] ToGenes() { var v = new double[Length]; for (int i = 0; i < Length; i++) v[i] = (double)GetGene(i).Value; return v; }
    public int[,] ToGrid() => DecodeR1_24(ToGenes());
}

// Fitness instrumentée : chaque évaluation est comptée — le budget se mesure, il ne se suppose pas.
public class SudokuR1Fitness24 : IFitness
{
    public static int Evals;
    public double Evaluate(IChromosome chromosome)
    {
        Evals++;
        return -CountConflicts24(((SudokuR1Chromosome24)chromosome).ToGrid());
    }
}

public static class Mgs24Host
{
    public static (int conflicts, int evals, double ms, double[] genes) RunSa(int seed, int popSize, int maxGens)
    {
        // Seeding AVANT création de population : le RNG est consommé par CreateNew()
        // de chaque individu initial (leçon #12071 / MGS-21).
        FastRandomRandomization.ResetSeed(seed);
        var compound = MetaHeuristicsService.CreateMetaHeuristicByName(
            "SimulatedAnnealing", maxGens, popSize);
        var adam = new SudokuR1Chromosome24();
        var pop = new MetaPopulation(popSize, popSize, adam);
        var ga = new MetaGeneticAlgorithm(
            pop, new SudokuR1Fitness24(),
            new EliteSelection(), new UniformCrossover(0.5f), new UniformMutation(true),
            compound);
        ga.Termination = new GenerationNumberTermination(maxGens);
        SudokuR1Fitness24.Evals = 0;
        var sw = Stopwatch.StartNew();
        ga.Start();
        sw.Stop();
        var best = (SudokuR1Chromosome24)ga.BestChromosome;
        return (CountConflicts24(best.ToGrid()), SudokuR1Fitness24.Evals,
                sw.Elapsed.TotalMilliseconds, best.ToGenes());
    }
}

// Échauffement JIT (course jetée), puis course témoin graine 7.
var warmupMgs = Mgs24Host.RunSa(123, 50, 10);
var demoMgs = Mgs24Host.RunSa(7, 50, 160);
Console.WriteLine($"MGS SA (graine 7, témoin) : {demoMgs.Item1} conflits, " +
                  $"{demoMgs.Item2} évaluations, {demoMgs.Item3:F0} ms.");
MGS SA (graine 7, témoin) : 26 conflits, 8000 évaluations, 651 ms.

Lecture. Le composé MGS SimulatedAnnealing (T_0 = 1,0, α = 0,95, population 50 avec réinsertion Metropolis) est branché sur le même harnais que le DE de MGS-23 : chromosome R1, fitness comptée, seeding avant création de population. La course témoin graine 7 donne 26 conflits pour 8 000 évaluations. Son temps est mesuré dans l’output plutôt qu’épinglé dans la prose, car il dépend de la machine ; l’échauffement JIT précède la course mesurée pour qu’elle ne paie pas la compilation.

// === Le pont PythonNet : mealpy dans le même kernel, la même exécution ===
// Recette validée MGS-22 (#12356) : pythonnet 3.1.0, DLL résolue par probe
// (PYTHONNET_PYDLL d'abord, sinon installs standards par OS — aucun chemin machine en dur).
// NB : pythonnet 3.1.0 exige CPython >= 3.13 (symbole PyThreadState_GetUnchecked absent
// de python311.dll) — le probe résout donc la version la plus HAUTE disponible.
#r "nuget: pythonnet,3.1.0"
using Python.Runtime;
static string ResolvePythonDll24()
{
    var env = Environment.GetEnvironmentVariable("PYTHONNET_PYDLL");
    if (!string.IsNullOrEmpty(env) && System.IO.File.Exists(env)) return env;
    if (OperatingSystem.IsWindows())
    {
        // Installs CPython.org standards d'abord (les plus récentes portent les packages récents
        // comme mealpy), ensuite le scan LOCALAPPDATA — un Python périmé qui n'a pas mealpy
        // ne doit pas masquer une install plus récente (pb 2026-08-25 : Python310 2023 devant Python313).
        foreach (var c in new[] { @"C:\Python313\python313.dll", @"C:\Python312\python312.dll" })
            if (System.IO.File.Exists(c)) return c;
        var local = Environment.GetEnvironmentVariable("LOCALAPPDATA");
        if (!string.IsNullOrEmpty(local))
        {
            var pyDir = System.IO.Path.Combine(local, "Programs", "Python");
            if (System.IO.Directory.Exists(pyDir))
            {
                var dirs = System.IO.Directory.GetDirectories(pyDir, "Python3*")
                    .OrderByDescending(d => System.IO.Path.GetFileName(d).Replace("Python", ""))
                    .ToList();
                foreach (var d in dirs)
                {
                    var hit = System.IO.Directory.GetFiles(d, "python3*.dll");
                    if (hit.Length == 0) continue;
                    // python3.dll (stub ABI stable, 10 chars) ne forwarde PAS
                    // PyThreadState_GetUnchecked : preferer la DLL versionnee la
                    // plus longue (ex. python313.dll), comme la branche miniconda.
                    return hit.OrderByDescending(h => System.IO.Path.GetFileName(h).Length).First();
                }
            }
        }
        var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
        foreach (var root in new[] {
                     System.IO.Path.Combine(home, "miniconda3"),
                     System.IO.Path.Combine(home, "anaconda3"),
                     @"C:\ProgramData\miniconda3",
                     @"C:\ProgramData\anaconda3" })
        {
            if (!System.IO.Directory.Exists(root)) continue;
            var hits = System.IO.Directory.GetFiles(root, "python3*.dll");
            var pick = "";
            foreach (var h in hits)
                if (System.IO.Path.GetFileName(h).Length > 11) pick = h;
            if (pick == "" && hits.Length > 0) pick = hits[0];
            if (pick != "")
            {
                var dirs = new[] { root,
                    System.IO.Path.Combine(root, "Library", "mingw-w64", "bin"),
                    System.IO.Path.Combine(root, "Library", "bin"),
                    System.IO.Path.Combine(root, "Scripts") };
                var path = Environment.GetEnvironmentVariable("PATH") ?? "";
                var toAdd = "";
                foreach (var d in dirs)
                    if (System.IO.Directory.Exists(d) && !path.Contains(d + ";"))
                        toAdd += d + ";";
                if (toAdd != "")
                    Environment.SetEnvironmentVariable("PATH", toAdd + path);
                return pick;
            }
        }
    }
    else
    {
        var libs = new[] { "/usr/lib/x86_64-linux-gnu", "/usr/lib", "/usr/local/lib", "/opt/homebrew/lib" };
        foreach (var dir in libs)
            if (System.IO.Directory.Exists(dir))
            {
                var hit = System.IO.Directory.GetFiles(dir, "libpython3.*");
                foreach (var h in hit)
                    if (h.EndsWith(".so") || h.EndsWith(".dylib")) return h;
            }
    }
    throw new System.IO.FileNotFoundException(
        "DLL Python introuvable : definir PYTHONNET_PYDLL ou installer Python 3.10+ (mealpy requis).");
}
Runtime.PythonDLL = ResolvePythonDll24();
PythonEngine.Initialize();

// Le problème Python : decode + coût réimplémentés à l'identique, compteur d'évals,
// solveur mealpy OriginalSA avec seed EXPLICITE en solve() (API 3.x — le seed du
// constructeur est ignoré) et journal muet (log_to='nothing').
public static PyModule S24;
using (Py.GIL())
{
    S24 = Py.CreateScope();
    S24.Set("puzzle_line24", PuzzleLine24);
    S24.Exec(@"import sys
import mealpy
from mealpy.physics_based.SA import OriginalSA
from mealpy import Problem, FloatVar
import json as _json

puzzle = [int(ch) for ch in puzzle_line24]
empties = [(i // 9, i % 9) for i in range(81) if puzzle[i] == 0]

def decode(vec):
    g = [puzzle[r * 9:(r + 1) * 9] for r in range(9)]
    for k in range(len(empties)):
        r, c = empties[k]
        v = int(round(float(vec[k])))
        g[r][c] = max(1, min(9, v))
    return g

def cost(g):
    conflicts = 0
    for i in range(9):
        units = ([g[i][j] for j in range(9)],
                 [g[j][i] for j in range(9)],
                 [g[3 * (i // 3) + j // 3][3 * (i % 3) + j % 3] for j in range(9)])
        for unit in units:
            seen = set()
            for v in unit:
                if v in seen:
                    conflicts += 1
                seen.add(v)
    return conflicts

def cost_of_vector(vec):
    return cost(decode(vec))

PY_EVALS = [0]

class SudokuProblem(Problem):
    def __init__(self, bounds=None, minmax='min', **kwargs):
        super().__init__(bounds, minmax, log_to='nothing', **kwargs)
    def obj_func(self, x):
        PY_EVALS[0] += 1
        return float(cost(decode(x)))

def run_mealpy_sa(seed, pop_size, epoch, temp_init=None, step_size=None):
    import time
    PY_EVALS[0] = 0
    prob = SudokuProblem(bounds=FloatVar(lb=(1.0,) * len(empties), ub=(10.0,) * len(empties), name='genes'), minmax='min')
    kw = {}
    if temp_init is not None:
        kw['temp_init'] = temp_init
    if step_size is not None:
        kw['step_size'] = step_size
    model = OriginalSA(epoch=epoch, pop_size=pop_size, **kw)
    t0 = time.perf_counter()
    g_best = model.solve(prob, seed=seed)
    dt = (time.perf_counter() - t0) * 1000.0
    sol = _json.dumps([float(v) for v in g_best.solution])
    return cost(decode(g_best.solution)), PY_EVALS[0], dt, sol

def bench_mealpy_sa(seeds_json, pop_size, epoch, reps=3, temp_init=None, step_size=None):
    out = []
    for sd in _json.loads(seeds_json):
        runs = [run_mealpy_sa(sd, pop_size, epoch, temp_init, step_size) for _ in range(reps)]
        cs = [r[0] for r in runs]
        es = [r[1] for r in runs]
        ts = sorted(r[2] for r in runs)
        med = ts[len(ts) // 2] if len(ts) % 2 == 1 else (ts[len(ts) // 2 - 1] + ts[len(ts) // 2]) / 2.0
        out.append({'seed': sd, 'conflicts': cs[0], 'all_same': len(set(cs)) == 1,
                    'evals': es[0], 'ms': med, 'sol': runs[0][3]})
    return _json.dumps(out)

def time_python_evals(vecs_json, reps=5):
    import time
    vecs = _json.loads(vecs_json)
    ts = []
    for _ in range(reps):
        t0 = time.perf_counter()
        for v in vecs:
            cost_of_vector(v)
        ts.append((time.perf_counter() - t0) * 1000.0)
    ts.sort()
    return ts[len(ts) // 2]

# Defaults mealpy OriginalSA (3.x) : mesures, pas doc
_m = OriginalSA(epoch=10, pop_size=2)
__defaults__ = f'mealpy OriginalSA defaults: temp_init={_m.temp_init}, step_size={_m.step_size}'
__mealpy_ver__ = 'mealpy ' + mealpy.__version__ + ' sur Python ' + sys.version.split()[0]");
    Console.WriteLine($"Pont PythonNet actif : {S24.Get<string>("__mealpy_ver__")}");
    Console.WriteLine($"Parametres defaut mealpy : {S24.Get<string>("__defaults__")}");
}

// --- Sanity check : la fonction de coût est-elle la MÊME des deux côtés ? ---
// 3 vecteurs témoins DÉTERMINISTES (LCG écrit à la main), décodés et costés des deux côtés.
public static double[] LcgVector24(int seed, int n)
{
    uint state = (uint)seed;
    var v = new double[n];
    for (int i = 0; i < n; i++)
    {
        state = state * 1664525u + 1013904223u;
        v[i] = 1.0 + (state / 4294967296.0) * 9.0; // uniforme dans [1, 10)
    }
    return v;
}

var witnessVectors = new[] { LcgVector24(1, 36), LcgVector24(2, 36), LcgVector24(3, 36) };
using (Py.GIL())
{
    S24.Set("__witness_json__",
        System.Text.Json.JsonSerializer.Serialize(witnessVectors.Select(v => v.ToList()).ToList()));
    S24.Exec(@"__py_costs__ = _json.dumps([cost_of_vector(v) for v in _json.loads(__witness_json__)])");
    var pyCosts = System.Text.Json.JsonSerializer.Deserialize<List<int>>(S24.Get<string>("__py_costs__"));
    var csCosts = witnessVectors.Select(v => CountConflicts24(DecodeR1_24(v))).ToList();
    bool identical = pyCosts.SequenceEqual(csCosts);
    Console.WriteLine($"Sanity check cout : C# {string.Join(",", csCosts)} | Python {string.Join(",", pyCosts)} " +
                      $"-> {(identical ? "IDENTIQUE" : "DIFFERENT")}");
}
Installing Packages
  • pythonnet
Pont PythonNet actif : mealpy 3.0.2 sur Python 3.13.12
Parametres defaut mealpy : mealpy OriginalSA defaults: temp_init=100.0, step_size=0.1
Sanity check cout : C# 67,71,60 | Python 67,71,60 -> IDENTIQUE

Lecture. Le pont PythonNet est actif et la sanity check porte tout le bench : les trois vecteurs témoins LCG, décodés et coûtés indépendamment des deux côtés, donnent exactement les mêmes conflits (67, 71, 60 des deux côtés — IDENTIQUE). Sans cette égalité prouvée, une différence mesurée entre moteurs pourrait n’être qu’une différence entre les deux fonctions de coût. Les défauts mealpy (temp_init, step_size) sont mesurés sur l’instance, pas recopiés de la doc — c’est la ligne « Parametres defaut mealpy » ci-dessus qui fait foi pour le tableau du §1.

// === Moteur mealpy : course témoin + contre-vérification croisée du vainqueur ===
// Budget du marcheur : epoch = 8000 (1 évaluation par epoch, la population initiale de 2 à part).
using (Py.GIL())
{
    // Échauffement symétrique (course jetée), puis course témoin graine 7.
    S24.Exec(@"_wu_c, _wu_e, _wu_t, _wu_sol = run_mealpy_sa(123, 2, 50)
__d_c__, __d_e__, __d_t__, __d_sol__ = run_mealpy_sa(7, 2, 8000)");
    int dConflicts = S24.Get<int>("__d_c__");
    int dEvals = S24.Get<int>("__d_e__");
    double dMs = S24.Get<double>("__d_t__");
    Console.WriteLine($"mealpy OriginalSA (graine 7, témoin) : {dConflicts} conflits, " +
                      $"{dEvals} évaluations, {dMs:F0} ms.");

    // Contre-vérification croisée : le vainqueur mealpy, décodé et costé côté C#.
    var solJson = S24.Get<string>("__d_sol__");
    var genes = System.Text.Json.JsonSerializer.Deserialize<double[]>(solJson);
    int csRecheck = CountConflicts24(DecodeR1_24(genes));
    Console.WriteLine($"Contre-vérif croisée : coût C# du meilleur mealpy = {csRecheck} " +
                      $"(Python rapporte {dConflicts}) -> {(csRecheck == dConflicts ? "IDENTIQUE" : "DIFFERENT")}");
}
mealpy OriginalSA (graine 7, témoin) : 34 conflits, 8002 évaluations, 2679 ms.
Contre-vérif croisée : coût C# du meilleur mealpy = 34 (Python rapporte 34) -> IDENTIQUE

2. Le croisement — 2 moteurs × 4 graines à budget égal

Population 50 × 160 générations côté MGS (~8 000 évaluations), epoch = 8 000 côté mealpy (1 évaluation par epoch : hors la population initiale de 2 individus, le budget du marcheur est exactement son nombre d’epochs). Graines {0, 1, 7, 42}, trois répétitions par graine côté MGS pour la médiane de temps (amendement anti-pic GC), déterminisme exigé partout.

// === LE BENCH : 2 moteurs x 4 graines {0,1,7,42} — MGS pop 50 x 160 générations,
// mealpy marcheur epoch 8000 : budgets mesurés par les compteurs, pas supposés. ===
public class BenchRow24
{
    public int seed { get; set; }
    public int conflicts { get; set; }
    public bool all_same { get; set; }
    public int evals { get; set; }
    public double ms { get; set; }
    public string sol { get; set; }
}

int[] Seeds24 = { 0, 1, 7, 42 };

// --- Côté MGS (C#) : 3 répétitions par graine, ms = médiane (amendement §2) ---
var mgsRows = new List<(int seed, int conflicts, int evals, double ms, bool allSame)>();
foreach (var sd in Seeds24)
{
    var runs3 = new List<(int c, int e, double t)>();
    for (int rep = 0; rep < 3; rep++)
    {
        var r = Mgs24Host.RunSa(sd, 50, 160);
        runs3.Add((r.Item1, r.Item2, r.Item3));
    }
    var times = runs3.Select(x => x.t).OrderBy(t => t).ToList();
    double med = times[1];
    mgsRows.Add((sd, runs3[0].c, runs3[0].e, med, runs3.All(x => x.c == runs3[0].c)));
}

// --- Côté mealpy (Python, boucle unique dans le scope) ---
string mealpyJson;
using (Py.GIL())
{
    S24.Set("__seeds_json__", System.Text.Json.JsonSerializer.Serialize(Seeds24.ToList()));
    S24.Exec(@"__bench_json__ = bench_mealpy_sa(__seeds_json__, 2, 8000)");
    mealpyJson = S24.Get<string>("__bench_json__");
}
var mealpyRows = System.Text.Json.JsonSerializer.Deserialize<List<BenchRow24>>(mealpyJson);

// --- Table ---
static double Median24(List<int> xs)
{
    var s = xs.OrderBy(x => x).ToList();
    return (s.Count % 2 == 1) ? s[s.Count / 2] : (s[s.Count / 2 - 1] + s[s.Count / 2]) / 2.0;
}

Console.WriteLine($"{"moteur",-9} {"graine",6} {"conflits",9} {"evals",7} {"ms",8} {"ms/eval",8}");
foreach (var r in mgsRows)
    Console.WriteLine($"{"MGS",-9} {r.seed,6} {r.conflicts,9} {r.evals,7} {r.ms,8:F0} {r.ms / r.evals,8:F3}");
foreach (var r in mealpyRows)
    Console.WriteLine($"{"mealpy",-9} {r.seed,6} {r.conflicts,9} {r.evals,7} {r.ms,8:F0} {r.ms / r.evals,8:F3}");

var mgsC = mgsRows.Select(r => r.conflicts).ToList();
var mpC = mealpyRows.Select(r => r.conflicts).ToList();
double mgsMsEval = mgsRows.Average(r => r.ms / r.evals);
double mpMsEval = mealpyRows.Average(r => r.ms / r.evals);
Console.WriteLine();
Console.WriteLine($"MGS    : médiane conflits {Median24(mgsC):F1} (min {mgsC.Min()}, max {mgsC.Max()}), ms/éval moyen {mgsMsEval:F3}");
Console.WriteLine($"mealpy : médiane conflits {Median24(mpC):F1} (min {mpC.Min()}, max {mpC.Max()}), ms/éval moyen {mpMsEval:F3}");
Console.WriteLine($"Rapport ms/éval mealpy/MGS : {mpMsEval / mgsMsEval:F2}x");
int deterministicRows = mgsRows.Count(r => r.allSame) + mealpyRows.Count(r => r.all_same);
Console.WriteLine($"Déterminisme : conflits identiques sur les 3 répétitions pour {deterministicRows}/8 paires graine-moteur.");

// Contrôle #13778 : les correctifs PSO (#50) et DE opt-in (#51) ne doivent pas modifier SA.
int[] preCorrectionMgsConflicts = { 22, 28, 26, 28 };
bool sameSeededQuality = mgsRows.Select(r => r.conflicts).SequenceEqual(preCorrectionMgsConflicts);
bool sameBudget = mgsRows.All(r => r.evals == 8000);
bool nonRegressionPassed = sameSeededQuality && sameBudget;
string sliceVerdict = nonRegressionPassed ? "NO IMPROVEMENT" : "INCONCLUSIVE";
Console.WriteLine($"Contrôle de non-régression MGS-24 : {(nonRegressionPassed ? "PASS" : "FAIL")}");
Console.WriteLine($"Verdict de tranche #13778 : {sliceVerdict}");
Console.WriteLine($"  conflits avant : {string.Join("/", preCorrectionMgsConflicts)}");
Console.WriteLine($"  conflits après : {string.Join("/", mgsRows.Select(r => r.conflicts))}");
Console.WriteLine($"  budget après   : {string.Join("/", mgsRows.Select(r => r.evals))} évaluations");
moteur    graine  conflits   evals       ms  ms/eval
MGS            0        22    8000      564    0,070
MGS            1        28    8000      453    0,057
MGS            7        26    8000      435    0,054
MGS           42        28    8000      387    0,048
mealpy         0        34    8002     2964    0,370
mealpy         1        28    8002     2556    0,319
mealpy         7        34    8002     3569    0,446
mealpy        42        27    8002     3046    0,381

MGS    : médiane conflits 27,0 (min 22, max 28), ms/éval moyen 0,057
mealpy : médiane conflits 31,0 (min 27, max 34), ms/éval moyen 0,379
Rapport ms/éval mealpy/MGS : 6,60x
Déterminisme : conflits identiques sur les 3 répétitions pour 8/8 paires graine-moteur.
Contrôle de non-régression MGS-24 : PASS
Verdict de tranche #13778 : NO IMPROVEMENT
  conflits avant : 22/28/26/28
  conflits après : 22/28/26/28
  budget après   : 8000/8000/8000/8000 évaluations
// === Coût par évaluation : la fitness seule, hors moteur, 500 vecteurs identiques ===
// Les vecteurs sont générés côté C# (LCG, graines 42..541) et passés en JSON au Python :
// les DEUX côtés chronomètrent decode+coût sur exactement les mêmes 500 points.
int K24 = 500;
var benchVecs = new List<double[]>();
for (int i = 0; i < K24; i++) benchVecs.Add(LcgVector24(42 + i, 36));

var csTimes = new List<double>();
for (int rep = 0; rep < 5; rep++)
{
    var swRep = Stopwatch.StartNew();
    foreach (var v in benchVecs) CountConflicts24(DecodeR1_24(v));
    swRep.Stop();
    csTimes.Add(swRep.Elapsed.TotalMilliseconds);
}
csTimes.Sort();
double csMs = csTimes[2]; // médiane de 5 (amendement §2)

double pyMs;
using (Py.GIL())
{
    S24.Set("__vecs_json__", System.Text.Json.JsonSerializer.Serialize(benchVecs.Select(v => v.ToList()).ToList()));
    S24.Exec(@"__py_ms__ = time_python_evals(__vecs_json__)");
    pyMs = S24.Get<double>("__py_ms__");
}

Console.WriteLine($"Fitness seule, {K24} vecteurs identiques (médiane de 5 répétitions par côté) :");
Console.WriteLine($"  C#     : {csMs:F1} ms total -> {csMs / K24:F3} ms/éval");
Console.WriteLine($"  Python : {pyMs:F1} ms total -> {pyMs / K24:F3} ms/éval");
Console.WriteLine($"  rapport Python/C# : {pyMs / csMs:F2}x");
Fitness seule, 500 vecteurs identiques (médiane de 5 répétitions par côté) :
  C#     : 5,0 ms total -> 0,010 ms/éval
  Python : 32,7 ms total -> 0,065 ms/éval
  rapport Python/C# : 6,47x

Lecture du croisement. MGS passe devant — et pour la première fois de l’Epic, sur les deux axes à la fois :

  • qualité : médiane 27,0 contre 31,0 conflits, étendues [22-28] contre [27-34] — le chevauchement se réduit à la marge (le minimum mealpy, 27, frôle le maximum MGS, 28). Aux graines : MGS gagne 0 et 7 net (22 contre 34, 26 contre 34), la graine 1 est ex æquo (28-28), la graine 42 va à mealpy (28 contre 27) ;
  • vitesse : les outputs mesurent l’avantage relatif par évaluation sur cette machine. La prose ne l’épingle pas, car les temps absolus et leur rapport varient avec l’environnement ;
  • le protocole est tenu : 8 000 évaluations MGS contre 8 002 mealpy (les deux individus initiaux du marcheur), déterminisme 8/8, contre-vérification croisée du coût IDENTIQUE (cellule témoin ci-dessus).

Lecture du coût par évaluation. La cellule suivante isole la fitness sur 500 vecteurs identiques et rapporte directement le coût C#, le coût Python et leur rapport pour l’exécution courante. Cette mesure distingue le coût du langage du surcoût propre aux deux moteurs sans figer dans la prose une valeur dépendante de la machine.

Verdict de la paire. Premier doublé de l’Epic : MGS domine qualité et vitesse par évaluation sur l’exécution observée. L’explication la plus économique tient à la divergence d’architecture déclarée au §1 : à budget d’évaluations égal, un recuit géométrique porté par une population de 50 (appariement Current×Random, croisement géométrique, réinsertion Metropolis) explore plus largement qu’un marcheur mono-solution à pas gaussien dont toute l’information tient dans un seul vecteur. Le confond paramétrique reste toutefois ouvert — temp_init = 100 contre T_0 = 1,0, step_size = 0,1 contre StepFraction = 0,5 — et les exercices 1 et 3 alignent chacun un paramètre transportable avant de conclure.


Exercice 1 : neutraliser le confond température — mealpy temp_init aligné sur T_0 MGS

Le protocole compare les bibliothèques à leurs paramètres par défaut, et les points de départ thermiques diffèrent d’un facteur 100 (T_0 = 1,0 contre temp_init = 100). L’alignement ne transporte que le point de départ — les schedules restent structurellement différents (géométrique α^k côté MGS contre temp_init/epoch côté mealpy) — mais si l’écart du croisement tenait surtout à cette échelle, temp_init = 1,0 devrait le refermer en grande partie.

À compléter (décommentez dans la cellule suivante) :
1. Relancez bench_mealpy_sa avec temp_init=1.0 (l'argument optionnel est déjà câblé dans run_mealpy_sa).
2. Comparez la médiane obtenue à la médiane mealpy ET à la médiane MGS du croisement.
3. Verdict : l'écart survit-il à l'alignement du point de départ thermique ?
// EXERCICE 1 : mealpy OriginalSA avec temp_init=1.0 (aligné sur le T_0 MGS),
// 4 graines, même budget (epoch 8000) que le croisement.
// Décommentez et exécutez :
// using (Py.GIL())
// {
//     S24.Set("__seeds_json__", System.Text.Json.JsonSerializer.Serialize(new[] {0, 1, 7, 42}.ToList()));
//     S24.Exec(@"__bench_ti_json__ = bench_mealpy_sa(__seeds_json__, 2, 8000, temp_init=1.0)");
//     Console.WriteLine(S24.Get<string>("__bench_ti_json__"));
// }

// Indice : la fonction Python accepte déjà temp_init optionnel — mesurez avant de conclure.

Console.WriteLine("Exercice a completer (decommentez le bloc ci-dessus).");
Exercice a completer (decommentez le bloc ci-dessus).

Exercice 2 : budget ×4 — l’écart de qualité se referme-t-il ?

MGS-22/23 posaient la même question pour PSO et DE. Un écart qui se referme à budget accru dit « le moteur distillé converge plus lentement mais atteint le même plateau » ; un écart stable dit « plateau différent ». Côté mealpy le budget ×4 se règle en epochs (32 000) ; côté MGS en générations (640).

// EXERCICE 2 : budget x4 — MGS pop 50, 640 générations ; mealpy epoch 32000. 4 graines.
// Décommentez et exécutez :
// foreach (var sd in new[] {0, 1, 7, 42})
// {
//     var r = Mgs24Host.RunSa(sd, 50, 640);
//     Console.WriteLine($"MGS SA x4 (graine {sd}) : {r.Item1} conflits, {r.Item2} évaluations, {r.Item3:F0} ms.");
// }
// using (Py.GIL())
// {
//     S24.Set("__seeds_json__", System.Text.Json.JsonSerializer.Serialize(new[] {0, 1, 7, 42}.ToList()));
//     S24.Exec(@"__bench_x4_json__ = bench_mealpy_sa(__seeds_json__, 2, 32000)");
//     Console.WriteLine(S24.Get<string>("__bench_x4_json__"));
// }

Console.WriteLine("Exercice a completer (decommentez le bloc ci-dessus).");
Exercice a completer (decommentez le bloc ci-dessus).

Exercice 3 : aligner l’amplitude du pas — step_size contre StepFraction

Le mouvement mealpy est un pas gaussien d’écart-type step_size = 0,1 sur chaque gène dans [1, 10) ; le mouvement MGS mélange deux parents par croisement géométrique avec une fraction de pas de 0,5. Aligner step_size sur 0,5 rend les deux opérateurs également « amples » — le troisième et dernier paramètre transportable de la paire.

// EXERCICE 3 : mealpy OriginalSA avec step_size=0.5 (aligné sur le StepFraction MGS),
// 4 graines, même budget (epoch 8000). Variantes : step_size=0.3, ou combiner temp_init=1.0.
// Décommentez et exécutez :
// using (Py.GIL())
// {
//     S24.Set("__seeds_json__", System.Text.Json.JsonSerializer.Serialize(new[] {0, 1, 7, 42}.ToList()));
//     S24.Exec(@"__bench_ss_json__ = bench_mealpy_sa(__seeds_json__, 2, 8000, step_size=0.5)");
//     Console.WriteLine(S24.Get<string>("__bench_ss_json__"));
// }

Console.WriteLine("Exercice a completer (decommentez le bloc ci-dessus).");
Exercice a completer (decommentez le bloc ci-dessus).
Retour au sommet