Consignes

Le rendu de ce TP se fera uniquement par TPLab et consistera en une archive (zip, tar, etc.) contenant un répertoire TP2-NOM avec :

Vous aurez compris que NOM devra être remplacé par votre nom (ou vos noms en cas de binomes) !

Tous vos fichiers doivent comporter une entête avec votre noms et votre groupe de TP.

Attention : votre code doit compiler avec les options suivantes :

-std=c99 -Wall -Wextra -pedantic -Werror -O4

(Vous pouvez éventuellement ajouter les options -Wno-unused-parameter et -Wno-unused-variable, en particulier pendant le développement.)

Le fichier Makefile fournit compile automatiquement avec ces options.

Tout non respect d'une ou plusieurs de ces consignes entrainera automatiquement un retrait de points sur votre note !

Outils nécessaires, prérequis

Pour ce TP, vous devez disposer d'un environnement de type Unix avec

Debian / Ubuntu

Les paquets suivants devraient être suffisants et sont probablement déjà installés (à part minisat) :

$ sudo apt install gcc make minisat

Vous pouvez ajouter quelques autres paquets, mais ce n'est pas obligatoire.

$ sudo apt install clang valgrind kcachegrind

macOS

Pour les étudiants qui veulent faire leur TP sous macOS, il est impératif que vous installiez ces outils avant la séance :

Point important

Cette séance de TP est ambitieuse.

Les temps de réflexion et assimilation des concepts seront aussi importants que les temps de codage. N'hésitez pas à demander des explications lorsque certains points ne sont pas clairs.

Nous vous conseillons d'anticiper la séance et de faire la partie préliminaire avant le début de la séance.

Liens utiles

Préliminaires

L'objectif de ce TP est de regarder comment les SAT solveurs sont écrits. La version utilisée dans le TP1 permet de comprendre le principe théorique, mais l'efficacité n'était pas trop au rendez vous !

Nous allons donc coder un SAT solveur (du style DPLL), en C, en utilisant des structures de données adaptées.

Rappels : le problème

Étant donnée une formule en forme normale conjonctive (FNC), comme (x1 ∨ ¬x2 ∨ x3) ∧ (¬x1 ∨ ¬x3 ∨ x4) ∧ (x2 ∨ ¬x4) le solveur doit chercher des valeurs pour x1, ..., x4 qui rendent la formule vraie.

La méthode standard utilise le backtracking : on essaie des solutions, et quand elles ne conviennent pas, on modifie la dernière décision possible.

La version naïve du TP1 était récursive, mais pour gagner en performances, nous allons devoir passer à une version itérative.

La structure de données utilisée : variables, littéraux et clauses

Chaque formule en FNC, comme (x1 ∨ ¬x2 ∨ x3) ∧ (¬x1 ∨ ¬x3 ∨ x4) ∧ (x2 ∨ ¬x4) sera représentée par 2 tableaux :

  1. Lit, qui donne la liste de tous les littéraux apparaissant dans la formule. (Il y a par exemple 8 littéraux dans la formule ci dessus.)

    Chaque littéral sera représenté par un entier :

    • 2k+1 pour le littéral xk

    • 2k pour le littéral ¬xk

    Il n'y a pas de variable x0. Les littéraux commencent donc à 2.

    On peut récupérer :

    • le "signe" d'un littéral en prenant son bit de poids faible : lit & 1 (macro SIGN(lit) dans le code fourni)

    • la "variable" d'un littéral en faisant un décalage de bits : lit >> 1 (macro VARIABLE(lit) dans le code fourni)

    Pour la formule ci dessus, le tableau ressemblerait à

    clause 0 clause 1 clause 2
    littéral x1 ¬x2 x3 ¬x1 ¬x3 x4 x2 ¬x4
    i 0 1 2 3 4 5 6 7
    Lit[i] 3 4 7 2 6 9 5 8
  2. Cl, qui donne pour chaque clause (0, 1 et 2 ci dessus), l'indice du premier littéral de la clause. Ici, on aurait

    k 0 1 2 3
    Cl[k] 0 3 6 8

    Notez que le tableau Cl contient une case supplémentaire pour savoir où se termine la dernière clause : la clause k contient les littéraux de Cl[k] jusqu'à Cl[k+1]-1 (inclus).

Ces deux tableaux, avec le nombre de variables, clauses et littéraux sont stockés dans une structure :

