Planification avec PSC (problème de satisfaction de contraintes)

L’objectif de cette série d’exercices consistera à planifier la traversée d’un groupe de cannibales et de missionnaires de la rive gauche à la rive droite d’une rivière. Ceux-ci disposent pour ce faire d’un bateau à deux places, qui ne peut être piloté que par un missionnaire. Nous supposons qu’il y a en tout deux missionnaires M_{1} et M_{2} et deux cannibales C_{1} et C_{2}. La traversée ne peut se faire qu’en empruntant un unique bateau B.

Cette série se compose de deux parties. Dans la première partie, correspondant à l’exercice 1, vous devrez concevoir sur papier un modèle PSC qui représente le problème de planification donné au paragraphe précédent et qui permette de le résoudre au moyen d’algorithmes de résolution de PSC. Dans la seconde partie, vous implémenterez ce modèle en Python, et vous le résoudrez en appliquant le module PSC implémenté au cours des exercices précédents.

Notez qu’il n’est pas nécessaire de lire l’énoncé de la deuxième partie pour accomplir la première. Vous pourriez en fait éprouver une certaine difficulté à comprendre cet énoncé avant d’avoir lu la solution de la première partie, étant donné qu’il y fait référence.

Modules squelettes

Les modules qui suivent constituent le squelette du programme que nous allons développer. exemple_missionnaires.py sert en particulier à définir le problème et vous permettra de tester votre implémentation lorsque vous aurez terminé.

planification.zip

Module .../moteur_psc_planification/axiomecadre.py :

from moteur_psc.contrainte import Contrainte

class ContrainteAxiomeCadre(Contrainte):
    """ Contrainte forçant l'utilisation d'opérateurs pour agir sur les\
        variables d'un problème."""

    def __init__(self, var_pre, ops, var_post):
        """
            :param var_pre: la variable au début de l'état - prop(Si).
            :param list var_ops: les variables correspondant aux opérateurs qui\
            ont prop comme postcondition.
            :param var_post: la variable à la fin de l'état - prop(Si+1).
        """

        Contrainte.__init__(self, (var_pre, var_post) + tuple(ops))

        self.var_pre = var_pre
        self.var_post = var_post
        self.vars_ops = ops

    def est_valide(self, var, val):
        """ Evalue la validité de la contrainte pour une valeur de variable\
            donnée.

            L'évaluation est paresseuse : si au moins une variable n'est pas\
            instanciée, on retourne ``True``.
            Sinon, si la variable change de valeur, alors au moins une variable\
            d'opérateur doit être ``True``.

            :param var: la variable à laquelle assigner la valeur ``val``.
            :param val: la valeur à assigner à ``var``.
            :return: ``True`` si la contrainte est valide pour la paire\
            variable/valeur passée en paramètre.
        """

        print('à compléter')

    def propage(self, var):
        """ Propage la nouvelle valeur d'une variable afin de vérifier que la\
            contrainte est toujours satisfaisable.

            La propagation est paresseuse: elle retourne ``True`` si plus d'une\
            variable n'a pas encore la valeur ``None``.

            :param var: la variable ayant reçu une nouvelle valeur.
            :return: ``True`` si la contrainte peut encore être satisfaite.
        """

        print('à compléter')

    def reviser(self):
        """ Réviser n'est pas définie pour les contraintes n-aires.

            :return: ``False``.
        """

        return False

    def __repr__(self):
        return 'Axiome de cadre:\n\t{}\n\t{}\n\t{}'.format(self.var_pre,
                                                           [op for op in self.vars_ops],
                                                           self.var_post)

Module .../moteur_planification/operateur.py :

class Operateur:
    """ Opérateur de planification."""

    def __init__(self, nom, precond, postcond):
        """
            :param str nom: nom représentant l'opérateur.
            :param list precond: liste de propositions nécessaires à l'opération.
            :param list postcond: liste de propositions résultant de l'opération.
        """

        self.nom = nom

        self.precond = precond
        self.postcond = postcond

    def __repr__(self):
        return self.nom

Module``.../moteur_planification/etat.py`` :

from moteur_psc_heuristique.variable_avec_label import VariableAvecLabel

