Logique propositionnelle

SAT Solvers

Florent Capelli

28 Janvier 2025

Rappels

  • Formes normales (conjonctive et disjonctive)
  • Transformation de Tseitin :
    • simuler efficacement n’importe quelle formule booléenne avec une FNC et des variables additionnelles
    • chaque nouvelle variable code un connecteur
  • Modélisation : coder en logique propositionnelle pour résoudre des problèmes !

Modélisation : quand modéliser ?

  • Problèmes pour lequel vous ne trouvez pas de meilleurs algorithmes que le brute force.
  • Souvent : problèmes NP-complets (voir le cours de Complexité), mais pas que.
  • Vers quoi modéliser ? Dépend du problème.
    • Dans ce cours : logique propositionnelle (et même FNC)
    • Contraintes “complexes” : CSPs (voir cours Contraintes au S2).
    • Problème généraux : logique du premier ordre (ou formalismes proches, e.g. Prolog, voir dernière séance).

Pourquoi modéliser ?

Pour utiliser des solvers optimisés !

Le problème SAT : étant donné un formule F en FNC, trouver un modèle τ de F ou répondre UNSAT si la formule est contradictoire.

def sat(F):
    n = len(var(F))
    for t in range(2**n):
        if all(satClause(t,C) for C in F):
            return t
    raise UNSAT

C’est un problème NP-complet, on ne connaît pas d’algorithme meilleur que O(2|var(F)|) dans le pire cas (bruteforce).

SAT Solvers

def sat(F):
    n = len(var(F))
    for t in range(2**n):
        if all(satClause(t,C) for C in F):
            return t
    raise UNSAT

Algorithme naïf, complexité O(2n) pour toute formule UNSAT, même :

F ≡ x1 ∧ ¬x1 ∧ G(x2,…,xn)

Un SAT solver résoud efficacement SAT dans de nombreux cas pratique en bruteforçant plus intelligemment.

L’algorithme DPLL

DPLL ?

  • Davis–Putnam–Logemann–Loveland (DPLL) algorithm
  • Inventé en 1961
  • Algorithme “backtracking” pour trouver les modèles d’une FNC

Expansion de Shannon et clauses

F ≡ F[x←0] ∨ F[x←1]

Soit C = ⋁ii une clause. C[xb] est :

  • Satisfaite si b = 0 et i = ¬x pour un i ou b = 1 et i = x pour un i
  • Sinon : une clause plus simple: C[x←0] = C \ {x} et C[x←1] = C \ {¬x}
  • Si x est la seule variable de C, alors C[xb] peut être la clause vide, non satisfiable.

Exemple: C = x ∨ ¬y ∨ z

  • C[x←1] est satisfaite
  • C[y←1] est équivalente à x ∨ z
  • C[x←0][y←1][z←0] = ∅, ne peut pas être satisfaite.
  • etc.

Application sur les FNC

  • F = ⋀iCi une FNC (conjonction de clause)
  • F[xb] := ⋀iCi[xb]
    • soit Ci[xb] est satisfaite, on peut l’enlever
    • soit Ci[xb] est une clause plus petite.

def sat(F, l): # F est une FNC, l une assignation d'une partie de ses variables
    if F is empty:
        return True

    ifin F:
        return False
    
    x = pickvar(F,l)
    b = pickval()
    
    l[x] = b
    if sat(F[x=b], l):
        return True
    else:
        l[x] = b-1
        return sat(F[x=1-b], l)

Propagation unitaire

Quelle variable/valeur choisir pour la formule suivante ?

(x) ∧ (¬xzy) ∧ (x∨¬z∨¬y) ∧ ...

x = 1 bien sûr !

  • (x) s’appelle une clause unitaire, ie, une clause à une variable.
  • Si une formule contient une clause unitaire, il n’y a qu’une façon de la satisfaire, donc on propage cette information!

Propagation unitaire : chaîne !

