MGS-27 : ForensicBasedInvestigation MGS contre mealpy — la paire miroir et ses deux dérives

Navigation : << MGS-26 (EO vs mealpy) | Index

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


Introduction

Quatre paires mesurées, quatre verdicts différents : mealpy devant sur PSO (MGS-22), écart resserré et vitesse inversée sur DE (MGS-23), premier doublé MGS sur le recuit (MGS-24), ex æquo MGS-MGS et mealpy derrière sur WOA (MGS-25). La paire FBI — Forensic-Based Investigation est d’une autre nature que les quatre précédentes : c’est la seule de l’Epic dont le port C# revendique être traduit directement depuis le fichier mealpy de référence (docstring du composé, PR giacomelli/GeneticSharp#87 : « implemented directly from the mealpy version »). En théorie, la paire miroir absolue : mêmes équations, zéro reconstruction indépendante.

  • MGS ForensicBasedInvestigation : composé géométrique population-based à quatre phases — A1 (investigation : un gène perturbé par un bruit normal), A2 (location : combinaison conditionnée par le rang de fitness), B1/B2 (pursuit : attraction vers le meilleur) — avec une réinsertion pairwise (FitnessBasedPairwiseReinsertion) ;
  • mealpy OriginalFBIO (human_based) : les quatre mêmes phases A1/A2/B1/B2 de Chou & Nguyen (2020), itérées par epoch sur une population.

Deux dérives séparent pourtant le miroir, toutes deux déclarées avant la mesure : le bruit de la phase A1 est N(0,1) côté MGS (transformée de Box-Muller — l’IRandomization upstream n’expose pas GetNormal) mais uniforme dans (−1, 1) côté mealpy 3.0.2 ; et les équations A2/B2 diffèrent dans le détail de leurs appariements d’individus. La comparaison reste loyale (même substrat, budget d’évaluations égalisé, protocole hérité) : c’est précisément la question de cette paire — deux implémentations qui se réclament de la même source ont-elles convergé ?

Enfant 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. Les deux moteurs étant population-based, la structure est symétrique : MGS consomme ~pop 50 × 200 générations ≈ 8 100 évaluations (la réinsertion pairwise n’évalue que ~40 enfants par génération de 50 — le compteur tranche) ; mealpy FBIO déroule ses quatre phases par epoch (4 × 50 évaluations par epoch après la population initiale) → 40 epochs ≈ 8 050 évaluations ;
  • 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 phases paramètres scalaires
MGS ForensicBasedInvestigation population 50, composé géométrique, réinsertion pairwise A1/A2/B1/B2 par génération aucun au-delà de la population
mealpy OriginalFBIO population, epochs A1/A2/B1/B2 par epoch aucun au-delà de epoch/pop_size

C’est la première paire de l’Epic sans aucun confond paramétrique : les deux constructeurs n’exposent pas le moindre réglage transportable. Ce qui subsiste est une divergence d’équations — le bruit A1 (N(0,1) contre uniforme (−1,1)) et les appariements d’individus des phases A2/B2 — déclarée d’entrée, mesurée au croisement, et qu’aucun paramètre ne peut annuler : contrairement au temp_init du recuit (MGS-24), elle ne se neutralise pas, elle se lit.

// === MGS-27 : socle commun — DLLs MGS, grille de référence, fonction de coût ===
// Même socle que MGS-22/23/24/25 : 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/24/25.
public static string PuzzleLine27 = "902005403100063025508407060026309001057010290090670530240530600705200304080041950";