class Etat:
    """ Un état du plan."""

    def __init__(self, no_etat, propositions, operateurs, etat_prec=None):
        """
            :param no_etat: numéro de l'état dans le plan.
            :param list propositions: liste des propositions de planification.
            :param list operateurs: liste des opérateurs applicables dans le plan.
            :param etat_prec: l'état précédent.
        """

        self.no_etat = no_etat
        self.etat_prec = etat_prec

        self.operateurs = { op.nom: op for op in operateurs }

        self.vars_initiales = {}
        self.vars_finales = {}

        self.construire_vars_operateurs(operateurs)
        self.construire_vars_propositions(propositions)

    def construire_vars_operateurs(self, ops):
        """ Construit les variables de l'état représentant l'utilisation des\
            opérateurs.
        """

        self.vars_operateurs = {}

        for op in ops:
            var_nom = '{} état {}'.format(op.nom, self.no_etat)
            self.vars_operateurs[op.nom] = VariableAvecLabel(var_nom,
                                                             [True, False])

    def construire_vars_propositions(self, props):
        """ Construit les variables de l'état représentant les propositions.

            Les variables initiales sont les variables finales de l'état\
            précédent sauf lorsqu'il n'en existe pas.
        """

        print('à compléter')

    def variables(self):
        """ La liste des variables de l'état (variables initiales, finales et\
            variables d'opérateurs).

            :return: une liste de variables.
        """

        return (list(self.vars_initiales.values()) +
                list(self.vars_finales.values()) +
                list(self.vars_operateurs.values()))

Module .../moteur_planification/planification.py :

from moteur_psc.contrainte import ContrainteUnaire
from moteur_psc_heuristique.contrainte_avec_propagation import ContrainteAvecPropagation
from moteur_psc_heuristique.psc_heuristique import PSCHeuristique
from moteur_psc_planification.axiomecadre import ContrainteAxiomeCadre
from .etat import Etat

