Projet de logique: solveur SAT et Sudoku

Version du 12/12/2016

L'objectif de ce projet est de se familiariser avec des techniques de base de test de satisfiabilité booléenne via l'implémentation d'un solveur SAT et d'utiliser la résolution SAT pour résoudre des Sudoku.

Le projet se découpe ainsi en deux parties: une partie solveur SAT et une partie modélisation en formule conjonctive (par fois appelée cnf) du problème de planification.

Modalités

Le projet peut être réalisé en binôme, mais pas en groupe de 3 étudiants ou plus.

Remplir lors de la première séance la case tomuss num_binome avec le numéro d'étudiant de votre binôme (8 chiffres). Si vous n'avez pas de binôme, laissez cette case vide.

La version finale du projet (avec toutes les parties) doit être déposée sur tomuss dans la case rendu_prj_final pour le 18/12/2016.

Les archives à déposer doivent avoir la même structure que l'archive fournie. Attention en particulier à ne pas déposer d'archive contenant des résidus de compilation ou des exécutables. Utiliser la commande make clean avant de créer l'archive à déposer.

Il est demandé de ne pas héberger vos sources sur un dépôt public. L'UCBL met à votre disposition une forge avec un hébergement de dépôts mercurial.

Le projet fourni peut être récupéré via la commande suivante exécutée dans le répertoire cloné d'un dépôt mercurial vierge:

hg pull https://forge.univ-lyon1.fr/hg/inf3034l-liflc-projet-2016

Le projet est par ailleurs également téléchargeable ici: lien archive

Le projet contient ce sujet, des exemples et un code de départ en C++. Il est demandé de regarder le code fourni afin d'avoir une idée de ce qui est déjà implémenté et de ce qui reste à faire.

La base de code fournie suppose que l'on travail dans un environnement de type Unix (Linux, MacOSX). Bien que le code soit assez générique vis-à-vis du système d'exploitation, aucune garantie n'est donnée sur le fonctionnement sous Windows et le projet sera testé sous Linux et/ou MacOSX.

Liens utiles:

Planning conseillé

Le travail pour ce projet peut être divisé en 4 parties correspondant chacune à une séance de TP et du travail autour. Après la première séance de TP, il est ainsi conseillé d'anticiper chaque séance afin d'avoir déjà des questions prêtes en début de créneau de TP.

Résolution de problème SAT

Cette partie consiste à écrire un programme qui lit une formule conjonctive au format Dimacs et génère un fichier indiquant si la formule est satisfiable. Dans le cas où la formule est satisfiable, le fichier contiendra également les littéraux vrais dans un des modèles de la formule.

Le format Dimacs se présente de la manière suivante:

Par exemple, la formule conjonctive \((p\vee q\vee \neg r)\wedge(\neg p \vee \neg q)\wedge(\neg p\vee r)\wedge(\neg p\vee q\vee \neg r)\) sera représentée par le fichier suivant (avec \(p\) a pour numéro \(1\), \(q\) a pour numéro \(2\) et \(r\) a pour numéro \(3\)):

p cnf 3 4
1 2 -3 0
-1 -2 0
-1 3 0
-1 2 -3 0

Les fichiers résultat ont la forme suivante:

Par exemple, un modèle de la formule précédente est \(I\) avec \(I(p)\) est faux (donc le littéral \(\neg p\) est affecté à vrai), \(I(q)\) est vrai (donc le littéral \(q\) est affecté à vrai) et \(I(r)\) est faux (donc le littéral \(\neg r\) est affecté à vrai). Ce résultat correspond au fichier suivant:

SAT
-1 2 -3 0

Les fonctions permettant de lire le format Dimacs et d'écrire le résultat sont déjà écrites dans le fichier dimacs.cpp. Le fichier sat.cpp contient des squelettes de fonctions pour implémenter le solveur SAT.

Le logiciel minisat est un solveur SAT utilisant les formats d'entrée et de sortie expliqués ci-dessus. Il peut se substituer à votre propre solveur SAT à des fins de comparaison ou pour tester la partie modélisation de Sudoku indépendamment de la partie SAT.

Structures de données pour représenter une formule conjonctive

