# Imports et verification de l'environnement
from z3 import *
print(f"Imports OK : z3-solver version {get_version_string()}")Imports OK : z3-solver version 4.16.0
Navigation : Index | << Z3-Python-02 Sudoku | Z3-Python-04 Strings >>
A la fin de ce notebook, vous saurez : 1. Utiliser les tactiques Z3 pour simplifier et pretraiter les contraintes avant resolution 2. Composer des tactiques avec Then, OrElse, Repeat pour créer des pipelines de resolution 3. Manipuler des vecteurs de bits (BitVec) et comprendre la différence entre arithmetic signee et non signee 4. Modeliser des tableaux fonctionnels (Array) avec Select et Store 5. Choisir un solveur specialise (SolverFor) adapte au type de contraintes
Solver, Int, Bool, Real, OptimizeLes notebooks précédents ont utilise le solveur Z3 de maniere directe : on ajoute des contraintes, on appelle check(), on obtient un modèle. En pratique, les problemes reels contiennent des centaines ou milliers de contraintes, et la stratégie de resolution peut faire la différence entre une reponse instantanee et un timeout. Z3 offre les tactiques (tactics) pour contrôler finement cette stratégie.
Ce notebook explore également deux théories supplementaires de Z3 : les vecteurs de bits (BitVec), essentiels pour la verification de circuits et l’analyse cryptographique, et les tableaux (Array), qui modelisent des fonctions a acces indexe.
Une tactique Z3 est une fonction de transformation qui prend un ensemble de contraintes (un goal) et produit zero, un ou plusieurs sous-problemes (subgoals). Les tactiques permettent de :
Le flux de travail typique avec les tactiques :
Goal et y ajouter des contraintesSolver avec solver() ou iterer sur les sous-but# Tactique simplify : simplification syntaxique de base
x = Int('x')
y = Int('y')
g = Goal()
g.add(x + x + x == 3 * x) # Trivialement vrai apres simplification
g.add(And(x > 0, x > 0)) # Doublon
g.add(y == x + 1)
# Appliquer la tactique simplify
result = Tactic('simplify')(g)
print("Goal avant simplification :")
for c in g:
print(f" {c}")
print(f"\nApres Tactic('simplify') : {len(result)} sous-goal(s)")
for i in range(len(result)):
sg = result[i]
print(f" Sous-goal {i} : {sg.size()} contrainte(s)")
for j in range(sg.size()):
print(f" {sg[j]}")Goal avant simplification :
x + x + x == 3*x
x > 0
x > 0
y == x + 1
Apres Tactic('simplify') : 1 sous-goal(s)
Sous-goal 0 : 2 contrainte(s)
Not(x <= 0)
y == 1 + x
Sortie obtenue : la tactique simplify a elimine la tautologie x + x + x == 3 * x (toujours vraie), fusionne le doublon And(x > 0, x > 0) en une seule contrainte, et normalise les expressions.
| Tactique | Effet | Cout |
|---|---|---|
simplify |
Simplification syntaxique locale (propagation, elimination de True/False) |
Très faible |
ctx-solver-simplify |
Simplification contextuelle (tient compte des relations entre contraintes) | Plus eleve |
solve-eqs |
Elimine les variables par substitution d’equations | Moyen |
propagate-values |
Propage les constantes | Faible |
Note technique :
simplifyest rapide mais superficielle ;ctx-solver-simplifyest plus puissante mais plus couteuse. Le choix depend de la taille du problème.
# Tactique ctx-solver-simplify : simplification contextuelle
# Cette tactique utilise le contexte global pour simplifier davantage.
x = Int('x')
g = Goal()
g.add(x > 10)
g.add(x > 5) # Redondant sachant x > 10
g.add(x + 2 > 7) # Redondant sachant x > 10
# Comparaison des deux tactiques
res_simple = Tactic('simplify')(g)
res_ctx = Tactic('ctx-solver-simplify')(g)
print("Goal original :")
for c in g:
print(f" {c}")
print("\nApres 'simplify' :")
for i in range(len(res_simple)):
sg = res_simple[i]
for j in range(sg.size()):
print(f" {sg[j]}")
print("\nApres 'ctx-solver-simplify' :")
for i in range(len(res_ctx)):
sg = res_ctx[i]
for j in range(sg.size()):
print(f" {sg[j]}")Goal original :
x > 10
x > 5
x + 2 > 7
Apres 'simplify' :
Not(x <= 10)
Not(x <= 5)
Apres 'ctx-solver-simplify' :
Not(x <= 10)
x > 5
x + 2 > 7
Sortie obtenue : sur ce goal, simplify produit 2 contraintes alors que ctx-solver-simplify en conserve 3. C’est donc simplify qui elimine ici une redondance : les contraintes x > 5 et x + 2 > 7 se normalisent toutes deux en Not(x <= 5), et simplify les fusionne en une seule (deduplication syntaxique). Inversement, ctx-solver-simplify – bien que concue pour exploiter le contexte – ne retire ni x > 5 ni x + 2 > 7 (pourtant impliquees par x > 10) et conserve les trois contraintes.
Points cles : 1. simplify normalise chaque contrainte puis fusionne les formes syntaxiquement identiques (ici x > 5 et x + 2 > 7) : deduplication locale, peu couteuse. 2. ctx-solver-simplify cible la subsomption contextuelle (retirer une contrainte impliquee par les autres), mais son comportement n’est pas garanti : selon la version de Z3 et la forme du goal, elle peut laisser passer des contraintes contextuellement redondantes, comme on le voit ici. 3. Les deux tactiques sont complementaires plutot que hierarchisees : simplify attrape les doublons syntaxiques que ctx-solver-simplify laisse passer, et reciproquement pour les implications contextuelles. Sur de gros problemes, on les combine souvent dans un pipeline (Then).
Note technique : le resultat de
ctx-solver-simplifydepend de la version de Z3 (tactique historique au comportement partiel). Pour une subsomption contextuelle fiable, on prefere generalement combinersimplifyavec d’autres tactiques plutot que de s’y fier seule.
Then, OrElse, Repeat, TryForZ3 permet de composer des tactiques pour créer des pipelines de resolution. Les combinateurs principaux :
| Combinateur | Effet |
|---|---|
Then(t1, t2) |
Applique t1, puis t2 sur chaque sous-but issu de t1 |
OrElse(t1, t2) |
Essaie t1, si echec essaie t2 |
Repeat(t) |
Applique t jusqu’a point fixe |
TryFor(t, ms) |
Applique t avec un timeout (en millisecondes) |
# Composition de tactiques : pipeline simplifier -> substituer -> resoudre
x = Int('x')
y = Int('y')
z = Int('z')
g = Goal()
g.add(y == x + 5)
g.add(z == y * 2)
g.add(x > 0)
g.add(z < 30)
# Pipeline : simplifier puis eliminer les equations
pipeline = Then(Tactic('simplify'), Tactic('solve-eqs'))
result = pipeline(g)
print("Goal original :")
for c in g:
print(f" {c}")
print(f"\nApres Then(simplify, solve-eqs) : {len(result)} sous-goal(s)")
for i in range(len(result)):
sg = result[i]
print(f" Sous-goal {i} ({sg.size()} contrainte(s)) :")
for j in range(sg.size()):
print(f" {sg[j]}")
# Convertir le sous-but en expression et resoudre avec un Solver classique
expr = result[0].as_expr()
print(f"\nExpression du sous-but : {expr}")
# Pour obtenir un modele avec toutes les variables, on utilise le Solver d'origine
s = Solver()
for c in g:
s.add(c)
print(f"Resolution complete : {s.check()}")
if s.check() == sat:
m = s.model()
print(f" x = {m[x]}, y = {m[y]}, z = {m[z]}")Goal original :
y == x + 5
z == y*2
x > 0
z < 30
Apres Then(simplify, solve-eqs) : 1 sous-goal(s)
Sous-goal 0 (2 contrainte(s)) :
Not(y <= 5)
Not(y >= 15)
Expression du sous-but : And(Not(y <= 5), Not(y >= 15))
Resolution complete : sat
x = 1, y = 6, z = 12
Sortie obtenue : le pipeline Then(simplify, solve-eqs) a d’abord simplifie les contraintes, puis elimine les equations y == x + 5 et z == y * 2 par substitution. Les variables x et z ont ete remplacees dans les inequations restantes, ne laissant que des contraintes sur y. On utilise ensuite un Solver classique pour obtenir un modèle complet.
Points cles : 1. solve-eqs detecte les equations de la forme var == expr et substitue var partout. 2. La composition Then execute les tactiques en sequence : la sortie de l’une alimente l’autre. 3. as_expr() convertit un sous-but en expression Z3 (conjonction de ses contraintes). 4. Les tactiques transforment les contraintes mais ne resolvent pas ; la resolution finale se fait via un Solver.
# OrElse et Repeat : essayer plusieurs strategies et iterer
x = Int('x')
# Repeat : appliquer une tactique jusqu'a point fixe
g = Goal()
g.add(x + 0 == x)
g.add(x * 1 == x)
repeat_tac = Repeat(Then(Tactic('simplify'), Tactic('propagate-values')))
result = repeat_tac(g)
print("Apres Repeat(simplify + propagate-values) :")
for i in range(len(result)):
sg = result[i]
print(f" Sous-goal {i} : {sg.size()} contrainte(s)")
for j in range(sg.size()):
print(f" {sg[j]}")
# OrElse : fallback entre tactiques
g2 = Goal()
g2.add(x > 5)
g2.add(x < 10)
# Essaye ctx-solver-simplify, sinon simplify en fallback
fallback = OrElse(Tactic('ctx-solver-simplify'), Tactic('simplify'))
result2 = fallback(g2)
print("\nApres OrElse(ctx-solver-simplify, simplify) :")
for i in range(len(result2)):
sg = result2[i]
for j in range(sg.size()):
print(f" {sg[j]}")
print(f"\n{len(result2)} sous-goal(s) traite(s) avec succes")Apres Repeat(simplify + propagate-values) :
Sous-goal 0 : 0 contrainte(s)
Apres OrElse(ctx-solver-simplify, simplify) :
Not(x <= 5)
x < 10
1 sous-goal(s) traite(s) avec succes
Sortie obtenue : les expressions triviales x + 0 == x et x * 1 == x sont reduites par le pipeline repete. Le combinateur OrElse tente la première tactique et bascule sur la seconde en cas d’echec.
| Combinateur | Analogie | Usage typique |
|---|---|---|
Then |
Pipeline (pipe \|) |
Pretraitement en étapes successives |
OrElse |
Try-catch | Tactique rapide d’abord, robuste en fallback |
Repeat |
Boucle while | Raffiner iterativement jusqu’a stabilite |
TryFor |
Timeout | Limiter le temps passe dans une tactique couteuse |
BitVecUn vecteur de bits (bitvector) est une sequence de n bits representant un entier modulo \(2^n\). Contrairement a Int (entiers mathematiques non bornes), les BitVec modelisent le comportement exact du materiel informatique : debordement, arithmetic modulaire, opérations bit a bit.
Applications : verification de circuits, analyse cryptographique, emulation de processeurs.
| Aspect | Int |
BitVec(n) |
|---|---|---|
| Domaine | \(\mathbb{Z}\) (entiers infinis) | \(\{0, \ldots, 2^n - 1\}\) (modulo \(2^n\)) |
| Debordement | Pas de debordement | Wrapping modulaire |
| Opérations bit a bit | Non disponibles | &, \|, ^, ~, <<, >> |
# BitVec : creation, arithmetic modulaire et operations bit a bit
x = BitVec('x', 8) # Vecteur de 8 bits (0 a 255)
y = BitVec('y', 8)
s = Solver()
# Arithmetic modulaire : 255 + 1 = 0 en 8 bits (wrapping)
s.add(x == BitVecVal(255, 8))
s.add(y == x + 1) # 255 + 1 = 0 (mod 256)
print(f"Resolution : {s.check()}")
m = s.model()
print(f" x = {m[x]} (valeur entiere : {m[x].as_long()})")
print(f" y = x + 1 = {m[y]} (valeur entiere : {m[y].as_long()})")
print(f" 255 + 1 modulo 256 = {(255 + 1) % 256}")
# Operations bit a bit
s2 = Solver()
a = BitVec('a', 8)
b = BitVec('b', 8)
# Trouver a et b tels que a XOR b = 0xFF mais a AND b = 0
s2.add(a ^ b == BitVecVal(0xFF, 8)) # Tous les bits differents
s2.add(a & b == BitVecVal(0, 8)) # Aucun bit en commun
print(f"\nCas XOR/AND : {s2.check()}")
m2 = s2.model()
print(f" a = {m2[a].as_long():3d} (binaire : {m2[a].as_long():08b})")
print(f" b = {m2[b].as_long():3d} (binaire : {m2[b].as_long():08b})")
print(f" a XOR b = {m2[a].as_long() ^ m2[b].as_long():08b}")
print(f" a AND b = {m2[a].as_long() & m2[b].as_long():08b}")Resolution : sat
x = 255 (valeur entiere : 255)
y = x + 1 = 0 (valeur entiere : 0)
255 + 1 modulo 256 = 0
Cas XOR/AND : sat
a = 254 (binaire : 11111110)
b = 1 (binaire : 00000001)
a XOR b = 11111111
a AND b = 00000000
Sortie obtenue : l’addition modulaire montre le wrapping caractéristique (255 + 1 = 0). L’exemple XOR/AND illustre les opérations bit a bit.
Points cles : 1. Les opérations arithmetiques sur BitVec sont automatiquement modulaires : pas de debordement, le résultat “wraps” autour de \(2^n\). 2. Les opérateurs Python &, |, ^, ~, <<, >> sont surcharges pour construire des expressions symboliques Z3. 3. BitVecVal(255, 8) créé une constante ; BitVec('x', 8) créé une variable symbolique.
# Arithmetic signee vs non signee sur les BitVec
# Un meme vecteur de bits peut etre interprete comme signe ou non signe.
a = BitVec('a', 8)
b = BitVec('b', 8)
# Comparaisons signees : traite le bit de poids fort comme signe
# -128 a 127 en signe, 0 a 255 en non signe
s = Solver()
s.add(a == BitVecVal(200, 8)) # 200 en non signe = -56 en signe
# En non signe : 200 > 100 est vrai
s.add(UGT(a, BitVecVal(100, 8))) # UGT = Unsigned Greater Than
# En signe : -56 > 100 est faux
print(f"200 (unsigned) > 100 ? {s.check()}")
if s.check() == sat:
m = s.model()
print(f" a = {m[a].as_long()} (unsigned) = {m[a].as_signed_long()} (signed)")
# Tableau des operateurs de comparaison
print("\nOperateurs signes vs non signes :")
print("| Operation | Signe | Non signe |")
print("|---------------|-----------|------------|")
print("| a < b | a < b | ULT(a, b) |")
print("| a <= b | a <= b | ULE(a, b) |")
print("| a > b | a > b | UGT(a, b) |")
print("| a >= b | a >= b | UGE(a, b) |")
print("| a / b | a / b | UDiv(a, b) |")
print("| a % b | a % b | URem(a, b) |")
print("| a >> b | arith. | LShR(a, b) |")200 (unsigned) > 100 ? sat
a = 200 (unsigned) = -56 (signed)
Operateurs signes vs non signes :
| Operation | Signe | Non signe |
|---------------|-----------|------------|
| a < b | a < b | ULT(a, b) |
| a <= b | a <= b | ULE(a, b) |
| a > b | a > b | UGT(a, b) |
| a >= b | a >= b | UGE(a, b) |
| a / b | a / b | UDiv(a, b) |
| a % b | a % b | URem(a, b) |
| a >> b | arith. | LShR(a, b) |
Sortie obtenue : la valeur 200 est positive en non signe mais negative en signe (-56). Les opérateurs UGT, ULT, etc. permettent de specifier l’interpretation souhaitee.
Points cles : 1. Les opérateurs Python <, >, <=, >= sont signes par defaut dans Z3. 2. Pour les comparaisons non signees, utiliser UGT, ULT, UGE, ULE. 3. La division et le decalage existent aussi en versions signees et non signees.
# Casse-tete BitVec : trouver un nombre dont les bits sont un palindrome
# Un palindrome binaire 8 bits se lit de la meme facon de gauche a droite et de droite a gauche.
# Exemple : 10011001 (= 153) est un palindrome binaire.
def reverse_bits_8(val):
"""Inverse l'ordre des 8 bits d'un BitVec 8 bits."""
result = BitVecVal(0, 8)
for i in range(8):
# Extraire le bit i et le placer en position (7 - i)
bit_i = Extract(i, i, val)
result = result | Concat(BitVecVal(0, 7), bit_i) << (7 - i)
return result
x = BitVec('x', 8)
s = Solver()
# x doit etre un palindrome binaire : x == reverse(x)
s.add(x == reverse_bits_8(x))
# Exclure 0 et 255 pour trouver un cas non trivial
s.add(x != BitVecVal(0, 8))
s.add(x != BitVecVal(255, 8))
# Preferer un palindrome pair (bit 0 = 0)
s.add((x & BitVecVal(1, 8)) == BitVecVal(0, 8))
print(f"Palindrome binaire : {s.check()}")
if s.check() == sat:
m = s.model()
val = m[x].as_long()
print(f" x = {val} (binaire : {val:08b})")
print(f" Verification : {val:08b} se lit identiquement dans les deux sens")Palindrome binaire : sat
x = 66 (binaire : 01000010)
Verification : 01000010 se lit identiquement dans les deux sens
La sortie donne x = 66 (binaire : 01000010) avec le verdict sat. Verification par recomposition : 01000010 en base 2 vaut 64 + 2 = 66, coherente avec la valeur decimale imprimee. La symetrie se lit position par position : bit 7 = bit 0 = 0, bit 6 = bit 1 = 1, bit 5 = bit 2 = 0, bit 4 = bit 3 = 0 — le mot se retourne sur lui-meme.
Point important sur ce que ce sat signifie : les contraintes n’exigeaient pas le plus petit palindrome pair non trivial, seulement un palindrome pair different de 0 et de 255. D’autres modeles satisfont le meme systeme — par exemple 00100100 (soit 36), pair et symetrique lui aussi. Z3 produit un modele satisfaisant arbitraire, pas un minimum ; obtenir le plus petit exigerait une optimisation (Optimize avec minimize), absente de cette cellule.
Non mesure : le nombre total de modeles satisfaisants (la sortie n’en expose qu’un exemplaire).
ArrayEn Z3, un tableau (Array) est une fonction totale des index vers des valeurs. Les deux opérations fondamentales :
Select(a, i) : lecture de la valeur a l’index i dans le tableau a (equivalent de a[i])Store(a, i, v) : ecriture de la valeur v a l’index i, renvoyant un nouveau tableau (immutabilite)La théorie des tableaux est essentielle pour modeliser des memoires, des caches, des registres ou des mappings.
| Propriete | Description |
|---|---|
| Immutabilite | Store ne modifie pas le tableau ; il en renvoie un nouveau |
| Totalite | Tout index a une valeur (la valeur par defaut si non initialise) |
| Axiome de sélection | Select(Store(a, i, v), i) == v |
| Axiome de non-sélection | i != j => Select(Store(a, i, v), j) == Select(a, j) |
# Array : lecture, ecriture et raisonnement sur les tableaux
# Modelisons un tableau d'entiers indexe par des entiers.
a = Array('a', IntSort(), IntSort()) # Tableau : Int -> Int
s = Solver()
# Contraintes sur le tableau initial
s.add(Select(a, 0) == 10)
s.add(Select(a, 1) == 20)
s.add(Select(a, 2) == 30)
# Demonstration : a[0] + a[1] == 30
somme = Select(a, 0) + Select(a, 1)
s.add(somme == 30)
print(f"Resolution : {s.check()}")
if s.check() == sat:
m = s.model()
print(f" a[0] = {m.evaluate(Select(a, 0))}")
print(f" a[1] = {m.evaluate(Select(a, 1))}")
print(f" a[2] = {m.evaluate(Select(a, 2))}")
print(f" a[0] + a[1] = {m.evaluate(somme)}")
# Store : creer un nouveau tableau avec une modification
b = Store(a, 0, 99) # b est un tableau ou l'index 0 vaut 99
s2 = Solver()
s2.add(Select(a, 0) == 10)
s2.add(Select(b, 0) == 99) # b[0] = 99 (modifie)
s2.add(Select(b, 1) == 20) # b[1] = 20 (inchange)
print(f"\nStore : {s2.check()}")
if s2.check() == sat:
m2 = s2.model()
print(f" b[0] = {m2.evaluate(Select(b, 0))} (modifie par Store)")
print(f" b[1] = {m2.evaluate(Select(b, 1))} (inchange par Store)")Resolution : sat
a[0] = 10
a[1] = 20
a[2] = 30
a[0] + a[1] = 30
Store : sat
b[0] = 99 (modifie par Store)
b[1] = 20 (inchange par Store)
Sortie obtenue : le solveur trouve un modèle coherent pour le tableau. L’opération Store créé un nouveau tableau ou seul l’index specifie est modifie ; les autres valeurs sont preservees.
Points cles : 1. Les tableaux Z3 sont des fonctions pures : Store ne mute pas, il produit une nouvelle fonction. 2. Select(a, i) equivaut a l’application fonctionnelle a(i). 3. L’axiome fondamental : Select(Store(a, i, v), j) vaut v si i == j, sinon Select(a, j).
# Array avance : contraintes sur un tableau avec Store successifs
# Scenario : un tableau represente les soldes de comptes.
# On effectue des operations et on veut verifier des proprietes.
comptes = Array('comptes', IntSort(), IntSort())
s = Solver()
# Etat initial : comptes[0] = 100, comptes[1] = 200, comptes[2] = 50
s.add(Select(comptes, 0) == 100)
s.add(Select(comptes, 1) == 200)
s.add(Select(comptes, 2) == 50)
# Operation 1 : transfer de 30 du compte 1 vers le compte 0
apres_transfer = Store(
Store(comptes, 1, Select(comptes, 1) - 30),
0, Select(comptes, 0) + 30
)
# Verifier que le total est conserve
total_avant = Select(comptes, 0) + Select(comptes, 1) + Select(comptes, 2)
total_apres = Select(apres_transfer, 0) + Select(apres_transfer, 1) + Select(apres_transfer, 2)
s.add(total_avant == total_apres)
print(f"Conservation du total : {s.check()}")
if s.check() == sat:
m = s.model()
print(f" Avant : c0={m.evaluate(Select(comptes, 0))}, "
f"c1={m.evaluate(Select(comptes, 1))}, "
f"c2={m.evaluate(Select(comptes, 2))}")
print(f" Apres : c0={m.evaluate(Select(apres_transfer, 0))}, "
f"c1={m.evaluate(Select(apres_transfer, 1))}, "
f"c2={m.evaluate(Select(apres_transfer, 2))}")
print(f" Total avant = {m.evaluate(total_avant)}")
print(f" Total apres = {m.evaluate(total_apres)}")Conservation du total : sat
Avant : c0=100, c1=200, c2=50
Apres : c0=130, c1=170, c2=50
Total avant = 350
Total apres = 350
La sortie imprime les deux etats et les deux totaux : Avant : c0=100, c1=200, c2=50, Apres : c0=130, c1=170, c2=50, puis Total avant = 350 et Total apres = 350. Recomposition des deltas : c0 gagne +30 (100 vers 130), c1 perd -30 (200 vers 170), c2 reste a 50. Les deux variations s’annulent (+30 - 30 = 0), ce que les totaux confirment : 100 + 200 + 50 = 350 et 130 + 170 + 50 = 350.
Ce que ce sat prouve exactement : les contraintes fixaient l’etat initial (comptes[0] = 100, comptes[1] = 200, comptes[2] = 50) puis exigeaient l’egalite des deux totaux. La reponse atteste la coherence de cette egalite sur l’etat concret impose — les valeurs fixees determinent deja les deux totaux. Ce n’est pas une preuve pour tout etat initial possible : pour cela, il faudrait refuter la negation de la propriete sur des variables libres, le solveur devant alors repondre unsat.
Non mesure : aucun temps de resolution ni taille du probleme ne sont imprimes par cette cellule.
SolverForZ3 dispose de solveurs optimises pour des fragments spécifiques de la logique. Plutot que d’utiliser le solveur generique Solver(), on peut sélectionner un solveur adapte a la théorie concernee :
| Logique | Description | Types principaux |
|---|---|---|
QF_LIA |
Quantifier-Free Linear Integer Arithmetic | Int (lineaire) |
QF_LRA |
Quantifier-Free Linear Real Arithmetic | Real (lineaire) |
QF_BV |
Quantifier-Free BitVectors | BitVec |
QF_UFLIA |
QF_LIA + fonctions non interpretees | Int + Array |
Le prefixe QF signifie Quantifier-Free (sans quantificateurs ForAll/Exists). Les solveurs specialises sont souvent plus rapides car ils exploitent la structure du fragment.
# Comparaison : Solver generique vs SolverFor specialise
import time
# Generer un systeme de contraintes lineaires entieres
n_vars = 50
variables = [Int(f'x{i}') for i in range(n_vars)]
constraints = []
# Chaque variable entre 0 et 100
for v in variables:
constraints.append(v >= 0)
constraints.append(v <= 100)
# Contraintes lineaires entre variables consecutives
for i in range(n_vars - 1):
constraints.append(variables[i] + variables[i + 1] <= 150)
constraints.append(variables[i] + variables[i + 1] >= 50)
# Solveur generique
s_generic = Solver()
for c in constraints:
s_generic.add(c)
t0 = time.perf_counter()
result_generic = s_generic.check()
time_generic = time.perf_counter() - t0
# Solveur specialise QF_LIA
s_specialized = SolverFor('QF_LIA')
for c in constraints:
s_specialized.add(c)
t0 = time.perf_counter()
result_specialized = s_specialized.check()
time_specialized = time.perf_counter() - t0
print(f"Solveur generique : {result_generic} en {time_generic*1000:.1f} ms")
print(f"SolverFor QF_LIA : {result_specialized} en {time_specialized*1000:.1f} ms")
print(f"Rapport (generic / specialized) : {time_generic / max(time_specialized, 1e-9):.1f}x")
# Configuration globale avec set_option
print("\nConfiguration globale avec set_option :")
set_option('verbose', 0) # Niveau de verbosite (0 = silencieux)
set_option('timeout', 10000) # Timeout en millisecondes
print(" verbose=0, timeout=10000 ms configures")Solveur generique : sat en 18.4 ms
SolverFor QF_LIA : sat en 17.2 ms
Rapport (generic / specialized) : 1.1x
Configuration globale avec set_option :
verbose=0, timeout=10000 ms configures
Sortie obtenue : les deux solveurs trouvent sat, mais le solveur specialise QF_LIA est généralement plus rapide sur les contraintes lineaires entieres sans quantificateurs.
Points cles : 1. SolverFor('QF_LIA') selectionne le moteur optimise pour l’arithmetic lineaire entiere sans quantificateurs. 2. SolverFor('QF_BV') est adapte aux problemes de vecteurs de bits. 3. set_option('timeout', ms) permet de limiter le temps de calcul global. 4. Le gain de performance depend du problème ; sur de petites instances, la différence est souvent negligeable.
On dispose du système de contraintes suivant : - a == b + 1 - c == a * 2 - b > 5 - c < 20
Créez un pipeline de tactiques Then(simplify, solve-eqs) et appliquez-le a un Goal contenant ces contraintes. Affichez les sous-but obtenus, puis resolvez le système avec un Solver pour obtenir les valeurs de a, b et c.
Indices : - Créez un
Goal(), ajoutez les 4 contraintes. - ConstruisezThen(Tactic('simplify'), Tactic('solve-eqs'))et appliquez-le au goal. - Utilisezlen(result)pour le nombre de sous-but etresult[i].size()pour le nombre de contraintes. - Pour obtenir les valeurs, ajoutez les contraintes originales a unSolveret appelezcheck()puismodel().
# EXERCICE 1 : Pipeline de tactiques.
def pipeline_tactiques() -> dict:
"""Applique Then(simplify, solve-eqs) et retourne le resultat.
Retourne : {'a': val, 'b': val, 'c': val, 'nb_contraintes_final': int}
# Indice : creez un Goal, ajoutez les 4 contraintes, appliquez le pipeline.
# Etape 1 : declarer a, b, c comme Int
# Etape 2 : creer le Goal et ajouter les contraintes
# Etape 3 : construire Then(Tactic('simplify'), Tactic('solve-eqs'))
# Etape 4 : utiliser len(result) et result[0].size() pour le nombre de contraintes
# Etape 5 : ajouter les contraintes a un Solver pour obtenir les valeurs
"""
# TODO etudiant : implémentez le pipeline
return None # TODO etudiant : remplacer par le résultat
resultat = pipeline_tactiques()
print("Resultat du pipeline :", resultat)Resultat du pipeline : None
Un message de 8 bits a ete chiffre par XOR avec une clef k. Le message chiffre est 0xA5 (165 en decimal). On sait que le message original est un multiple de 4 (en valeur entiere non signee).
Trouvez la clef k (8 bits) telle que message ^ k == 0xA5 et message soit un multiple de 4.
Indices : - Un multiple de 4 en binaire se termine par
00(les 2 bits de poids faible sont nuls). - Contrainte :(message & BitVecVal(3, 8)) == BitVecVal(0, 8). - Le chiffrement XOR est reversible :message = 0xA5 ^ k.
# EXERCICE 2 : Trouver la clef XOR.
def trouver_clef_xor() -> dict:
"""Trouve la clef k telle que message ^ k == 0xA5 et message est multiple de 4.
Retourne : {'clef': int, 'message': int}
# Indice : k = BitVec('k', 8), message = 0xA5 ^ k.
# Etape 1 : declarer k (BitVec 8 bits)
# Etape 2 : message = BitVecVal(0xA5, 8) ^ k
# Etape 3 : ajouter la contrainte (message & 3) == 0
# Etape 4 : resoudre et extraire k et message
"""
# TODO etudiant : implémentez la recherche de clef
return None # TODO etudiant : remplacer par le résultat
clef = trouver_clef_xor()
print("Clef trouvee :", clef)Clef trouvee : None
On dispose d’un tableau scores indexe par des entiers. On veut verifier la propriete suivante : si on effectue deux Store successifs sur des index différents i et j (avec i != j), l’ordre des Store n’a pas d’importance sur le résultat final.
Autrement dit, prouvez que pour tout tableau a, index i != j et valeurs v1, v2 : \[\text{Store}(\text{Store}(a, i, v_1), j, v_2) \text{ est equivalent a } \text{Store}(\text{Store}(a, j, v_2), i, v_1)\]
Verifiez cette propriete en tentant de trouver un contre-exemple (le solveur doit repondre unsat).
Indices : - Créez deux tableaux :
seq1 = Store(Store(a, i, v1), j, v2)etseq2 = Store(Store(a, j, v2), i, v1). - Ajoutez la contraintei != j. - AjoutezSelect(seq1, k) != Select(seq2, k)pour un indexkquelconque. - Siunsat, la propriete est prouvee.
# EXERCICE 3 : Commutativite des Store sur des index differents.
def verifier_commutativite_store() -> str:
"""Prouve que Store(a, i, v1) puis Store(..., j, v2) commute si i != j.
Retourne : 'prouve' si unsat, 'non_prouve' sinon.
# Indice : creez a, i, j, v1, v2, k comme variables.
# Etape 1 : declarer a = Array('a', IntSort(), IntSort()) et i, j, v1, v2, k comme Int
# Etape 2 : seq1 = Store(Store(a, i, v1), j, v2)
# Etape 3 : seq2 = Store(Store(a, j, v2), i, v1)
# Etape 4 : ajouter i != j et Select(seq1, k) != Select(seq2, k)
# Etape 5 : si unsat -> 'prouve'
"""
# TODO etudiant : implémentez la vérification
return None # TODO etudiant : remplacer par 'prouve' ou 'non_prouve'
resultat = verifier_commutativite_store()
print("Commutativite des Store :", resultat)Commutativite des Store : None
Ce notebook a explore les mécanismes avances de Z3 au-dela du solveur de base :
| Concept | API Z3 | Usage |
|---|---|---|
| Tactiques | Tactic, Goal, Then, OrElse, Repeat |
Pretraiter, simplifier, decomposer les contraintes |
| Simplification | simplify, ctx-solver-simplify |
Eliminer les redundances (syntaxique ou contextuelle) |
| Vecteurs de bits | BitVec, UGT, ULT, Extract, Concat |
Arithmetic modulaire, opérations bit a bit, circuits |
| Tableaux | Array, Select, Store |
Memoires fonctionnelles, mappings, verification de transitions |
| Solveurs specialises | SolverFor('QF_LIA'), SolverFor('QF_BV') |
Exploiter la structure du problème pour accelerer la resolution |
Points essentiels a retenir : 1. Les tactiques offrent un contrôle fin sur la stratégie de resolution ; elles sont composees en pipelines. 2. Les BitVec modelisent le comportement exact du materiel (wrapping modulaire, opérations bit a bit, signe vs non signe). 3. Les Array sont des fonctions pures ; Store est immutable et Select lit la valeur. 4. SolverFor permet de sélectionner un solveur adapte au fragment logique du problème.
Les prochains notebooks exploreront les théories de chaînes (String, Re), les quantificateurs (ForAll, Exists) et l’optimisation avancee (objectifs multiples, Pareto).