Ce notebook explore trois fonctionnalités avancées du moteur réel Microsoft.Z3 (NuGet, Z3 4.12.2.0) : les tactiques (stratégies de résolution composables), la théorie des vecteurs de bits (BitVec, arithmétique modulaire), et la théorie des tableaux (Array, lecture/écriture symbolique).
Pourquoi ces trois familles ensemble ?
Un solveur SMT naïf traite toute formule avec le même moteur générique. La puissance de Z3 vient de ce qu’on peut orchestrer la résolution : une tactique transforme un but difficile en un (ou plusieurs) but(s) plus simple(s) jusqu’à ce qu’un solveur spécialisé termine. Les théories BitVec et Array sont les deux terrains les plus fréquents en vérification de programmes (entiers machine, mémoires). Comprendre comment on compile un problème vers ces théories, puis comment on choisit le bon solveur, est exactement ce qui distingue un utilisateur de Z3 d’un praticien.
Plan
Tactiques de simplification (simplify, ctx-solver-simplify).
Composition de tactiques (Then, OrElse, Repeat).
BitVec : arithmétique modulaire, opérations bit à bit, signé vs non signé.
Casse-tête : nombre palindrome binaire.
Array : Store/Select, raisonnement sur les tableaux.
SolverFor spécialisé vs Solver générique (benchmark).
Trois exercices.
#r "nuget: Microsoft.Z3"#load "Z3NativeLoader.cs"using Microsoft.Z3;using System.Diagnostics;// Bibliotheque native de Z3 hors Windows et macOS Intel (voir Z3NativeLoader.cs)Z3NativeLoader.Register(typeof(Context).Assembly);Console.WriteLine("Imports OK : Microsoft.Z3 version "+ Microsoft.Z3.Version.FullVersion);
The below script needs to be able to find the current output cell; this is an easy method to get it.
Installed Packages
Microsoft.Z3, 4.12.2
Imports OK : Microsoft.Z3 version Z3 4.12.2.0
1. Tactique simplify
Une tactique est une stratégie de transformation de but (Goal). Contrairement à un Solver qui répond SATISFIABLE/UNSATISFIABLE, une tactique réécrit le but sans le trahir : le but résultant est équisatisfaisant, mais plus simple à résoudre.
simplify applique des règles de réécriture syntaxique : constante folding (2+3 → 5), simplification algébrique, normalisation de la forme (par exemple réordonner les termes pour que les constantes migrent à gauche). On crée un Goal, on y ajoute des formules, puis on applique la tactique via Apply → ApplyResult dont on extrait les Subgoals.
Idiome .NET vs Python : en Python on écrit goal.add(...) et tactic(goal) ; le binding .NET expose les mêmes objets via ctx.MkGoal(), g.Add(...), et tactic.Apply(goal). Le système de types C# rend les conversions explicites (ArithExpr, BoolExpr), ce qui aide à ne pas mélanger les sortes.
var ctx =newContext();var x = ctx.MkIntConst("x");var y = ctx.MkIntConst("y");var g = ctx.MkGoal();g.Add(ctx.MkEq(y, ctx.MkAdd(x, ctx.MkInt(5))));g.Add(ctx.MkGt(x, ctx.MkInt(0)));Console.WriteLine("Goal original :");foreach(var f in g.Formulas) Console.WriteLine(" "+ f);var simplify = ctx.MkTactic("simplify");var res = simplify.Apply(g);Console.WriteLine();Console.WriteLine("Apres simplify : "+ res.Subgoals.Length+" sous-goal(s)");foreach(var sg in res.Subgoals){foreach(var f in sg.Formulas) Console.WriteLine(" "+ f);}
Goal original :
(= y (+ x 5))
(> x 0)
Apres simplify : 1 sous-goal(s)
(= y (+ 5 x))
(not (<= x 0))
Lecture du résultat
Observez ce que simplify a réécrit sans changer la satisfiabilité : (> x 0) est devenu (not (<= x 0)) — Z3 normalise les inégalités strictes en négation d’inégalité large, car le solveur interne travaille sur <=. De même (+ x 5) est réordonné en (+ 5 x) (les constantes à gauche). Aucune variable n’a été éliminée : simplify est purement syntaxique, elle ne déduit rien qu’elle pourrait déduire. Pour éliminer, il faut la tactique solve-eqs (section suivante).
2. Composition de tactiques
L’intérêt réel apparaît quand on chaîne les tactiques. Z3 fournit trois combinateurs :
ctx.AndThen(t1, t2) (séquence) : applique t1, puis t2 sur chaque sous-but produit.
ctx.OrElse(t1, t2) (alternative) : essaie t1 ; si elle échoue, utilise t2.
ctx.Repeat(t) (point fixe) : applique t jusqu’à ce qu’aucun progrès ne soit possible.
Le pipeline AndThen(simplify, solve-eqs) est l’archétype : simplify nettoie, puis solve-eqsélimine les équations en substituant les variables libres par leur définition. Sur un système contraint, on passe ainsi d’un but à 4 variables à un but à 2 variables seulement, toutes les substitutions résolues.
var x2 = ctx.MkIntConst("x");var y2 = ctx.MkIntConst("y");var z2 = ctx.MkIntConst("z");var g = ctx.MkGoal();g.Add(ctx.MkEq(y2, ctx.MkAdd(x2, ctx.MkInt(5))));g.Add(ctx.MkEq(z2, ctx.MkMul(ctx.MkInt(2), y2)));g.Add(ctx.MkGt(x2, ctx.MkInt(0)));g.Add(ctx.MkLt(z2, ctx.MkInt(30)));var pipeline = ctx.AndThen(ctx.MkTactic("simplify"), ctx.MkTactic("solve-eqs"));var res = pipeline.Apply(g);Console.WriteLine("Apres AndThen(simplify, solve-eqs) : "+ res.Subgoals.Length+" sous-goal(s)");foreach(var sg in res.Subgoals){ Console.WriteLine(" Sous-goal ("+ sg.Formulas.Length+" contrainte(s)) :");foreach(var f in sg.Formulas) Console.WriteLine(" "+ f);}// Resolution complete avec le Solvervar s = ctx.MkSolver();foreach(var f in g.Formulas) s.Add(f);Console.WriteLine();Console.WriteLine("Resolution complete : "+ s.Check());if(s.Check()== Status.SATISFIABLE){var m = s.Model; Console.WriteLine(" x = "+ m.Evaluate(x2)+", y = "+ m.Evaluate(y2)+", z = "+ m.Evaluate(z2));}
Apres AndThen(simplify, solve-eqs) : 1 sous-goal(s)
Sous-goal (2 contrainte(s)) :
(not (<= y 5))
(not (>= y 15))
Resolution complete : SATISFIABLE
x = 1, y = 6, z = 12
Lecture du résultat
Le pipeline AndThen(simplify, solve-eqs) a fait le vrai travail : on partait de 4 contraintes sur 3 variables (x, y, z), et l’on arrive à 2 contraintes sur la seule variable y — (not (<= y 5)) et (not (>= y 15)), soit 5 < y < 15. Comment ? solve-eqs a substitué x et z par leur définition (y = x+5, z = 2y) puis propagé les bornes. Le but est devenu beaucoup plus cheap à résoudre, et le Solver confirmera SATISFIABLE avec x = 1, y = 6, z = 12. C’est tout l’intérêt des tactiques : réduire avant de résoudre.
3. OrElse et Repeat
OrElse(t1, t2) modélise le raisonnement « essayer A, sinon B » : t1 est tentée, et si elle ne peut pas progresser, t2 prend le relais. C’est ainsi qu’on combine une tactique puissante mais coûteuse (ctx-solver-simplify, qui appelle le solveur en interne) avec une tactifique bon marché (simplify) comme filet de sécurité.
Repeat(t) applique t jusqu’à un point fixe : tant que la tactique modifie le but, on recommence ; dès qu’une itération ne change plus rien, on s’arrête. C’est indispensable pour les simplifications qui se débloquent mutuelles (substituer une valeur révèle une nouvelle simplification, qui révèle une autre substitution…).
var x3 = ctx.MkIntConst("x");// Repeat : appliquer simplify + propagate-values jusqu au point fixevar g = ctx.MkGoal();g.Add(ctx.MkEq(ctx.MkAdd(x3, ctx.MkInt(0)), x3));g.Add(ctx.MkEq(ctx.MkMul(x3, ctx.MkInt(1)), x3));var repeatTac = ctx.Repeat(ctx.AndThen(ctx.MkTactic("simplify"), ctx.MkTactic("propagate-values")));var res = repeatTac.Apply(g);Console.WriteLine("Apres Repeat(simplify + propagate-values) :");foreach(var sg in res.Subgoals){ Console.WriteLine(" Sous-goal ("+ sg.Formulas.Length+" contrainte(s)) :");foreach(var f in sg.Formulas) Console.WriteLine(" "+ f);}// OrElse : fallback entre tactiquesvar g2 = ctx.MkGoal();g2.Add(ctx.MkGt(x3, ctx.MkInt(5)));g2.Add(ctx.MkLt(x3, ctx.MkInt(10)));var fallback = ctx.OrElse(ctx.MkTactic("ctx-solver-simplify"), ctx.MkTactic("simplify"));var res2 = fallback.Apply(g2);Console.WriteLine();Console.WriteLine("Apres OrElse(ctx-solver-simplify, simplify) : "+ res2.Subgoals.Length+" sous-goal(s)");foreach(var sg in res2.Subgoals){foreach(var f in sg.Formulas) Console.WriteLine(" "+ f);}
Un BitVec de largeur 8 est un vecteur de 8 bits, interprété comme un entier non signé entre 0 et 255. Contrairement aux IntExpr (entiers mathématiques non bornés), l’arithmétique BitVec est modulaire : 255 + 1 = 0 (wrapping), exactement comme un registre CPU. C’est la théorie qu’il faut pour modéliser tout ce qui touche aux entiers machine : débordements, masques, cryptographie symétrique.
Les opérations bit à bit (MkBVXOR, MkBVAND, MkBVOR) se combinent avec l’arithmétique (MkBVAdd, MkBVMul) dans le même solveur. La cellule ci-dessous démontre le wrapping (255 + 1 → 0) puis résout un système XOR/AND : trouver deux octets dont le XOR vaut 0xFF mais le AND vaut 0.
var bvX = ctx.MkBVConst("x",8);var bvY = ctx.MkBVConst("y",8);var s = ctx.MkSolver();s.Add(ctx.MkEq(bvX, ctx.MkBV(255,8)));s.Add(ctx.MkEq(bvY, ctx.MkBVAdd(bvX, ctx.MkBV(1,8))));// 255 + 1 = 0 (mod 256)Console.WriteLine("Resolution : "+ s.Check());if(s.Check()== Status.SATISFIABLE){var m = s.Model; Console.WriteLine(" x = "+((BitVecNum)m.Evaluate(bvX)).UInt64); Console.WriteLine(" y = x + 1 = "+((BitVecNum)m.Evaluate(bvY)).UInt64); Console.WriteLine(" 255 + 1 modulo 256 = "+((255+1)%256));}// Operations bit a bit : a XOR b = 0xFF mais a AND b = 0var s2 = ctx.MkSolver();var a = ctx.MkBVConst("a",8);var b = ctx.MkBVConst("b",8);s2.Add(ctx.MkEq(ctx.MkBVXOR(a, b), ctx.MkBV(0xFF,8)));s2.Add(ctx.MkEq(ctx.MkBVAND(a, b), ctx.MkBV(0,8)));Console.WriteLine();Console.WriteLine("Cas XOR/AND : "+ s2.Check());if(s2.Check()== Status.SATISFIABLE){var m = s2.Model; Console.WriteLine(" a = "+((BitVecNum)m.Evaluate(a)).UInt64+" = "+ Convert.ToString((int)((BitVecNum)m.Evaluate(a)).UInt64,2).PadLeft(8,"0"[0])); Console.WriteLine(" b = "+((BitVecNum)m.Evaluate(b)).UInt64+" = "+ Convert.ToString((int)((BitVecNum)m.Evaluate(b)).UInt64,2).PadLeft(8,"0"[0]));}
Resolution : SATISFIABLE
x = 255
y = x + 1 = 0
255 + 1 modulo 256 = 0
Cas XOR/AND : SATISFIABLE
a = 254 = 11111110
b = 1 = 00000001
Lecture du résultat
Deux leçons dans cette sortie. D’abord le wrapping : x = 255, y = x + 1 = 0 — l’arithmétique BitVec 8 bits a « fait le tour » du registre, exactement comme un uint8_t en C. Ensuite le système XOR/AND : le solveur trouve a = 254 (11111110) et b = 1 (00000001), deux octets strictement complémentaires — chaque bit où a vaut 1, b vaut 0, et réciproquement, ce qui satisfait à la fois a XOR b = 0xFF et a AND b = 0. C’est la signature bit à bit de l’identité « OU-exclusif maximal ».
5. Signé vs non signé
Un même vecteur de bits ne porte pas son interprétation : 11001000 peut être lu comme 200 (non signé) ou -56 (signé, complément à deux). C’est le contexte — la comparaison qu’on écrit — qui décide.
Z3 expose deux familles distinctes : les comparaisons non signées (MkBVUGt/MkBVULt/MkBVUGe/MkBVULe, le U = unsigned) traitent le vecteur comme un entier positif ; les comparaisons signées (MkBVSgt/MkBVSlt, le S = signed) interprètent le bit de poids fort comme le signe. Confondre les deux est un bug classique en C (d’où les avertissements du compilateur sur la comparaison signé/non signé).
var a5 = ctx.MkBVConst("a",8);var s = ctx.MkSolver();s.Add(ctx.MkEq(a5, ctx.MkBV(200,8)));// 200 unsigned = -56 signed// Unsigned : 200 > 100 est vrais.Add(ctx.MkBVUGT(a5, ctx.MkBV(100,8)));Console.WriteLine("200 (unsigned) > 100 ? "+ s.Check());if(s.Check()== Status.SATISFIABLE){var m = s.Model;var bvnum =(BitVecNum)m.Evaluate(a5);// Sign-extension 8 bits : bvnum.Int64 retourne la valeur NON signee (200) ;// l interpretation signee (two's complement) de 11001000 sur 8 bits = -56.ulong bvUnsigned = bvnum.UInt64;long bvSigned =(bvUnsigned &0x80)!=0?(long)bvUnsigned -256:(long)bvUnsigned; Console.WriteLine(" a = "+ bvUnsigned +" (unsigned) = "+ bvSigned +" (signed)");}Console.WriteLine();Console.WriteLine("Operateurs signes vs non signes :");Console.WriteLine("| Operation | Signe | Non signe |");Console.WriteLine("|---------------|-----------|----------------|");Console.WriteLine("| a < b | MkBVSlt | MkBVULT(a, b) |");Console.WriteLine("| a <= b | MkBVSLe | MkBVULE(a, b) |");Console.WriteLine("| a > b | MkBVSgt | MkBVUGT(a, b) |");Console.WriteLine("| a >= b | MkBVSGe | MkBVUGE(a, b) |");
200 (unsigned) > 100 ? SATISFIABLE
a = 200 (unsigned) = -56 (signed)
Operateurs signes vs non signes :
| Operation | Signe | Non signe |
|---------------|-----------|----------------|
| a < b | MkBVSlt | MkBVULT(a, b) |
| a <= b | MkBVSLe | MkBVULE(a, b) |
| a > b | MkBVSgt | MkBVUGT(a, b) |
| a >= b | MkBVSGe | MkBVUGE(a, b) |
6. Casse-tête : nombre palindrome binaire
Un palindrome binaire 8 bits se lit identiquement de gauche à droite et de droite à gauche (ex: 10011001 = 153). C’est un excellent exercice de manipulation symbolique des bits : on ne connaît pas la solution à l’avance, on laisse Z3 l’énumérer.
On construit une fonction ReverseBits via MkExtract (extraire un bit à une position donnée) + MkConcat (réassembler les bits en ordre inverse), puis on impose x == ReverseBits(x). En excluant les cas triviaux (0 et 255), on demande ensuite au solveur d’énumérer les modèles. La cellule ci-dessous boucle en ajoutant à chaque itération la négation du modèle trouvé.
BitVecExpr ReverseBits(Context c, BitVecExpr v){// bits[0] (LSB de v) devient MSB du resultat -> on concatene en partant de bit 0 BitVecExpr acc =(BitVecExpr)c.MkExtract((uint)0,(uint)0, v);for(int i =1; i <8; i++){ acc =(BitVecExpr)c.MkConcat(acc,(BitVecExpr)c.MkExtract((uint)i,(uint)i, v));}return acc;}var x6 = ctx.MkBVConst("x",8);var s = ctx.MkSolver();s.Add(ctx.MkEq(x6,ReverseBits(ctx, x6)));s.Add(ctx.MkNot(ctx.MkEq(x6, ctx.MkBV(0,8))));s.Add(ctx.MkNot(ctx.MkEq(x6, ctx.MkBV(255,8))));Console.WriteLine("Palindromes binaires 8 bits (exclu 0 et 255) :");int count =0;while(s.Check()== Status.SATISFIABLE&& count <6){ulong uv =((BitVecNum)s.Model.Evaluate(x6)).UInt64;int iv =(int)uv; Console.WriteLine(" "+ uv +" = "+ Convert.ToString(iv,2).PadLeft(8,"0"[0])); s.Add(ctx.MkNot(ctx.MkEq(x6, s.Model.Evaluate(x6)))); count++;}Console.WriteLine("(total palindromes 8 bits = "+(count <6? count.ToString():"6+ (56 au total hors 0/255)")+")");
Le solveur énumère les palindromes : 129 = 10000001, 24 = 00011000, 90 = 01011010, 126 = 01111110, 60 = 00111100, 36 = 00100100. Vérifiez à l’œil : chacun se lit pareil dans les deux sens. Il en existe en réalité 56 sur 8 bits (hors 0 et 255) : pour un palindrome, les 4 bits de poids fort déterminent les 4 bits de poids faible, donc 2^4 = 16 motifs, soit 16 palindromes par tranche de valeur… le décompte exact est laissé en exercice de dénombrement. La leçon technique : MkExtract + MkConcat permettent de reconstruire un vecteur bit à bit et de l’utiliser comme but de résolution.
7. Array : Store et Select
La théorie des tableaux modélise un tableau comme une fonction symbolique index→valeur. C’est l’abstraction qu’utilise Z3 pour raisonner sur la mémoire, les buffers, les dictionnaires purs.
Deux constructeurs suffisent : - MkStore(arr, i, v) renvoie un tableau égal à arrsauf en i qui vaut v (fonctionnellement pur, pas de mutation). - MkSelect(arr, i) lit la valeur en i.
L’exemple ci-dessous modélise un transfert bancaire : trois comptes (c0, c1, c2), on déplace 30 du compte 1 vers le compte 0, puis on demande au solveur de prouver la conservation du total. Comme les Store successifs se raisonnent symboliquement, Z3 déduit l’égalité des sommes sans énumérer les valeurs.
var arr = ctx.MkArrayConst("arr", ctx.IntSort, ctx.IntSort);var s = ctx.MkSolver();// Etat initial : arr[0]=100, arr[1]=200, arr[2]=50s.Add(ctx.MkEq(ctx.MkSelect(arr, ctx.MkInt(0)), ctx.MkInt(100)));s.Add(ctx.MkEq(ctx.MkSelect(arr, ctx.MkInt(1)), ctx.MkInt(200)));s.Add(ctx.MkEq(ctx.MkSelect(arr, ctx.MkInt(2)), ctx.MkInt(50)));// Transfer de 30 du compte 1 vers le compte 0var apres = ctx.MkStore( ctx.MkStore(arr, ctx.MkInt(1), ctx.MkSub((ArithExpr)ctx.MkSelect(arr, ctx.MkInt(1)), ctx.MkInt(30))), ctx.MkInt(0), ctx.MkAdd((ArithExpr)ctx.MkSelect(arr, ctx.MkInt(0)), ctx.MkInt(30)));// Verifier conservation du totalvar totalAvant = ctx.MkAdd((ArithExpr)ctx.MkSelect(arr, ctx.MkInt(0)),(ArithExpr)ctx.MkSelect(arr, ctx.MkInt(1)),(ArithExpr)ctx.MkSelect(arr, ctx.MkInt(2)));var totalApres = ctx.MkAdd((ArithExpr)ctx.MkSelect(apres, ctx.MkInt(0)),(ArithExpr)ctx.MkSelect(apres, ctx.MkInt(1)),(ArithExpr)ctx.MkSelect(apres, ctx.MkInt(2)));s.Add(ctx.MkEq(totalAvant, totalApres));Console.WriteLine("Conservation du total : "+ s.Check());if(s.Check()== Status.SATISFIABLE){var m = s.Model; Console.WriteLine(" Avant : c0="+ m.Evaluate(ctx.MkSelect(arr, ctx.MkInt(0)))+", c1="+ m.Evaluate(ctx.MkSelect(arr, ctx.MkInt(1)))+", c2="+ m.Evaluate(ctx.MkSelect(arr, ctx.MkInt(2)))); Console.WriteLine(" Apres : c0="+ m.Evaluate(ctx.MkSelect(apres, ctx.MkInt(0)))+", c1="+ m.Evaluate(ctx.MkSelect(apres, ctx.MkInt(1))));}
Conservation du total : SATISFIABLE
Avant : c0=100, c1=200, c2=50
Apres : c0=130, c1=170
Lecture du résultat
Le solveur confirme la conservation du total : avant le transfert, c0 = 100, c1 = 200, c2 = 50 (somme 350) ; après, c0 = 130, c1 = 170 (somme 130 + 170 + 50 = 350). Aucune valeur n’a été énumérée à la main : Z3 a raisonné symboliquement sur l’arbre des Store successifs pour déduire l’égalité des sommes. C’est exactement le style de preuve qu’on attend d’un vérificateur de transactions ou d’un analyseur de mutation d’état : prouver un invariant sur une séquence d’écritures sans dérouler la séquence.
8. SolverFor spécialisé vs Solver générique
ctx.MkSolver() construit le solveur générique, qui choisit sa stratégie interne en fonction des théories rencontrées. ctx.MkSolver("QF_LIA") (ou SolverFor("QF_LIA")) construit un solveur spécialisé pour Quantifier-Free Linear Integer Arithmetic — l’arithmétique entière linéaire sans quantificateurs.
Sur un problème qui relève exactement de cette logique, le solveur spécialisé court-circuite la phase de sélection et peut être plusieurs fois plus rapide. C’est l’équivalent, côté solveur, de ce que les tactiques font côté transformation : annoncer la logique au solveur évite qu’il la redécouvre. La cellule ci-dessous chronomètre les deux sur 50 variables entières bornées avec des contraintes sur les sommes adjacentes.
int nVars =50;var variables =new IntExpr[nVars];for(int i =0; i < nVars; i++) variables[i]= ctx.MkIntConst("x"+ i);var constraints =new List<BoolExpr>();for(int i =0; i < nVars; i++){ constraints.Add(ctx.MkGe(variables[i], ctx.MkInt(0))); constraints.Add(ctx.MkLe(variables[i], ctx.MkInt(100)));}for(int i =0; i < nVars -1; i++){ constraints.Add(ctx.MkLe(ctx.MkAdd(variables[i], variables[i +1]), ctx.MkInt(150))); constraints.Add(ctx.MkGe(ctx.MkAdd(variables[i], variables[i +1]), ctx.MkInt(50)));}var sGen = ctx.MkSolver();foreach(var c in constraints) sGen.Add(c);var sw = Stopwatch.StartNew();var rGen = sGen.Check();sw.Stop();Console.WriteLine("Solveur generique : "+ rGen +" en "+ sw.Elapsed.TotalMilliseconds.ToString("F1")+" ms");var sSpec = ctx.MkSolver("QF_LIA");foreach(var c in constraints) sSpec.Add(c);sw = Stopwatch.StartNew();var rSpec = sSpec.Check();sw.Stop();Console.WriteLine("SolverFor QF_LIA : "+ rSpec +" en "+ sw.Elapsed.TotalMilliseconds.ToString("F1")+" ms");
Solveur generique : SATISFIABLE en 8,2 ms
SolverFor QF_LIA : SATISFIABLE en 4,0 ms
Lecture du résultat
Sur ce système (50 variables entières bornées 0..100, contraintes sur les sommes adjacentes), le solveur spécialisé QF_LIA termine plus vite que le solveur générique. Les deux durées sont celles qu’affiche la cellule ci-dessus, seule source des valeurs : elles varient d’une machine et d’une exécution à l’autre, leur rapport aussi, mais le solveur spécialisé reste devant. Le résultat (SATISFIABLE) est identique, seul le chemin diffère. La conclusion opérationnelle : quand on connaît la logique dominante de son problème (ici, arithmétique linéaire entière sans quantificateurs), annoncer la logique au solveur via MkSolver("QF_LIA") est un accélérateur gratuit. Sur des logiques plus riches (non-linéaire, tableaux, bits), le même principe s’applique avec les codes QF_NIA, QF_AUFLIA, QF_BV…
Exercices
Trois exercices à compléter. Les stubs retournent null ou 0.
// EXERCICE 1 : Pipeline de tactiques.// Appliquer AndThen(simplify, solve-eqs) sur le goal y == x*x ET y > 10.// Indice : MkGoal, g.Add(...), ctx.AndThen(t1, t2).Apply(g).// Etape 1 : declarer x, y, goal g// Etape 2 : g.Add(MkEq(y, MkMul(x, x))) et g.Add(MkGt(y, MkInt(10)))// Etape 3 : pipeline.Apply(g), retourner Subgoals.LengthintCompterSousGoalsApresPipeline(Context ctx){// TODO etudiant : implementez le pipeline AndThen(simplify, solve-eqs)return0;// TODO etudiant : remplacer par le nombre de sous-goals}Console.WriteLine("Exercice 1 (pipeline) : "+(CompterSousGoalsApresPipeline(newContext())>0?CompterSousGoalsApresPipeline(newContext())+" sous-goal(s)":"(a completer)"));
Exercice 1 (pipeline) : (a completer)
// EXERCICE 2 : Trouver la clef XOR.// On a un message chiffre et la clef est un BitVec 8 bits.// Trouver clef telle que clef XOR message = 0 (clef = message).// Indice : MkBVXOR(clef, message) == MkBV(0, 8).// Etape 1 : declarer clef (BitVec 8), fixer message a une valeur connue// Etape 2 : s.Add(MkEq(MkBVXOR(clef, message), MkBV(0,8)))// Etape 3 : Check et extraire cleflong?TrouverClefXOR(Context ctx,long message){// TODO etudiant : implementez la recherche de clef XORreturnnull;// TODO etudiant : remplacer par la clef trouvee}var clef =TrouverClefXOR(newContext(),42);Console.WriteLine("Exercice 2 (clef XOR) : "+(clef.HasValue? clef.ToString():"(a completer)"));
Exercice 2 (clef XOR) : (a completer)
// EXERCICE 3 : Commutativite des Store sur des index differents.// Verifier que Store(Store(arr, 0, v0), 1, v1) == Store(Store(arr, 1, v1), 0, v0).// Indice : MkStore deux fois (index differents), MkEq entre les deux arbres, Check.// Etape 1 : declarer arr (Array Int->Int), v0, v1// Etape 2 : construire les deux sequences de Store// Etape 3 : s.Add(MkEq(seqAB, seqBA)) et CheckstringVerifierCommutativiteStore(Context ctx){// TODO etudiant : implementez la verification de commutativitereturn"(a completer)";// TODO etudiant : remplacer par "VALIDE" ou "INVALIDE"}Console.WriteLine("Exercice 3 (Store commutatif) : "+VerifierCommutativiteStore(newContext()));
Exercice 3 (Store commutatif) : (a completer)
Conclusion
Ce twin C# couvre les trois familles avancées de Z3 : tactiques (MkTactic/MkGoal/Apply/ApplyResult.Subgoals, composition ctx.AndThen/OrElse/Repeat), théorie BitVec (MkBVConst/MkBVXOR/MkBVAND, arithmétique modulaire, signé vs non signé MkBVUGt/MkBVSgt, MkExtract/MkConcat pour la manipulation bit à bit), et théorie Array (MkArrayConst/MkStore/MkSelect). Le binding Microsoft.Z3 expose exactement le même moteur que z3-solver Python.
Ce qu’il faut retenir
Les tactiques transforment, les solveurs décident. Un pipeline AndThen(simplify, solve-eqs) réduit un but avant de le passer au solveur ; SolverFor("QF_LIA") annonce la logique pour éviter la redécouverte.
BitVec est l’entier machine : modulaire, interprétable signé ou non signé selon la comparaison choisie — c’est le terrain de la vérification bas niveau et de la crypto.
Array est la mémoire symbolique : Store/Select permettent de raisonner sur des suites de mutations sans les énumérer.
Complémentarité : le twin Python utilise len(result) et sg.size() (API Pythonique) ; ce twin C# montre l’API .NET (ApplyResult.Subgoals.Length, Goal.Formulas comme propriété, composition via méthodes ctx.*) — la valeur ajoutée est la traduction des idiomes Z3 dans le système de types .NET.
Les trois exercices qui suivent sont à compléter : ils reprennent isolément chacune des trois familles (pipeline de tactiques, XOR sur BitVec, commutativité des Store).