MGS-30 : Scatter Search MGS contre son ombre — la diversité du RefSet en question, et le jumeau mealpy

Paire 9/9 de l’EPIC #12373 (MGS vs mealpy). Le composé MGS ScatterSearch transcrit le template à cinq méthodes de Glover (1998) sur la grammaire géométrique du moteur : la population joue le Reference Set, la combinaison convexe (lambda * x_a + (1 - lambda) * x_b, un lambda ~ U(0,1) tiré une fois par enfant) joue le path-relinking, et la réinsertion garde b1 slots par qualité + le reste par diversité max-min (ScatterSearchReinsertion, QualityFraction = 0.7 par défaut).

mealpy 3.0.2 ne porte aucun scatter search — mesuré dans ce notebook même : le scan exhaustif des optimiseurs du paquet rend 0 occurrence. Le jumeau est donc construit comme un subclass Optimizer (point d’extension natif mealpy) pinnant la même formule, exactement comme la paire 7 (MGS-28, Bare Bones PSO) l’a fait pour son jumeau absent.

Le banc croise trois bras pour démêler ce que l’écart brut mélange :

Bras Moteur Sémantique
1 MGS ScatterSearch complet — b1 qualité + b2 diversité (qf = 0,7)
2 MGS ScatterSearch ablaté — qf = 1,0, b2 désactivé, élitesse pure
3 mealpy OriginalScatterSearch (subclass) jumeau Glover pinné, même qf = 0,7