F ≡ (x) ∧ (¬x∨¬z) ∧ (¬xz∨¬y) ∧ G

  • Propagation unitaire : F[x=1] = (¬z) ∧ (¬z∨¬y) ∧ G[x=1]
  • Propagation unitaire : F[x=1][z=0] = (¬y) ∧ G[x=1][z=0]
  • Propagation unitaire : F[x=1][z=0][y=0] = G[x=1][z=0][y=0]

Pseudo code amélioré


def sat(F, l): # F est une FNC, l une assignation d'une partie de ses variables
    # Cas d'arrêt : plus de clauses, tout est satisfait par l
    if F is empty:
        return True
 
    # Propagation unitaire
    while units(F):
        F = F[units(F)]
        l = l + units(F)

    # Cas d'arrêt : une clause n'est plus satisfiable !
    ifin F:
        return False
        
    # Branchement 
    x = pickvar(F,l)
    b = pickval()
    
    l[x] = b
    if sat(F[x=b], l):
        return True
    else:
        l[x] = b-1
        return sat(F[x=1-b], l)

Propagation des littéraux purs

Comment satisfaire : F ≡ (xz∨¬y) ∧ (x∨¬zy) ∧ (¬y∨¬z) ∧ (z∨¬y)

  • La variable x apparaît toujours positivement.
  • Si F est satisfiable alors elle a une solution où x = 1:
    • F[x=1] = (¬y∨¬z) ∧ (z∨¬y)
    • Encore une fois, ¬y est pur!
    • F[x=1][y=0] = ∅, on a trouvé un modèle de F (on peut choisir la valeur de z arbitrairement).

Si un litéral apparaît dans F mais ¬ℓ n’apparaît pas, on peut forcer à 1: on ne peut que enlever des clauses.

On dit que est pur et on fait une propagation de littéral pur.

Propagation unitaire vs pur

  • Si est une clause unitaire de F alors tous les modèles de F forcent à vrai.
  • Si est un littéral pur, alors au moins un modèle de F force à vrai (si F est satisfiable).

Si le but est de trouver tous les modèles, alors on ne peut pas faire d’élimination de littéraux purs.

Pseudocode pour DPLL


# F est une FNC, 
# l une assignation d'une partie de ses variables
def sat(F, l): 

    # Cas d'arrêt : plus de clauses, tout est satisfait par l
    if F is empty:
        return True
 
    # Propagation des littéraux unitaires et purs
    while units(F) != [] and purs(F) !=[]:
        F = F[units(F)][purs(F)]
        l = l + units(F) + purs(F)

    # Cas d'arrêt : une clause n'est plus satisfiable !
    ifin F:
        return False
        
    # Branchement 
    x = pickvar(F,l)
    b = pickval()
    
    l[x] = b
    if sat(F[x=b], l):
        return True
    else:
        l[x] = b-1
        return sat(F[x=1-b], l)

Implémentation de DPLL : watched literals

  • Structure de données pour facilement calculer F[xb], units(F) et purs(F).
    • F[xb] n’est pas vraiment calculée
    • Watched literals: pour chaque clause C, on maintient deux littéraux non assignés
    • F[xb]:
      • si x n’est dans aucun watched literal de C, rien à faire
      • sinon, on cherche un autre littéral non assigné dans C.
      • s’il n’y en a pas, C est unitaire!

On maintient F de façon la plus paresseuse possible.

Watched literals : un exemple

  • F = ( x y  ∨ ¬z ∨ w) : x et y sont watched.
  • F[x←0] = ( y ¬z  ∨ w) : ¬z est watched puisqu’on perd x.
  • F[x←0][w←0] = ( y ¬z) : rien à faire, on a encore deux watched literals.
  • F[x←0][w←0][y←0] = ( ¬z) : on ne trouve pas de nouveaux littéraux libres.

On sait que ¬z peut être propagé !

Implémentation de DPLL : heuristiques

    x = pickvar(F,l)
    b = pickval()   

Comment bien choisir x et b pour essayer de trouver rapidement une solution ? on utilise une heuristique !

Une heuristique peut être vue comme une fonction h(ℓ,F) ∈ ℚ+ qui donne un score à chaque litéral non assigné de F.

On choisira le litéral qui a le plus grand score.