class Planification:
    def __init__(self, propositions, operateurs,
                 mutex_propositions, mutex_operateurs,
                 depart, but, nb_etats):
        """
            :param list propositions: liste des propositions du problème.
            :param list operateurs: liste des opérateurs du problème.
            :param list mutex_propositions: liste des mutex de propositions.
            :param list mutex_operateurs: liste des mutex d'opérateurs.
            :param depart: contraintes de départ.
            :param but: contraintes finales.
            :param nb_etats: nombre d'états du plan.
        """

        self.operateurs = operateurs
        self.mutex_propositions = mutex_propositions
        self.mutex_operateurs = mutex_operateurs

        self.depart = depart
        self.but = but

        self.nb_etats = nb_etats

        self.propositions = propositions

        self.etats = []
        self.construire_etats()

        self.psc = PSCHeuristique(self.variables(), self.construire_contraintes())

    def construire_etats(self):
        """ Construction des états de la planification.

            Chaque état est constitué de ses variables initiales, de ses\
            variables d'opérateurs et de ses variables finales.

            /!\ Les variables finales d'un état coïncident avec les variables\
            initiales de l'état suivant /!\.
        """

        print('à compléter')

    def variables(self):
        """
            :return: la liste des variables du problème transformé en PSC.
        """

        # Utiliser un set évite les doublons entre variables finales et
        # initiales.
        variables = set()
        for etat in self.etats:
            variables.update(etat.variables())

        return list(variables)

    def construire_contraintes(self):
        """ Méthode intermédiaire pour faire l'aggrégation de toutes les\
            contraintes.

            :return: les contraintes du problème transformé en PSC.
        """

        return (self.construire_contraintes_propositions() +
                self.construire_contraintes_operateurs() +
                self.construire_contraintes_conditions() +
                self.construire_contraintes_axiomes_cadre() +
                self.construire_contraintes_initiales() +
                self.construire_contraintes_finales())

    def construire_contraintes_propositions(self):
        """ Construit les contraintes traduisant les mutex de propositions.

            :return: une liste de contraintes.
        """

        print('à compléter')

    def construire_contraintes_operateurs(self):
        """ Construit les contraintes traduisant les mutex d'opérateurs.

            :return: une liste de Contraintes.
        """

        print('à compléter')

    def construire_contraintes_conditions(self):
        """ Constuit les contraintes traduisant les pré- et post-conditions des\
            opérateurs.

            :return: une liste de contraintes.
        """

        print('à compléter')

    def construire_contraintes_axiomes_cadre(self):
        """ Construit les contraintes d'axiomes de cadre.

            Un axiome de cadre traduit le fait qu'une variable ne peut pas\
            changer de valeur sans l'action d'un opérateur.

            :return: une liste de contraintes.
        """

        print('à compléter')

    def construire_contraintes_initiales(self):
        """ Construit les contraintes qui fixent la situation de départ.

            :return: une liste de contraintes.
        """

        print('à compléter')

    def construire_contraintes_finales(self):
        """ Construit les contraintes qui fixent la situation but.

            :return: une liste de contraintes.
        """

        print('à compléter')

    def resoudre(self):
        """ Utilise le Forward Checking pour résoudre le problème de\
            planification transformé en PSC.

            Note: La méthode ``solution`` permet d'afficher la solution sans\
            avoir à traiter la valeur de retour de cette méthode.

            :return: le dictionnaire ``{variable : valeur}`` retourné par la\
            méthode de Forward Checking.
        """

        self.psc.consistance_noeuds()
        self.psc.consistance_arcs()
        self.psc.variable_ordering()

        self.psc.forward_checking(0, True)
        self.sol = self.psc.solutions

        return self.sol

    def affice_solutions(self):
        """ Méthode pour afficher les solutions découvertes par l'algorithme."""

        print('Recherche terminée en {} itérations'.format(self.psc.iterations))

        if len(self.psc.solutions) == 0:
            print('Aucune solution trouvée')
            return

        for sol in self.psc.solutions:
            print('Solution')
            print('========')
            for etat in self.etats:
                print('État {}: '.format(etat.no_etat))
                print('  Propositions initiales:')
                for nom, var in sorted(etat.vars_initiales.items()):
                    if sol[var.nom]:
                        print('    ' + nom)

                print('  Opérateurs:')
                for nom, var in sorted(etat.vars_operateurs.items()):
                    if sol[var.nom]:
                        print('    ' + nom)

                print('  Propositions finales:')
                for nom, var in sorted(etat.vars_finales.items()):
                    if sol[var.nom]:
                        print('    ' + nom)
                print()

Module .../exemple_missionnaires.py :

from moteur_planification.operateur import Operateur
from moteur_planification.planification import Planification

def format_g(acteur):
    """ Retourne une string représentant un acteur à gauche. """

    return 'g({})'.format(acteur)

def format_d(acteur):
    """ Retourne une string représentant un acteur à droite. """

    return 'd({})'.format(acteur)

def format_dg(bateau, pilote):
    """ Retourne une string représentant une traversée de droite à gauche. """

    return 'dg({}, {})'.format(bateau, pilote)

def format_gd(bateau, pilote, passager):
    """ Retourne une string représentant une traversée de gauche à droite. """

    return 'gd({}, {}, {})'.format(bateau, pilote, passager)


bateaux = ['B']
missionnaires = ['M1', 'M2']
cannibales = ['C1', 'C2']

acteurs = bateaux + missionnaires + cannibales

# Ajoute les propositions pour la position des acteurs.
propositions = []
print('à compléter')

# Ajoute les opérateurs de déplacement.
operateurs = []
print('à compléter')

# Ajoute les mutex de proposition (un acteur ne peut pas être sur les deux rives
# simultanément).
mutex_propositions = []
print('à compléter')

# Ajoute les mutex d'opérateurs.
mutex_operateurs = []
print('à compléter')

# Ajoute les contraintes initiales (tous les acteurs à gauche).
depart = []
print('à compléter')

# Ajoute les contraintes finales (but: tous les acteurs à droite).
but = []
print('à compléter')

# Transforme le problème de planification en PSC.
plan = Planification(propositions, operateurs,
                     mutex_propositions, mutex_operateurs,
                     depart, but,
                     nb_etats=5)
plan.resoudre()

plan.affice_solutions()

Veillez à respecter la structure des dossiers telle qu’elle est reflétée dans les noms des modules ci-dessus, sous peine de devoir modifier les instructions import.

