Démontrer des théorèmes mathématiques en écrivant des programmes Charles Bouillaguet 19 janvier 2016 La semaine dernière, on a vu que dans les années 1970, l’apparition d’une des premières « preuves générées par ordinateur » a posé le problème de sa validité : est-ce qu’une preuve que personne ne peut vérifier est valide ? À la même période, Le développement de l’informatique et des langages de programmation a apporté un autre éclairage sur la notion de « preuve mathématique ». S’il fallait la résumer par un slogan, on pourrait dire « preuve = programme ». Echauffement. Considérons les fonctions ML 1 suivantes : 1. let i x = x 2. let k x y = x 3. let s x y z = x z (y z) 4. let a f x = f x 5. let c f g x = f (g x) 6. let t f x y = f y x . Question 1: Laquelle de ces fonctions implémente en fait la composition f ◦ g ? . Question 2: Déterminer leurs types. Dans les types obtenus, α → β → γ se lit en fait α → (β → γ). En effet, une fonction de deux arguments de types α et β est en fait une fonction qui prend un argument de type α et qui renvoie... une fonction qui prend un argument de type β qui renvoie elle-même un résultat de type γ. Maintenant, on va transformer les types de ces expressions ML en formules logiques : les variables de type (α, β, . . . ) vont devenir des variables booléennes, et la « flèche de fonction » (→) va devenir l’opérateur d’implication (⇒). . Question 3: En supposant que les variables de type sont des variables booléennes, dresser la table de vérités des formules logiques dérivées des types ci-dessus. Qu’observe-t-on ? Isomorphisme de Curry-Howard Dans ce poly, une justification raisonnable de ce phénomène est fournie. De plus, on va voir qu’il est possible de le « renverser » : pour démontrer une formule logique, il suffit d’écrire un programme ML qui a le bon type ! On a donc la correspondance : Système de types Type Programme Calcul Programmer Théorie logique Formule logique Preuve Simplification des preuves Prouver des théorèmes ! 1. Haskell et CAML sont des langages de la famille ML 1 Cette correspondance a pris le nom d’isomorphisme de Curry-Howard. En effet, cette idée générale (« programme = preuve ») s’est élaborée au fil de l’observations d’abord par Curry 2 (en 1934 et 1958) puis par Howard (en 1969) de la correspondance entre des systèmes de preuves et des mécanismes de calcul. L’idée générale est qu’une fonction de type S → T est une fonction qui prend une preuve de S et produit une preuve de T . Tous les autres constructeurs logiques s’expriment de la même façon. Assistants de preuve La poursuite de cette analogie « programme = preuve » a aboutit à la mise de points de langages de programmations spécifiques, dédiés non pas à la construction de programmes, mais à la construction de preuves mathématiques (coq, développé à l’INRIA, est l’un d’entre eux). L’idée générale est que pour prouver un théorème ϕ, il suffit d’écrire dans ces langages un programme dont le type correspond à ϕ. Ensuite, un type-checker vérifie que votre programme a bien le type annoncé. Si c’est oui, félicitation : vous avez démontré ϕ. Si c’est non : votre « preuve » est fausse, et vous devez la réparer. Ces programmes de vérification des types sont des assistants de preuve. Leur nom est très mal choisis parce que la plupart du temps ils vous empêchent de faire votre preuve en vous faisant remarquer tel ou tel défaut. Mais ils vous aident à faire des preuves correctes. Du coup, on pourrait se demander laquelle des deux à le plus de chances d’être « correcte » : une preuve écrite en français 3 et approuvée par un petit comité d’experts de la bonne branche des mathématiques ? Ou bien une preuve écrite dans un langage de programmation et vérifiée par un algorithme sans état d’âme ? En 2016, les assistants de preuve restent des outils difficiles à utiliser. Formaliser dedans des mathématiques même peu sophistiquées, par exemple le programme de Licence, pose des problèmes et nécessite un gros effort. Démontrer n’importe quoi dedans nécessite une formation de plusieurs mois. En plus, la quantité de travail humain nécessaire pour faire accepter une preuve à un assistant de preuve est considérablement plus élevée que celle qui est nécessaire pour faire accepter une preuve du même théorème à un comité de mathématiciens. Malgré tout, en 2005, une équipe de recherche a réussi un exploit : ils ont réalisé une preuve complète du théorème des 4 couleurs dans un assistant de preuve. Ceci laisse finalement peu de doutes quand à la validité du théorème. Quelle est la différence avec la preuve de départ ? La preuve de départ consistait à exécuter un programme ad hoc, et à accepter l’idée que si ce programme renvoie « OK », alors le théorème est vrai. Le programme est assez compliqué (écrit à la main, en assembleur, manipule des graphes, des centaines de cas, etc.). Le problème c’était qu’il était difficile de vérifier si le programme est correct. Dans un assistant de preuve moderne, la partie critique est le type-checker, mais c’est un programme plus simple, et plus court. De mémoire, celui de coq fait 3000 lignes de OCaml. En plus, il n’est pas spécialisé pour chaque théorème : on peut le vérifier une fois pour toute, et après s’en servir pour démontrer tout et n’importe quoi. En plus, le fait qu’il soit générique et qu’il serve beaucoup augmente la probabilité qu’on trouve les bugs qu’il pourrait contenir. On peut donc dire que le développement de l’informatique a bousculé la notion de « vérité mathématique » de deux façons. D’une part en posant le problème des preuves générées par ordinateur, et non-vérifiables à la main. D’autre part, avec les systèmes de vérification de preuves, on peut se demander si une « vraie » preuve, ce n’est pas plutôt celle qui est vérifiée par ordinateur. Mais avant de rentrer dans les détails, il faut « réviser » les bases. 1 Logique propositionnelle Vous êtes familier avec la logique propositionnelle, c’est-à-dire où les variables sont booléennes (les seules valeurs possibles sont “vrai” ou “faux”). Le fameux problème SAT que vous avez sûrement étudié consiste à déterminer, étant donné un formule de la logique propositionnelle, une valuation qui la satisfait. Les SAT-solver sont des programmes efficaces, optimisés depuis des décennies pour résoudre ce problème. 2. Haskell Curry. Oui, oui. 3. Avec les formules habituelles : « les autres cas se traitent de la même façon », « il est évident que ... », « Le soin de vérifier ces détails est laissé au lecteur », etc. 2 Définition (Valuation) Si ϕ est une formule de la logique propositionnelle, une « valuation » de ϕ est simplement une affectation d’une valeur aux variables qui apparaissent dans ϕ. Quand on s’intéresse aux preuves mathématiques, on a plutôt besoin d’un moyen de tester si une formule est un théorème, c’est-à-dire si elle est vraie quelles que soient les valeurs des variables (on dit qu’elle est valide, et parle aussi de « tautologie »). Il y a un moyen facile de tester si une formule ϕ(x1 , . . . , xn ) est un théorème : il suffit d’essayer les 2n combinaisons des variables, et de vérifier si on obtient « Vrai » à chaque fois. Ceci demande clairement un temps exponentiel, et l’inconvénient c’est qu’on ne voit pas trop ce qui peut faire office de certificat. En fait, il est facile de montrer le que problème Non-Theorem, qui consiste à montrer qu’une formule de la logique propositionnelle n’est pas un théorème, est NP-complet (puisqu’il suffit d’exhiber une valuation qui la rend fausse). . Question 4: Si on a un SAT-solver efficace sous la main, comment peut-on s’en servir pour tester si des formules de la logique propositionnelle sont des théorèmes ? Les mathématiciens, eux, sont capables de produire des certificats que des formules logiques sont des théorèmes : ces certificats sont les preuves mathématiques. Ils sont d’ailleurs capables de le faire pour des formules qui appartiennent à une logique très riche : les variables peuvent être des ensembles, des entiers, des objets géométriques, des nombres réels, etc. A contrario, la logique propositionnelle a un faible pouvoir expressif. En effet, on ne peut pas s’en servir pour exprimer de grandes vérités mathématiques telles que « il existe une infinité de nombres premiers ». Pour cela, il faudrait au moins qu’on puisse avoir sous la main des variables entières, et pas seulement des variables booléennes. Les langages dédiés comme coq ont un système de types très riche, qui permet d’exprimer les mathématiques usuelles. Cependant, ce problème n’est pas fondamental pour comprendre la correspondance « Preuve ↔ Programme ». On va voir un système de preuve qui permet de « démontrer » des formules de la logique propositionnelle. 2 Calcul des séquents Les mathématiciens ne s’embêtent généralement pas à formaliser le système dans lequel ils essayent de démonter des théorèmes. Il y a des « règles de raisonnement » qui sont connues de tous, et dont l’application (correcte) donne des raisonnements valables. Ce flou artistique est adapté aux êtres humains, mais pas vraiment aux machines. Plusieurs systèmes de preuves formels ont été conçus au début du XXème siècle. Dans ces systèmes, les règles de raisonnement sont explicites, on doit les appliquer à la lettre, et on ne doit rien faire d’autre. Du coup ces systèmes sont bien adapté à un traitement automatisé (c’est d’ailleurs pour cela qu’ils avaient été conçu). Le calcul des séquents, conçu par Gentzen 4 est le plus pratique (pour des machines). Définition (Séquent) Un « séquent » est une expression de la forme : H1 , . . . , Hm ` C1 , . . . , Cn . Le séquent est composé de deux séquences (finies) de formules logiques, les hypothèses (à gauche) et les conclusions (à droite). Notation : on note généralement les formules par des lettres majuscules (A, B, C, . . . ), et les séquences de formules par des lettres grecques majuscules (∆, Λ, Π, . . . ). La “signification” intuitive du séquent est (H1 ∧ · · · ∧ Hm ) =⇒ (C1 ∨ · · · ∨ Cn ), c’est-à-dire que sous les hypothèses H1 , . . . , Hm au moins une des conclusions C1 , . . . , Cn est vraie. Le calcul des séquents est un ensemble de règles permettant de construire des preuves. 4. 1909–1945. Devenu nazi pendant les années 1930, il est mort dans un camp soviétique. 3 Définition (Règle) De manière très (trop) générale, une règle du calcul des séquents s’écrit : H1 ... H2 C Hm (R) Dans cette règle (qui s’appelle R, comme indiqué à droite), H1 , . . . , Hm et C sont des séquents. Les Hi sont les prémisses de la règle, tandis que C en est la conclusion. Si les prémisses sont valides, alors l’application de la règle garantit que la conclusion est valide. Les preuves dans le calcul des séquents sont des arbres dont les noeuds correspondent à l’application de règles. Les feuilles de cet arbre sont des règles sans prémisses, c’est-à-dire des règles de la forme : (R) C Il n’est pas très difficile de démontrer que si une formule de la logique proposistionnelle se retrouve à la racine d’une arbre de preuve bien formé dans le calcul des séquents, alors cette formule est un théorème (la réciproque est plus délicate, mais vraie aussi). 2.1 Calcul des séquents propositionnel Le calcul des séquents peut servir à « prouver » des formules dans la logique du premier ordre, qui est plus puissante que la logique propositionnelle, mais ça en nous concerne pas aujourd’hui. Le calcul des séquents propositionnel, lui, permet de prouver des formules de la logique propositionnelle. Il est formé des règles suivantes, qui se répartissent traditionnellement en trois groupes. Groupe identité. Ces règles font apparaı̂tre (Ax) ou disparaı̂tre (Cut) une formule A A`A (Ax) Γ ` ∆, A Γ, A ` ∆ Γ`∆ ( Cut) Groupe logique. Donne les règles correspondant aux connecteurs logiques. Le ET et le OU jouent des rôles symmétriques : Γ, A, B ` ∆ Γ, A ∧ B ` ∆ Γ ` A, ∆ Γ ` B, ∆ Γ ` A ∧ B, ∆ (∧ ` ) Γ, A ` ∆ Γ, B ` ∆ Γ, A ∨ B ` ∆ Γ ` A, B, ∆ Γ ` A ∨ B, ∆ (∨ ` ) (` ∧ ) (` ∨) La négation permet de faire passer des formules d’un côté ou de l’autre du séquent : Γ ` A, ∆ Γ, ¬A ` ∆ Γ, A ` ∆ Γ ` ¬A, ∆ (¬ `) (` ¬) On pourrait définir A ⇒ B par ¬A ∨ B, mais on peut aussi introduire directement des règles : Γ ` A, ∆ Γ, B ` ∆ Γ, A ⇒ B ` ∆ Γ, A ` B, ∆ Γ ` A ⇒ B, ∆ (⇒` ) (`⇒) . Question 5: Les règles de la négation sont probablement les plus étranges. Justifiez-les. Groupe structurel. Il s’agit de règle qui n’ont pas de contenu logique à proprement parler, mais qui sont là parce qu’on suppose que les différentes formules qui composent un séquent sont ordonnés. L’affaiblissement (Weakening) consiste à retirer des hypothèses, où ajouter des conclusions (« qui peut le plus peut le moins »), tandis que la contraction et la permutation dupliquent ou réarrangent des formules : 4 Γ`∆ Γ, A ` ∆ Γ`∆ Γ ` B, ∆ (W` ) Γ, A, A ` ∆ Γ, A ` ∆ Γ ` B, B∆ Γ ` B, ∆ (C` ) Γ, A, Π, B ` ∆ Γ, B, Π, A ` ∆ (` W) (` C) Γ ` A, ∆, B, Σ Γ ` B, ∆, A, Σ (P` ) (` P) Ces règles permettent de démontrer les formules valides de la logique propositionnelle (où les variables valent « vrai » ou « faux »), et où il n’y a pas de quantificateurs. 2.2 exemples De petits dessins valant mieux que de longs discours, voici deux « preuves » : (Ax) A`A ` ¬A, A ` A, ¬A ` A ∨ ¬A (Ax) A ` A (` W) A ` B, A (`⇒) (Ax) ` (A ⇒ B), A A`A (⇒` ) (A ⇒ B) ⇒ A ` A, A (`C ) (A ⇒ B) ⇒ A ` A (`⇒ ) ` ((A ⇒ B) ⇒ A) ⇒ A (` ¬ ) (` P ) (` ∨ ) En fait, les arbres de preuves sont très redondants : dans presque tous les cas, si on connaı̂t le dessous de la règle, alors on connaı̂t aussi le dessus. On pourrait donc retirer presque tout le contenu de l’arbre, sauf la conclusion, et on serait encore capable de reconstituer ce qui est parti : Ax `W `⇒ Ax ⇒` `⇒ ` (((a ⇒ b) ⇒ a) ⇒ a) Il y a cependant deux petites exceptions : — Dans (Cut), on ne connaı̂t pas A, qui est une formule arbitraire. — Dans (P`) et (`P) on ne connaı̂t pas la position de B. Donc, si on veut retirer tout ce qui ne sert à rien des arbres de preuve, il faut quand même y faire figurer les informations ci-dessus. Pour Cut il faut donner la formule A, et pour les permutations, il suffit d’ajouter un entier qui indique la position de B. Il faut noter qu’une même formule peut admettre des preuves différentes. Par exemple, la formule h i h i a ⇒ (b ∨ c) =⇒ (b ⇒ ¬a) ∧ ¬c ⇒ ¬a admet (au moins) deux preuves différentes : 5 Ax Ax Ax Ax Ax P1 ` ` P1 ¬` Ax ⇒` ¬` `¬ `¬ P1 ` P2 ` ` P2 ` P1 P1 ` Ax `∨ ⇒` `∨ P1 ` ⇒` Ax P2 ` ⇒` ∧` P1 ` `⇒ ∧` `⇒ `⇒ `⇒ En regardant ces arbres, on comprend qu’écrire une telle « preuve » en XML n’est vraiment pas fait pour les êtres humains... Cependant, écrire un programme qui prend en argument une formule de la logique propositionnelle ainsi qu’un et un arbre de preuve, et qui vérifie la correction de la « preuve » n’est pas horriblement difficile. . Question 6: Quel petit « détail » fait que ceci n’entraine pas automatiquement que le problème Theorem est dans la classe NP N 3 3.1 Corresponsance avec le système de type des langages ML Processus de typage. Pour expliquer le mystère révélé dans l’introduction, il nous faut comprendre comment les types sont calculés. Une des particularités des langages de la famille ML, comme Caml ou Haskell, est en effet un système de typage, inventé une première fois par J. Roger Hindley en 1969, une deuxième fois par Robin Milner en (1978). Il a pris le nom de système Hindley-Milner-Damas. Un algorithme efficace, l’algorithme W, permet de calculer les types sans aucune annotation de la part du programmeur. Mais en fait, ce qui nous intéresse, ce n’est pas tant comment les types sont calculés, mais comment on peut justifier le résultat. Si le typeur de Caml nous dit : « oui, cette expression est correctement typable, et son type est : α → int », il faudrait qu’il puisse fournir un certificat facile à vérifier. Conformément à toute la littérature sur le sujet, on va utiliser des idées et des notations standard. On écrit « e : T » pour dire que l’expression de Caml e possède le type T (on va écrire les types en majuscule). On appelle ça un « jugement de typage ». Le cas le plus simple est le cas de l’application d’une fonction à un argument : si f : F1 → F2 et a : F1 , alors (f a) : F2 . On peut résumer ça par une règle de typage : u : F1 → F2 uv : F2 v : F1 Le problème, avec cette formulation, c’est qu’il n’y a pas de moyen très clair de déterminer le type des variables. La solution habituelle consiste à dire que le typage a toujours lieu dans un environnement, qui fournit les réponses à ce genre de question. 6 Définition (Contexte de typage) Un contexte de typage Γ est un ensemble fini de liaisons x : F , où les variables x sont distinctes. Le domaine de Γ est l’ensemble des variables auxquelles un type est lié. On note Γ, ∆ l’union des deux contextes Γ et ∆ (dont les domaines doivent être disjoints). En particulier, on onte Γ, x : F l’union de Γ et de la liaison x : F . Définition (Jugement de typage) Un jugement de typage Γ ` u : F signifie que l’expression u possède le type F dans le contexte Γ, qui donne les types des variables libres de u. Les règles de typage sont : Γ, x : F ` x : F (Var) Γ ` u : F1 → F2 Γ ` v : F1 Γ ` uv : F2 (App) Γ, y : F1 ` u[x := y] : F2 (Abs) Γ ` (fun x → u) : F1 → F2 La règle (Abs) peut s’appliquer seulement si y n’appartient pas déjà au domaine de Γ, et si y n’est pas libre dans u (si une de ces conditions n’est pas respectée, il faut remplacer y par un autre nom de variable). Par exemple, reprenons le terme a ci-dessus, et écrivons sa dérivation de type : (Var) (Var) g : S → T, y : S ` g : S → T g : S → T, y : S ` y : S (App) g : S → T, y : S ` g y : T (Abs) g : S → T ` (fun x → g x) : S → T (Abs) ` (fun f → (fun x → f x)) : (S → T ) → S → T . Question 7: Écrivez la dérivation de type du terme s ci-dessus. 3.2 Relation avec les preuves en calcul des séquents. En fait, on peut transformer une dérivation du jugement de typage ` e : F en une preuve de la formule correspondant à F dans le calcul des séquents, en utilisant les transformations suivantes : Γ, x : F ` x : F (Var) F `F (Ax) (Ax) Γ ` u : F1 → F2 Γ ` v : F1 Γ ` uv : F2 Γ ` F1 F2 ` F2 (⇒`) Γ ` F1 ⇒ F2 Γ, F1 ⇒ F2 ` F2 ( Cut) Γ, Γ ` F2 ( C `) Γ ` F2 (App) Γ, y : F1 ` u[x := y] : F2 (Abs) Γ ` (fun x → u) : F1 → F2 Γ, F1 ` F2 Γ ` F1 ⇒ F2 (`⇒) . Question 8: Ecrivez une preuve de ` (A ⇒ B) ⇒ A ⇒ B en « traduisant » la dérivation du type de la fonction a. Par conséquent, si on arrive à trouver un programme Caml qui a le type correspondant à une formule F , on a tout simplement trouvé une preuve de F . 3.3 Limitations du système de type de ML On voit bien qu’avec ça on peut prouver des formules propositionnelles grace à Caml. Mais qu’en est-il des formules du premier ordre ? Le problème, c’est qu’on ne peut pas exprimer facilement de valeurs dans le système de type des langages ML. En effet, on dispose de constructeurs de types, comme list : c’est en quelque sorte une fonction qui prend un type et qui renvoie un autre type (étant donné le type int, ça renvoie le type int list). 7 Mais on ne dispose pas de constructeurs de types qui prenne en argument une valeur. Par exemple, on ne peut pas faire un type fixed_array qui prend en argument un entier (disons 5) et qui renvoie un type fixed_array(5) (le type des tableaux de taille 5). Des types qui dépendent de valeurs du langage sont appelés des types dépendants. Et le problème, c’est que d’une part il n’est plus possible de « deviner » automatiquement les types, comme dans Caml. Mais en plus, il n’est même pas facile (=décidable) de vérifier qu’ils sont corrects, une fois fournis par le programmeur ! Les « vrais » assistants de preuve (comme Coq) contiennent en fait un langage de programmation fonctionnel avec un système de types beaucoup plus sophistiqué que celui de Caml. Il y a une interface utilisateur qui aide le programmeur à écrire des programmes ayant le type donné, donc à prouver le théorème donné. 4 Application : un assistant de preuve jouet On a vu ci-dessus comment traiter la question de l’implication : une preuve de A → B est en fait une fonction qui prend en argument une preuve de A et produit une preuve de B. Et. Qu’en est-il de A ∧ B ? C’est facile : une preuve de A ∧ B est un objet du langage qui contient à la fois une preuve de A et une preuve de B. Ca peut donc être une paire : -- Ceci est du code Haskell data And a b = And (a,b) (* Ceci est du code OCaml *) type (’a,’b) et = And of (’a * ’b) Les fonctions qui produisent une preuve de A ∧ B à partir de preuves de A et B, ou qui « explosent » une preuve de A ∧ B sont très faciles : let and_intro x y = (And (x,y)) let and_elim_l (And (x,y)) = x let and_elim_r (And (x,y)) = y and_intro x y = (And (x,y)) and_elim_l (And (x,y)) = x and_elim_r (And (x,y)) = y Ou. Le cas de A ∨ B est plus difficile. Définir le type des preuves de « A ou B » est facile avec les « types-somme » : type (’a,’b) ou = | Or_left of ’a | Or_right of ’b data Ou a b = Or_left a | Or_right b Fabriquer des preuves de A ∨ B est facile : or_intro_l x = Or_left x or_intro_r x = Or_right x let or_intro_l x = Or_left x let or_intro_r x = Or_right x Ce qui est plus difficile, c’est de « consommer » une preuve de A ∨ B. L’idée générale est la suivante : « Si je peux transformer une preuve de A en preuve de C et que je peux transformer une preuve de B et preuve de C de B, alors je serai toujours capable de transformer une preuve de A ∨ B en preuve de C ». Cette idée s’incarne là-dedans : let or_elim f g = function | Or_left x -> f x | Or_right y -> g y or_elim f g (Or_left x) = f x or_elim f g (Or_right y) = g y Non. Enfin, la négation n’est pas très simple non plus. En gros dire que ¬A est vrai, c’est dire que A ne peut pas être vrai. Une preuve de ¬A est donc un programme qui prend une preuve de A et qui produit... une contradiction ! Une contradiction est une valeur de type bot : 8 type bot = | Bot data Bot = Bot L’idée c’est qu’étant donné une contradiction, on peut déduire n’importe quoi. Il nous faut donc une fonction qui prend en entrée la valeur spéciale Bot et qui peut produire une valeur... de n’importe quel type. Pour cela, on triche : let rec bot_elim Bot = bot_elim Bot bot_elim Bot = bot_elim Bot Ceci est en effet une fonction qui ne termine pas. Si on a un moyen de transformer une preuve de A en contradiction, alors on a une preuve de ¬A. Si on a une preuve de ¬A et une preuve de A, alors on en dérive une contradiction : type ’a neg = Neg of ’a -> Bot let neg_elim x (Neg f) = f x let neg_intro f = Neg f data Neg ’a = Neg (’a -> Bot) neg_elim x (Neg f) = f x neg_intro f = Neg f 9