typedef struct {
    int nb_var;     // number of different variables x_1, ..., x_n
    int nb_cl;      // number of clauses in the CNF formula
    int nb_lit;     // total number of literals in the CNF formula
    int* Lit;       // array of literals (of size nb_lit)
    int* Cl;        // array of size nb_cl+1 giving the start of each clause in the Lit array
} formula_t;

La structure contient également un tableau VarName dont vous n'aurez pas besoin. Ce tableau contient, pour chaque variable i, une chaine de caractères pour l'affichage. Cela permet de lire plus facilement les solutions trouvées.

Avant de commencer le TP, vous devez :

Vous pouvez appeler la fonction de tests (que vous pourrez compléter au fur et à mesure du TP) du fichier test-NOM.c. Par exemple, le test struct permet d'afficher le contenu de la structure correspondant à la formule lue dans le fichier correspondant :

$ ./sat -T struct CNF-files/test-001.cnf
The formula contains 4 variable(s), 3 clause(s) for a total of 8 literal(s)
nb_var = 4
nb_cl  = 3
nb_lit = 8

Cl[0] = (0)
Cl[1] = (3)
Cl[2] = (6)
Cl[3] = (8)

Lit[0] = 3, càd 1
Lit[1] = 4, càd -2
Lit[2] = 7, càd 3
Lit[3] = 2, càd -1
Lit[4] = 6, càd -3
Lit[5] = 9, càd 4
Lit[6] = 5, càd 2
Lit[7] = 8, càd -4

La fonction affiche également le contenu du tableau VarName que vous pouvez ignorer.

Complétez la fonction print_CNF dans le fichier solve-NOM.c. Cette fonction affiche une formule de manière "lisible", avec une clause par ligne.

$ ./sat -T CNF CNF-files/test-001.cnf
The formula contains 4 variable(s), 3 clause(s) for a total of 8 literal(s)
1 -2 3
-1 -3 4
2 -4

La formule est donc (x1 ∨ ¬x2 ∨ x3) ∧ (¬x1 ∨ ¬x3 ∨ x4) ∧ (x2 ∨ ¬x4).

Données importantes :

  • les champs de la structure F: F->nb_lit, F->Lit, etc.

  • LIT2INT pour transformer un nombre (littéral) de la forme 2k+1 en +k et 2k en -k,

Le corps de ma fonction fait 6 lignes (dont deux lignes ne contenant que }).

L'algorithme naïf

Les solutions partielles

La plupart des algorithmes essaient d'agrandir une solution partielle jusqu'à trouver une vraie solution, ou une contradiction (auquel cas, ils reviennent en arrière). Une telle solution pourrait par exemple être "x3=FALSE, x1=TRUE".

Les solutions partielles sont représentées par 2 tableaux

  1. Var, qui contient les (numéros des) variables de la solution actuelle, par exemple {3, 1}.

    La taille de Var, appelée n, évolue au cours de l'exécution : elle augmente lorsqu'on ajoute une variable à la solution, et elle diminue lors du backtracking quand on est arrivé à une solution incorrecte.

    La valeur de n peut devenir négative (-1) lorsque le backtracking échoue, et supérieure au nombre total de variables lorsque que l'algorithme a trouvé une solution.

  2. State, de taille 1+nb_var, qui donne, pour chaque variable x1, ..., x4 l'état actuel de la variable : RIEN (pour les variables qui ne sont pas dans la solution partielle), VRAI ou FAUX, par exemple {RIEN, TRUE, RIEN, FALSE, RIEN, ...}

    Comme il faut se souvenir des valeurs déjà testées, il y a en fait 5 états distincts. Dans le code fourni, ces états sont appelés UNSET, FALSE, TRUE, FALSE_WAS_TRUE et TRUE_WAS_FALSE.

    Ces constantes sont des synonymes pour -1, 0, 1, 2 et 3, et on obtient (sauf pour UNSET) la valeur booléenne correspondante en regardant le bit de poids faible de l'état : State[x] & 1.

Ces données sont elles aussi stockées dans une structure :

typedef struct {
    int n;      // number of variable in the current assignement
    int* Var;   // array of variables in the current assignement: the array is of size nb_var, but
                // only the first n entries are relevant. The other ones should be UNSET (-1).
    int* State; // array of values ("states") of the variable. Variables not in the Var array should
                // have value UNSET (-1).
} sol_t;

