SL-6 (C#) : Moteurs ILP modernes — apprendre des programmes recursifs

Twin C# (.NET Interactive / from-scratch) du notebook Python SL-6-ModernILP (Aleph, Metagol, Popper, dILP).

L’Inductive Logic Programming (ILP) apprend un programme logique (un ensemble de clauses de Horn) a partir d’exemples positifs/negatifs et d’une base de connaissances de fond (background knowledge). Le defi pedagogique central : apprendre une relation récursive. Tous les moteurs du twin Python (Aleph, Metagol, Popper, dILP) resolvent la même tâche — inferer la relation ancestor(X,Y) a partir de faits parent(X,Y).

Ce twin C# implemente from-scratch l’algorithme fondateur FOIL (Quinlan 1990) — la reference algorithmique de l’ILP relationnel — qui apprend les deux clauses recursives d’ancestor par recherche dans le graphe de raffinement guidee par un gain d’information (FOIL_Gain). Les 4 moteurs du twin Python (bases sur SWI-Prolog, clingo/ASP, TensorFlow) relevent de RECOVERABLE-MACHINE / INTRINSIC (verdict SOTA section 6) — sauf Popper, bridge par un pont Process.Start vers clingo (§7, SOTA-OK).

Parite : le twin Python compare 4 frameworks externes (Aleph/Metagol = Prolog, Popper = ASP+clingo, dILP = TensorFlow). Cote .NET, nous implementons le noyau algorithmique (FOIL) en C# pur (0 NuGet), qui est la substance que ces frameworks automatisent. Le verdict SOTA (#3801) est explicite section 6.

using System;
using System.Linq;
using System.Collections.Generic;

Console.WriteLine("SL-6 ModernILP (C# twin) - environnement pret.");
SL-6 ModernILP (C# twin) - environnement pret.

1. La tâche commune : apprendre ancestor

Tous les moteurs ILP recevront exactement la même tâche : etant donne 5 faits parent(X,Y) sur 6 personnes, inferer la relation ancestor(X,Y) (cloture transitive). La source unique de verite = la cloture transitive calculee en code (une liste manuelle oubliait ancestor(bob, sue) via bob -> ann -> sue, cf twin Python).

Exemples positifs = les 11 couples (x,y) tels que x est un ancetre de y. Exemples negatifs = les 19 couples (x,y) avec x != y hors cloture.

// Base de connaissances commune (parite exacte avec twin Python)
var PEOPLE = new List<string>{"tom","bob","ann","pat","jim","sue"};
var PARENT = new List<(string,string)>{("tom","bob"),("bob","ann"),("bob","pat"),("pat","jim"),("ann","sue")};

// Cloture transitive : les vrais couples ancestor (source unique de verite)
static HashSet<(string,string)> TransitiveClosure(List<(string,string)> edges) {
    var succ = new Dictionary<string,HashSet<string>>();
    foreach (var (a,b) in edges) { if(!succ.ContainsKey(a)) succ[a]=new(); succ[a].Add(b); }
    var cur = new HashSet<(string,string)>(edges);
    while (true) {
        var nxt = new HashSet<(string,string)>(cur);
        foreach (var (a,b) in cur) if (succ.ContainsKey(b)) foreach (var c in succ[b]) nxt.Add((a,c));
        if (nxt.SetEquals(cur)) return cur;
        cur = nxt;
    }
}

var POS = TransitiveClosure(PARENT).OrderBy(t=>t).ThenBy(t=>t.Item2).ToList();
var posSet = new HashSet<(string,string)>(POS);
var NEG = new List<(string,string)>();
foreach (var x in PEOPLE) foreach (var y in PEOPLE) if (x!=y && !posSet.Contains((x,y))) NEG.Add((x,y));

Console.WriteLine($"parent/2 : {PARENT.Count} faits");
Console.WriteLine($"positifs : {POS.Count} couples ancestor (cloture transitive)");
foreach (var (a,b) in POS) Console.WriteLine($"    ancestor({a}, {b})");
Console.WriteLine($"negatifs : {NEG.Count} couples (x != y, hors cloture)");
parent/2 : 5 faits
positifs : 11 couples ancestor (cloture transitive)
    ancestor(ann, sue)
    ancestor(bob, ann)
    ancestor(bob, jim)
    ancestor(bob, pat)
    ancestor(bob, sue)
    ancestor(pat, jim)
    ancestor(tom, ann)
    ancestor(tom, bob)
    ancestor(tom, jim)
    ancestor(tom, pat)
    ancestor(tom, sue)
negatifs : 19 couples (x != y, hors cloture)

2. L’algorithme FOIL (Quinlan 1990)

FOIL (First-Order Inductive Learner) apprend un programme logique une clause a la fois :

  1. Tant que des exemples positifs ne sont pas couverts :
  2.   Apprendre une nouvelle clause en partant du corps vide ancestor(A,B) :- (couvre tout).
  3.   Tant que la clause couvre des negatifs : ajouter le literal (ex. parent(A,B), ancestor(C,B)) qui maximise le FOIL_Gain (gain d’information).
  4.   Retirer les positifs couverts par la nouvelle clause.

Le FOIL_Gain d’un literal = t * [ log2(p/(p+n)) - log2(p0/(p0+n0)) ] ou t = positifs encore couverts après ajout, p/n = positifs/negatifs couverts après, p0/n0 avant. On prefere les literals qui augmentent la purete (ratio positifs).

Key : FOIL autorise le predicat cible (ancestor) dans le corps — c’est ce qui permet la recursion.

// Representations : Term (Variable | Constant), Literal, Clause
record Term(string Var, string Const) {
    public bool IsVar => Var != null;
    public string Show => IsVar ? Var : Const;
}
static Term V(string n) => new(n, null);   // variable
static Term C(string v) => new(null, v);   // constante

record Literal(string Pred, Term A, Term B) {
    public string Show => $"{Pred}({A.Show},{B.Show})";
}
record Clause(Literal Head, List<Literal> Body) {
    public string Show => Head.Show + " :- " + string.Join(", ", Body.Select(l=>l.Show)) + ".";
}

// Base extensionnelle : faits ground par predicat. ancestorDB sera recalculee des clauses apprises.
var parentDB = new HashSet<(string,string)>(PARENT);
Console.WriteLine("parentDB : " + parentDB.Count + " faits ground.");
parentDB : 5 faits ground.

2.1 Evaluation extensionnelle et couverture

Une clause ancestor(A,B) :- body couvre un exemple (x,y) s’il existe une assignation A=x, B=y et des valeurs pour les variables existentielles telles que tous les literals du corps soient vrais dans la base extensionnelle. On evalue par enumeration des constantes pour les variables nouvelles (petite base = tractable).

// Evaluer si une clause couvre un exemple (x,y), en retournant aussi les bindings couverts.
// ancestorDB passe en parametre (pour evaluer ancestor dans le corps via clauses deja apprises).
static bool Covers(Clause cl, (string x,string y) ex, HashSet<(string,string)> parentDB, HashSet<(string,string)> ancestorDB, List<string> consts) {
    // Binder les variables de tete A=x, B=y
    var headVars = new[]{cl.Head.A, cl.Head.B};
    // Collecter toutes les variables du corps
    var vars = new HashSet<string>();
    foreach (var l in cl.Body) { if(l.A.IsVar) vars.Add(l.A.Var); if(l.B.IsVar) vars.Add(l.B.Var); }
    var varList = vars.ToList();
    // Enumerer les assignations des variables (hors A,B fixes par la tete)
    var freeVars = varList.Where(v => v!=cl.Head.A.Var && v!=cl.Head.B.Var).ToList();
    string[] dom = consts.ToArray();
    int attempts = 0, maxAttempts = 20000;
    // Backtracking simple par produit cartesien (base petite)
    IEnumerable<Dictionary<string,string>> Assigns(List<string> fv, int i, Dictionary<string,string> cur) {
        if (attempts++ > maxAttempts) yield break;
        if (i==fv.Count) { yield return new(cur); yield break; }
        foreach (var c in dom) { cur[fv[i]]=c; foreach (var a in Assigns(fv,i+1,cur)) yield return a; cur[fv[i]]=null; }
    }
    var init = new Dictionary<string,string>{{cl.Head.A.Var,ex.x},{cl.Head.B.Var,ex.y}};
    foreach (var assign in Assigns(freeVars,0,init)) {
        bool ok = true;
        foreach (var l in cl.Body) {
            var va = l.A.IsVar ? assign[l.A.Var] : l.A.Const;
            var vb = l.B.IsVar ? assign[l.B.Var] : l.B.Const;
            var db = l.Pred=="ancestor" ? ancestorDB : parentDB;
            if (!db.Contains((va,vb))) { ok=false; break; }
        }
        if (ok) return true;
    }
    return false;
}
Console.WriteLine("Helper Covers pret (backtracking extensionnel).");
Helper Covers pret (backtracking extensionnel).

2.2 Generation des literals candidats + FOIL_Gain

A chaque étape, on genere les literals candidats : pour chaque predicat p dans {parent, ancestor} et chaque paire de variables prise dans {A, B, C} (C = variable nouvelle), on evalue le FOIL_Gain. On retient le meilleur.

static double FoilGain(int p0, int n0, int p, int n) {
    // t = positifs encore couverts apres ajout du literal
    if (p==0) return double.NegativeInfinity;
    double before = (p0+n0)==0 ? 0 : Math.Log2((double)p0/(p0+n0));
    double after  = Math.Log2((double)p/(p+n));
    return p * (after - before);
}

// Generer les literals candidats : predicat in {parent,ancestor}, args in {A,B,C(new)}
static List<Literal> Candidates(List<string> vars, Literal head) {
    var cands = new List<Literal>();
    foreach (var pred in new[]{"parent","ancestor"})
        foreach (var v1 in vars) foreach (var v2 in vars) {
            var lit = new Literal(pred, V(v1), V(v2));
            // Pruner : pas de literal identique a la tete (ancestor(A,B) = tete = circulaire)
            if (lit.Pred==head.Pred && lit.A.Var==head.A.Var && lit.B.Var==head.B.Var) continue;
            cands.Add(lit);
        }
    return cands;
}
Console.WriteLine("FOIL_Gain + Candidates prets.");
FOIL_Gain + Candidates prets.

3. FOIL apprend ancestor : les deux clauses recursives

Lançons la boucle FOIL complete sur la tâche ancestor. L’algorithme doit decouvrir : - Clause 1 (cas de base) : ancestor(A,B) :- parent(A,B). - Clause 2 (pas récursif) : ancestor(A,B) :- parent(A,C), ancestor(C,B).

La clause 2 utilise ancestor dans son propre corps : c’est la recursion, possible parce que FOIL evalue ancestor(C,B) avec les clauses déjà apprises (la clause 1 a ce stade).

// FOIL complet : apprend un ensemble de clauses pour le predicat cible.
static List<Clause> Foil(
    List<(string x,string y)> pos, List<(string x,string y)> neg,
    HashSet<(string,string)> parentDB, List<string> consts, int maxBody=3) {
    var learned = new List<Clause>();
    var remaining = new List<(string,string,string)>(pos.Select(e=>("a",e.x,e.y)));
    var remPos = new List<(string x,string y)>(pos);
    int iter = 0;
    while (remPos.Count > 0 && iter++ < 6) {
        // ancestorDB = faits derivables des clauses apprises JUSQU ICI (pour evaluer ancestor dans le corps)
        var ancestorDB = DeriveAncestor(learned, parentDB, consts);
        var clause = new Clause(new Literal("ancestor", V("A"), V("B")), new());
        // couverture courante
        var cp = remPos.Where(e=>Covers(clause,e,parentDB,ancestorDB,consts)).ToList();
        var cn = neg.Where(e=>Covers(clause,e,parentDB,ancestorDB,consts)).ToList();
        int guard=0;
        while (cn.Count > 0 && guard++ < 6 && clause.Body.Count < maxBody) {
            // Toutes les variables courantes du corps + une nouvelle
            var bodyVars = new List<string>{"A","B"};
            foreach (var l in clause.Body) { if(l.A.IsVar && !bodyVars.Contains(l.A.Var)) bodyVars.Add(l.A.Var); if(l.B.IsVar && !bodyVars.Contains(l.B.Var)) bodyVars.Add(l.B.Var); }
            string newVar = "V"+(clause.Body.Count+3);
            var existingVars = new HashSet<string>(bodyVars);  // vars AVANT la nouvelle
            if (!bodyVars.Contains(newVar)) bodyVars.Add(newVar);
            Literal best=null; double bestGain=double.NegativeInfinity; int bestNewVars=int.MaxValue;
            // Variables de tete pas encore liees dans le corps (range restriction / connection FOIL)
            var boundInBody = new HashSet<string>();
            foreach (var l in clause.Body) { if(l.A.IsVar) boundInBody.Add(l.A.Var); if(l.B.IsVar) boundInBody.Add(l.B.Var); }
            var headVars = new[]{clause.Head.A.Var, clause.Head.B.Var};
            var unboundHead = headVars.Where(v=>!boundInBody.Contains(v)).ToList();
            foreach (var cand in Candidates(bodyVars, clause.Head)) {
                // Pruner : literal deja dans le corps
                if (clause.Body.Any(bl => bl.Pred==cand.Pred && bl.A.Var==cand.A.Var && bl.B.Var==cand.B.Var)) continue;
                var tryClause = new Clause(clause.Head, new List<Literal>(clause.Body){cand});
                var np = cp.Where(e=>Covers(tryClause,e,parentDB,ancestorDB,consts)).Count();
                var nn = cn.Where(e=>Covers(tryClause,e,parentDB,ancestorDB,consts)).Count();
                var g = FoilGain(cp.Count, cn.Count, np, nn);
                // Range restriction (FOIL safety) : un literal qui LIE une variable de tete
                // encore libre est fortement prefere (sinon la clause derive ancestor(A, anything)).
                bool bindsUnboundHead = (cand.A.IsVar && unboundHead.Contains(cand.A.Var))
                                     || (cand.B.IsVar && unboundHead.Contains(cand.B.Var));
                if (bindsUnboundHead) g += 3.0;
                int nNew = (cand.A.IsVar && !existingVars.Contains(cand.A.Var) ? 1:0)
                         + (cand.B.IsVar && !existingVars.Contains(cand.B.Var) ? 1:0);
                bool better;
                if (g > bestGain + 1e-9) better = true;
                else if (g < bestGain - 1e-9) better = false;
                else if (nNew != bestNewVars) better = nNew < bestNewVars;
                // Mode-bias (Aleph/Metagol) : a gain et novation egaux, preferer le predicat cible
                // UNIQUEMENT en position de fermeture recursive (ancestor(bodyVar, headVar)) :
                // 1er argument deja lie dans le corps, 2e argument = variable de tete non liee.
                // Cela produit la forme canonique parent(A,C),ancestor(C,B) plutot qu une
                // recursion purement transitive ancestor(A,V),ancestor(V,B).
                else better = (cand.Pred=="ancestor" && (best==null || best.Pred!="ancestor")
                              && cand.A.IsVar && boundInBody.Contains(cand.A.Var)
                              && cand.B.IsVar && unboundHead.Contains(cand.B.Var));
                if (better) { bestGain=g; best=cand; bestNewVars=nNew; }
            }
            if (best==null || bestGain<=double.NegativeInfinity) break;
            clause.Body.Add(best);
            cp = cp.Where(e=>Covers(clause,e,parentDB,ancestorDB,consts)).ToList();
            cn = cn.Where(e=>Covers(clause,e,parentDB,ancestorDB,consts)).ToList();
        }
        if (clause.Body.Count==0) break;  // rien appris -> stop
        learned.Add(clause);
        // Recalculer ancestorDB AVEC la nouvelle clause (recursion vue a la verification)
        var ancWithNew = DeriveAncestor(learned, parentDB, consts);
        remPos = remPos.Where(e=>!Covers(clause,e,parentDB,ancWithNew,consts)).ToList();
    }
    return learned;
}

// Deriver les faits ancestor a partir des clauses (fixpoint, extensionnel)
static HashSet<(string,string)> DeriveAncestor(List<Clause> learned, HashSet<(string,string)> parentDB, List<string> consts) {
    var anc = new HashSet<(string,string)>();
    bool changed=true; int rounds=0;
    while (changed && rounds++<20) {
        changed=false;
        foreach (var x in consts) foreach (var y in consts) {
            if (anc.Contains((x,y))) continue;
            var ex=(x,y);
            foreach (var cl in learned) if (Covers(cl,ex,parentDB,anc,consts)) { anc.Add((x,y)); changed=true; break; }
        }
    }
    return anc;
}

var learnedClauses = Foil(POS, NEG, parentDB, PEOPLE);
Console.WriteLine($"\nFOIL a appris {learnedClauses.Count} clause(s) :");
foreach (var cl in learnedClauses) Console.WriteLine("  " + cl.Show);

FOIL a appris 2 clause(s) :
  ancestor(A,B) :- parent(A,B).
  ancestor(A,B) :- parent(A,V3), ancestor(V3,B).

4. Verification : couverture et corrections

Un bon programme ILP doit couvrir tous les positifs et aucun negatif. Verifions le programme appris (re-derive a la main les 11 couples attendus).

var finalAnc = DeriveAncestor(learnedClauses, parentDB, PEOPLE);
int tp = POS.Count(p => finalAnc.Contains(p));
int fn = POS.Count(p => !finalAnc.Contains(p));
int fp = NEG.Count(n => finalAnc.Contains(n));
int tn = NEG.Count(n => !finalAnc.Contains(n));
Console.WriteLine($"Couverture du programme appris :");
Console.WriteLine($"  TP (vrais positifs)  = {tp} / {POS.Count}");
Console.WriteLine($"  FN (faux negatifs)   = {fn}  (doit etre 0 : tous les positifs couverts)");
Console.WriteLine($"  FP (faux positifs)   = {fp}  (doit etre 0 : aucun negatif couvert)");
Console.WriteLine($"  TN (vrais negatifs)  = {tn} / {NEG.Count}");
Console.WriteLine($"\nVerdict : {(fn==0 && fp==0 ? "PARFAIT" : "IMPARFAIT")} — {(fn==0&&fp==0 ? "programme ILP correct (11/11 positifs, 0 negatif)" : "erreurs residuelles")}");
Console.WriteLine($"\nLes 11 couples ancestor reconstruits : {finalAnc.Count}");
Couverture du programme appris :
  TP (vrais positifs)  = 11 / 11
  FN (faux negatifs)   = 0  (doit etre 0 : tous les positifs couverts)
  FP (faux positifs)   = 0  (doit etre 0 : aucun negatif couvert)
  TN (vrais negatifs)  = 19 / 19

Verdict : PARFAIT — programme ILP correct (11/11 positifs, 0 negatif)

Les 11 couples ancestor reconstruits : 11

5. Benchmark : FOIL sur un graphe parent croissant

Mesurons le temps d’apprentissage FOIL quand la taille du graphe parent augmente (chaîne lineaire de profondeur variable).

Console.WriteLine("profondeur  parents  positifs  clauses  temps(ms)");
Console.WriteLine(new string('-',52));
foreach (int depth in new[]{5,8,12,16,20}) {
    var p2 = new List<(string,string)>();
    var people2 = new List<string>();
    for (int i=0;i<depth;i++) people2.Add($"n{i}");
    for (int i=0;i<depth-1;i++) p2.Add(($"n{i}",$"n{i+1}"));
    var pos2 = TransitiveClosure(p2).OrderBy(t=>t).ToList();
    var ps2 = new HashSet<(string,string)>(pos2);
    var neg2 = new List<(string,string)>();
    foreach (var x in people2) foreach (var y in people2) if (x!=y && !ps2.Contains((x,y))) neg2.Add((x,y));
    var pdb2 = new HashSet<(string,string)>(p2);
    var sw = System.Diagnostics.Stopwatch.StartNew();
    var cls = Foil(pos2, neg2, pdb2, people2);
    sw.Stop();
    Console.WriteLine($"{depth,-11} {p2.Count,-8} {pos2.Count,-9} {cls.Count,-8} {sw.Elapsed.TotalMilliseconds:F1}");
}
Console.WriteLine("\nFOIL apprend toujours 2 clauses (base + recursion) ; le cout croit avec la profondeur.");
profondeur  parents  positifs  clauses  temps(ms)
----------------------------------------------------
5           4        10        2        12,6
8           7        28        2        76,8
12          11       66        2        307,1
16          15       120       2        420,3
20          19       190       2        1121,6

FOIL apprend toujours 2 clauses (base + recursion) ; le cout croit avec la profondeur.

6. Verdict SOTA : les 4 moteurs du twin Python

Le twin Python compare 4 frameworks ILP externes. Verdict SOTA (#3801) sur leur portabilite cote .NET :

Moteur Techno Verdict Raison
FOIL (this twin) C# from-scratch SOTA-OK vrai moteur ILP relationnel, apprend la recursion
Aleph / Progol SWI-Prolog + janus_swi RECOVERABLE-MACHINE Prolog natif ; bridge .NET improbable (pas de port .NET de SWI)
Metagol (MIL) SWI-Prolog + metagol.pl RECOVERABLE-MACHINE idem, Prolog
Popper (LFF) Python + clingo (ASP) SOTA-OK (pont §7) clingo = solver ASP C++ ; pont Process.Start sur le même binaire (section 7)
dILP / Lernd TensorFlow INTRINSIC apprentissage differentiable par gradient (paradigme différent)

Lecon : la substance algorithmique de l’ILP relationnel (recherche dans le graphe de raffinement, FOIL_Gain, couverture extensionnelle, recursion via clauses apprises) est entierement capturable en C# from-scratch. Les frameworks externes (Aleph, Metagol, Popper) automatisent cette recherche avec des optimisations (headless Prolog, ASP metier), et dILP est un paradigme différent (neuro-symbolique differentiable) — ce sont des compagnons, pas des prerequis.


7. Pont vers le solveur SOTA : clingo (Process.Start)

La section 6 déclarait Popper RECOVERABLE-MACHINE (« clingo = solver ASP C++ ; pont .NET non natif »). Ce verdict est révoqué : le pendant C# honnête est Process.Start sur le même exécutable que le jumeau Python invoque pour Popper — Potassco clingo, le solveur ASP qui porte le Learning From Failures (mandat #10382, bucket 2 : « le moteur est un binaire, le pendant C# honnête est Process.Start sur le même exécutable — pas une réimplémentation »).

La cellule suivante :

  1. rejoue le programme appris par le FOIL from-scratch de la cellule 5 (learnedClauses — les deux clauses récursives d’ancestor) sous forme d’un programme ASP ;
  2. interroge clingo (--outf=2) : quels couples ancestor/2 dérive-t-il sur le graphe parent/2 (5 faits, 6 personnes) ?
  3. vérifie la parité : TP/FN/FP/TN contre la cloture transitive — clingo doit dériver exactement les 11 positifs et 0 négatif.
Axe (checklist #10459) Réponse
1 · NuGet .NET N/A — pas de binding clingo officiel pour .NET
2 · P/Invoke N/A — pas de DLL C stable documentée invocable
3 · CLI Process.Start LE CANAL — clingo.exe (clingo hors Windows) / python -m clingo (ou python3) est un binaire invocable
4 · IKVM N/A — clingo n’est pas une lib Java
5 · PythonNet N/A — pas besoin de pont CPython, le binaire suffit
6 · lib équivalente clingo (ASP, Potassco) = le moteur SOTA qu’utilise Popper (LFF) — même moteur, les deux jumeaux

Verdict : SOTA-OK (pont) — le from-scratch FOIL est conservé (il enseigne la recherche dans le graphe de raffinement), le pont est en plus, jamais à la place.

// === Pont clingo (Process.Start) : le moteur ASP SOTA derrière Popper, invoqué depuis le C# ===
using System.IO;
using System.Diagnostics;
using System.Text.Json;

// Resout l'executable clingo : variable CLINGO_DIR -> repo ext_tools/clingo -> python (puis python3) -m clingo.
string[] FindClingoCmd()
{
    // Binaire Potassco : clingo.exe sous Windows, clingo ailleurs
    var clingoName = OperatingSystem.IsWindows() ? "clingo.exe" : "clingo";
    var envDir = Environment.GetEnvironmentVariable("CLINGO_DIR");
    if (!string.IsNullOrEmpty(envDir))
    {
        var exe = Path.Combine(envDir, clingoName);
        if (File.Exists(exe)) return new[] { exe };
    }
    var repoClingo = Path.Combine(
        Environment.CurrentDirectory, "MyIA.AI.Notebooks", "SymbolicAI", "ext_tools", "clingo", clingoName);
    if (File.Exists(repoClingo)) return new[] { repoClingo };
    foreach (var py in new[] { "python", "python3" })   // python3 : Linux et macOS sans alias `python`
    {
        try
        {
            var pi = new ProcessStartInfo(py, "-c \"import clingo; print(clingo.__version__)\"")
            { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true };
            var p = Process.Start(pi);
            p.StandardOutput.ReadToEnd(); p.WaitForExit(5000);
            if (p.ExitCode == 0) return new[] { py, "-m", "clingo" };
        }
        catch { }
    }
    throw new FileNotFoundException(
        "clingo introuvable. Installer le solveur ASP SOTA (Potassco) : " +
        "python -m pip install clingo  OU  MyIA.AI.Notebooks/SymbolicAI/scripts/install_clingo.py");
}

// Clause C# -> regle ASP : le format .Show est deja valide (variables MAJUSCULES, constantes minuscules).
string AspRule(Clause c) => c.Body.Count == 0
    ? $"{c.Head.Show}."
    : $"{c.Head.Show} :- {string.Join(",", c.Body.Select(l => l.Show))}.";

// 1. Programme ASP : faits parent/2 + clauses apprises par FOIL (cellule 5)
string aspPath = Path.Combine(Path.GetTempPath(), $"sl6_ancestor_{Guid.NewGuid():N}.asp");
var aspLines = new List<string>();
foreach (var (a, b) in PARENT) aspLines.Add($"parent({a},{b}).");
foreach (var r in learnedClauses) aspLines.Add(AspRule(r));
File.WriteAllText(aspPath, string.Join("\n", aspLines));

// 2. Invocation clingo : --outf=2 (answer set JSON), parse des atomes ancestor/2
var cmd = FindClingoCmd();
var psi = new ProcessStartInfo(cmd[0])
{
    RedirectStandardOutput = true, RedirectStandardError = true,
    UseShellExecute = false, CreateNoWindow = true,
    WorkingDirectory = Path.GetDirectoryName(aspPath) ?? Path.GetTempPath(),
};
foreach (var a in cmd.Skip(1)) psi.ArgumentList.Add(a);
psi.ArgumentList.Add("--outf=2");
psi.ArgumentList.Add(Path.GetFileName(aspPath));
var proc = Process.Start(psi);
string stdout = proc.StandardOutput.ReadToEnd();
string stderr = proc.StandardError.ReadToEnd();
proc.WaitForExit();

var derived = new HashSet<(string,string)>();
if (proc.ExitCode == 0 && !string.IsNullOrWhiteSpace(stdout))
{
    using var doc = JsonDocument.Parse(stdout);
    var witnesses = doc.RootElement.GetProperty("Call")[0].GetProperty("Witnesses");
    foreach (var w in witnesses.EnumerateArray())
        foreach (var atom in w.GetProperty("Value").EnumerateArray())
        {
            string s = atom.GetString();
            if (s.StartsWith("ancestor("))
            {
                var inner = s.Substring("ancestor(".Length, s.Length - "ancestor(".Length - 1).Split(',');
                derived.Add((inner[0], inner[1]));
            }
        }
}

// 3. Verdict de parite : clingo derive-t-il exactement les 11 positifs, 0 negatif ?
Console.WriteLine("===== Pont clingo (Process.Start) =====");
Console.WriteLine($"Programme ASP ({aspLines.Count} lignes) :");
foreach (var l in aspLines) Console.WriteLine($"  {l}");
Console.WriteLine($"Solveur : {string.Join(" ", cmd)}");
Console.WriteLine();
Console.WriteLine($"Ancetres derives par clingo : {derived.Count}");
foreach (var (x, y) in derived.OrderBy(d => d.Item1).ThenBy(d => d.Item2))
    Console.WriteLine($"  ancestor({x}, {y})");
Console.WriteLine();
int tp2 = POS.Count(p => derived.Contains(p));
int fn2 = POS.Count(p => !derived.Contains(p));
int fp2 = NEG.Count(n => derived.Contains(n));
int tn2 = NEG.Count(n => !derived.Contains(n));
Console.WriteLine($"  TP (vrais positifs)  = {tp2} / {POS.Count}");
Console.WriteLine($"  FN (faux negatifs)   = {fn2}");
Console.WriteLine($"  FP (faux positifs)   = {fp2}");
Console.WriteLine($"  TN (vrais negatifs)  = {tn2} / {NEG.Count}");
Console.WriteLine($"Verdict parite : {(fn2 == 0 && fp2 == 0 ? "FOIL from-scratch == clingo (SOTA) sur ancestor/2" : "ECART - investiguer")}");
===== Pont clingo (Process.Start) =====
Programme ASP (7 lignes) :
  parent(tom,bob).
  parent(bob,ann).
  parent(bob,pat).
  parent(pat,jim).
  parent(ann,sue).
  ancestor(A,B) :- parent(A,B).
  ancestor(A,B) :- parent(A,V3),ancestor(V3,B).
Solveur : python -m clingo

Ancetres derives par clingo : 11
  ancestor(ann, sue)
  ancestor(bob, ann)
  ancestor(bob, jim)
  ancestor(bob, pat)
  ancestor(bob, sue)
  ancestor(pat, jim)
  ancestor(tom, ann)
  ancestor(tom, bob)
  ancestor(tom, jim)
  ancestor(tom, pat)
  ancestor(tom, sue)

  TP (vrais positifs)  = 11 / 11
  FN (faux negatifs)   = 0
  FP (faux positifs)   = 0
  TN (vrais negatifs)  = 19 / 19
Verdict parite : FOIL from-scratch == clingo (SOTA) sur ancestor/2

Lecture du résultat : FOIL from-scratch ≡ clingo (SOTA)

clingo dérive exactement les 11 couples ancestor/2 de la cloture transitive — TP = 11/11, FP = 0, et les 19 négatifs sont tous exclus (TN = 19/19). Les deux clauses apprises par le FOIL from-scratch de la cellule 5 (le cas de base ancestor(A,B) :- parent(A,B) et le pas récursif ancestor(A,B) :- parent(A,C), ancestor(C,B)) produisent, sur le même moteur ASP que Popper utilise en interne, exactement le même programme. La parité est native-both : les deux jumeaux référencent clingo (Popper côté Python, ce pont côté C#).

Le FOIL from-scratch garde sa valeur pédagogique : il montre comment la recherche dans le graphe de raffinement construit ces deux clauses. Le pont vérifie que le résultat est bien ce que le moteur SOTA dérive.


Synthese : FOIL vs les 4 moteurs

Aspect FOIL (C#) Aleph/Metagol Popper dILP
Paradigme Raffinement glouton Entailment inverse / Meta-interpretive Learning From Failures (ASP) Differentiable (gradient)
Recursion Oui (clauses apprises dans le corps) Oui Oui Oui
Background Faits extensionnels Théorie Prolog ASP atoms Tenseurs de probabilites
Bruit Non (ILP classique) Partiel Oui Oui (naturel)
Paradigme .NET Natif (this twin) RECOVERABLE-MACHINE SOTA-OK (pont §7) INTRINSIC

Lecon cles : 1. ILP = apprendre des programmes : contrairement au ML classique (qui apprend des fonctions/paramètres), l’ILP apprend une structure logique interpretable. 2. La recursion est le vrai test : un moteur qui ne sait qu’apprendre des clauses non-recursives rate ancestor. FOIL la permet en evaluant le predicat cible via les clauses déjà apprises. 3. FOIL_Gain guide la recherche : sans heuristique, l’espace des clauses est immense ; le gain d’information privilegie les literals qui purifient la couverture. 4. Compromis expressivite/bruit : l’ILP classique (FOIL, Aleph) est déterministe et exact mais fragile au bruit ; dILP sacrifie l’exactitude symbolique pour la robustesse (gradient).

Parite avec le twin Python : la substance algorithmique (sections 2-5) est equivalente. Aleph/Metagol (Prolog) et dILP (TF) n’ont pas de port .NET natif — verdict RECOVERABLE-MACHINE/INTRINSIC section 6, conformement a la discipline SOTA (#3801). Popper (ASP) est en revanche bridge par Process.Start vers clingo (§7, SOTA-OK) — le même moteur que le jumeau Python invoque.


Exercices

Completez les squelettes ci-dessous (result = null // TODO).

// Exercice 1 : Sur la tache ancestor, combien de clauses FOIL apprend-il ?
// (re-derive, puis verifiez avec learnedClauses.Count)
object exo1Result = null;  // TODO etudiant : int attendu
Console.WriteLine(exo1Result == null ? "Exercice a completer" : exo1Result);
Exercice a completer
// Exercice 2 : Definir un jeu de test ou la relation a apprendre est NON recursive
// (ex. grandparent(X,Y) :- parent(X,Z), parent(Z,Y)) et lancer FOIL.
object exo2Result = null;  // TODO etudiant : liste de clauses attendues
Console.WriteLine(exo2Result == null ? "Exercice a completer" : exo2Result);
Exercice a completer
// Exercice 3 : Ajouter un exemple negatif incorrect (ancestor(bob,jim) est VRAI mais
// marqué negatif) et observer comment FOIL echoue a couvrir tous les positifs.
object exo3Result = null;  // TODO etudiant : string verdict attendu
Console.WriteLine(exo3Result == null ? "Exercice a completer" : exo3Result);
Exercice a completer
Retour au sommet