Bras 1 contre bras 2 (même engine) isole la contribution de l’axe diversité ; bras 1 contre bras 3 (formules pinnées identiques) isole l’écart de noyau (engine C# vs harnais mealpy).

Position dans la série. Huit paires ont précédé celle-ci ; aucune n’a ablaté l’axe diversité. Les comparaisons MGS contre mealpy portaient jusqu’ici sur des formules distinctes implantées de part et d’autre — l’écart mesuré y mélangeait noyau et sémantique. Cette paire 9/9 retourne le problème : les formules sont pinnées identiques dans les trois bras, et l’ablation de QualityFraction interroge une hypothèse qu’aucune paire précédente ne pouvait isoler — sur ce puzzle, à ce budget, l’investissement de 30 % du Reference Set dans la diversité max-min paie-t-il ? C’est la recommandation de Glover portée au banc : le template à cinq méthodes prescrit la diversité comme police d’assurance contre la stagnation prématurée ; le banc la met à l’épreuve.

Plan de lecture. Le socle pose la grille et le coût ; le moteur instancie les deux bras MGS (complet et ablaté) ; le pont PythonNet fait entrer mealpy dans le même kernel ; la cellule formules prouve l’absence de scatter search dans mealpy 3.0.2 et pinnise les formules des trois bras ; la course témoin calibre ; le banc croisé mesure ; la cellule coût/éval isole la fitness du moteur ; la lecture du croisement confronte les trois questions aux mesures ; trois exercices prolongent.


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

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

public static List<(int r, int c)> EmptyCells30(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_30(double[] genes)
{
    var Puzzle = ParsePuzzle30();
    var empties = EmptyCells30(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 Puzzle30 = ParsePuzzle30();
Console.WriteLine($"Grille de référence : {CountEmpty30(Puzzle30)} cellules vides, " +
                  $"{81 - CountEmpty30(Puzzle30)} indices fixes, {EmptyCells30(Puzzle30).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-28 au nom près — c’est voulu : la comparabilité entre paires exige le même substrat (grille, représentation R1, fonction de coût). 51 gènes continus dans [1, 10], arrondis + bornés vers 1..9 au décodage ; le coût compte les conflits de lignes, colonnes et blocs, 0 si et seulement si la grille est résolue.

Deux choix de ce socle portent toute la paire. D’abord la représentation R1 : les métaheuristiques de ce banc (combinaison convexe comprise) travaillent dans le continu, alors que le Sudoku est discret — le décodage continu → arrondi → borné est le pont obligé, et il est identique des deux côtés du bench : un vecteur donné produira la même grille et le même coût côté C# et côté Python (la cellule pont le vérifiera sur témoins). Ensuite la fonction de coût comme unique source de vérité : elle seule définit « mieux », pour les trois bras comme pour les exercices — aucune métrique annexe (diversité, distance) n’entre dans le classement final ; ces métriques ne servent qu’à construire les candidats.

// === Moteur MGS : chromosome R1, fitness instrumentée, composé ScatterSearch (2 variantes) ===
// Bras 1 : ScatterSearch complet via le catalogue (QualityFraction = 0,7 par défaut).
// Bras 2 : ScatterSearch ABLATÉ construit directement (QualityFraction = 1,0) — le catalogue
//          n'expose pas QualityFraction, la construction directe duplique son converter identité.
public class SudokuR1Chromosome30 : ChromosomeBase
{
    private const double LO = 1.0, HI = 10.0;
    public SudokuR1Chromosome30() : base(EmptyCells30(ParsePuzzle30()).Count) { CreateGenes(); }
    public override Gene GenerateGene(int index)
        => new Gene(RandomizationProvider.Current.GetDouble(LO, HI));
    public override IChromosome CreateNew() => new SudokuR1Chromosome30();
    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_30(ToGenes());
}

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

public static class Mgs30Host
{
    // Trajectoire best-so-far : conflits du meilleur APRÈS chaque génération (event GenerationRan).
    public static List<int> RunTrajectory(int seed, int popSize, int maxGens, double qualityFraction,
        out int conflicts, out int evals, out double ms, out double[] genes)
    {
        // Seeding AVANT création de population : le RNG est consommé par CreateNew()
        // de chaque individu initial (leçon #12071 / MGS-21).
        FastRandomRandomization.ResetSeed(seed);
        IMetaHeuristic compound;
        if (qualityFraction < 0.0)
        {
            // Bras 1 : chemin catalogue — QualityFraction par défaut (0,7).
            compound = MetaHeuristicsService.CreateMetaHeuristicByName("ScatterSearch", maxGens, popSize);
        }
        else
        {
            // Bras 2 : construction directe pour poser QualityFraction = 1,0 (b2 désactivé).
            // Miroir exact du bloc converter-identité de MetaHeuristicsService (double-identity).
            var noEmbeddingConverter = new GeometricConverter<double>
            {
                IsOrdered = false,
                DoubleToGeneConverter = (geneIndex, geomValue) => geomValue,
                GeneToDoubleConverter = (genIndex, geneValue) => geneValue
            };
            var typed = new TypedGeometricConverter();
            typed.SetTypedConverter(noEmbeddingConverter);
            var ss = new ScatterSearch()
            {
                MaxGenerations = maxGens,
                GeometricConverter = typed,
                NoMutation = true,
                QualityFraction = qualityFraction
            };
            compound = ss.Build();
        }
        var adam = new SudokuR1Chromosome30();
        var pop = new MetaPopulation(popSize, popSize, adam);
        var ga = new MetaGeneticAlgorithm(
            pop, new SudokuR1Fitness30(),
            new EliteSelection(), new UniformCrossover(0.5f), new UniformMutation(true),
            compound);
        ga.Termination = new GenerationNumberTermination(maxGens);
        var traj = new List<int>(maxGens + 1);
        ga.GenerationRan += (s, e) =>
            traj.Add(CountConflicts30(((SudokuR1Chromosome30)ga.BestChromosome).ToGrid()));
        SudokuR1Fitness30.Evals = 0;
        var sw = Stopwatch.StartNew();
        ga.Start();
        sw.Stop();
        var best = (SudokuR1Chromosome30)ga.BestChromosome;
        conflicts = CountConflicts30(best.ToGrid());
        evals = SudokuR1Fitness30.Evals;
        ms = sw.Elapsed.TotalMilliseconds;
        genes = best.ToGenes();
        return traj;
    }

    // qualityFraction < 0 => bras 1 (catalogue, 0,7) ; sinon bras 2 à la fraction donnée.
    public static (int conflicts, int evals, double ms, double[] genes, int[] cp)
        RunScatter(int seed, int popSize, int maxGens, double qualityFraction = -1.0)
    {
        var traj = RunTrajectory(seed, popSize, maxGens, qualityFraction, out var c, out var e, out var t, out var g);
        var q = new[] { traj[maxGens / 4 - 1], traj[maxGens / 2 - 1], traj[3 * maxGens / 4 - 1], traj[maxGens - 1] };
        return (c, e, t, g, q);
    }
}

// Échauffement JIT (course jetée), puis les deux courses témoins graine 7.
var warmupMgs = Mgs30Host.RunScatter(123, 50, 10);
var demoFull = Mgs30Host.RunScatter(7, 50, 160);            // bras 1 : complet (qf = 0,7)
var demoAbl  = Mgs30Host.RunScatter(7, 50, 160, 1.0);       // bras 2 : ablaté (qf = 1,0)
Console.WriteLine($"MGS ScatterSearch complet  (graine 7, témoin) : {demoFull.Item1} conflits, {demoFull.Item2} évaluations, " +
                  $"{demoFull.Item3:F0} ms, checkpoints 25/50/75/100 % = {string.Join("/", demoFull.Item5)}.");
Console.WriteLine($"MGS ScatterSearch ablaté  (graine 7, témoin) : {demoAbl.Item1} conflits, {demoAbl.Item2} évaluations, " +
                  $"{demoAbl.Item3:F0} ms, checkpoints 25/50/75/100 % = {string.Join("/", demoAbl.Item5)}.");
MGS ScatterSearch complet  (graine 7, témoin) : 54 conflits, 8000 évaluations, 1017 ms, checkpoints 25/50/75/100 % = 54/54/54/54.
MGS ScatterSearch ablaté  (graine 7, témoin) : 50 conflits, 8000 évaluations, 185 ms, checkpoints 25/50/75/100 % = 50/50/50/50.

Lecture. Le composé est invoqué par son nom de catalogue ("ScatterSearch") pour le bras complet ; le bras ablaté construit le composé directement parce que le catalogue n’expose pas QualityFraction — le converter-identité du service est dupliqué à l’identique pour que la seule différence entre les deux bras soit la fraction. À QualityFraction = 1,0 la réinsertion garde tous les slots par qualité : le RefSet s’effondre sur l’élitisme pur, c’est la limite FitnessBasedElitistReinsertion documentée dans ScatterSearchReinsertion.cs — « pure quality collapses the reference set onto the best point and starves the combination operator of distant mates ». Les témoins graine 7 donnent le premier signal de cette famine.

Lu depuis Glover (1998), le template à cinq méthodes se projette ainsi sur la grammaire du moteur : la génération diversifiée initiale est la population de départ ; l’amélioration locale est différée (axe mémétique hors composé, comme le note la cellule formules) ; le Reference Set est la population réinsérée à chaque génération ; la combinaison est la corde convexe lambda * x_a + (1 - lambda) * x_b ; la réinsertion referme la boucle. À population 50 et QualityFraction = 0,7, le partage concret est 35 slots par qualité, 15 par diversité max-min : à chaque génération, le RefSet retient les 35 meilleurs puis complète par les 15 plus éloignés (au sens RMS gène à gène) des déjà retenus. L’ablaté (qf = 1,0) garde 50 slots par qualité et 0 par diversité : il retire exactement ces 15 places — l’écart mesuré entre bras 1 et bras 2 sera donc attribuable à cette seule politique de réinsertion.

// === 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).
#r "nuget: pythonnet,3.1.0"
using Python.Runtime;
static string ResolvePythonDll30()
{
    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))
                foreach (var d in System.IO.Directory.GetDirectories(pyDir, "Python3*"))
                {
                    var hit = System.IO.Directory.GetFiles(d, "python3*.dll");
                    if (hit.Length > 0) return hit[0];
                }
        }
        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 = ResolvePythonDll30();
PythonEngine.Initialize();

// Le problème Python : decode + coût réimplémentés à l'identique, compteur d'évals,
// et le JUMEAU SCATTER SEARCH : subclass Optimizer (point d'extension natif mealpy) pinnant
// le template de Glover — mealpy 3.0.2 n'en porte AUCUN (preuve mesurée cellule formules).
public static PyModule S30;
using (Py.GIL())
{
    S30 = Py.CreateScope();
    S30.Set("puzzle_line30", PuzzleLine30);
    S30.Exec(@"import sys
import numpy as np
import mealpy
from mealpy import Problem, FloatVar, Optimizer
import json as _json

puzzle = [int(ch) for ch in puzzle_line30]
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)))

class OriginalScatterSearch(Optimizer):
    # Scatter Search — template Glover 1998 / Laguna & Marti 2003 — jumeau mealpy du composé MGS.
    # Formules pinnées (celles du composé MGS ScatterSearch.cs) :
    #   COMBINAISON  : x_new = lam * x_a + (1 - lam) * x_b, lam ~ U(0,1) tiré UNE fois par enfant,
    #                  x_a = position courante, x_b = membre aléatoire du RefSet SANS soi-même
    #                  (RandomIncludesCurrent = false côté MGS, remap MatchPicker).
    #   REFSET       : pool parents+enfants ; b1 = max(1, round(N*qf)) slots par qualité,
    #                  le reste par diversité max-min (distance RMS gène à gène).
    #   AMÉLIORATION : différée (axe mémétique hors template — Glover : optionnelle).
    def __init__(self, epoch=10000, pop_size=100, quality_fraction=0.7, **kwargs):
        super().__init__(**kwargs)
        self.epoch = self.validator.check_int('epoch', epoch, [1, 100000])
        self.pop_size = self.validator.check_int('pop_size', pop_size, [2, 10000])
        self.quality_fraction = float(quality_fraction)
        self.checkpoints = {}

    def generate_agent(self, solution=None):
        agent = self.generate_empty_agent(solution)
        agent.target = self.get_target(agent.solution)
        return agent

    def evolve(self, epoch):
        # Checkpoint APRÈS l'epoch m : enregistré à l'entrée de l'epoch m+1 (g_best à jour).
        if epoch - 1 in (self.epoch // 4, self.epoch // 2, 3 * self.epoch // 4):
            self.checkpoints[epoch - 1] = float(self.g_best.target.fitness)
        # 1) Subset generation + combination : un enfant par membre, mate aléatoire != soi.
        offspring = []
        for idx in range(self.pop_size):
            j = int(self.generator.integers(0, self.pop_size - 1))
            if j == idx:
                j = self.pop_size - 1
            lam = float(self.generator.uniform())
            pos_new = self.correct_solution(lam * self.pop[idx].solution + (1.0 - lam) * self.pop[j].solution)
            agent = self.generate_empty_agent(pos_new)
            agent.target = self.get_target(pos_new)
            offspring.append(agent)
        # 2) Reference-set update : b1 par qualité (minmax='min' => coût croissant), reste max-min.
        # Distance RMS vectorisée (mêmes valeurs que la boucle scalaire, zéro tirage RNG ajouté).
        candidates = sorted(list(self.pop) + offspring, key=lambda a: a.target.fitness)
        target = min(self.pop_size, len(candidates))
        b1 = max(1, min(target, int(round(target * self.quality_fraction))))
        ref = [a for a in candidates[:b1]]
        remaining = candidates[b1:]
        ref_mat = np.array([a.solution for a in ref])
        while len(ref) < target and remaining:
            rem_mat = np.array([a.solution for a in remaining])
            dmin = np.sqrt(np.mean((rem_mat[:, None, :] - ref_mat[None, :, :]) ** 2, axis=2)).min(axis=1)
            best_i = int(np.argmax(dmin))
            ref.append(remaining.pop(best_i))
            ref_mat = np.vstack([ref_mat, ref[-1].solution])
        self.pop = ref

def run_mealpy_ss(seed, pop_size, epoch, quality_fraction=0.7):
    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 = OriginalScatterSearch(epoch=epoch, pop_size=pop_size, quality_fraction=quality_fraction)
    t0 = time.perf_counter()
    g_best = model.solve(prob, seed=seed)
    dt = (time.perf_counter() - t0) * 1000.0
    cps = [int(model.checkpoints[epoch // 4]), int(model.checkpoints[epoch // 2]),
           int(model.checkpoints[3 * epoch // 4]), int(g_best.target.fitness)]
    sol = _json.dumps([float(v) for v in g_best.solution])
    return cost(decode(g_best.solution)), PY_EVALS[0], dt, sol, cps

def bench_mealpy_ss(seeds_json, pop_size, epoch, reps=3, quality_fraction=0.7):
    out = []
    for sd in _json.loads(seeds_json):
        runs = [run_mealpy_ss(sd, pop_size, epoch, quality_fraction) 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, 'cp': runs[0][4], '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]

__mealpy_ver__ = 'mealpy ' + mealpy.__version__ + ' sur Python ' + sys.version.split()[0]");
    Console.WriteLine($"Pont PythonNet actif : {S30.Get<string>("__mealpy_ver__")}");
}

// --- 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[] LcgVector30(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[] { LcgVector30(1, 51), LcgVector30(2, 51), LcgVector30(3, 51) };
using (Py.GIL())
{
    S30.Set("__witness_json__",
        System.Text.Json.JsonSerializer.Serialize(witnessVectors.Select(v => v.ToList()).ToList()));
    S30.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>>(S30.Get<string>("__py_costs__"));
    var csCosts = witnessVectors.Select(v => CountConflicts30(DecodeR1_30(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.13
Sanity check cout : C# 67,71,60 | Python 67,71,60 -> IDENTIQUE

Lecture. Le pont est actif et la sanity check porte tout le bench : la fonction de coût C# et sa réimplémentation Python rendent les mêmes valeurs sur trois vecteurs témoins déterministes. Toute différence mesurée ensuite vient des moteurs, pas de la fonction de coût. Le subclass OriginalScatterSearch pousse le template Glover dans le harnais mealpy : génération d’un enfant par membre (combinaison convexe, mate aléatoire sans soi-même — RandomIncludesCurrent = false côté MGS), puis mise à jour du Reference Set b1-qualité / b2-diversité sur le pool parents+enfants — la sémantique de ScatterSearchReinsertion.

La sanity check mérite son statut de précondition du banc entier : elle fixe trois vecteurs témoins déterministes et exige que la fonction C# et sa réimplémentation Python rendent le même conflit. Si elle échouait, aucun écart ultérieur ne serait attribuable — un décalage de décodage ou de comptage se glisserait dans chaque mesure. C’est parce qu’elle passe que la suite peut lire « écart de moteur » dans une différence de conflits. Le subclass mealpy, lui, écrit les formules du bras 1 dans le harnais numpy/Agent du paquet : generate_agent/evolve portent le tirage lambda, le mate sans soi-même et la réinsertion b1/b2 — RNG numpy en lieu et place du RNG du moteur, même sémantique déclarée.

// === CELLULE FORMULES : la preuve d'absence, puis les jumeaux AVANT toute mesure ===
// Objectif : prouver que mealpy 3.0.2 ne porte AUCUN scatter search (le subclass se justifie),
// puis pinner les formules des trois bras pour que l'écart mesuré s'interprète (axe vs noyau).
using (Py.GIL())
{
    // 1) Le scan exhaustif : chaque optimiseur mealpy, famille par famille, cherché en 'scatter'.
    S30.Exec(@"import inspect, pkgutil
__ss_scan__ = []
__n_opt__ = 0
for _pkg in pkgutil.iter_modules(mealpy.__path__):
    if not _pkg.ispkg:
        continue
    _fam = 'mealpy.' + _pkg.name
    for _sub in pkgutil.iter_modules(__import__(_fam, fromlist=['']).__path__):
        _mod = __import__(f'{_fam}.{_sub.name}', fromlist=[''])
        for _cn, _obj in inspect.getmembers(_mod, inspect.isclass):
            if issubclass(_obj, Optimizer) and _obj.__module__ == _mod.__name__:
                __n_opt__ += 1
                if 'scatter' in _cn.lower():
                    __ss_scan__.append(_cn)
__ss_verdict__ = f'{__n_opt__} optimisateurs mealpy scannes, scatter search trouves : ' + (str(__ss_scan__) if __ss_scan__ else 'AUCUN')");
    Console.WriteLine(S30.Get<string>("__ss_verdict__"));
}
Console.WriteLine(@"
Formules pinnées des trois bras :
  MGS ScatterSearch COMPLET (in-source, ScatterSearch.cs + ScatterSearchReinsertion.cs) :
      combinaison : x_new = lam * x_i + (1 - lam) * x_j,  lam ~ U(0,1) par enfant (store,
                    partage par tous les gènes du meme enfant), x_j membre aleatoire SANS soi
      RefSet       : pool parents+enfants, b1 = round(N*0.7) par qualite, reste par diversite
                    max-min (distance RMS gene a gene)
      amelioration : differee (axe memetique hors compound — Glover : optionnelle)
  MGS ScatterSearch ABATE (bras 2) : identique, QualityFraction = 1.0
      => b2 = 0 slot diversite, l'elitisme pur (limite FitnessBasedElitistReinsertion)
  mealpy OriginalScatterSearch (bras 3, subclass) : formules du bras 1 pinnées dans le harnais
      mealpy (numpy RNG, generate_agent/evolve) — qf = 0.7, distance RMS identique.

TRANCHE : le composé porte DEUX axes distincts — (1) l'AXE DIVERSITÉ (b2 max-min vs elitisme
pur), (2) le NOYAU (engine C# vs harnais mealpy). L'écart brut des paires précédentes les
mélange ; le bench croise donc TROIS bras ->
  bras 1 MGS complet  vs bras 2 MGS ablaté : meme engine => ÉCART D'AXE (contribution de b2)
  bras 1 MGS complet  vs bras 3 mealpy-jumeau : formules pinnées identiques => ÉCART NOYAU.");
213 optimisateurs mealpy scannes, scatter search trouves : AUCUN

Formules pinnées des trois bras :
  MGS ScatterSearch COMPLET (in-source, ScatterSearch.cs + ScatterSearchReinsertion.cs) :
      combinaison : x_new = lam * x_i + (1 - lam) * x_j,  lam ~ U(0,1) par enfant (store,
                    partage par tous les gènes du meme enfant), x_j membre aleatoire SANS soi
      RefSet       : pool parents+enfants, b1 = round(N*0.7) par qualite, reste par diversite
                    max-min (distance RMS gene a gene)
      amelioration : differee (axe memetique hors compound — Glover : optionnelle)
  MGS ScatterSearch ABATE (bras 2) : identique, QualityFraction = 1.0
      => b2 = 0 slot diversite, l'elitisme pur (limite FitnessBasedElitistReinsertion)
  mealpy OriginalScatterSearch (bras 3, subclass) : formules du bras 1 pinnées dans le harnais
      mealpy (numpy RNG, generate_agent/evolve) — qf = 0.7, distance RMS identique.

TRANCHE : le composé porte DEUX axes distincts — (1) l'AXE DIVERSITÉ (b2 max-min vs elitisme
pur), (2) le NOYAU (engine C# vs harnais mealpy). L'écart brut des paires précédentes les
mélange ; le bench croise donc TROIS bras ->
  bras 1 MGS complet  vs bras 2 MGS ablaté : meme engine => ÉCART D'AXE (contribution de b2)
  bras 1 MGS complet  vs bras 3 mealpy-jumeau : formules pinnées identiques => ÉCART NOYAU.

Lecture. La preuve d’absence est mesurée dans l’exécution même : aucun des optimisateurs mealpy 3.0.2 ne porte « scatter » dans son nom — le subclass n’est donc pas un choix de commodité, c’est la seule voie pour un jumeau. Les formules pinnées fixent l’interprétation : l’écart bras 1 ↔︎ bras 2 est attributable à l’axe diversité (même engine), l’écart bras 1 ↔︎ bras 3 au noyau (mêmes formules). Sans cette tranche, un écart brut ne dirait pas lequel des deux axes le porte.

Noter que l’absence est mesurée dans l’exécution même (scan des 213 optimisateurs, 0 occurrence) : le notebook porte sa propre preuve, et une évolution future de mealpy qui ajouterait un scatter search rendrait la cellule fausse à la prochaine exécution — le signal viendrait du notebook, pas d’une note de version. C’est plus fort qu’une absence documentée en prose, qui dérive silencieusement.

// === Moteur mealpy : course témoin (graine 7) + contre-vérification croisée ===
using (Py.GIL())
{
    // Échauffement symétrique (course jetée), puis course témoin graine 7.
    S30.Exec(@"_wu_c, _wu_e, _wu_t, _wu_sol, _wu_cp = run_mealpy_ss(123, 50, 10)
__m_c__, __m_e__, __m_t__, __m_sol__, __m_cp__ = run_mealpy_ss(7, 50, 160)");
    Console.WriteLine($"mealpy ScatterSearch-jumeau (graine 7, témoin) : {S30.Get<int>("__m_c__")} conflits, " +
                      $"{S30.Get<int>("__m_e__")} évaluations, {S30.Get<double>("__m_t__"):F0} ms.");

    // Contre-vérification croisée : le vainqueur mealpy, décodé et costé côté C#.
    var solJson = S30.Get<string>("__m_sol__");
    var genes = System.Text.Json.JsonSerializer.Deserialize<double[]>(solJson);
    int csRecheck = CountConflicts30(DecodeR1_30(genes));
    Console.WriteLine($"Contre-vérif croisée : coût C# du meilleur mealpy = {csRecheck} " +
                      $"(Python rapporte {S30.Get<int>("__m_c__")}) -> " +
                      $"{(csRecheck == S30.Get<int>("__m_c__") ? "IDENTIQUE" : "DIFFERENT")}");
}
mealpy ScatterSearch-jumeau (graine 7, témoin) : 53 conflits, 8050 évaluations, 927 ms.
Contre-vérif croisée : coût C# du meilleur mealpy = 53 (Python rapporte 53) -> IDENTIQUE

// === LE BENCH : 3 bras x 4 graines {0,1,7,42}, population 50, 160 générations/epochs ===
// Bras 1 MGS complet (qf 0,7) | Bras 2 MGS ablaté (qf 1,0) | Bras 3 mealpy-jumeau (qf 0,7).
public class BenchRow30
{
    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 List<int> cp { get; set; }
    public string sol { get; set; }
}

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

// --- Bras 1 et 2 : côté MGS (C#), 3 répétitions par graine, ms = médiane (amendement §2) ---
var mgsFullRows = new List<(int seed, int conflicts, int evals, double ms, bool allSame, int[] cp)>();
var mgsAblRows  = new List<(int seed, int conflicts, int evals, double ms, bool allSame, int[] cp)>();
foreach (var sd in Seeds30)
{
    foreach (var (rows, qf) in new[] { (mgsFullRows, -1.0), (mgsAblRows, 1.0) })
    {
        var runs3 = new List<(int c, int e, double t, int[] q)>();
        for (int rep = 0; rep < 3; rep++)
        {
            var r = Mgs30Host.RunScatter(sd, 50, 160, qf);
            runs3.Add((r.Item1, r.Item2, r.Item3, r.Item5));
        }
        var times = runs3.Select(x => x.t).OrderBy(t => t).ToList();
        double med = times[1];
        rows.Add((sd, runs3[0].c, runs3[0].e, med, runs3.All(x => x.c == runs3[0].c), runs3[0].q));
    }
}

// --- Bras 3 : côté mealpy (Python, boucle unique dans le scope) ---
string mealpySsJson;
using (Py.GIL())
{
    S30.Set("__seeds_json__", System.Text.Json.JsonSerializer.Serialize(Seeds30.ToList()));
    S30.Exec(@"__bench_ss_json__ = bench_mealpy_ss(__seeds_json__, 50, 160, quality_fraction=0.7)");
    mealpySsJson = S30.Get<string>("__bench_ss_json__");
}
var mealpySsRows = System.Text.Json.JsonSerializer.Deserialize<List<BenchRow30>>(mealpySsJson);

// --- Table : conflits, budget mesuré, coût par éval, checkpoints 25/50/75/100 % ---
static double Median30(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",-18} {"graine",6} {"conflits",9} {"evals",7} {"ms",7} {"ms/eval",8} {"cp25/50/75/100",-18}");
foreach (var r in mgsFullRows)
    Console.WriteLine($"{"MGS-complet",-18} {r.seed,6} {r.conflicts,9} {r.evals,7} {r.ms,7:F0} {r.ms / r.evals,8:F3} {string.Join("/", r.cp),-18}");
foreach (var r in mgsAblRows)
    Console.WriteLine($"{"MGS-ablate",-18} {r.seed,6} {r.conflicts,9} {r.evals,7} {r.ms,7:F0} {r.ms / r.evals,8:F3} {string.Join("/", r.cp),-18}");
foreach (var r in mealpySsRows)
    Console.WriteLine($"{"mealpy-jumeau",-18} {r.seed,6} {r.conflicts,9} {r.evals,7} {r.ms,7:F0} {r.ms / r.evals,8:F3} {string.Join("/", r.cp),-18}");

var arm1 = mgsFullRows.Select(r => r.conflicts).ToList();
var arm2 = mgsAblRows.Select(r => r.conflicts).ToList();
var arm3 = mealpySsRows.Select(r => r.conflicts).ToList();
double msEval1 = mgsFullRows.Average(r => r.ms / r.evals);
double msEval2 = mgsAblRows.Average(r => r.ms / r.evals);
double msEval3 = mealpySsRows.Average(r => r.ms / r.evals);
Console.WriteLine();
Console.WriteLine($"MGS-complet    : médiane conflits {Median30(arm1):F1} (min {arm1.Min()}, max {arm1.Max()}), ms/éval moyen {msEval1:F3}");
Console.WriteLine($"MGS-ablate     : médiane conflits {Median30(arm2):F1} (min {arm2.Min()}, max {arm2.Max()}), ms/éval moyen {msEval2:F3}");
Console.WriteLine($"mealpy-jumeau  : médiane conflits {Median30(arm3):F1} (min {arm3.Min()}, max {arm3.Max()}), ms/éval moyen {msEval3:F3}");
Console.WriteLine();
Console.WriteLine($"ÉCART D'AXE (bras1 - bras2, même engine) : médianes {Median30(arm1):F1} vs {Median30(arm2):F1}");
Console.WriteLine($"ÉCART NOYAU (bras1 - bras3, formules pinnées) : médianes {Median30(arm1):F1} vs {Median30(arm3):F1}, ms/éval {msEval1 / msEval3:F2}x");

// --- Classement dynamique : rangs dérivés des mesures (OrderBy), zéro récit codé en dur ---
var classement = new[] {
    ("MGS-complet", Median30(arm1)), ("MGS-ablate", Median30(arm2)), ("mealpy-jumeau", Median30(arm3))
}.OrderBy(x => x.Item2).ToList();
Console.WriteLine();
Console.WriteLine("Classement qualité (médiane conflits croissante) : " +
                  string.Join(" < ", classement.Select(x => $"{x.Item1} ({x.Item2:F1})")));

// --- Forme de l'écart aux checkpoints : axe (1-2) et noyau (1-3), par graine ---
// delta < 0 => le premier bras est DEVANT (moins de conflits) à ce checkpoint.
static string ShapeOf30(int[] d)
{
    if (d.All(x => x == 0)) return "nul (identiques)";
    var sgn = d.Select(x => x == 0 ? 0 : (x < 0 ? -1 : 1)).ToList();
    bool earlySame = sgn.Take(2).All(x => x == sgn[0]) && sgn[0] != 0;
    if (earlySame && sgn.All(x => x == sgn[0])) return sgn[0] < 0 ? "persistant (bras A devant)" : "persistant (bras B devant)";
    if (earlySame && sgn.Last() != 0 && sgn.Last() != sgn[0]) return "croisement";
    if (sgn.Last() == 0) return "refermeture";
    return "mixte";
}

Console.WriteLine();
Console.WriteLine("Forme axe (complet - ablaté) et noyau (complet - mealpy-jumeau), par graine :");
for (int i = 0; i < Seeds30.Length; i++)
{
    var da = Enumerable.Range(0, 4).Select(k => mgsFullRows[i].cp[k] - mgsAblRows[i].cp[k]).ToArray();
    var dn = Enumerable.Range(0, 4).Select(k => mgsFullRows[i].cp[k] - mealpySsRows[i].cp[k]).ToArray();
    Console.WriteLine($"  graine {Seeds30[i],2} : axe [{string.Join(",", da)}] {ShapeOf30(da),-28} | " +
                      $"noyau [{string.Join(",", dn)}] {ShapeOf30(dn)}");
}

int detMgs = mgsFullRows.Count(r => r.allSame) + mgsAblRows.Count(r => r.allSame) + mealpySsRows.Count(r => r.all_same);
Console.WriteLine();
Console.WriteLine($"Déterminisme (3 répétitions identiques par graine) : {detMgs}/12 bras-graines stables.");
moteur             graine  conflits   evals      ms  ms/eval cp25/50/75/100    
MGS-complet             0        54    7998     833    0,104 54/54/54/54       
MGS-complet             1        56    8000     872    0,109 56/56/56/56       
MGS-complet             7        54    8000     809    0,101 54/54/54/54       
MGS-complet            42        43    7998    1091    0,136 43/43/43/43       
MGS-ablate              0        54    8000     180    0,023 54/54/54/54       
MGS-ablate              1        56    8000     170    0,021 56/56/56/56       
MGS-ablate              7        50    8000     182    0,023 50/50/50/50       
MGS-ablate             42        45    8000     223    0,028 45/45/45/45       
mealpy-jumeau           0        53    8050    1040    0,129 53/53/53/53       
mealpy-jumeau           1        48    8050     978    0,121 50/50/48/48       
mealpy-jumeau           7        53    8050     952    0,118 53/53/53/53       
mealpy-jumeau          42        50    8050     980    0,122 50/50/50/50       

MGS-complet    : médiane conflits 54,0 (min 43, max 56), ms/éval moyen 0,113
MGS-ablate     : médiane conflits 52,0 (min 45, max 56), ms/éval moyen 0,024
mealpy-jumeau  : médiane conflits 51,5 (min 48, max 53), ms/éval moyen 0,123

ÉCART D'AXE (bras1 - bras2, même engine) : médianes 54,0 vs 52,0
ÉCART NOYAU (bras1 - bras3, formules pinnées) : médianes 54,0 vs 51,5, ms/éval 0,92x

Classement qualité (médiane conflits croissante) : mealpy-jumeau (51,5) < MGS-ablate (52,0) < MGS-complet (54,0)

Forme axe (complet - ablaté) et noyau (complet - mealpy-jumeau), par graine :
  graine  0 : axe [0,0,0,0] nul (identiques)             | noyau [1,1,1,1] persistant (bras B devant)
  graine  1 : axe [0,0,0,0] nul (identiques)             | noyau [6,6,8,8] persistant (bras B devant)
  graine  7 : axe [4,4,4,4] persistant (bras B devant)   | noyau [1,1,1,1] persistant (bras B devant)
  graine 42 : axe [-2,-2,-2,-2] persistant (bras A devant)   | noyau [-7,-7,-7,-7] persistant (bras A devant)

Déterminisme (3 répétitions identiques par graine) : 12/12 bras-graines stables.
// === 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 K30 = 500;
var benchVecs = new List<double[]>();
for (int i = 0; i < K30; i++) benchVecs.Add(LcgVector30(42 + i, 51));

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

Console.WriteLine($"Fitness seule, {K30} vecteurs identiques (médiane de 5 répétitions par côté) :");
Console.WriteLine($"  C#     : {csMs:F1} ms total -> {csMs / K30:F3} ms/éval");
Console.WriteLine($"  Python : {pyMs:F1} ms total -> {pyMs / K30: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,5 ms total -> 0,007 ms/éval
  Python : 18,4 ms total -> 0,037 ms/éval
  rapport Python/C# : 5,25x

Lecture du croisement. Trois lectures, une par question posée à la cellule formules — chacune confrontée aux mesures du banc.

  1. L’axe diversité (complet vs ablaté, même engine) — attendu : si le b2 max-min travaille, le complet devance l’ablaté ou le rattrape tard ; la forme par graine dit quand la diversité paie. Mesuré : médianes 54,0 contre 52,0 — l’ablaté devant, et la forme n’est pas du bruit : écart nul sur les graines 0 et 1 (les deux bras finissent identiques), +4 persistant pour l’ablaté sur la graine 7, −2 seulement pour le complet sur la graine 42. Verdict : sur ce puzzle à ce budget, les 15 slots de diversité max-min ne paient pas — l’ablaté les égalise ou les devance sur 3 graines sur 4. Les checkpoints plats du complet (54/54/54/54 dès 25 % du budget) montrent le mécanisme : un RefSet diversifié entretient des mates éloignés dont la corde convexe produit des enfants décodant loin de toute solution, sans amélioration locale pour les raffiner (amélioration différée, hors composé).
  2. Le noyau (complet vs jumeau mealpy, formules pinnées) — attendu : à formules égales, l’écart restant mesure l’engine. Mesuré : médianes 54,0 contre 51,5 — le jumeau mealpy devant sur 3 graines sur 4 (écarts persistants de 1 à 8 conflits ; seule la graine 42 inverse, −7 pour MGS). La colonne ms/éval (0,113 contre 0,123, ratio 0,92×) écarte l’explication par le coût : la fitness pure est 5,25× plus rapide côté C# (0,007 contre 0,037 ms/éval) — l’écart de qualité vient de l’ordre des opérations et des tirages propres à chaque harnais, pas d’un avantage de calcul. À formules déclarées identiques, deux moteurs divergent quand même : le pinning fixe les équations, pas la trajectoire.
  3. Le déterminisme — mesuré : 12/12 bras-graines stables sur 3 répétitions (seeding explicite ResetSeed / solve(seed=...)). Les lectures 1 et 2 portent sur du signal, pas du bruit.

Le classement final — jumeau mealpy (51,5) < ablaté (52,0) < complet (54,0) — est inverse de l’intuition « plus de diversité, meilleur scatter search » : c’est précisément ce que le troisième bras rendait visible.

Exercice 1 : à budget ×4, l’écart d’axe se referme-t-il ou s’installe-t-il ?

Le banc courant est calibré (pop 50, 160 générations). Refais tourner les deux bras MGS à budget ×4 (160 → 640 générations, même population, mêmes 4 graines, 1 répétition suffit ici). Deux issues opposées : si l’ablaté referme l’écart, la diversité b2 n’était qu’une accélération de phase précoce ; s’il plafonne, l’élitisme pur verrouille le RefSet et le verrou s’aggrave avec le temps. À compléter : resultats64 rempli, verdict écrit.

Ce que « se referme » ou « s’installe » signifie pour l’hypothèse diversité : si l’écart complet-ablaté s’annule à budget ×4, la diversité ne faisait que ralentir la convergence précoce (le complet rattrape une fois la phase d’exploitation atteinte) ; s’il persiste ou s’agrandit, la politique de réinsertion dégrade la recherche en profondeur sur ce puzzle (mates éloignés → enfants hors bassin, sans amélioration locale pour les récupérer). Le témoin graine 7 (54 contre 50 à budget ×1, checkpoints plats du complet) donne la prédiction à battre. Compte tenu du témoin (~1 s pour 160 générations côté complet), attends-toi à ~4 s par graine et par bras — environ une demi-minute pour la cellule complète.

// EXERCICE 1 : budget x4 (pop 50, 640 generations), 4 graines, les deux bras MGS.
// TODO etudiant — remplis resultats64 puis ecris ton verdict (refermeture, installation, mixte ?).
// Indice : Mgs30Host.RunScatter(seed, 50, 640) = bras complet ; RunScatter(seed, 50, 640, 1.0) = ablate (qf = 1).
// Etape 1 : pour chaque graine de Seeds30, lancer les deux courses a 640 generations.
// Etape 2 : afficher graine, conflits complet/ablate et le delta.
// Etape 3 : comparer aux deltas du banc (160 generations) et conclure.
var resultats64 = new List<(int seed, int complet, int ablate)>();
// ... courses et affichage a ecrire ...
Console.WriteLine("Exercice a completer : verdict budget x4 — refermeture, installation, ou mixte ?");
Exercice a completer : verdict budget x4 — refermeture, installation, ou mixte ?

Exercice 2 : où vit la contribution diversité — balayage de QualityFraction

L’ablation du banc est un point extrême (qf = 1,0). Balaye la fraction côté MGS sur {0,5 · 0,85 · 1,0} (4 graines, 160 générations) et confronte au bras complet (qf = 0,7) du banc : la contribution b2 est-elle monotone, présente un optimum intérieur, ou plate ? Attention au sens de lecture : qf baisse = plus de slots diversité (b1 = round(N·qf) qualité, le reste diversité). À compléter : sweepRows rempli, verdict écrit.

Les trois lectures possibles du balayage : monotone croissant en qf (plus d’élitisme = mieux → le puzzle est un problème d’exploitation, la diversité n’y sert à rien) ; optimum intérieur autour de 0,85 (un peu de diversité aide, trop dessert — le régime que Glover recommande en général) ; plat (indifférence : le RefSet est assez petit pour que la corde convexe domine la politique de réinsertion). Le banc courant ne mesure que les extrêmes 0,7 et 1,0 — le balayage ajoute le point 0,5, côté sous-investi.

// EXERCICE 2 : balayage QualityFraction {0.5, 0.85, 1.0} cote MGS, 4 graines, 160 generations.
// TODO etudiant — complete sweepRows et ecris le verdict (monotone / optimum interieur / plat).
// Indice : Mgs30Host.RunScatter(sd, 50, 160, qf) accepte la fraction en 4e argument.
// Indice : qf baisse = plus de slots diversite (b1 = round(N*qf) qualite, le reste diversite).
// Etape 1 : double boucle qf x Seeds30, une course par combinaison.
// Etape 2 : mediane des conflits par qf (Median30 est disponible).
// Etape 3 : confronter au bras complet du banc (qf = 0.7) et conclure.
var sweepRows = new List<(double qf, int seed, int conflicts)>();
// ... balayage et affichage a ecrire ...
Console.WriteLine("Exercice a completer : verdict balayage — monotone, optimum interieur, ou plat ?");
Exercice a completer : verdict balayage — monotone, optimum interieur, ou plat ?

Exercice 3 : profiler la fitness — où va la milliseconde ?

L’écart ms/éval entre bras peut venir du decode (R1 → grille), du comptage de conflits, ou du moteur lui-même. La cellule coût/éval mesure decode+coût ensemble ; sépare-les : chronomètre 500 vecteurs en deux passes (decode seul avec coût jeté, coût seul sur grilles pré-décodées), côté C# et côté Python. À compléter : profilRows rempli, verdict écrit.

Le prior à confronter : la cellule coût/éval mesure 5,25× en faveur du C# sur la fitness complète (decode + coût), mais le rapport ms/éval du banc n’est que de 0,92×. Si le profil confirme que decode et coût sont massivement plus rapides côté C#, alors le temps restant du bench vit dans le moteur (allocation, collections, RNG), pas dans la fonction — et la question devient : où exactement ?

// EXERCICE 3 : profil decode vs cost, 500 vecteurs, deux cotes.
// TODO etudiant — complete profilRows (4 lignes : C#-decode, C#-cost, Py-decode, Py-cost).
// Indice : Stopwatch cote C#, time.perf_counter cote Python via Py.GIL().
// Indice : benchVecs = 500 vecteurs temoins ; K30 normalise en ms par evaluation.
// Etape 1 : mesurer le decode seul (C# puis Python) sur benchVecs.
// Etape 2 : mesurer le cout seul (CountConflicts30 / conflicts Python) sur les grilles decodees.
// Etape 3 : ranger ms/eval dans profilRows et commenter le rapport decode/cout.
var profilRows = new List<(string cote, string passe, double msParEval)>();
// ... mesures a ecrire ...
Console.WriteLine("Exercice a completer : profil decode vs cost — ou va la milliseconde ?");
Exercice a completer : profil decode vs cost — ou va la milliseconde ?

Conclusion de la paire 9/9

Trois énoncés sortent du banc, tous mesurés : (1) la diversité max-min ne paie pas sur ce puzzle à ce budget — l’ablaté (qf = 1,0) égalise ou devance le complet sur 3 graines sur 4, médianes 52,0 contre 54,0 ; (2) à formules pinnées identiques, le jumeau mealpy devance l’engine C# (51,5 contre 54,0) alors que sa fitness pure est 5,25× plus lente — l’écart de noyau est un écart de trajectoire, pas de coût ; (3) le déterminisme est intégral (12/12), les deux premiers énoncés portent sur du signal.

Ce que cette paire ne dit pas : Scatter Search en général n’est pas condamné — un puzzle Sudoku 51 à 8000 évaluations, une amélioration locale différée et une combinaison convexe unique sont trois choix spécifiques ; un RefSet plus petit, une amélioration engagée ou un path-relinking plus riche pourraient renverser le verdict. La valeur de la paire est ailleurs : elle montre qu’une prescription de template (« garder 30 % de diversité », Glover) est une hypothèse testable, pas une évidence — et que le banc à trois bras la teste sans mélanger noyau et sémantique. La synthèse croisée des neuf paires (coût systématique contre qualité spécifique) vit dans MGS-31.

Retour au sommet