Le "jeux" SAT Game donne une assez bonne intuition sur cette représentation :

Faites quelques parties avant de continuer !

Il sera important, lorsque notre algorithme a trouvé une solution, de vérifier que la solution ... est bien une solution.

Pour cela, il faut parcourir les clauses de la formule F, et pour chacune d'entre elle, vérifier qu'elle contient un littéral vrai, c'est à dire, un littéral pour lequel la valeur booléenne de la variable est égale à son signe :

Écrivez la fonction is_solution(formula_t* F, sol_t* S) qui vérifie que la solution S rend bien la formule F vraie.

Données importantes :

  • le tableau S->State qui contient l'état de chaque variable (UNSET, FALSE, TRUE, FALSE_WAS_TRUE ou TRUE_WAS_FALSE)

  • la valeur booléenne correspondante s'obtient en prenant le bit de poids faible d'un état (sauf si c'est UNSET, auquel cas la variable n'a pas de valeur),

  • les macros VARIABLE et SIGN qui permettent de récupérer la variable et le signe d'un littéral.

Ma fonction fait une quinzaine de lignes et ressemble à ça :

... .... .. . .. .. . ......... ..... .
    ... . . ..
    ... .... . . .......... . . ........ . ... .... .
        ... ... . ..........
        ... . . ..............
        ... . . ..........
        .. ............ .. ..... .. ............ . .. .. .. .
            . . ..
            ......
        .
    .
    .. .. .. .. .
        ...... ..
    .
.
...... ..

Cette fonction est automatiquement appelée dans la fonction main (fichier main.c) lorsque que le solveur a trouvé une solution. Avant modification, cette fonction renvoie 0, ce qui provoque le message suivant :

$ ./sat -N CNF-files/test-001.cnf
SATISFIABLE
1 2 3 4
*** IS THAT REALLY A SOLUTION?

Attention : n'oubliez pas l'option -N sur la ligne de commande. Sans cela, c'est le solveur optimisé (à trous) qui sera appelé !

Le code

L'algorithme itératif naïf sur ces structure de données ressemble à

int solve_naive(formula_t* F, sol_t* S)
{
    // NOTE : S->n est initialisé à 0

    while (0 <= S->n && S->n < F->nb_var) {
        // on regarde la n-ème variable de la solution actuelle :
        if (S->Var[S->n] == UNSET) {
            // il faut choisir une variable à ajouter à la solution actuelle
            // on peut prendre les variables dans l'ordre : 1, 2, 3, .
            // (Attention au décalage de 1 : les variables commencent à 1.)
            S->Var[S->n] = S->n + 1;
            // il faut aussi choisir une valeur pour cette variable
            // on peut prendre VRAI.
            S->State[S->Var[S->n]] = TRUE;
        } else if (S->State[S->Var[S->n]] == TRUE || S->State[S->Var[S->n]] == FALSE) {
            // si la variable avait déjà une valeur, il faut en choisir une autre
            // on change la valeur de TRUE (1) à FALSE_WAS_TRUE (2) et de FALSE (0) à TRUE_WAS_FALSE
            // (3)
            S->State[S->Var[S->n]] = 3 - S->State[S->Var[S->n]];
        }

        if (is_non_false(F, S)) {
            // si les valeurs des variables ne rendent pas la formule fausse, on continue
            S->n++;
        } else {
            // sinon, il faut revenir en arrière !
            // NOTE : la fonction backtrack_naive diminue la valeur de S->n
            backtrack_naive(S);
        }
    }
    if (S->n < 0) {
        // non satisfiable
        return 0;
    } else {
        // satisfiable
        return 1;
    }
}

La fonction backtrack_naive est assez simple : elle enlève des variables d'une solution partielle pour obtenir une dernière variable dont on peut changer la valeur (càd une variable où on a testé qu'une seule valeur booléenne). Cette fonction modifie la structure S ! Il est possible que la solution renvoyée soit vide (càd avec S->n == -1). Ceci arrive lorsqu'on a testé toutes les solutions possible et que la formule n'est pas satisfiable.