Les formules conjonctives ayant une forme très régulière (conjonction de clauses), elles peuvent être représentées simplement par un tableau (vector) de clauses. Les clauses sont elles-mêmes représentées comme des ensembles de littéraux. Les littéraux sont représentés par des entiers positifs (pairs pour les littéraux positifs, impairs pour les littéraux négatifs).

De même, chaque variable se voit attribuer un numéro unique. Attention les entiers utilisés pour numéroter les littéraux et les variables sont différents. Si une variable \(p\) est numérotée avec un entier \(n\), le littéral positif correspondant (i.e. \(p\)) est numéroté avec l'entier \(2n\), alors que le littéral négatif correspondant (i.e. \(\neg p\)) est numéroté avec l'entier \(2n+1\).

Attention, ce codage sous forme d'entiers est donc différent du codage Dimacs. Les fonctions fournies pour lire et écrire ces fichiers s'occupent de faire la conversion correctement.

Afin de permettre de faire facilement évoluer le code (en particulier afin d'ajouter facilement des éléments additionnels), on encapsule ces types dans des struct.

On obtient les structures définies dans le fichier sat.hpp.

Coder les fonctions var2lit, lit2var, positif et oppose.

Digression: tests unitaires et affichage

Le projet est fourni avec une bibliothèque de tests unitaires: unittest-cpp.

Des exemples de tests unitaires se trouvent dans les fichiers test-exemples.cpp et test-code-fourni.cpp. Les tests sont exécutés via la commande make test1.

Il est à noter que tout fichier nommé test-xxx.cpp sera considéré comme contenant des tests unitaires. En particulier, les fonctions définies dans ces fichiers ne seront pas incluses dans les autres exécutables.

Lancer make test. Un test échoue. Trouver et supprimer ce test.

Écrire des tests pour vérifier le bon fonctionnement des fonctions traitant des littéraux.

Par la suite, lorsque l'on demandera de tester une fonction, cela sous-entendra qu'il faut le faire via un test unitaire.

Solveur naïf

Dans un premier temps, on va implémenter un solveur naïf qui évoluera ensuite au gré des optimisations vers un solveur plus efficace.

Évaluation de clauses et de formule conjonctive

La structure etat_t est utilisée pour conserver les informations concernant l'exploration courante de l'espace de recherche, comme la valeur affectée à chaque variable ou le numéro de la dernière variable sur laquelle on a effectué un choix.

Au cours de l'exploration de l'espace de recherche, on peut représenter les interprétations des variables propositionnelles par un tableau qui à chaque numéro de variable fait correspondre une valeur (de type val_t):

Le type val_t est une énumaration C++. Il est ainsi possible d'utiliser les valeurs dans un switch.

Coder la fonction init_etat afin d'initialiser le tableau des valeurs en mettant toutes les variables à la valeur indeterminee. Le numéro de la dernière variable affectée pourra par exemple être initialisé à -1.

Coder la fonction valeur qui calcule la valeur d'un littéral. Tester la fonction.

La valeur d'une clause peut être déterminée comme suit:

Coder la fonction evaluer_clause et la tester.

La valeur d'une formule conjonctive peut être déterminée comme suit:

Coder la fonction evaluer_cnf et la tester.

Le test des fonctions sur les formules conjonctives peut utiliser des fichiers au format Dimacs. Il suffit de s'inspirer de la fonction main de main-sat.cpp pour cela ou des tests dans test-code-fourni.cpp. Attention aux noms des fichiers qui doivent être relatifs au répertoire c++ (depuis lequel est exécuté run-test).

Exploration de l'espace de recherche

Afin d'explorer l'espace de recherche, on peut procéder en suivant le pseudo-code suivant:

fonction chercher(etat, cnf):
    l = choisit_litteral(etat, cnf)
    ret_arr = affecte(etat, cnf, l)

    val_cnf = evaluer_cnf(etat, cnf)

    si val_cnf == vrai
           || (val_cnf == indeterminee && chercher(etat, cnf)) alors
        renvoyer true
    fin si

    retour_arriere(etat, cnf, ret_arr)

    l = oppose(l)
    ret_arr = affecte(etat, l)

    val_cnf = evaluer_cnf(etat, cnf)

    si val_cnf == vrai
       || (val_cnf == indeterminee && chercher(etat, cnf)) alors
        renvoyer true
    sinon
        retour_arriere(etat, cnf, ret_arr)
        renvoyer false
    fin si
fin chercher

La fonction choisit_litteral choisit un littéral à affecter à vrai, i.e. une variable et une valeur pour cette variable.

La fonction affecte modifie l'état de l'exploration en changeant la valeur de la variable. Elle change également la dernière variable affectée. Plus tard, cette fonction pourra faire des déductions comme de la propagation unitaire. La fonction renvoie les informations nécessaires au retour arrière, c'est à dire pour le moment l'avant-dernière variable affectée et le littéral affecté.

La fonction retour_arriere utilise les informations précédentes pour remettre l'état à sa valeur avant la dernière affectation de littéral.

Ces fonctions sont à implémenter dans sat.cpp. Les tests peuvent utiliser des formules conjonctives lues dans un fichier dimacs de manière similaire à ce qui se fait dans la fonction main du fichier main-sat.cpp.

Implémenter la fonction affecte, puis la fonction retour_arriere et les tester.

Le choix du littéral à affecter peut se faire de manière heuristique pour tenter de rendre l'exploration plus efficace. Pour ce solveur naïf, on prendra la première variable qui n'a pas encore de valeur et lui donnera la valeur vrai. On pourra ici utiliser la dernière variable affectée (numéro \(n\)) pour connaître la variable à affecter ensuite (numéro \(n+1\)).

Implémenter la fonction choisi_litteral et la tester.

La fonction chercher permet de terminer l'implémentation du solveur naïf.

Implémenter la fonction chercher et la tester.

Problème de Sodoku

Le problème de Sodoku consiste à placer des nombres sur une grille \(n^2\times n^2\) (pour le sudoku classique \(n=3\)).

Un problème de Sudoku sera représenté par un fichier représentant la grille initiale à compléter:

Les régions sont des sous-grilles carrées de taille \(n\times n\) découpant la grille initiale en \(n\times n\) parties disjointes. On suppose que les lignes et les colonnes sont numérotées à partir de \(0\) en partant du haut et de la gauche. Les coordonnées du coin supérieur gauche de chaque région sont ainsi de la forme \((i\times n, j \times n)\)\(0\leq i\leq n-1\) et \(0\leq j\leq n-1\).

Compléter une grille de Sudoku se fait en respectant les 3 contraintes suivantes:

Pour résoudre un Sudoku, on procède comme suit:

  1. Un premier programme, sudoku2dimacs, va convertir un problème de Sudoku en formule conjonctive (sous forme de fichier Dimacs), telle que la solution du Sudoku correspond au modèle de cette formule (un problème de Sudoku traditionnel n'admet normalement qu'une seule solution).
  2. Le programme sat calcule un éventuel modèle de la formule conjonctive.
  3. Un dernier programme res2sudoku, convertit le résultat obtenu en grille de sudoku complétée.

Les fonctions lit_sudoku et ecrit_sudoku permettent de lire et écrire des grilles de sudoku. Le fichier exemples/sudoku/grille-test1.txt contient un exemple de grille de sudoku \(3^2\times 3^2\)

Variables booléennes pour le problème de sudoku

Afin de coder le problème de Sudoku sous forme de formule conjonctive, on introduit \(n^2\) variables booléennes par case de la grille. On note la variable \(p_{i,j}^v\) la \(v^{ième}\) variable de la case située à la position \((i,j)\) dans la grille (avec \(0\leq i\leq n^2-1\),\(0\leq i\leq n^2-1\) et \(1\leq v\leq n^2\)). On utilisera la convention que la variable \(p_{i,j}^v\) prend la valeur vrai si et seulement si la case \((i,j)\) voit être remplie avec la valeur \(v\).

On suppose que l'on numérote les variables comme suit (où \(num(p)\) est le numéro de la variable \(p\)):

Autrement dit: les variables d'une même case sont numérotées consécutivement. Le numéro de la première variable d'une case suit immédiatement le numéro de la dernière variable de la case précédente, les cases étant considérées lignes par ligne.

Par exemple, si on considère une grille de Sudoku de taille \(2^2\times 2^2\):

Sur une grille de taille \(3^2\times 3^2\), \(num(p_{0,8}^9)=80\) et \(num(p_{8,8}^9)=728\).

Coder et tester la fonction l_case_v(n2,i,j,v) qui renvoie le littéral positif associé à la variable codant la valeur v dans la case (i,j) si le nombre le nombre de lignes dans la grille de Sudoku est n2 (i.e. \(n^2\)). Cette fonction doit avoir un temps de calcul constant.

Contrainte "exactement \(1\) vrai parmi \(n\)"

Afin de coder le problème de Sudoku sous forme d'un ensemble de clauses plus facilement, on introduit une contrainte appelée "exactement \(1\) vrai parmi \(n\)". Une contrainte peut être vue comme une formule contraignant les valeurs de certaines variables. Dans le cas de "exactement \(1\) vrai parmi \(n\)", cette formule va imposer que parmi \(n\) littéraux, un littéral prend la valeur vrai, les autres prenant la valeur faux.

En s'appuyant sur les remarques suivantes, déduire un ensemble de clauses codant cette contrainte:

Coder la fonction exactement_1_parmi_n. Si votre solveur SAT est fonctionnel, il est possible de tester ces clauses en utilisant la fonction cherche.

Codage des contraintes de case, de ligne, de colonne et de région

La première chose dont on veut s'assurer est que toutes les cases sont remplies et que l'on n’a pas placé 2 nombres différents dans la même case. Pour cela il suffit que pour chaque case il y ait exactement une des variables codant les valeurs de la case à vrai.

Commencer à coder la fonction genere_probleme_sat en utilisant pour chaque case du sudoku la fonction exactement_1_parmi_n afin d'assurer que chaque case possède exactement une valeur.

Chaque ligne doit comporter des nombres différents. Comme il y a autant de nombres que la taille de la ligne, chaque nombre doit apparaître exactement une fois. Donc pour chaque nombre \(v\), pour chaque ligne \(i\) il y a exactement une case \((i,j)\) dans la ligne pour laquelle la variable \(p^v_{i,j}\) vaut vrai.

Continuer le codage de la fonction genere_probleme_sat en utilisant pour chaque ligne et chaque nombre la fonction exactement_1_parmi_n afin d'assurer que chaque valeur est placée exactement une fois sur chaque ligne.

On peut remarquer qu'assurer que toutes les valeurs sont différentes sur chaque colonne et dans chaque région est similaire au cas des lignes.

Coder les contraintes sur les colonnes et les régions dans genere_probleme_sat.

Enfin il reste à prendre en compte les nombres déjà placés sur la grille. Si la case \(i,j\) a la valeur \(v\), il suffit de forcer la valeur de la variable \(p^v_{i,j}\) à vrai en utilisant une clause unitaire.

Terminer la fonction genere_probleme_sat en générant une clause unitaire par case déjà remplie dans la grille.

Décodage du modèle

La dernière étape consiste à convertir un modèle en grille de sudoku résolue. C'est le programme res2sudoku qui s'en charge. Il prend en argument la grille de sudoku initiale, le fichier contenant un modèle de la formule conjonctive et le nom du fichier dans lequel écrire le résultat.

Le fichier main-res2sudoku.cpp contient un squelette de code pour res2sudoku (lecture et écriture des fichiers).

Compléter le code de la fonction main dans main-res2sudoku.cpp afin de remplir la variable sudoku_resolu avec le bon nombre pour chaque case de la grille.

Amélioration du solveur SAT

Indexation de clauses et calcul de valeur de CNF

L'objectif est ici de construire un index pour accéder rapidement au clauses qui contiennent un littéral donné.

L'index sera codé dans un tableau de tableau de pointeur sur clauses. On ajoutera donc le champ suivant à cnf_t:

vector< vector<cls_t*> > index

On rappelle qu'en C++, il est possible de récupérer l'adresse du contenu d'un vecteur via &, comme le montre le test suivant:

struct toto {
  std::vector<int> a;
};

TEST(ref_in_vector) {
  using namespace std;
  vector<toto> v1;
  toto t1;
  t1.a.push_back(1);
  v1.push_back(t1);
  // rappel: 1Lu est 1 en unsigned long
  CHECK_EQUAL(1Lu,v1[0].a.size());
  vector<toto*> v2;
  v2.push_back(&v1[0]);
  CHECK_EQUAL(1Lu,v2[0]->a.size());
  v1[0].a.push_back(3);
  CHECK_EQUAL(2Lu,v2[0]->a.size());
}

Créer une fonction genere_index(cnf_t& cnf) qui génère la structure d'index et appeler cette fonction avant la recherche.

À cause du fonctionnement de vector (i.e. réallocation du tableau sous-jacent au vector en cas de manque de place), l'approche précédement proposée qui consistait à insérer dans l'index des pointeurs sur clause au fur et à mesure peut mener à des erreurs de segmentation. Il faut donc construire l'index seulement une fois que toutes les clauses ont été ajoutées à la formule conjonctive.

On peut remarquer que si l'affectation d'un littéral rend la CNF fausse, c'est qu'une des clauses contenant l'opposé du littéral devient fausse. On peut donc optimiser la vérification de la valeur de vérité de la CNF en ne vérifiant que les clauses contenant le littéral opposé au littéral affecté. Afin de réutiliser ce test dans evaluer_cnf, on peut ajouter un champ booléen est_faux dans etat_t (attention à gérer correctement ce champs dans retour_arriere). Par défaut, ce champ vaudra false (i.e. la CNF n'est pas fausse) et pourra être modifié en true (i.e. la CNF est devenue fausse) dans affecte si une clause est dédectée comme étant devenue fausse. Dans evaluer_cnf, il suffit alors d'utiliser ce champ pour tester si la CNF est fausse (au lieu de regarder toutes les clauses).