Exercice 1 : Modélisation sur papier

Avant de commencer à coder, vous devez modéliser le problème de planification sous forme de PSC. Pour ce faire, il convient de procéder en deux étapes :

  • Définition du problème de planification en termes de propositions et d’opérateurs ;
  • Définition d’un PSC correspondant, en termes de variables et de contraintes sur ces variables.

Définition du problème de planification

Un problème de planification peut être défini par trois éléments :

  • Un ensemble de propositions qui décrivent complètement l’état du monde à un moment donné. Certaines de ces propositions peuvent être mutuellement exclusives. Il faut alors expliciter les contraintes d’exclusion.
  • Deux ensembles d’instanciations partielles de ces propositions, qui décrivent respectivement l’état initial et l’état final, qui est le but à atteindre.
  • Un ensemble d’opérateurs qui permettent de faire évoluer le monde d’un état à un autre.

Rappelons qu’une proposition est, par définition, une affirmation portant sur l’état d’une partie du monde et qui peut être vraie ou fausse. Les opérateurs, quant à eux, se définissent comme des actions dont l’exécution nécessite que certaines propositions (leurs préconditions) soient vraies ou fausses, et qui ont pour conséquence d’imposer à certaines propositions (leurs postconditions) d’être vraies ou fausses.

Pour commencer, proposez donc une définition d’un problème de planification qui corresponde à la description informelle du problème telle qu’elle vous est donnée en introduction.

Privilégiez un modèle simple, qui ne contienne pas trop d’opérateurs superflus. Par exemple, il est inutile d’introduire des opérateurs pour décrire le fait qu’un missionnaire ou un cannibale embarque sur le bateau ou qu’il en débarque. Pour chaque acteur, vous pouvez utiliser une proposition indiquant s’il se trouve ou non sur la rive gauche, et une autre proposition indiquant s’il se trouve ou non sur la rive droite.

Définition d’un PSC correspondant

Par définition, un PSC est décrit par :

  • Un ensemble de variables, qui prennent des valeurs dans des domaines définis ;
  • Un ensemble de contraintes sur ces variables, qui définissent les combinaisons de valeurs admissibles.

Proposez un modèle PSC pour le problème de planification. Les variables doivent décrire complètement les propositions et les opérateurs lors de chaque état. Les contraintes, quant à elles, doivent exprimer les propriétés et les limitations du problème. Par exemple, un bateau ne peut contenir que deux acteurs au maximum, et l’un d’eux (le pilote) doit être un missionnaire. Ou encore, le bateau doit initialement être à gauche pour pouvoir faire la traversée de gauche à droite.

Afin de reformuler le problème de planification sous la forme d’un PSC, vous devrez faire une hypothèse sur le nombre d’états nécessaires pour l’existence d’un plan aboutissant à une solution (c’est-à-dire sur le nombre d’applications successives d’opérateurs). Notez que votre modèle PSC n’est pas obligé de se limiter aux contraintes unaires ou binaires. Il peut comporter des contraintes n-aires avec n > 2. Par exemple, les contraintes correspondant aux axiomes de cadre.

Exercice 2 : Construction du problème de planification

Le module exemple_missionnaires.py contient une routine qui, pour commencer, définit les acteurs, opérateurs, mutex et conditions de départ et de fin du problème, puis construit le problème de planification en utilisant la classe Planification du module planification.py. Une fois l’objet Planification construit, sa méthode resoudre utilise les outils de résolution de PSC développés les séries précédentes pour résoudre le problème.

Construction des propositions

Rédigez le code nécessaire pour contruire les propositions du problème. Une proposition doit être simplement représentée par une string. Stockez toutes les propositions dans la liste propositions.

Construction des opérateurs

Construisez à présent les opérateurs du problème, qui seront représentés par la classe Operateur du module operateur.py. Le constructeur de cette classe prend comme arguments trois paramètres :

  • Le nom de l’opérateur à créer (similaire à la représentation des propositions) ;
  • La liste des préconditions (une liste de propositions) ;
  • La liste des postconditions (une liste de propositions).

Spécification des mutex de propositions