Heuristiques : exemples

  • Jeroslow-Wang : h(ℓ,F) = ∑C ∈ F, ℓ ∈ C2−|C|, ie, on privilégie les littéraux qui apparaissent beaucoup et dans des petites clauses. Intuitivement, forcer va éliminer de nombreuses petites clauses.
  • Dynamic Largest Individual Sum (DLIS) : h(ℓ,F) est le nombre de clauses de F qui seraient satisfaites si on force à vrai, ie, on va choisir le littéral qui enlève le plus de clauses.
  • etc.
  • Heuristique utilisée souvent en pratique : Variable State Independent Decaying Sum (VSIDS détails plus bas)

L’algorithme CDCL

CDCL ?

  • Conflict-Driven Clause Learning: Marques-Silva, Karem A. Sakallah, Bayardo, Schrag (~1995).
  • Amélioration de DPLL, paradigme dominant dans les SAT solvers les plus efficaces actuellement.

Idée générale : quand on trouve un conflit, on apprend une nouvelle clause permettant de détecter le conflit plus tôt.

Un exemple

(x1x4)∧

(x1∨¬x3∨¬x8)∧

(x1x8x12)∧

(x2x11)∧

x7∨¬x3x9)∧

x7x8∨¬x9)∧

(x7x8∨¬x10)∧

(x7x10∨¬x12)

  • Choix 1 : x1 ← 0 : UP de x4 ← 1.
  • Choix 2 : x3 ← 1 : UP de x8 ← 0, x12 ← 1.
  • Choix 3 : x2 ← 0 : UP de x11 ← 1
  • Choix 4 : x7 ← 1 : UP de x9 ← 0 et x9 ← 1 CONFLIT

Analyse du conflit: il suffit de mettre x7 ← 1, x3 ← 1, x8 ← 0 pour dériver ce conflit !

Cela veut dire que la clause ¬x7 ∨ ¬x3 ∨ x8 est satisfaite par tous les modèles de F. On peut l’ajouter et backtracker au choix 2 !

Stratégie

Clauses apprises par conflit :

  • On essaie d’apprendre la clause qui induit le plus petit backtrack
  • Détecter plus tôt des inconsistences (donc de voir plus de propagations unitaires)
  • Empêche de visiter deux fois le même état, la procédure termine !
  • En revanche, trop de clauses apprises ralentissent l’algorithme ou peuvent être plus gênantes qu’aidante : on nettoie régulièrement l’espace des clauses apprises, la procédure la plus radicale étant le restart (on efface tout et on recommence).

Aller plus loin

La recherche concernant les SAT solvers est encore très active et se concentre sur :

  • Améliorer les heuristiques (certaines fine-tuned via du machine learning)
  • Améliorer/trouver de nouveau preprocessing (simplification de la formule en amont)
  • Utilisation des SAT solvers dans la résolution de problèmes plus complexes (SAT solver incrémentaux pour SMT solving, formules quantifiées etc.)
  • Certification : comment vérifier une réponse “UNSAT” ?

Des outils

Sat solvers

  • MiniSAT : solver CDCL en C++ open source avec un code simple et minimaliste permettant une modification facile. Une référence pour commencer.
  • Glucose : solver CDCL développé en partie au CRIL (Gilles Audemard) et LaBRI (Laurent Simon) !
  • Kissat : solver CDCL développé en C par Armin Biere.
  • Cryptominisat : SAT solver spécialisé dans le traitement de clause “affines”, permettant entre autres de résoudre des problèmes algébriques issus de la cryptographie.
  • PySAT : librairie Python offrant un wrapper vers de nombreux SAT solvers

Voir la compétition des SAT solvers !

Format d’entrée

Tous ces outils prennent en entrée un fichier au format DIMACS (ou une variation) décrivant textuellement une FNC :  ∧ ¬∨

c ceci est un commentaire
c en-tête, on déclare une cnf avec trois variables et deux clauses
p cnf 3 2
c première clause: x1 ∨ x2 ∨ ¬x3 
1 2 -3 0
c deuxième clause : ¬x2 ∨ x3
-2 3 0

Pour une construction plus programmatique, on peut utiliser PySAT.