A ce stade, l'indexation se fait au moment de la construction de la CNF et l'index est utilisé dans affecte pour tester les clauses fausse.

Propagation unitaire

La propagation unitaire consiste à déduire des valeurs à partir de l'affectation courante selon le principe suivant: si tous les littéraux d'une clause sauf un (que l'on appellera \(l\)) sont faux, alors pour que la clause puisse s'évaluer à vrai il est nécessaire que \(l\) prenne la valeur vrai. Une telle clause est dite unitaire.

On peut remarquer que les littéraux déduits (i.e. ceux que l'on a affecté à vrai suite à la déduction précédente) peuvent eux-même déclencher de nouvelles propagations, c'est-à-dire permettre de déduire d'autre propagations. Il faut donc itérer la déduction des littéraux jusqu'à ce qu'il n'y ait plus aucun littéral déductible.

Pour gérer correctement le retour arrière, il ne faut plus se contenter d'un seul littéral affecté (litteral_affecte dans infos_retour_arriere_t), mais il sera nécessaire de gérer une collection (e.g. un vector) de littéraux. Lors du retour arrière la valeur de chacun de ces littéraux sera à remettre à indeterminee.

Vérifier si une clause est unitaire n'est pas très différent de vérifier si elle est fausse. On peut ainsi combiner le calcul à la volée de la valeur des clauses avec la propagation unitaire.

Écrire une fonction bool verifie_clause(const cls_t & clause, const etat_t & etat, lit_t & l, val_t & val) qui a le comportement suivant:

Tester cette fonction.

Le pseudo-code suivant permet de procéder à une propagation unitaire:

fonction propager(l, etat, cnf, ret_arr):
    lit_a_propager = {}
    ajouter(l, lit_a_propager)
    tant que lit_a_propager non vide
        lp = prendre(lit_a_propager)
        mettre_a_vrai(lp, etat)
        ajouter(lp, ret_arr.litteral_affectes)
        olp = oppose(lp)
        clauses = clauses_contenant_l(cnf, olp)    // utilisation de l'index
        pour cl dans clauses:
            si verifie_clause(cl, etat, l2, val)
                ajouter(l2, lit_a_propager)
            sinon si val == faux
                etat.est_faux = true
                renvoyer false
            fin si
        fin pour
    fin tant que
    renvoyer true
fin propager

On peut remarquer que la fonction propager renvoie false si elle mène à une contradiction (i.e. elle s'évalue à faux). Elle met par ailleurs à jour le contenu de ret_arr et de etat en fonction des déductions faites lors de la propagation.

Coder la fonction propager et utiliser cette fonction dans affecte. Modifier la fonction retour_arriere (gestion de la collection des littéraux). Tester ces deux fonctions.

Optimisations supplémentaires

Ces optimisations sont optionnelles et donneront des points bonus.

Auteur: Emmanuel Coquery
CC Attribution-Noncommercial-Share Alike 3.0 Unported Ce document est placé sous les termes de la licence suivante : CC Attribution-Noncommercial-Share Alike 3.0 Unported

  1. Cette commande compile et lance l'exécutable run-tests depuis le répertoire c++.