Ajoutez le code nécessaire à construire les mutex de propositions et qui les stockera dans une liste sous forme de tuples (prop1, prop2). prop1 et prop2 sont ainsi deux propositions qui ne doivent pas être vraies en même temps.

Spécification des mutex d’opérateurs

Ajoutez ensuite le code qui définira les mutex d’opérateurs avec le même format que les mutex de propositions, c’est-à-dire comme une liste de tuples (op1, op2), avec op1 et op2 deux opérateurs qui ne doivent pas être exécutés en même temps.

Déclaration des contraintes initiales et finales

Spécifiez maintenant les contraintes initiales et finales du problème de planification avec deux listes de tuples (proposition, valeur).

Exercice 3 : Implémentation des axiomes de cadre, états et planificateur

Les modules axiomecadre.py, etat.py et planification.py contiennent les classes et les algorithmes qui permettent de modéliser un problème de planification comme un PSC, avant de résoudre celui-ci pour trouver un plan valide. Le module PSC utilisé sera celui qui a été développé au cours des exercices précédents.

La classe ContrainteAxiomeCadre

Cette classe, définie dans axiomecadre.py, est une sous-classe de la classe Contrainte et implémente une contrainte d’axiome de cadre pour un état S_{i} donné et une proposition prop donnée :

Si prop(S_{i}=False) et prop(S_{i+1})=True, alors, pour au moins un opérateur op qui a prop comme postcondition, on a (opS_{i})=True.

Cette contrainte est une contrainte n-aire qui porte sur plus de deux variables. Ces variables sont les attributs de la classe :

  • var_pre est la variable prop(S_{i}) ;
  • var_post est la variable prop(S_{i+1}) ;
  • vars_ops est la liste des variables correspondant aux opérateurs qui ont prop comme postcondition.

La méthode est_valide

Il vous faut tout d’abord implémenter la méthode est_valide. Notez que contrairement au cas des contraintes unaires et binaires, cette méthode peut être appelée alors que toutes les variables de la contrainte ne sont pas encore instanciées (c’est-à-dire même quand leur valeur est None). Traitez donc ce cas en premier, et faites une hypothèse de présomption de validité : la contrainte est présumée valide tant qu’on n’a pas pu prouver qu’elle était violée (c’est-à-dire tant qu’au moins une de ses variables n’est pas encore instanciée).

La méthode propage

Implémentez ensuite la méthode propage. Cette méthode est appelée juste après qu’une valeur ait été choisie pour une variable de la contrainte. Elle tente alors de propager les conséquences de ce choix aux variables non encore instanciées de la contrainte pour réduire leurs labels. En effet, il est possible que l’assignation d’une valeur à une variable rende incompatibles certaines valeurs des labels des variables non encore instanciées. Cette méthode doit retourner True si et seulement si aucune inconsistance n’a été découverte.

Imaginons par exemple que la seule variable déjà instanciée est prop(S_{i})=False, et qu’on désire propager aux variables d’opérateurs les conséquences de l’assignation prop(S_{i+1})=Flase. Il est clair que la contrainte sera alors toujours vérifiée et que la méthode retournera True sans avoir pu découvrir aucune valeur incompatible dans les labels des variables d’opérateurs. Inversement, si l’on choisit l’assignation prop(S_{i+1})=True, on peut en déduire qu’au moins un des labels des variables d’opérateurs doit contenir la valeur True. Si ce n’est pas le cas, cette assignation est inconsistante. Si seulement une des variables d’opérateurs possède un label qui contient True, alors on peut d’ores et déjà conclure que seule la valeur True est possible pour cette variable, et on peut retirer la valeur False de son label.

Comme vous le soupçonnez peut-être déjà sur la base de cet exemple, l’implémentation d’un algorithme de propagation performant pour une contrainte n-aire s’avère être un problème difficile dans le cas général, surtout si l’on veut accélérer la recherche en découvrant le plus tôt possible les inconsistances et en réduisant au maximum les labels des variables non encore instanciées.