public static int[,] ParsePuzzle27()
{
    var g = new int[9, 9];
    for (int i = 0; i < 81; i++) g[i / 9, i % 9] = PuzzleLine27[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 CountConflicts27(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 CountEmpty27(int[,] p) { int n = 0; foreach (var v in p) if (v == 0) n++; return n; }

public static List<(int r, int c)> EmptyCells27(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_27(double[] genes)
{
    var Puzzle = ParsePuzzle27();
    var empties = EmptyCells27(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 Puzzle27 = ParsePuzzle27();
Console.WriteLine($"Grille de référence : {CountEmpty27(Puzzle27)} cellules vides, " +
                  $"{81 - CountEmpty27(Puzzle27)} indices fixes, {EmptyCells27(Puzzle27).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/24/25 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é ForensicBasedInvestigation ===
// FBI GÉOMÉTRIQUE MGS : composé population-based à quatre phases A1/A2/B1/B2
// (investigation-location-pursuit), réinsertion FitnessBasedPairwiseReinsertion par défaut.
public class SudokuR1Chromosome27 : ChromosomeBase
{
    private const double LO = 1.0, HI = 10.0;
    public SudokuR1Chromosome27() : base(EmptyCells27(ParsePuzzle27()).Count) { CreateGenes(); }
    public override Gene GenerateGene(int index)
        => new Gene(RandomizationProvider.Current.GetDouble(LO, HI));
    public override IChromosome CreateNew() => new SudokuR1Chromosome27();
    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_27(ToGenes());
}

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

public static class Mgs27Host
{
    public static (int conflicts, int evals, double ms, double[] genes) RunFbi(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(
            "ForensicBasedInvestigation", maxGens, popSize);
        var adam = new SudokuR1Chromosome27();
        var pop = new MetaPopulation(popSize, popSize, adam);
        var ga = new MetaGeneticAlgorithm(
            pop, new SudokuR1Fitness27(),
            new EliteSelection(), new UniformCrossover(0.5f), new UniformMutation(true),
            compound);
        ga.Termination = new GenerationNumberTermination(maxGens);
        SudokuR1Fitness27.Evals = 0;
        var sw = Stopwatch.StartNew();
        ga.Start();
        sw.Stop();
        var best = (SudokuR1Chromosome27)ga.BestChromosome;
        return (CountConflicts27(best.ToGrid()), SudokuR1Fitness27.Evals,
                sw.Elapsed.TotalMilliseconds, best.ToGenes());
    }
}

// Échauffement JIT (course jetée), puis course témoin graine 7.
var warmupMgs = Mgs27Host.RunFbi(123, 50, 10);
var demoMgs = Mgs27Host.RunFbi(7, 50, 200);
Console.WriteLine($"MGS FBI (graine 7, témoin) : {demoMgs.Item1} conflits, " +
                  $"{demoMgs.Item2} évaluations, {demoMgs.Item3:F0} ms.");
MGS FBI (graine 7, témoin) : 39 conflits, 7962 évaluations, 458 ms.

Lecture. Le composé MGS ForensicBasedInvestigation est branché sur le même harnais que les paires précédentes : chromosome R1, fitness comptée, seeding avant création de population. La course témoin graine 7 donne le premier chiffre — 39 conflits pour 7 962 évaluations en 458 ms (run original : 322 ms) — et surtout la mesure structurelle du budget : ~40 évaluations par génération de 50 (la réinsertion pairwise de FBI ne soumet au duel que les enfants effectivement candidats), donc 200 générations ≈ 8 000 évaluations pour répondre aux 8 050 de mealpy (50 individus initiaux + 40 epochs × 4 phases × 50). L’échauffement JIT la précède pour que la course mesurée 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 ResolvePythonDll27()
{
    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 = ResolvePythonDll27();
PythonEngine.Initialize();

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

puzzle = [int(ch) for ch in puzzle_line27]
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_fbio(seed, pop_size, epoch):
    import time
    PY_EVALS[0] = 0
    prob = SudokuProblem(bounds=FloatVar(lb=(1.0,) * len(empties), ub=(10.0,) * len(empties), name='genes'), minmax='min')
    model = OriginalFBIO(epoch=epoch, pop_size=pop_size)
    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_fbio(seeds_json, pop_size, epoch, reps=3):
    out = []
    for sd in _json.loads(seeds_json):
        runs = [run_mealpy_fbio(sd, pop_size, epoch) 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 OriginalFBIO (3.x) : mesures, pas doc. FBI n'expose AUCUN parametre
# scalaire au-dela d'epoch/pop_size — la signature fait foi.
_sig = str(_inspect.signature(OriginalFBIO.__init__))
__defaults__ = 'mealpy OriginalFBIO defaults: signature ' + _sig + ' (aucun parametre scalaire transportable)'
__mealpy_ver__ = 'mealpy ' + mealpy.__version__ + ' sur Python ' + sys.version.split()[0]");
    Console.WriteLine($"Pont PythonNet actif : {S27.Get<string>("__mealpy_ver__")}");
    Console.WriteLine($"Parametres defaut mealpy : {S27.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[] LcgVector27(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[] { LcgVector27(1, 36), LcgVector27(2, 36), LcgVector27(3, 36) };
using (Py.GIL())
{
    S27.Set("__witness_json__",
        System.Text.Json.JsonSerializer.Serialize(witnessVectors.Select(v => v.ToList()).ToList()));
    S27.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>>(S27.Get<string>("__py_costs__"));
    var csCosts = witnessVectors.Select(v => CountConflicts27(DecodeR1_27(v))).ToList();
    bool identical = pyCosts.SequenceEqual(csCosts);
    Console.WriteLine($"Sanity check cout : C# {string.Join(",", csCosts)} | Python {string.Join(",", pyCosts)} " +
                      $"-> {(identical ? "IDENTIQUE" : "DIFFERENT")}");
}
Installed Packages
  • pythonnet, 3.1.0
Pont PythonNet actif : mealpy 3.0.2 sur Python 3.13.3
Parametres defaut mealpy : mealpy OriginalFBIO defaults: signature (self, epoch: int = 10000, pop_size: int = 100, **kwargs: object) -> None (aucun parametre scalaire transportable)
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, doivent donner exactement les mêmes conflits. 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. La signature mealpy OriginalFBIO est mesurée sur l’instance : FBI n’expose aucun paramètre au-delà de epoch/pop_size — c’est la ligne « Parametres defaut mealpy » ci-dessus qui fait foi pour le tableau du §1 : la première paire de l’Epic sans confond paramétrique.

// === Moteur mealpy : course témoin + contre-vérification croisée du vainqueur ===
// Budget mealpy : 40 epochs (quatre phases x pop 50 par epoch + population initiale).
using (Py.GIL())
{
    // Échauffement symétrique (course jetée), puis course témoin graine 7.
    S27.Exec(@"_wu_c, _wu_e, _wu_t, _wu_sol = run_mealpy_fbio(123, 10, 5)
__d_c__, __d_e__, __d_t__, __d_sol__ = run_mealpy_fbio(7, 50, 40)");
    int dConflicts = S27.Get<int>("__d_c__");
    int dEvals = S27.Get<int>("__d_e__");
    double dMs = S27.Get<double>("__d_t__");
    Console.WriteLine($"mealpy OriginalFBIO (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 = S27.Get<string>("__d_sol__");
    var genes = System.Text.Json.JsonSerializer.Deserialize<double[]>(solJson);
    int csRecheck = CountConflicts27(DecodeR1_27(genes));
    Console.WriteLine($"Contre-vérif croisée : coût C# du meilleur mealpy = {csRecheck} " +
                      $"(Python rapporte {dConflicts}) -> {(csRecheck == dConflicts ? "IDENTIQUE" : "DIFFERENT")}");
}
mealpy OriginalFBIO (graine 7, témoin) : 45 conflits, 8050 évaluations, 1349 ms.
Contre-vérif croisée : coût C# du meilleur mealpy = 45 (Python rapporte 45) -> IDENTIQUE

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

Population 50 × 200 générations côté MGS (~40 évaluations par génération), 40 epochs × population 50 côté mealpy (quatre phases par epoch) : les deux budgets visent ~8 000 évaluations et les compteurs rendent leur mesure. Graines {0, 1, 7, 42}, trois répétitions par graine des deux côtés 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 200 générations,
// mealpy FBI pop 50 x 40 epochs : budgets mesurés par les compteurs, pas supposés. ===
public class BenchRow27
{
    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[] Seeds27 = { 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 Seeds27)
{
    var runs3 = new List<(int c, int e, double t)>();
    for (int rep = 0; rep < 3; rep++)
    {
        var r = Mgs27Host.RunFbi(sd, 50, 200);
        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())
{
    S27.Set("__seeds_json__", System.Text.Json.JsonSerializer.Serialize(Seeds27.ToList()));
    S27.Exec(@"__bench_json__ = bench_mealpy_fbio(__seeds_json__, 50, 40)");
    mealpyJson = S27.Get<string>("__bench_json__");
}
var mealpyRows = System.Text.Json.JsonSerializer.Deserialize<List<BenchRow27>>(mealpyJson);

// --- Table ---
static double Median27(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 {Median27(mgsC):F1} (min {mgsC.Min()}, max {mgsC.Max()}), ms/éval moyen {mgsMsEval:F3}");
Console.WriteLine($"mealpy : médiane conflits {Median27(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 detMgs = mgsRows.Count(r => r.allSame) + mealpyRows.Count(r => r.all_same);
Console.WriteLine($"Déterminisme : conflits identiques sur les 3 répétitions pour {detMgs}/8 paires graine-moteur.");
moteur    graine  conflits   evals       ms  ms/eval
MGS            0        37    7988      241    0,030
MGS            1        37    8138      254    0,031
MGS            7        39    7962      240    0,030
MGS           42        25    8172      263    0,032
mealpy         0        43    8050     1633    0,203
mealpy         1        44    8050     1498    0,186
mealpy         7        45    8050     1233    0,153
mealpy        42        43    8050     1250    0,155

MGS    : médiane conflits 37,0 (min 25, max 39), ms/éval moyen 0,031
mealpy : médiane conflits 43,5 (min 43, max 45), ms/éval moyen 0,174
Rapport ms/éval mealpy/MGS : 5,63x
Déterminisme : conflits identiques sur les 3 répétitions pour 8/8 paires graine-moteur.
// === 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 K27 = 500;
var benchVecs = new List<double[]>();
for (int i = 0; i < K27; i++) benchVecs.Add(LcgVector27(42 + i, 36));

var csTimes = new List<double>();
for (int rep = 0; rep < 5; rep++)
{
    var swRep = Stopwatch.StartNew();
    foreach (var v in benchVecs) CountConflicts27(DecodeR1_27(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())
{
    S27.Set("__vecs_json__", System.Text.Json.JsonSerializer.Serialize(benchVecs.Select(v => v.ToList()).ToList()));
    S27.Exec(@"__py_ms__ = time_python_evals(__vecs_json__)");
    pyMs = S27.Get<double>("__py_ms__");
}

Console.WriteLine($"Fitness seule, {K27} vecteurs identiques (médiane de 5 répétitions par côté) :");
Console.WriteLine($"  C#     : {csMs:F1} ms total -> {csMs / K27:F3} ms/éval");
Console.WriteLine($"  Python : {pyMs:F1} ms total -> {pyMs / K27: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#     : 3,0 ms total -> 0,006 ms/éval
  Python : 15,6 ms total -> 0,031 ms/éval
  rapport Python/C# : 5,13x

Lecture du croisement. MGS passe devant — et pour la deuxième fois de l’Epic après le recuit (MGS-24), sur les deux axes à la fois, cette fois avec séparation totale :

  • qualité : médiane 37,0 contre 43,5 conflits, étendues [25-39] contre [43-45] — le meilleur mealpy (43) reste au-dessus du pire MGS (39). Aux graines : MGS gagne 4/4 (37 contre 43, 37 contre 44, 39 contre 45, 25 contre 43), la graine 42 offrant le meilleur run de la paire ;
  • vitesse : l’évaluation MGS est 5,63× moins chère (0,031 contre 0,174 ms/éval ; run original : 4,11×) — le plus grand écart moteur de l’Epic, devant le recuit (SA 3,26×, DE 2,37×, WOA 1,78×, EO 1,41×, PSO 0,8×-1,3× coude-à-coude — re-exécutions #13407 ; l’amplitude du ratio est sensible à la machine, mais l’ordre FBI devant SA tient dans les deux campagnes) ;
  • le protocole est tenu : 7 962 à 8 172 évaluations MGS contre 8 050 mealpy (±1,5 %), déterminisme 8/8, contre-vérification croisée du coût IDENTIQUE (45 = 45, cellule témoin ci-dessus).

Deux signatures à lire ensemble : mealpy est régulier mais médiocre (43-45 sur quatre graines), MGS dispersé mais gagnant partout (25-39). C’est l’inverse exact du WOA (MGS-25), où les deux colonnes MGS étaient ex æquo et mealpy derrière — ici l’écart entre les deux implémentations dépasse l’écart typique entre algorithmes différents de l’Epic.

Lecture du coût par évaluation. La fitness C# isolée reste 5,13× plus rapide (0,006 contre 0,031 ms/éval ; run original : 6,36× — l’amplitude du ratio fitness est sensible à la machine, l’ordre ne l’est pas ; même ordre que les 5,13× du PSO, 5,33× du SA, 5,56× de l’EO, 3,71× du DE — re-exécutions #13407). Cette fois l’écart moteur (5,63×) dépasse l’écart fitness (5,13×), à la différence de DE et SA : le composé MGS paie sa mécanique géométrique par évaluation, mais l’OriginalFBIO de mealpy (quatre phases NumPy par epoch, génération d’agents intermédiaires) paie encore plus cher.

Verdict de la paire. Deuxième doublé de l’Epic — le plus net : séparation totale de qualité et le plus grand écart de vitesse de l’Epic (5,63×, devant les 3,26× du recuit re-exécuté), sur la paire qui prétendait être un miroir. La divergence déclarée au §1 en est la lecture la plus économique : le bruit A1 du port MGS, N(0,1) par Box-Muller, a des queues plus lourdes que l’uniforme (−1, 1) de mealpy 3.0.2 — une perturbation du gène investigé parfois bien plus ample, cohérente avec une médiane plus basse ET une variance plus grande côté MGS. C’est une hypothèse déclarée, pas une preuve (aucun paramètre ne permet de l’isoler d’une exécution à l’autre) ; mais la paire démontre au moins ceci : deux implémentations qui se réclament de la même source ont ici plus divergé que des paires conçues différentes.


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

MGS-22/23/24 posaient la même question pour PSO, DE et le recuit. 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é MGS le budget ×4 se règle en générations (800) ; côté mealpy en epochs (160).

À compléter (décommentez dans la cellule suivante) :
1. Relancez Mgs27Host.RunFbi sur les 4 graines à pop 50, 640 générations.
2. Relancez bench_mealpy_fbio à pop 50, 160 epochs.
3. Verdict : les médianes se rapprochent-elles, ou l'écart du croisement est-il un plateau ?
// EXERCICE 1 : budget x4 — MGS pop 50, 800 générations ; mealpy pop 50, 160 epochs. 4 graines.
// Décommentez et exécutez :
// foreach (var sd in new[] {0, 1, 7, 42})
// {
//     var r = Mgs27Host.RunFbi(sd, 50, 800);
//     Console.WriteLine($"MGS FBI x4 (graine {sd}) : {r.Item1} conflits, {r.Item2} évaluations, {r.Item3:F0} ms.");
// }
// using (Py.GIL())
// {
//     S27.Set("__seeds_json__", System.Text.Json.JsonSerializer.Serialize(new[] {0, 1, 7, 42}.ToList()));
//     S27.Exec(@"__bench_x4_json__ = bench_mealpy_fbio(__seeds_json__, 50, 160)");
//     Console.WriteLine(S27.Get<string>("__bench_x4_json__"));
// }

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

Exercice 2 : population ×2 à budget égal — la diversité paie-t-elle ?

FBI est un algorithme de population : ses phases A2/B2 tirent des partenaires aléatoires, et leur efficacité dépend du réservoir de diversité disponible. Doubler la population (100) en dépensant le même budget d’évaluations divise les générations/epochs par deux : si l’écart du croisement tient à la diversité, il doit bouger ; s’il tient au moteur, il doit rester.

À compléter (décommentez dans la cellule suivante) :
1. Relancez Mgs27Host.RunFbi sur les 4 graines à pop 100, 80 générations.
2. Relancez bench_mealpy_fbio à pop 100, 20 epochs (100 individus initiaux + 20 × 400).
3. Verdict : à budget égal, plus de diversité change-t-elle le classement ?
// EXERCICE 2 : population x2 a budget egal — MGS pop 100, 100 générations ; mealpy pop 100,
// 20 epochs. 4 graines.
// Décommentez et exécutez :
// foreach (var sd in new[] {0, 1, 7, 42})
// {
//     var r = Mgs27Host.RunFbi(sd, 100, 100);
//     Console.WriteLine($"MGS FBI pop100 (graine {sd}) : {r.Item1} conflits, {r.Item2} évaluations, {r.Item3:F0} ms.");
// }
// using (Py.GIL())
// {
//     S27.Set("__seeds_json__", System.Text.Json.JsonSerializer.Serialize(new[] {0, 1, 7, 42}.ToList()));
//     S27.Exec(@"__bench_p100_json__ = bench_mealpy_fbio(__seeds_json__, 100, 20)");
//     Console.WriteLine(S27.Get<string>("__bench_p100_json__"));
// }

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

Exercice 3 : budget réduit ×¼ — qui tient le petit budget ?

L’inverse de l’exercice 1 : 2 000 évaluations (MGS pop 50 × 50 générations ≈ 2 020 ; mealpy pop 50 × 10 epochs = 2 050). Un moteur qui garde une partie de son avance à petit budget a une convergence précoce plus efficace ; un moteur qui s’effondre vit de la longue course. Combiné à l’exercice 1, ce bracket encadre la réponse du budget sur les deux moteurs.

À compléter (décommentez dans la cellule suivante) :
1. Relancez Mgs27Host.RunFbi sur les 4 graines à pop 50, 40 générations.
2. Relancez bench_mealpy_fbio à pop 50, 10 epochs.
3. Verdict : le classement du croisement survit-il au petit budget ?
// EXERCICE 3 : budget reduit x1/4 — MGS pop 50, 50 générations ; mealpy pop 50, 10 epochs.
// 4 graines.
// Décommentez et exécutez :
// foreach (var sd in new[] {0, 1, 7, 42})
// {
//     var r = Mgs27Host.RunFbi(sd, 50, 50);
//     Console.WriteLine($"MGS FBI x1/4 (graine {sd}) : {r.Item1} conflits, {r.Item2} évaluations, {r.Item3:F0} ms.");
// }
// using (Py.GIL())
// {
//     S27.Set("__seeds_json__", System.Text.Json.JsonSerializer.Serialize(new[] {0, 1, 7, 42}.ToList()));
//     S27.Exec(@"__bench_q_json__ = bench_mealpy_fbio(__seeds_json__, 50, 10)");
//     Console.WriteLine(S27.Get<string>("__bench_q_json__"));
// }

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