int backtrack_naive(sol_t* S)
{
  // tant que la dernière variable de la solution à été testée sur les 2 valeurs
  while (S->n >= 0 && (S->State[S->Var[S->n]] == TRUE_WAS_FALSE ||
                       S->State[S->Var[S->n]] == FALSE_WAS_TRUE)) {
    S->State[S->Var[S->n]] = UNSET; // on rénitialise cette variable
    S->Var[S->n] = UNSET;           // on la supprime de la solution courante
    S->n--;
  }
  return S->n;
}

La fonction is_non_false n'est pas très difficile mais nécessite de parcourir le tableau Lit. Elle regarde si une clause est fausse, càd ne contient que des littéraux dont la valeur est FAUX. Elle ressemble donc à

int is_non_false(formula_t* F, sol_t* S)
{
    // pour toutes les clauses
    for (int cl = 0; cl < F->nb_cl; cl++) {
        // on calcule la valeur de vérité
        int clause_value = 0;
        // pour tous les littéraux de la clause
        for (int k = F->Cl[cl]; k < F->Cl[cl + 1]; k++) {
            int lit = F->Lit[k];     // littéral courant
            int x = VARIABLE(lit);   // variable correspondante
            int s = SIGN(lit);       // signe correspondant
            int v = S->State[x] & 1; // valeur de la variable dans la solution
            // si ce littéral n'est pas faux, la clause n'est pas fausse !
            if (S->State[x] == UNSET || s == v) {
                clause_value = 1;
                break;
            }
        }
        if (clause_value == 0) {
            return 0;
        }
    }
    return 1;
}

La version naïve en Python faisait un quinzaine de lignes. Cette version naïve en C en fait une quarantaine (si on supprime les commentaires et autres lignes inutiles, et les lignes ne comportant que "}").

Ce code se trouve dans le fichier naive.c. Vous pouvez l'utiliser en donnant l'option -N ou --naive sur la ligne de commandes :

$ ./sat -N CNF-files/test-001.cnf
...

Pour obtenir des affichages sur le fonctionnement de l'algorithme, utilisez l'option -v ou --verbose (éventuellement plusieurs fois) :

$ ./sat -Nv CNF-files/test-001.cnf
...
$ ./sat -Nvv CNF-files/test-001.cnf
...
$ ./sat -Nvvv CNF-files/test-001.cnf
...
$ ./sat -Nvvvv CNF-files/test-001.cnf
...

Vérifiez que vous comprenez les étapes de la recherche de solution en utilisant les formules suivantes :

N'hésitez pas à demander des explications à votre encadrant si vous n'êtes pas sûr de ce que vous observez.

Invariants et assertions

Lors de l'exécution, les solutions partielles doivent vérifier la contrainte suivante :

  • le tableau Var contient des entiers strictement positifs jusqu'à n, et des UNSET après,

  • toutes les variables qui apparaissent dans Var ont un état différent de UNSET, et celles qui n'apparaissent pas dans Var ont un état UNSET.

  • Attention, Var[n] peut contenir une variable (càd un nombre strictement positif), mais dans ce cas, son état doit être TRUE ou FALSE. (C'est ce qui se passe lors du backtracking: Var[n] contient la variable dont on doit changer la valeur.)

Graphiquement, la solution partielle "x3=FALSE, x7=TRUE, x2=TRUE, ..." est représentée par

i 0 1 2 ... n ......
Var[i] 3 7 2 ... ??? UNSET ... UNSET
x 0 1 2 3 4 5 6 7 ...
State[x] UNSET TRUE FALSE UNSET UNSET UNSET TRUE ...

La seule case ambigüe est Var[n], dont la valeur peut être à UNSET (il faut choisir une nouvelle variable) ou FALSE ou TRUE (il faut modifier la valeur de la variable correspondante).

Une telle propriété, qui doit rester vraie pendant toute la durée d'un programme (ou d'une fonction) est appelée un invariant.

Lors du développement, il est important de mettre en place une vérification des invariants pour détecter l'apparition de bugs le plus tôt possible.