Dans cet exercice, nous vous proposons d’en implémenter une version simple, peu performante mais suffisante pour le problème de planification qui nous occupe. Cette implémentation paresseuse consiste à ne tenter de réduire les labels et de détecter les inconsistances que lorsqu’il ne reste plus qu’une seule variable de la contrainte qui ne soit pas encore instanciée. Lorsque c’est le cas, vérifiez simplement les valeurs du label de cette variable une par une, et retirez du label celles qui ne respectent pas la contrainte. Retournez True si et seulement si le label résultant n’est pas vide. Dans le cas contraire, lorsqu’au moins deux variables de la contraintes ne sont pas encore instanciées, utilisez la même hypothèse de présomption de validité que pour la méthode est_valide, et retournez systématiquement True sans vous mettre en peine de réduire les labels de ces variables.

Remarque sur la méthode reviser

Notez que l’implémentation de la fonction reviser qui vous est fournie retourne simplement False, c’est-à-dire qu’elle n’essaie pas de réduire les domaines des variables en appliquant la consistance des arcs. La raison en est que la consistance des arcs n’est pas définie pour des contraintes n-aires. Pour ces contraintes, on parle plutôt de consistance des arcs généralisée (Generalized Arc Consistency, ou GAC) : pour chaque valeur du domaine de chaque variable, il doit exister une combinaison de valeurs pour toutes les autres variables qui satisfasse la contrainte. Mais dans l’exemple simple qui nous occupe, il n’est pas nécessaire d’implémenter la GAC, de même qu’il n’est pas nécessaire d’implémenter une méthode de propagation très performante.

La classe Etat

Un état contient six attributs :

  • vars_initiales : une liste de variables correspondant aux propositions au début de l’état (égales à celles qui existent à la fin de l’état précédent). Cette ``liste’’ est en fait un dictionnaire qui associe les propositions à leurs variables respectives.
  • vars_finales : un dictionnaire de variables associées aux propositions à la fin de l’état (égales à celles du début de l’état suivant).
  • vars_operateurs : un dictionnaire de variables associées aux opérateurs pour cet état.
  • no_etat : le numéro de l’état, inclus dans l’intervalle [0, Planification.nb_etats).
  • etat_prec: l’objet Etat qui précède l’état courant dans le plan.

Le constructeur vous est donné, et appelle les méthodes construire_vars_operateurs et construire_vars_propositions, qui remplissent les attributs vars_operateurs, et vars_initiales et vars_finales respectivement.

La première de ces méthodes est déjà implémentée. Vous devez coder la seconde en vous inspirant de la première. Nommez les variables à l’aide du numéro de l’état au début duquel se trouve la variable. N’oubliez pas que les variables finales d’un état doivent être les mêmes que les variables initiales de l’état suivant.

La classe Planification

La classe Planification est la classe centrale du planificateur. Elle transforme un problème de planification en un PSC afin de découvrir un plan valide. Cette classe possède les attributs suivants :

  • propositions, operateurs, mutex_propositions, mutex_operateurs, depart et but, qui correspondent aux listes construites dans exemple_missionnaires.py ;
  • nb_etats : le nombre d’états dans le plan, c’est-à-dire la longueur de celui-ci ;
  • etats: la liste des états du problème ;
  • psc : l’instance de PSC qui représente le problème modélisé en PSC.

Les méthodes de la classe

Vous allez maintenant implémenter les méthodes de la classe Planification. Ce sont les suivantes :

  • contruire_etats : construit tous les états de la planification et les ajoute à la liste self.etats. Faites en sorte que la liste soit triée par ordre croissant du numéro de l’état.
  • constuire_contraintes_propositions : construit les contraintes binaires d’exclusion mutuelle entre propositions.
  • construire_contraintes_operateurs : construit les contraintes binaires d’exclusion mutuelle entre opérateurs.
  • construire_contraintes_initiales et construire_contraintes_finales: ajoutent les contraintes initiales sur les propositions de l’état 0 et les contraintes finales sur les propositions de l’état final.
  • constuire_contraintes_conditions: ajoute les contraintes de pré- et postconditions entre propositions et opérateurs.
  • construire_contraintes_axiomes_cadre: ajoute les contraintes d’axiomes de cadre en utilisant la classe ContrainteAxiomeCadre.

Test du programme

Finalement, testez votre programme en lançant exemple_missionaires.py.