Twin C# du notebook Python Planners-1-Introduction (Python + lib unified-planning). Tranche 1 = les fondations : le modèle STRIPS (Fikes & Nilsson, 1971), l’espace d’etats, et un planificateur BFS from-scratch qui resout le problème canonique de l’interrupteur. Implementation BCL .NET seule, 0 NuGet. Tranche 2 = le moteur de production : les mêmes problèmes (plus la livraison du jumeau Python) délégués au vrai Fast Downward via son API Docker, en PDDL standard.
Complementarite (#3801 Prong B)
Twin
Outil
Valeur
Python (unified-planning)
lib up.shortcuts (modelisation + solveur 1 appel)
resoudre vite des problemes PDDL reels
This — Tranche 1 (.NET from-scratch)
implementation C# main : STRIPS + BFS + state-space
comprendre la sémantique STRIPS et la recherche dans l’espace d’etats
This — Tranche 2 (Fast Downward)
API Docker HTTP (astar(lmcut())), PDDL standard
produire : moteur PDDL SOTA, plan optimal, zéro code de recherche
Pas d’equivalent NuGet maintenu et didactique a unified-planning en .NET → la Tranche 1 from-scratch porte la valeur pedagogique (on code chaque brique), la Tranche 2 porte la parité d’outil (le même moteur de production que le jumeau Python enveloppe, cf Planners-4-Fast-Downward-Csharp).
Stack technique : BCL .NET seule + HttpClient vers l’API Docker Fast Downward (Tranche 2). Kernel .net-csharp (.NET Interactive).
Cadre pédagogique et prérequis
Ce notebook ouvre la série Planners avec une approche from-scratch : le moteur de recherche (BFS) et la représentation des états (STRIPS) sont implémentés à la main en C# pur (BCL .NET seule, sans dépendance externe). L’objectif est pédagogique : comprendre comment un planificateur fonctionne avant d’utiliser des moteurs SOTA (Fast Downward, OR-Tools, LLM, cf. tranches suivantes).
Pourquoi C# / .NET Interactive ? Le choix reflète le contexte du dépôt CoursIA, qui mêle notebooks Python et C# pour montrer les deux écosystèmes en action. C# apporte un typage statique rigoureux, des HashSet<T> natifs (idéaux pour représenter un état ensembliste), et un kernel .NET Interactive stable pour Jupyter. Voir docs/reference/kernels-runtime.md pour l’installation et le déblocage du kernel.
Prérequis pour suivre ce notebook :
Maîtrise de C# 9+ (types génériques, record, HashSet<T>, LINQ de base). Les spécificités BCL .NET utilisées ici sont documentées inline.
Notions de recherche dans un graphe (BFS, DFS, A*). Si elles sont fraîches, la section 1 pose le cadre et la section 6 construit BFS pas à pas.
Plan du notebook : 10 sections progressives (définition STRIPS, implémentation, exemples croissant en complexité, exercices), puis la Tranche 2 qui introduit le moteur Fast Downward via API Docker en comparaison directe. Les exercices (section 10) sont à compléter pour valider la compréhension.
Note d’écosystème : la Tranche 2 s’appuie sur le moteur fast-downward.py exposé par une API Docker en localhost:8200 — c’est un prérequis explicite. La cellule Tranche 2 appelle l’API directement (HttpClient, échec rapide si le service n’est pas lancé) : pas de repli silencieux sur BFS, conformément à la règle SOTA du dépôt — pas de sortie dégradée maquillée en résultat. La Tranche 1 (BFS from-scratch, zéro dépendance) reste exécutable seule.
1. Qu’est-ce que la planification ?
La planification automatique est la branche de l’IA qui genere des sequences d’actions pour atteindre un objectif. Etant donne : - un etat initial (le monde tel qu’il est), - un ensemble d’actions (chacune decrite par ses preconditions et ses effets), - un but (un etat a atteindre),
le planificateur cherche automatiquement une suite d’actions qui mene de l’etat initial au but. La question n’est pas « que predire ? » (apprentissage) mais « que faire ? ».
Contraste : - Recherche (Search series) : on explore un graphe donne. La planification construit ce graphe (l’espace d’etats) a partir de la sémantique des actions. - CSP (Constraint series) : on satisfait des contraintes sur des variables. La planification cherche une sequence (l’ordre compte).
La planification est une technologie eprouvee : elle pilote des robots, optimise la logistique, et a dirige des engins spatiaux autonomes (Remote Agent sur Deep Space 1).
2. Le modèle STRIPS
STRIPS (STanford Research Institute Problem Solver, Fikes & Nilsson 1971) est le formalisme canonique. L’etat du monde = un ensemble de predicats (faits vrais). Une action (appelee opérateur) comporte :
un nom (ex: allumer(lampe)),
des preconditions : predicats qui doivent etre vrais pour que l’action soit applicable,
des effets : predicats ajoutes (deviennent vrais) et retires (deviennent faux) après l’action.
La transition : si l’etat courant contient toutes les preconditions, l’action est applicable. L’etat resultant = (etat courant − effets de retrait) ∪ effets d’ajout.
Formalisme
Soit un opérateur \(o = (pre(o), add(o), del(o))\). Pour un etat \(S\) :
applicable ssi \(pre(o) \subseteq S\)
successeur : \(S' = (S \setminus del(o)) \cup add(o)\)
Un plan = sequence d’opérateurs \(o_1, o_2, \dots, o_n\) telle que chaque \(o_i\) est applicable dans l’etat atteint après \(o_1, \dots, o_{i-1}\), et le but est atteint a la fin.
3. Setup et helpers invariant
Helper de formatage invariant (evite la collision virgule-decimale FR dans les sorties).
using System;using System.Collections.Generic;using System.Globalization;using System.Linq;staticstringFI(double x,string fmt ="F4")=> x.ToString(fmt, CultureInfo.InvariantCulture);Console.WriteLine("Setup OK — kernel .net-csharp, BCL seule.");
The below script needs to be able to find the current output cell; this is an easy method to get it.
Setup OK — kernel .net-csharp, BCL seule.
4. Implementation du modèle STRIPS from-scratch
On represente l’etat comme un HashSet<string> de predicats (ex: {"lampe_allumee", "lampe_fonctionne"}). Un opérateur est une classe avec preconditions, ajouts, retraits. La transition applique l’opérateur si applicable.
// Modele STRIPS from-scratch (BCL .NET seule).// Un etat = HashSet<string> (ensemble de predicats vrais).// Un operateur STRIPS : nom + preconditions + effets (add/del).class Operator {publicstring Name {get; init;}="";public HashSet<string> Pre {get; init;}=new();public HashSet<string> Add {get; init;}=new();public HashSet<string> Del {get; init;}=new();}// Une transition : applicable ssi pre <= S ; successeur = (S - del) | add.staticboolIsApplicable(Operator op, HashSet<string> s)=> op.Pre.IsSubsetOf(s);static HashSet<string>Apply(Operator op, HashSet<string> s){// On ne devrait appeler Apply que si IsApplicable.var s2 =new HashSet<string>(s);foreach(var d in op.Del) s2.Remove(d);foreach(var a in op.Add) s2.Add(a);return s2;}// Le but est atteint ssi tous les predicats du but sont dans l'etat.staticboolGoalReached(HashSet<string> goal, HashSet<string> s)=> goal.IsSubsetOf(s);Console.WriteLine("Modele STRIPS compile : Operator + IsApplicable + Apply + GoalReached.");
Le problème canonique de l’interrupteur (cf twin Python) : - Etat initial : la lampe est eteinte. - Actions : allumer (pre: lampe en bon etat ; add: lampe allumee), eteindre (add: lampe eteinte, del: lampe allumee). - But : la lampe est allumee.
Ce problème trivial sert de sanity check : un planificateur BFS doit trouver le plan [allumer].
// Exemple de l'interrupteur (sanity check).var allumer =new Operator { Name ="allumer", Pre =new HashSet<string>{"lampe_fonctionne"}, Add =new HashSet<string>{"lampe_allumee"}, Del =new HashSet<string>{"lampe_eteinte"}};var eteindre =new Operator { Name ="eteindre", Pre =new HashSet<string>{"lampe_allumee"}, Add =new HashSet<string>{"lampe_eteinte"}, Del =new HashSet<string>{"lampe_allumee"}};HashSet<string> initial =new HashSet<string>{"lampe_eteinte","lampe_fonctionne"};var goal =new HashSet<string>{"lampe_allumee"};Console.WriteLine("Etat initial : {"+string.Join(", ", initial.OrderBy(x => x))+"}");Console.WriteLine("But : {"+string.Join(", ", goal)+"}");Console.WriteLine("But atteint directement ? "+GoalReached(goal, initial));Console.WriteLine("'allumer' applicable ? "+IsApplicable(allumer, initial));var apres =Apply(allumer, initial);Console.WriteLine("Apres 'allumer' : {"+string.Join(", ", apres.OrderBy(x => x))+"}");Console.WriteLine("But atteint apres 'allumer' ? "+GoalReached(goal, apres));
Etat initial : {lampe_eteinte, lampe_fonctionne}
But : {lampe_allumee}
But atteint directement ? False
'allumer' applicable ? True
Apres 'allumer' : {lampe_allumee, lampe_fonctionne}
But atteint apres 'allumer' ? True
Interpretation — l’interrupteur
L’etat initial ne contient pas le but (lampe_allumee absent). L’action allumer est applicable car sa precondition lampe_fonctionne est satisfaite. Après application, lampe_allumee est ajoutee → le but est atteint. Sanity OK : le plan [allumer] resout le problème.
C’est exactement ce que ferait unified-planning (twin Python) en un appel — mais ici on voit chaque étape (precondition, ajout, retrait).
6. Le planificateur BFS from-scratch
Un planificateur = une recherche dans l’espace d’etats. L’espace d’etats = graphe ou les noeuds sont les etats et les aretes sont les opérateurs applicables. On cherche un chemin de l’etat initial a un etat-but.
BFS (Breadth-First Search) = parcours en largeur. Garantit le plan le plus court (en nombre d’actions). Pour un petit espace d’etats, BFS suffit. Pour de grands espaces, on aurait besoin d’heuristiques (A*, relaxations — cf Planners-5).
Implementation : une file d’etats a explorer, un dictionnaire etat -> (opérateur, etat parent) pour reconstruire le plan une fois le but atteint.
#nullable enable// Planificateur BFS from-scratch — trouve le plan le plus court.static List<string>?PlanBFS(HashSet<string> initial, HashSet<string> goal, List<Operator> ops,int maxExpansions =100000){var queue =new Queue<HashSet<string>>(); queue.Enqueue(initial);// came_from[etat] = (operateur applique, etat parent)var cameFrom =new Dictionary<string,(string opName, HashSet<string> parent)>(newStateComparer()); cameFrom[StateKey(initial)]=("__INIT__", initial);int expansions =0;while(queue.Count>0&& expansions < maxExpansions){var current = queue.Dequeue(); expansions++;if(GoalReached(goal, current)){// Reconstruire le plan.var plan =new List<string>();var key =StateKey(current);while(cameFrom[key].opName!="__INIT__"){var(opName, parent)= cameFrom[key]; plan.Add(opName); key =StateKey(parent);} plan.Reverse();return plan;}foreach(var op in ops){if(IsApplicable(op, current)){var next =Apply(op, current);var nkey =StateKey(next);if(!cameFrom.ContainsKey(nkey)){ cameFrom[nkey]=(op.Name, current); queue.Enqueue(next);}}}}returnnull;// pas de plan trouve dans maxExpansions}// Cle string canonique pour un etat (predicats tries).staticstringStateKey(HashSet<string> s)=>string.Join("|", s.OrderBy(x => x));// Comparateur d'etats base sur la cle string.class StateComparer : IEqualityComparer<string>{publicboolEquals(string? a,string? b)=> a == b;publicintGetHashCode(string s)=> s?.GetHashCode()??0;}Console.WriteLine("Planificateur BFS compile.");
Planificateur BFS compile.
Plan sur l’interrupteur
Lançons BFS sur le problème de l’interrupteur. Il doit trouver [allumer] (1 action, le plus court).
// Plan sur l'interrupteur.var ops =new List<Operator>{ allumer, eteindre };var plan =PlanBFS(initial, goal, ops);if(plan !=null){ Console.WriteLine($"Plan trouve ({plan.Count} action(s)) : [{string.Join("->", plan)}]");}else{ Console.WriteLine("Aucun plan trouve.");}
Plan trouve (1 action(s)) : [allumer]
7. Problème plus riche — multi-interrupteurs (non-trivial)
L’interrupteur seul est trivial (1 action). Pour mettre en valeur le planificateur (cf #3801 Prong B — problème non-trivial), considerons N interrupteurs a allumer dans un ordre quelconque, mais avec une contrainte : une maitresse (master switch) doit etre allumee avant de pouvoir allumer les autres (precondition chainee). Le but = tous les interrupteurs allumes.
Ce problème a une structure (la maitresse d’abord) que BFS doit decouvrir — ce n’est pas un graphe a cout uniforme trivial.
// N interrupteurs + maitresse (probleme non-trivial).int N =3;var multiOps =new List<Operator>();// allumer_master : pre = rien ; add = master_onmultiOps.Add(new Operator { Name ="allumer_master", Pre =new HashSet<string>(), Add =new HashSet<string>{"master_on"}, Del =new HashSet<string>{"master_off"}});// allumer_i : pre = master_on ; add = switch_i_on ; del = switch_i_offfor(int i =1; i <= N; i++){int idx = i; multiOps.Add(new Operator { Name = $"allumer_sw{idx}", Pre =new HashSet<string>{"master_on"}, Add =new HashSet<string>{ $"switch_{idx}_on"}, Del =new HashSet<string>{ $"switch_{idx}_off"}});}HashSet<string> multiInitial =new HashSet<string>();multiInitial.Add("master_off");for(int i =1; i <= N; i++) multiInitial.Add($"switch_{i}_off");var multiGoal =new HashSet<string>{"master_on"};for(int i =1; i <= N; i++) multiGoal.Add($"switch_{i}_on");Console.WriteLine($"Probleme : {N} interrupteurs + 1 maitresse");Console.WriteLine($"Etat initial : {multiInitial.Count} predicats (tout eteint)");Console.WriteLine($"But : master_on + {N} switch_*_on");Console.WriteLine();var multiPlan =PlanBFS(multiInitial, multiGoal, multiOps);if(multiPlan !=null){ Console.WriteLine($"Plan trouve ({multiPlan.Count} actions) :"); Console.WriteLine($" [{string.Join("->", multiPlan)}]"); Console.WriteLine();// Verifier : master_on doit preceder tous allumer_sw*int masterIdx = multiPlan.IndexOf("allumer_master"); Console.WriteLine($"'allumer_master' a la position {masterIdx + 1}/{multiPlan.Count} — doit etre PREMIERE (precondition chainee)."); Console.WriteLine($"Structure decouverte par BFS : maitresse d'abord, puis les {N} interrupteurs (ordre arbitraire).");}else{ Console.WriteLine("Aucun plan trouve.");}
Probleme : 3 interrupteurs + 1 maitresse
Etat initial : 4 predicats (tout eteint)
But : master_on + 3 switch_*_on
Plan trouve (4 actions) :
[allumer_master -> allumer_sw1 -> allumer_sw2 -> allumer_sw3]
'allumer_master' a la position 1/4 — doit etre PREMIERE (precondition chainee).
Structure decouverte par BFS : maitresse d'abord, puis les 3 interrupteurs (ordre arbitraire).
Interpretation — multi-interrupteurs
BFS decouvre automatiquement la structure du problème : - Il faut d’abord allumer la maitresse (seule action applicable sans precondition). - Puis allumer chaque interrupteur (precondition master_on satisfaite).
Le plan a N+1 actions : allumer_master puis allumer_sw1, allumer_sw2, …, allumer_swN (l’ordre des sw* est arbitraire — BFS trouve l’un des plans optimaux). C’est exactement le genre de plan que unified-planning produirait, mais ici on voit la recherche explorer l’espace d’etats.
Pourquoi c’est non-trivial : sans precondition chainee, le problème serait degenere (toutes les actions independantes). La maitresse force un ordre partiel que le planificateur doit decouvrir — c’est ce qui met BFS en valeur (cf #3801 Prong B, problème non-degenere).
8. Explosion combinatoire de l’espace d’etats
Avec \(n\) predicats (faits booléens), il y a jusqu’a \(2^n\) etats possibles. Pour 10 predicats = 1024 etats, pour 20 = 1 million, pour 50 = \(10^{15}\)… C’est le fleau de la combinatoire qui motive les heuristiques (A*, relaxations — cf Planners-5).
// Explosion combinatoire : 2^n etats pour n predicats.Console.WriteLine("Explosion combinatoire de l'espace d'etats (2^n) :");Console.WriteLine();Console.WriteLine(" n predicats | etats possibles");Console.WriteLine(" ----------- | ---------------");foreach(int n innew[]{5,10,15,20,30,50}){double states = Math.Pow(2, n);string repr = states <1e6?FI(states,"F0"): states.ToString("E2", CultureInfo.InvariantCulture); Console.WriteLine($" {n,11} | {repr,15}");}Console.WriteLine();Console.WriteLine(">>> 50 predicats = ~1e15 etats : aucun planificateur aveugle (BFS) ne peut explorer.");Console.WriteLine(">>> D'ou les HEURISTIQUES (A*, relaxations) pour guider la recherche (cf Planners-5).");
Explosion combinatoire de l'espace d'etats (2^n) :
n predicats | etats possibles
----------- | ---------------
5 | 32
10 | 1024
15 | 32768
20 | 1.05E+006
30 | 1.07E+009
50 | 1.13E+015
>>> 50 predicats = ~1e15 etats : aucun planificateur aveugle (BFS) ne peut explorer.
>>> D'ou les HEURISTIQUES (A*, relaxations) pour guider la recherche (cf Planners-5).
9. Types de planification
La planification se declinent en plusieurs variantes (cf twin Python) :
Ce notebook couvre les fondations classiques (STRIPS + BFS). Les notebooks suivants (PDDL, heuristiques, etc.) approfondissent.
10. Exercices
Trois exercices pour aller plus loin. Chaque stub est conforme a la règle C.1 (pas d’erreur volontaire — le notebook s’execute de bout en bout même exercices non completes).
Exercice 1 — Le monde des blocs (blocks world)
Enonce : Modelisez le monde des blocs : 3 blocs A, B, C. Etat initial : A sur la table, B sur A, C sur la table. But : C sur B sur A (pyramide). Actions : deplacer(x, y, z) (pre: x sur y, bras libre ; add: x sur z ; del: x sur y).
Étape 1 : Definissez les opérateurs (avec les predicats sur(x,y), Bras_libre, sur_table(x)). Étape 2 : Codez l’etat initial et le but. Étape 3 : Lancez PlanBFS et observez le plan.
Indice : attention aux precondition Bras_libre — un seul bloc peut etre deplace a la fois. C’est un problème classique (Sussman anomaly) plus riche que l’interrupteur.
// Exercice 1 — Monde des blocs (A sur table, B sur A, C sur table -> C sur B sur A).// TODO etudiant : definissez les operateurs deplacer + etat initial + but, lancez PlanBFS.Console.WriteLine("Exercice 1 a completer : monde des blocs (blocks world).");
Exercice 1 a completer : monde des blocs (blocks world).
Exercice 2 — Ajouter une heuristique (A* au lieu de BFS)
Enonce : BFS trouve le plan le plus court mais explore en aveugle. Implementez A* avec une heuristique simple : nombre de predicats du but non encore satisfaits dans l’etat courant.
Questions : (a) A* explore-t-il moins d’etats que BFS sur le multi-interrupteurs ? (b) L’heuristique est-elle admissible (ne surestime jamais le cout) ?
Indice : remplacez la Queue BFS par une PriorityQueue ordonnée par g + h (cout parcouru + heuristique). g = nombre d’actions depuis l’initial, h = predicats du but manquants.
// Exercice 2 — A* avec heuristique (predicats du but manquants).// TODO etudiant : PriorityQueue g+h, h = |goal - S|, comparez explorations vs BFS.Console.WriteLine("Exercice 2 a completer : A* avec heuristique de but non-satisfait.");
Exercice 2 a completer : A* avec heuristique de but non-satisfait.
Exercice 3 — Echec : problème non-realisable
Enonce : Créez un problème sans solution (ex: but = lampe allumee, mais lampe_fonctionne absent de l’etat initial et aucune action ne l’etablit). Observez le comportement de PlanBFS (retourne null après maxExpansions).
Questions : (a) Combien d’etats BFS explore-t-il avant d’abandonner ? (b) Comment detecter l’insatisfiabilite plus tot (ex: analyse des preconditions — un fait du but non atteignable par aucun effet d’ajout) ?
Indice : maxExpansions borne l’exploration. Un fait du but qui n’apparait dans aucun Add d’opérateur est mort — detection statique possible.
// Exercice 3 — Probleme non-realisable (but avec fait non-atteignable).// TODO etudiant : etat initial sans lampe_fonctionne, but lampe_allumee, observez null.Console.WriteLine("Exercice 3 a completer : probleme non-realisable + detection statique.");
Exercice 3 a completer : probleme non-realisable + detection statique.
Tranche 2 — Le moteur de production : Fast Downward via API Docker
La Tranche 1 a construit le planificateur (STRIPS + BFS from-scratch). Le jumeau Python, lui, invoque des moteurs de production via unified-planning (pyperplan sur l’interrupteur, plan optimal 3 actions sur le problème de livraison). Cette Tranche 2 fait de même côté .NET : on délègue au vrai Fast Downward — le moteur PDDL SOTA (Helmert 2006, lignée championne IPC) — exposé comme service Docker HTTP (conteneur coursia-fast-downward, port 8200), la même approche que Planners-4-Fast-Downward-Csharp.
Étape
Tranche 1 (from-scratch)
Tranche 2 (Fast Downward)
Encodage du domaine
classes C# Operator à la main
texte PDDL standard
Traduction + grounding
rien (déjà STRIPS)
faites par le moteur
Recherche
BFS (aveugle)
A* + heuristique lmcut (admissible)
Garantie
plan le plus court
plan optimal (coût minimal)
Prérequis : le service Docker doit tourner (docker start fast-downward, vérif GET http://localhost:8200/health).
// === Tranche 2 : Fast Downward via API Docker (moteur PDDL de production) ===// Meme approche que le jumeau Python (unified-planning enveloppe les moteurs de// production) et que Planners-4-Csharp : on poste domaine + probleme en PDDL// standard, le moteur fait la traduction, le grounding et la recherche.using System.Net.Http;using System.Text;using System.Text.Json;using System.Text.RegularExpressions;staticreadonly HttpClient FdHttp =new HttpClient { Timeout = TimeSpan.FromSeconds(30)};// Un appel au planificateur : POST /plan {domain, problem, search} -> (rc, stdout, stderr).static(int rc,string stdout,string stderr)RunFd(string domain,string problem,string search){var payload = JsonSerializer.Serialize(new{ domain, problem, search });var resp = FdHttp.PostAsync("http://localhost:8200/plan",newStringContent(payload, Encoding.UTF8,"application/json")).Result;var body = resp.Content.ReadAsStringAsync().Result;usingvar doc = JsonDocument.Parse(body);return(doc.RootElement.GetProperty("returncode").GetInt32(), doc.RootElement.GetProperty("stdout").GetString()??"", doc.RootElement.GetProperty("stderr").GetString()??"");}// Extraction du plan depuis le log Fast Downward.// Ligne de plan FD : "pick rob pkg depot (1)" = action + arguments + (cout).static List<string>FdPlanLines(string stdout){var lines =new List<string>();foreach(var raw in stdout.Split('\n')){var t = raw.Trim();if(Regex.IsMatch(t, @"^[a-z][a-z0-9_-]*(\s+\S+)*\s+\(\d+\)\s*$")) lines.Add(t);}return lines;}// Premiere metrique regex-matching du log (ex : heuristique initiale, etats expanses).staticintFdMetric(string stdout,string pattern){var m = Regex.Match(stdout, pattern);return m.Success?int.Parse(m.Groups[1].Value):-1;}Console.WriteLine("Sante du service : "+ FdHttp.GetAsync("http://localhost:8200/health").Result.Content.ReadAsStringAsync().Result);Console.WriteLine();// --- L'interrupteur de la section 5, rejoue des deux cotes ---// Cote Tranche 1 : BFS from-scratch sur le modele STRIPS (modeles frais, noms locaux).var opsSw =new List<Operator>{new Operator { Name ="allumer", Pre =new HashSet<string>{"lampe_fonctionne"}, Add =new HashSet<string>{"lampe_allumee"}, Del =new HashSet<string>{"lampe_eteinte"}},new Operator { Name ="eteindre", Pre =new HashSet<string>{"lampe_allumee"}, Add =new HashSet<string>{"lampe_eteinte"}, Del =new HashSet<string>{"lampe_allumee"}}};var initSw =new HashSet<string>{"lampe_eteinte","lampe_fonctionne"};var goalSw =new HashSet<string>{"lampe_allumee"};var planBfsSw =PlanBFS(initSw, goalSw, opsSw);// Cote Tranche 2 : le meme domaine en PDDL (turn-on <-> allumer).var DOMAIN_SW = @"(define (domain interrupteurs)(:requirements :strips)(:predicates(lampe-fonctionne)(lampe-allumee)(lampe-eteinte))(:action turn-on :parameters():precondition(lampe-fonctionne):effect(and(lampe-allumee)(not(lampe-eteinte))))(:action turn-off :parameters():precondition(lampe-allumee):effect(and(lampe-eteinte)(not(lampe-allumee)))))";var PROBLEM_SW = @"(define (problem interrupteur-simple)(:domain interrupteurs)(:init(lampe-fonctionne)(lampe-eteinte))(:goal(lampe-allumee)))";var(rcSw, outSw, errSw)=RunFd(DOMAIN_SW, PROBLEM_SW,"astar(lmcut())");var fdPlanSw =FdPlanLines(outSw);Console.WriteLine($"[FD] interrupteur : returncode = {rcSw}");Console.WriteLine($"[FD] Plan ({fdPlanSw.Count} action(s)) : [{string.Join("->", fdPlanSw)}]");Console.WriteLine($"[FD] h(lmcut) initiale = {FdMetric(outSw, @"Initial heuristic value for lmcut:\s*(\d+)")}"+ $" | etats expanses = {FdMetric(outSw, @"Expanded\s+(\d+)\s+state")}");Console.WriteLine();Console.WriteLine($"PARITE : BFS from-scratch = [{string.Join("->", planBfsSw!)}] ({planBfsSw!.Count} action(s))");Console.WriteLine(planBfsSw!.Count== fdPlanSw.Count?"-> MEME LONGUEUR OPTIMALE (1 action) : turn-on <-> allumer, les deux moteurs concluent.":"-> DIVERGENCE de longueur ! (imprevu : BFS est optimal lui aussi)");
Le problème non-trivial de la Tranche 1 (maitresse + 3 interrupteurs, but conjonctif) est fourni à Fast Downward sans écrire une seule classe C# : le texte PDDL suffit. On compare plan FD et plan BFS action par action — et on lit l’heuristique initiale lmcut, qui vaut ici exactement le nombre de faits du but (4 landmarks : 1 maitresse + 3 interrupteurs).
// Multi-interrupteurs (Tranche 1 section 7) : BFS from-scratch ET Fast Downward sur le meme probleme.constint Nfd =3;var opsMultiFd =new List<Operator>();opsMultiFd.Add(new Operator { Name ="allumer_master", Pre =new HashSet<string>(), Add =new HashSet<string>{"master_on"}, Del =new HashSet<string>{"master_off"}});for(int i =1; i <= Nfd; i++){int idx = i; opsMultiFd.Add(new Operator { Name = $"allumer_sw{idx}", Pre =new HashSet<string>{"master_on"}, Add =new HashSet<string>{ $"switch_{idx}_on"}, Del =new HashSet<string>{ $"switch_{idx}_off"}});}var initMultiFd =new HashSet<string>{"master_off"};for(int i =1; i <= Nfd; i++) initMultiFd.Add($"switch_{i}_off");var goalMultiFd =new HashSet<string>{"master_on"};for(int i =1; i <= Nfd; i++) goalMultiFd.Add($"switch_{i}_on");var planBfsMulti =PlanBFS(initMultiFd, goalMultiFd, opsMultiFd);var DOMAIN_MULTI = @"(define (domain multi-interrupteurs)(:requirements :strips)(:predicates(master-on)(master-off)(sw-on ?s)(sw-off ?s))(:action master-on :parameters():precondition(and):effect(and(master-on)(not(master-off))))(:action sw-on :parameters(?s):precondition(master-on):effect(and(sw-on ?s)(not(sw-off ?s)))))";var PROBLEM_MULTI = @"(define (problem multi-3)(:domain multi-interrupteurs)(:objects s1 s2 s3)(:init(master-off)(sw-off s1)(sw-off s2)(sw-off s3))(:goal(and(master-on)(sw-on s1)(sw-on s2)(sw-on s3))))";var(rcM, outM, errM)=RunFd(DOMAIN_MULTI, PROBLEM_MULTI,"astar(lmcut())");var fdPlanM =FdPlanLines(outM);int masterPos = fdPlanM.FindIndex(a => a.StartsWith("master-on"))+1;Console.WriteLine($"[FD] multi-interrupteurs : returncode = {rcM}");Console.WriteLine($"[FD] Plan ({fdPlanM.Count} actions) : [{string.Join("->", fdPlanM)}]");Console.WriteLine($"[FD] h(lmcut) initiale = {FdMetric(outM, @"Initial heuristic value for lmcut:\s*(\d+)")}"+ $" (= 4 faits du but conjonctif) | etats expanses = {FdMetric(outM, @"Expanded\s+(\d+)\s+state")}");Console.WriteLine();Console.WriteLine($"PARITE : BFS = {planBfsMulti!.Count} actions ; FD = {fdPlanM.Count} actions ;"+ $" 'master-on' en position {masterPos}/{fdPlanM.Count}");Console.WriteLine(planBfsMulti!.Count== fdPlanM.Count&& masterPos ==1?"-> MEME STRUCTURE : maitresse d'abord (precondition chainee), puis les 3 interrupteurs.":"-> DIVERGENCE ! (imprevu)");
[FD] multi-interrupteurs : returncode = 0
[FD] Plan (4 actions) : [master-on (1) -> sw-on s1 (1) -> sw-on s2 (1) -> sw-on s3 (1)]
[FD] h(lmcut) initiale = 4 (= 4 faits du but conjonctif) | etats expanses = 5
PARITE : BFS = 4 actions ; FD = 4 actions ; 'master-on' en position 1/4
-> MEME STRUCTURE : maitresse d'abord (precondition chainee), puis les 3 interrupteurs.
Le problème de livraison — miroir de l’Exemple guide 1 du jumeau Python
Le jumeau Python résout avec unified-planning un problème de livraison (robot rob, colis pkg, de depot vers dest) et obtient le plan optimal 3 actions pick → move → drop. Ce domaine n’existait pas dans la Tranche 1 : c’est l’apport le plus visible du moteur de production — un nouveau domaine = un simple texte PDDL, aucune classe C# à écrire.
[FD] livraison : returncode = 0
[FD] Plan (3 actions) :
pick rob pkg depot (1)
move rob depot dest (1)
drop rob pkg dest (1)
[FD] h(lmcut) initiale = 2 | etats expanses = 4
Miroir du jumeau Python (Exemple guide 1, unified-planning) :
PY : Plan optimal trouve (3 actions) : pick(rob,pkg,depot) -> move(rob,depot,dest) -> drop(rob,pkg,dest)
C# : [pick rob pkg depot (1) -> move rob depot dest (1) -> drop rob pkg dest (1)]
-> MEME PLAN OPTIMAL 3 actions : pick, move, drop (lmcut admissible => optimalite garantie).
Interpretation — Tranche 2
Mêmes verdicts, deux moteurs : interrupteur (1 action), multi-interrupteurs (4 actions, maitresse en premier) — Fast Downward retrouve exactement les plans de notre BFS from-scratch ; sur la livraison, le même plan optimal 3 actions que le jumeau Python.
Ce que le moteur apporte en plus : un nouveau domaine (livraison) = un texte PDDL, zéro classe C# ; lmcut heuristique admissible → plan optimal garanti ; le log expose des métriques (heuristique initiale, états expansés) qui deviennent décisives quand l’espace d’états explose (cf section 8).
Ce que la Tranche 1 garde : la compréhension — on sait exactement ce que Fast Downward fait sous le capot (traduire PDDL, ground, chercher), parce qu’on l’a construit à la main.
Conclusion — Tranches 1 et 2
Ce que nous avons appris :
Concept
Definition
Planification
Generer une sequence d’actions pour atteindre un but
\((S \setminus del) \cup add\) si \(pre \subseteq S\)
BFS planner
Recherche en largeur dans l’espace d’etats (plan le plus court)
Explosion combinatoire
\(2^n\) etats pour \(n\) predicats → motive les heuristiques
Fast Downward (Tranche 2)
moteur PDDL SOTA via API Docker — PDDL standard en entrée, plan optimal A*+lmcut
Lecon cles : un planificateur = une recherche dans l’espace d’etats construit a partir de la sémantique des actions. Le twin Python unified-planning invoque un solveur ; ce twin from-scratch construit le solveur (modèle STRIPS + BFS), puis la Tranche 2 délègue au vrai moteur (Fast Downward) sur les mêmes instances — mêmes plans optimaux, et un nouveau domaine (livraison) en PDDL seul. Comprendre, puis produire.
Suite : PDDL (Planners-2 — le langage standard, ici déjà utilisé en Tranche 2), heuristiques (Planners-5 — A*, relaxations pour combattre l’explosion combinatoire).
Tranche 1 from-scratch (BCL .NET seule, 0 NuGet) + Tranche 2 Fast Downward via API Docker. Twin du notebook Python unified-planning Planners-1-Introduction. Marathon #4956, parité lib-vs-lib Epic #10382.