La fonction assert du langage C (#include <assert.h>) permet de vérifier une condition. Lorsque cette condition n'est pas vérifiée, le programme est stoppé, et lorsque la condition est vérifiée, assert ne fait rien.

  1. Complétez la fonction check_sol qui vérifie l'invariant sur les solutions partielles.

    Votre fonction doit renvoyer 1 lorsque les conditions sont vérifiées. Si les conditions ne sont pas vérifiées, votre fonction peut soit stopper le programme (c'est ce qui se passe si vous avez utilisé un assert pour vérifier certaines des conditions) ou renvoyer 0.

    (Attention, si vous l'écrivez, cette fonction sera appelée dans la suite du TP. Si elle n'est pas correcte, elle risque de stopper l'exécution du solveur.)

    Pour éviter de parcourir plusieurs fois le tableau Var, vous pouvez utiliser un tableau local (SET dans le code fourni) :

    • Un premier parcours du tableau Var permet de vérifier qu'il y a bien des variables au début et des UNSET à la fin.

      Ce parcours permet également de mettre SET[x] à 1 pour toutes les variables qui apparaissent dans la solution.

    • Un parcours du tableaux SET permet de vérifier que chaque variable de la solution à un état autre que UNSET et que les autres ont un état UNSET.

    Attention : à bien gérer la case n du tableau Var !

  2. Ajoutez une ligne assert(check_sol(F, S)); au début du corps de la boucle principale de la fonction solve_naive.

1. Amélioration de la structure de données : "watch lists"

Profilage

Presque toute la complexité de la version naïve vient de la fonction is_non_false, qui parcourt à chaque fois l'intégralité du tableau Lit. La version Python du TP1 ne parcourait que des versions simplifiées de la formule, ce qui est un avantage non négligeable. (Par contre, le mécanisme de récursion nécessite de conserver en mémoire toutes les versions de la formule.)

La démarche de regarder d'où vient la complexité d'un programme de manière expérimentale s'appelle le profilage, ou profiling. Il existe des outils pour faire ceci. Deux de ces outils sont

Vous pouvez choisir l'un ou l'autre de ces outils pour faire la question suivante.

Utilisation de gprof

  1. Compiler son code avec l'option -pg (mode "debug"). Ce n'est pas obligatoire, mais il est en général préférable de désactiver les optimisation du compilateur avec l'option -O0.

  2. Lancer le code normalement, avec ./sat .... Ceci génèrera un fichier gmon.out dans le répertoire courant.

  3. Lancer la commande gprof sur l'exécutable : gprof sat.

    Les premières lignes du résultat donneront le temps relatif passé dans chaque fonction. On peut par exemple obtenir quelque chose comme (sur un autre programme que sat)

          $ gprof edc
          Flat profile:
    
          Each sample counts as 0.01 seconds.
            %   cumulative   self              self     total
           time   seconds   seconds    calls  ms/call  ms/call  name
           36.16      0.30     0.30 50449359     0.00     0.00  PLOG4
           19.28      0.46     0.16   344073     0.00     0.00  shift
           18.08      0.61     0.15   344071     0.00     0.00  DECIPHER
           ...

Pour modifier les options de compilation, vous pouvez modifier la variable FLAGS définie en haut du fichier Makefile. Utilisez la ligne (commentée) qui contient les options -pg -O0.

Utilisation de callgrind

  1. Compiler son code sans optimisation avec l'option -O0.

  2. Exécuter le code avec l'outils callgrind :

    valgrind --tool=callgrind --callgrind-out-file=callgrind.out EXE OPTIONS``

    (Dans notre cas, EXE sera ./sat et OPTIONS, les options passées à ./sat.)

    Attention, l'exécution est nettement plus lente car le code est "surveillé" par valgrind.

  3. Visualiser avec la commande callgrind_annotate --threshold=99.5 callgrind.out

    Le résultat donnera le nombre absolu d'instructions assembleur exécutées par chaque fonction. On peut par exemple obtenir quelque chose comme (sur un autre programme que sat)

        $ callgrind_annotate --threshold=99.5 callgrind.out                                                                           [13:52] <168 076 137 (1945)>
        --------------------------------------------------------------------------------
        Profile data file 'callgrind.out' (creator: callgrind-3.14.0)
        --------------------------------------------------------------------------------
        ...
        <<< QUELQUES LIGNES SUPPRIMÉES >>>
        ...
    
        --------------------------------------------------------------------------------
                    Ir
        --------------------------------------------------------------------------------
        12,360,561,053  PROGRAM TOTALS
    
        --------------------------------------------------------------------------------
                   Ir  file:function
        --------------------------------------------------------------------------------
        2,851,051,876  ???:ENCIPHER_PAIR [/export/home/hyvernat/doc/Enseignement/2021/info910-crypto/TP/TP-historical/correction/playfair/edc]
        2,433,473,810  ???:DECIPHER [/export/home/hyvernat/doc/Enseignement/2021/info910-crypto/TP/TP-historical/correction/playfair/edc]
        2,392,888,698  ???:PLOG4 [/export/home/hyvernat/doc/Enseignement/2021/info910-crypto/TP/TP-historical/correction/playfair/edc]
        1,421,313,000  ???:shift [/export/home/hyvernat/doc/Enseignement/2021/info910-crypto/TP/TP-historical/correction/playfair/edc]
        ...

    Au total, 12360561053 instructions ont été exécutées, dont 2851051876 par la fonction ENCIPHER, 2433473810 par la fonction DECIPHER, etc.

  4. Sous Linux, vous pouvez aussi utiliser kcachegrind pour visualiser le résultat sous forme graphique.

Pour modifier les options de compilation, vous pouvez modifier la variable FLAGS définie en haut du fichier Makefile. Utilisez la ligne (commentée) qui contient les options -pg et -O0, mais en supprimant l'option -pg.

Utilisez gprof ou callgrind et donnez les 3 fonctions qui occupent le plus le processeur pendant l'execution de sat sur le fichier CNF-files/queens-15-SAT.cnf (sans les options d'affichage supplémentaire).

1.1. "Watch lists"

Pour accélérer la fonction is_non_false, il va falloir utiliser des structures de données supplémentaires.

Une formule en FNC est fausse lorsqu'une de ses clauses ne contient que des littéraux faux. Tant que toutes les clauses contiennent au moins un littéral non faux (càd vrai ou non-instancié), on peut essayer d'étendre la solution courante.

Le point de départ de l'optimisation qui va suivre est le suivant :

La formule peut devenir fausse uniquement lorsque le dernier littéral d'une clause devient faux.

Les "watch lists" (listes de surveillance en français) vont donner, pour chaque littéral, une liste de clauses à surveiller. Par exemple, si la "watch list" du littéral ¬x3 contient (2), (7) et (9), cela signifie que lorsque ¬x3 devient faux, il suffit de vérifier que les clauses (2), (7) et (9) ne sont pas devenue fausses. Au lieu de parcourir toute la formule, on ne regarde donc que 3 clauses.

Initialement, chaque clause est surveillée par son premier littéral. Par exemple, pour la formule (x1 ∨ ¬x2 ∨ x3) ∧ (¬x1 ∨ ¬x3 ∨ x4) ∧ (x2 ∨ ¬x4) ∧ (x2 ∨ ¬x1), les "watch lists" sont :

Initialement, les "watch lists" vérifient les propriétés suivantes :

  1. chaque clause apparait dans exactement une "watch list",

  2. cette "watch list" est celle du premier littéral de la clause.

  3. ce littéral n'est pas faux (il est non instancié).

Si l'algorithme décide de passer la variable x2 à FAUX, la fonction is_non_false n'aura besoin de regarder que les clauses (2) et (3). Pour ces clauses :

Pour cette raison, la fonction is_not_false s'appellera désormais update_watch_lists.

Par exemple, lorsque x2 devient FAUX, la clause (2) (x2 ∨ ¬x4) ne peut plus apparaitre dans la "watch list" du littéral x2. La fonction update_watch_lists devra donc :

Lorsque ceci aura été fait pour les clauses 2 et 3, il faudra également vider la "watch list" du littéral x2. Après ceci, l'invariant

  1. Chaque clause apparait dans exactement une "watch list",

  2. cette "watch list" est celle du premier littéral de la clause.

  3. ce littéral n'est pas faux.

sera à nouveau vérifié.

La formule est fausse lorsque cette mise à jours n'est pas possible, c'est à dire lorsqu'on ne trouve pas de nouveau littéral non faux à mettre en tête de clause.

La fonction update_watch_list est fournie dans le fichier solve-NOM.c, mais elle utilise une fonction intermédiaire.

Écrivez la fonction new_watching_literal(formula_t* F, sol_t* S, int cl) qui recherche un nouveau littéral pour remplacer le premier littéral de la clause cl. La fonction devra parcourir la clause cl et renvoyer l'indice du premier littéral non faux de cette clause. Si ce n'est pas possible, la fonction renvoie -1.

(Attention à ne pas renvoyer l'indice du premier littéral car c'est celui que l'on souhaite remplacer !)

Données importantes :

  • tableaux F->Cl F->Lit et S->State

  • macros VARIABLE et SIGN

Le corps de ma fonction fait une demi-douzaine de lignes.

Un point fondamental (mais non nécessaire pour la suite du TP) est le suivant : le backtracking est facile avec une fonction récursive, car la récursion sauvegarde les valeurs successives des données. Lors d'un retour en arrière, c'est la donnée correspondant à cet étape qui est donc utilisée automatiquement.

Lors d'un backtracking itératif, c'est au programmeur de gérer ceci. L'avantage est qu'il n'est pas obligé de sauvegarder toutes les valeurs successives, mais il peut modifier une valeur au fur et à mesure. C'est ce que fait la fonction backtrack sur les solutions.

La structure de données des "watch lists" a la propriété très intéressante de ne pas nécessiter de modification lors du backtracking ! Même si sa valeur n'est pas exactement la même, elle est équivalente. Ceci évite donc de complexifier le code et fait gagner en efficacité.

Exemples très simples

Vous devriez maintenant pouvoir tester :

Testez le solveur sur le fichier test-002.cnf et vérifiez que vous comprenez l'évolution des "watch lists".

$ ./sat -Wvvvv CNF-files/test-002.cnf

Benchmarks

Comparez les vitesses d'exécution de la version naïve et de la version avec "watch lists" sur les fichiers

$ ./sat -N CNF-files/queens-15-SAT.cnf
...
$ ./sat -W CNF-files/queens-15-SAT.cnf
...

Remarque : vous pouvez utiliser la commande time pour chronométrer le temps d'exécution de votre commande :

$ time ./sat -N CNF-files/queens-15-SAT.cnf
...
$ time ./sat -W CNF-files/queens-15-SAT.cnf
...

Pour observer l'impact des options de compilation, vous pouvez faire d'autres tests :

  • remplacer l'option -O4 par -O0 pour supprimer les optimisations du compilateur, et recompiler

  • ajouter l'option -NDEBUG et recompiler. (Faites une recherche pour découvrir à quoi correspond cette option !)

Comparez les vitesses d'exécution de la version C et de la version DPLL récursive écrite en Python sur les fichiers

$ time ./sat -W CNF-files/queens-15-SAT.cnf
...
$ time python3 ./naivesat.py -D CNF-files/queens-15-SAT.cnf
...

Testez ensuite sur le fichier

Décrivez ce que vous observez.

Les solutions trouvées ne seront pas forcément identiques, mais le résultat SATISFIABLE ou UNSATISFIABLE doit être identique.

Pour les 3 fichiers mentionnés ici, la formule correspondante est satisfiable.

1.2. Liste des variables actives

L'objectif final est d'obtenir une version de l'algorithme qui détecte les clauses unitaires (celles avec un unique littéral). Pour éviter de parcourir toutes les clauses à la recherche d'une clause unitaire, nous allons utiliser une autre liste : celle des variables qui apparaissent au début d'une clause. En effet, s'il y a une clause unitaire, la variable correspondante apparait nécessairement en tête d'une clause.

Si la formule ressemble à

(x1 ∨ x2 ∨ ...)
(¬x2)
(x2 ∨ x17)
(x1 ∨ ...)

x17 ne peut pas apparaitre dans une clause unitaire car elle a des "watch lists" vides. Seules les variables x1 et x2 peuvent apparaitre dans une clause unitaire.

Dans ce cas là, on pourra donc instancier x2 à FAUX (rendant ¬x2 VRAI). La formule deviendra alors

(x1 ∨ ...)
(la clause unitaire disparait)
(x17)
(x1 ∨ ...)

et x17 pourra maintenant être considérée car elle a une "watch list" non vide.

La liste des variables actives (A) est initialisée par le code fourni, à condition que l'on lance le programme avec l'option -A (ou -D).

Il faut par contre gérer les mises à jours de cette liste :

  1. Lors du choix d'une valeur pour une variable (fonction solve), il faut supprimer la variable de la liste des variables actives.

    • Pour cela, comme la variable choisie est la première variable active, il suffit de supprimer la première variable active (fonction pop_active).

    • Attention, il ne faut pas faire ceci lorsque la variable en question est déjà instanciée. En effet, dans ce cas, la variable a déjà été retirée des variables actives à une étape précédente. (Cette situation arrive en cas de backtracking.)

  2. Lors de la mise à jours des "watch lists" (fonction update_watch_lists), la nouvelle variable qui vient en tête d'une clause peut devenir active (fonction push_active). Il faut :

    • que cette variable soit à l'état UNSET (une variable instanciée ne peut pas être active),

    • que cette variable ait des "watch list" vides (avec une "watch list" non vide, elle était déjà active).

  3. Lors du backtracking (fonction backtrack), lorsqu'une variable est enlevée de la solution courante, son état devient UNSET. Si elle a une "watch list" non vide, elle doit redevenir active (fonction push_active).

Il n'est pas nécessaire de comprendre comment sont implémentées les "watch lists", mais il faut pouvoir vérifier si elles sont vides. La structure W contient ces "watch lists", et en particulier, un champs W->Head.

On peut obtenir la (tête de la) "watch list"

Autrement dit, W->Head est un tableau à deux dimensions : le premier indice est le signe du littéral, et le second le numéro de la variable.

Une telle liste peut être vide (valeur EOL), ou pas.

Le code fournit contient 3 TODO dans les fonctions solve, update_watch_lists et backtrack.

Complétez ces fonctions en utilisant les remarques ci dessus et les commentaires qui précèdent chacun des TODO.

Données importantes :

Dans mon code, chacun des TODO correspond à quelques lignes de C.

Normalement, lorsqu'on supprime la variable active de tête (avec pop_active), cette variable de tête est égale à la variable courante (current_var).

Utilisez la fonction assert du C pour vérifier ceci.

2. Algorithme DPLL, avec "watch lists" et variables actives

Tout est maintenant en place pour ajouter la simplification des clauses unitaires :

La fonction backtrack fournie gère déjà les états FORCED_FALSE et FORCED_TRUE.

Vérifiez que votre code (dans la fonction solve) qui supprime la variable courante de la liste des variables actives gère correctement les états FORCED_FALSE (4) et FORCED_TRUE (5) : il faut supprimer la tête des variables actives lorsque current_var a un état FALSE, TRUE, FORCED_FALSE ou FORCED_TRUE.

2.1. Recherche des clauses unitaires

Écrivez la fonction is_unit(formula_t* F, sol_t* S, int cl) qui teste si la clause cl est une clause unitaire : elle doit donc parcourir les littéraux de cette clause et vérifier qu'ils sont tous faux, sauf le premier (qui est UNSET).

Données importantes :

Mon code fait 6 lignes, dont 2 return et 2 } !

Ce n'est pas très important pour comprendre le TP, mais la liste des variables active est circulaire.

Lors de la recherche d'une clause unitaire, l'algorithme se déplace dans la liste jusqu'à trouver une telle clause (ou à faire un tour complet). Ceci lui permet de reprendre la recherche là où il s'était arrêté. (S'il n'y avait pas de clause unitaire au début, il n'est pas forcément nécessaire de reprendre la recherche au début !)

2.2. Quelques tests

Le répertoire CNF-files contient de nombreux fichiers, et en particulier des fichiers "de plus en plus gros" :

Faites des tests d'efficacité et présentez vos résultats.

Vous pouvez par exemple comparer les différentes versions du programme en C (-N, -W, -A et -D), en incluant éventuellement les versions en Python.

Vous pouvez aussi expérimenter avec l'option -P (à utiliser en conjonction avec les versions -A et -D), qui "preprocess" la formule en supprimant les clauses unitaires avant de lancer la recherche.

N'hésitez pas à générer d'autres exemples avec les scripts du TP1.

Je vous conseille de recompiler le code avec l'option -DNDEBUG pour supprimer la vérification des assertions.

Vous pouvez aussi utiliser minisat (si vous l'avez installé). Pour l'utiliser, il suffit d'entrer la commande

$ minisat FICHIER.cnf

(Si vous souhaitez obtenir la solution, il faut donner un autre fichier dans lequel minisat écrira la solution.)

Vous pouvez également utiliser le générateur "Tough SAT Project", par exemple pour générer des fichiers aléatoires ("random k-Sat").

Pour faire vos tests, les commandes suivantes peuvent vous faciliter la tâche :

À l'aide du profiling, étudiez quels seraient les parties de l'algorithme à optimiser pour améliorer l'efficacité de notre programme.