Kernel : Lean 4 (WSL) natif — chaque cellule de code est du Lean 4 exécuté directement, sans intermédiaire Python.
Ce notebook complète Lean-16b qui présente le récit et les illustrations du Game of Life via un kernel Python. Ici, à l’inverse, l’étudiant vit Lean comme un REPL interactif : on définit la grille, la règle B3/S23, le moteur d’évolution, puis on démontre et certifie de petits faits — le tout dans des cellules Lean évaluées en place.
1. Pourquoi un kernel Lean natif ?
Le sous-titre de la série Conway est « source de vérité formelle ». Pourtant, jusqu’ici, le formalisme Lean était encapsulé derrière des appels Python (run_lake, run_lean_snippet) dans Lean-16b. L’étudiant n’interagissait jamais avec Lean directement.
Ce notebook corrige cette incohérence : on utilise le kernel lean4-wsl, qui exécute chaque cellule comme une commande Lean 4 dans un REPL. C’est la même expérience que les notebooks GameTheory-2b, 4b, 8b, 15b.
Vérifions que le kernel répond (deux diagnostics standard) :
Le Game of Life se joue sur le plan entier \(\mathbb{Z}^2\). Une cellule est une paire d’entiers (Int × Int). Une grille est la liste des cellules vivantes (l’ordre importe peu ; les cellules absentes sont mortes).
On choisit List (Int × Int) plutôt qu’un Finset car l’égalité de listes se réduit à une comparaison structurelle — les tactiques de décision (decide, native_decide) et le code natif la manipulent efficacement, sans le goulet Quot.lift des Finset.
-- Une grille = liste des cellules vivantes
abbrev Grid := List (Int × Int)
-- Les 8 voisins de Moore (déplacements du roi aux échecs) d'une cellule
def mooreNeighbors (p : Int × Int) : List (Int × Int) :=
[(p.1 - 1, p.2 - 1), (p.1 - 1, p.2), (p.1 - 1, p.2 + 1),
(p.1, p.2 - 1), (p.1, p.2 + 1),
(p.1 + 1, p.2 - 1), (p.1 + 1, p.2), (p.1 + 1, p.2 + 1)]
#eval mooreNeighbors (0, 0)
-- Une grille = liste des cellules vivantes
abbrevGrid:=List(Int×Int)
-- Les 8 voisins de Moore (déplacements du roi aux échecs) d'une cellule
Raw input{"cmd": "-- Une grille = liste des cellules vivantes\nabbrev Grid := List (Int \u00d7 Int)\n\n-- Les 8 voisins de Moore (d\u00e9placements du roi aux \u00e9checs) d'une cellule\ndef mooreNeighbors (p : Int \u00d7 Int) : List (Int \u00d7 Int) :=\n [(p.1 - 1, p.2 - 1), (p.1 - 1, p.2), (p.1 - 1, p.2 + 1),\n (p.1, p.2 - 1), (p.1, p.2 + 1),\n (p.1 + 1, p.2 - 1), (p.1 + 1, p.2), (p.1 + 1, p.2 + 1)]\n\n#eval mooreNeighbors (0, 0)\n", "env": 0}Raw output{"messages":
[{"severity": "info",
"pos": {"line": 10, "column": 0},
"endPos": {"line": 10, "column": 5},
"data":
"[(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]"}],
"env": 1}
Interprétation. La sortie #eval mooreNeighbors (0, 0) affiche les huit voisins de l’origine : la liste littérale dessine littéralement le carré 3x3 privé de son centre. Trois choix de représentation méritent une lecture :
Grille creuse (List (Int × Int)) : seules les cellules vivantes sont stockées. Une figure de 5 cellules sur un plan infini coûte 5 éléments, quand une matrice booléenne devrait choisir une borne, gérer un cadre, et interdire les coordonnées négatives. Ici le plan est \(\mathbb{Z}^2\) tout entier, sans clôture ni cas limite.
Voisinage comme donnée : les 8 déplacements de Moore sont une constante littérale, pas une double boucle calculée. La géométrie du voisinage est lisible dans le code ; la règle B3/S23 qui suivra n’aura qu’à consommer cette liste.
abbrev plutôt que def : le synonyme reste réductible — Lean peut toujours voir à travers Grid vers List (Int × Int) lors de l’unification, ce qui dispensera d’instances de coercion dans les exercices.
3. La règle B3/S23
Pour chaque cellule, on compte ses voisins vivants : - Birth (B3) : une cellule morte avec exactement 3 voisins vivants naît ; - Survival (S23) : une cellule vivante avec 2 ou 3 voisins vivants survit ; - sinon la cellule est morte à la génération suivante.
-- La cellule `p` est-elle vivante dans `g` ?
def isAlive (g : Grid) (p : Int × Int) : Bool := g.contains p
-- Nombre de voisins vivants de `p` dans `g`
def liveNeighborCount (g : Grid) (p : Int × Int) : Nat :=
(mooreNeighbors p).countP (isAlive g)
-- La règle B3/S23 : `p` doit-elle être vivante à la génération suivante ?
def aliveNext (g : Grid) (p : Int × Int) : Bool :=
let n := liveNeighborCount g p
if isAlive g p then n == 2 || n == 3 -- S23
else n == 3 -- B3
-- Un oscillateur simple : le clignoteur (blinker), 3 cellules en ligne
def blinker : Grid := [(0,0), (0,1), (0,2)]
-- La cellule centrale (0,1) a ses 2 voisins vivants => elle survit
#eval liveNeighborCount blinker (0, 1)
#eval aliveNext blinker (0, 1)
-- La cellule `p` est-elle vivante dans `g` ?
defisAlive(g:Grid)(p:Int×Int):Bool:=g.containsp
-- Nombre de voisins vivants de `p` dans `g`
defliveNeighborCount(g:Grid)(p:Int×Int):Nat:=
(mooreNeighborsp).countP(isAliveg)
-- La règle B3/S23 : `p` doit-elle être vivante à la génération suivante ?
defaliveNext(g:Grid)(p:Int×Int):Bool:=
letn:=liveNeighborCountgp
ifisAlivegpthenn==2||n==3-- S23
elsen==3-- B3
-- Un oscillateur simple : le clignoteur (blinker), 3 cellules en ligne
defblinker:Grid:=[(0,0),(0,1),(0,2)]
-- La cellule centrale (0,1) a ses 2 voisins vivants => elle survit
2
true
--% env 2
Raw input{"cmd": "-- La cellule `p` est-elle vivante dans `g` ?\ndef isAlive (g : Grid) (p : Int \u00d7 Int) : Bool := g.contains p\n\n-- Nombre de voisins vivants de `p` dans `g`\ndef liveNeighborCount (g : Grid) (p : Int \u00d7 Int) : Nat :=\n (mooreNeighbors p).countP (isAlive g)\n\n-- La r\u00e8gle B3/S23 : `p` doit-elle \u00eatre vivante \u00e0 la g\u00e9n\u00e9ration suivante ?\ndef aliveNext (g : Grid) (p : Int \u00d7 Int) : Bool :=\n let n := liveNeighborCount g p\n if isAlive g p then n == 2 || n == 3 -- S23\n else n == 3 -- B3\n\n-- Un oscillateur simple : le clignoteur (blinker), 3 cellules en ligne\ndef blinker : Grid := [(0,0), (0,1), (0,2)]\n\n-- La cellule centrale (0,1) a ses 2 voisins vivants => elle survit\n#eval liveNeighborCount blinker (0, 1)\n#eval aliveNext blinker (0, 1)\n", "env": 1}Raw output{"messages":
[{"severity": "info",
"pos": {"line": 18, "column": 0},
"endPos": {"line": 18, "column": 5},
"data": "2"},
{"severity": "info",
"pos": {"line": 19, "column": 0},
"endPos": {"line": 19, "column": 5},
"data": "true"}],
"env": 2}
Interprétation. Les deux #eval renvoient 2 puis true : le centre du clignoteur a deux voisins vivants, et il sera donc vivant à la génération suivante. La règle complète de Conway tient dans un seul if :
une cellule vivante survit si et seulement si elle a 2 ou 3 voisins (branche S23) ;
une cellule morte naît si et seulement si elle a exactement 3 voisins (branche B3).
Deux observations de méthode. D’abord, aliveNext est totale : quelle que soit la cellule, vivante ou morte, la fonction renvoie un booléen — il n’existe pas de « troisième état » laissé au hasard ; step pourra donc statuer sur chaque candidate sans cas caché. Ensuite, le calcul est composé : aliveNext appelle liveNeighborCount, qui appelle isAlive — chaque brique reste minuscule et testable isolément ; c’est cette granularité qui rendra possible, en section 8, de prouver des faits locaux comme blinker_center_has_two_neighbors par simple decide.
4. Le moteur d’évolution
Une étape (step) : on rassemble les cellules candidates (vivantes + leurs voisins), on garde celles qui satisfont aliveNext, et on déduplique. Puis evolve itère step.
Pourquoi les cellules mortes sont-elles candidates ? C’est l’insight algorithmique central du moteur. Une cellule vivante ne peut que survivre ou mourir : itérer la seule liste des vivantes suffirait à appliquer la règle S23. Mais la règle B3 fait naître une cellule morte lorsqu’elle compte exactement 3 voisins vivants. Or ces nouveaux-nés n’apparaissent dans aucune liste de cellules vivantes — il faut donc énumérer les voisins des cellules vivantes (mortes incluses) pour ne rater aucune naissance. Le moteur parcourt ainsi l’union « vivantes ∪ voisins », évalue aliveNext sur chacune, puis déduplique : une même cellule morte peut être voisine de plusieurs cellules vivantes et serait comptée deux fois sans cette étape.
-- Cellules candidates = vivantes + tous leurs voisins (mortes incluses)
def candidates (g : Grid) : List (Int × Int) :=
g ++ g.flatMap mooreNeighbors
-- Une génération de Game of Life
def step (g : Grid) : Grid :=
((candidates g).filter (aliveNext g)).eraseDups
-- Itération : `evolve g n` applique `n` fois la règle
def evolve (g : Grid) : Nat → Grid
| 0 => g
| n + 1 => step (evolve g n)
-- Le clignoteur bascule d'horizontal à vertical
#eval step blinker
#eval step (step blinker) -- retour à la ligne horizontale : période 2
-- Cellules candidates = vivantes + tous leurs voisins (mortes incluses)
defcandidates(g:Grid):List(Int×Int):=
g++g.flatMapmooreNeighbors
-- Une génération de Game of Life
defstep(g:Grid):Grid:=
((candidatesg).filter(aliveNextg)).eraseDups
-- Itération : `evolve g n` applique `n` fois la règle
defevolve(g:Grid):Nat→Grid
|0=>g
|n+1=>step(evolvegn)
-- Le clignoteur bascule d'horizontal à vertical
[(0,1),(-1,1),(1,1)]
[(0,1),(0,0),(0,2)]
--% env 3
Raw input{"cmd": "-- Cellules candidates = vivantes + tous leurs voisins (mortes incluses)\ndef candidates (g : Grid) : List (Int \u00d7 Int) :=\n g ++ g.flatMap mooreNeighbors\n\n-- Une g\u00e9n\u00e9ration de Game of Life\ndef step (g : Grid) : Grid :=\n ((candidates g).filter (aliveNext g)).eraseDups\n\n-- It\u00e9ration : `evolve g n` applique `n` fois la r\u00e8gle\ndef evolve (g : Grid) : Nat \u2192 Grid\n | 0 => g\n | n + 1 => step (evolve g n)\n\n-- Le clignoteur bascule d'horizontal \u00e0 vertical\n#eval step blinker\n#eval step (step blinker) -- retour \u00e0 la ligne horizontale : p\u00e9riode 2\n", "env": 2}Raw output{"messages":
[{"severity": "info",
"pos": {"line": 15, "column": 0},
"endPos": {"line": 15, "column": 5},
"data": "[(0, 1), (-1, 1), (1, 1)]"},
{"severity": "info",
"pos": {"line": 16, "column": 0},
"endPos": {"line": 16, "column": 5},
"data": "[(0, 1), (0, 0), (0, 2)]"}],
"env": 3}
Interprétation. La sortie #eval step blinker renvoie [(0, 1), (-1, 1), (1, 1)] : les trois cellules vivantes, d’abord alignées horizontalement sur la ligne r = 0, forment maintenant une verticale sur la colonne c = 1. Le second #eval step (step blinker) redonne [(0, 1), (0, 0), (0, 2)] — la ligne horizontale initiale. Le clignoteur pivote donc de 90° à chaque génération.
Pourquoi la cellule centrale (0, 1) survit-elle ? Elle a 2 voisins vivants (les deux extrémités du clignoteur) → règle S23 satisfaite. Les deux cellules (−1, 1) et (1, 1), mortes au départ, ont chacune 3 voisins vivants → règle B3 : elles naissent. Le moteur step filtre les candidates par aliveNext, et l’oscillation émerge de la seule application locale de B3/S23 — aucun état global n’est stocké.
5. Configurations stables (still-lifes)
Un still-life est une figure qui ne bouge pas : step g = g. Le bloc (carré 2×2) et la ruche (beehive) sont les exemples canoniques.
Interprétation.#eval step block renvoie [(0, 0), (0, 1), (1, 0), (1, 1)] — strictement identique au bloc de départ. Et #eval liveNeighborCount block (0, 0) donne 3. Chaque cellule du carré 2×2 compte les deux autres cellules de sa ligne/colonne (2) plus la diagonale opposée (1) = 3 voisins vivants. Comme elle est vivante et a 3 voisins, S23 la maintient en vie ; aucune cellule morte adjacente n’atteint le seuil B3 (leurs comptes restent à 2). Le bloc est donc un still-life : step g = g exactement, vérifié par l’exécution et non par une observation visuelle.
6. Oscillateurs
Un oscillateur de période \(p\) vérifie evolve g p = g. Le clignoteur est de période 2 ; le crapaud (toad) et le phare (beacon) aussi.
-- Crapeau (toad) : période 2
def toad : Grid := [(0,1), (0,2), (0,3), (1,0), (1,1), (1,2)]
-- Phare (beacon) : période 2
def beacon : Grid := [(0,0), (0,1), (1,0), (1,1), (2,2), (2,3), (3,2), (3,3)]
-- L'ensemble des cellules est préservé après 2 pas (set-égalité)
def sameSet (a b : Grid) : Bool := a.all (fun p => b.contains p) && b.all (fun p => a.contains p)
#eval sameSet (evolve blinker 2) blinker
#eval sameSet (evolve toad 2) toad
#eval sameSet (evolve beacon 2) beacon
Interprétation. Les trois #eval sameSet (evolve _ 2) _ renvoient tous true : clignoteur, crapaud et phare retrouvent leur configuration initiale après 2 générations. C’est la définition même d’un oscillateur de période 2 — evolve g 2 = g (à l’ordre des cellules près, d’où le sameSet qui teste l’égalité ensembliste plutôt que l’ordre de liste).
La nuance sameSet vs = est importante : step réordonne la grille (via filter + eraseDups), donc l’égalité structurelle = échouerait même sur un still-life parfait. sameSet neutralise cet artefact d’implémentation et capture la propriété sémantique attendue — la stabilité du patron de cellules, pas de leur ordre dans la liste.
7. Vaisseau (glider)
Un vaisseau (spaceship) se déplace. Le planeur (glider), découvert par Richard Guy en 1970, parcourt la diagonale : après 4 générations il est translaté de (1, 1).
-- Planeur : 5 cellules, se déplace en diagonale
def glider : Grid := [(0,1), (1,2), (2,0), (2,1), (2,2)]
-- Translation d'une grille par (dr, dc)
def translate (dr dc : Int) (g : Grid) : Grid :=
g.map (fun (r, c) => (r + dr, c + dc))
#eval sameSet (evolve glider 4) (translate 1 1 glider) -- le planeur avance
-- Planeur : 5 cellules, se déplace en diagonale
defglider:Grid:=[(0,1),(1,2),(2,0),(2,1),(2,2)]
-- Translation d'une grille par (dr, dc)
deftranslate(drdc:Int)(g:Grid):Grid:=
g.map(fun(r,c)=>(r+dr,c+dc))
true
--% env 6
Raw input{"cmd": "-- Planeur : 5 cellules, se d\u00e9place en diagonale\ndef glider : Grid := [(0,1), (1,2), (2,0), (2,1), (2,2)]\n\n-- Translation d'une grille par (dr, dc)\ndef translate (dr dc : Int) (g : Grid) : Grid :=\n g.map (fun (r, c) => (r + dr, c + dc))\n\n#eval sameSet (evolve glider 4) (translate 1 1 glider) -- le planeur avance\n", "env": 5}Raw output{"messages":
[{"severity": "info",
"pos": {"line": 8, "column": 0},
"endPos": {"line": 8, "column": 5},
"data": "true"}],
"env": 6}
Interprétation.#eval sameSet (evolve glider 4) (translate 1 1 glider) renvoie true : après 4 générations, le planeur coïncide (ensemblistement) avec sa propre translation de vecteur (1, 1). Il s’est donc déplacé d’une case vers le sud-est. Contrairement aux oscillateurs — qui reviennent à leur point de départ — un vaisseau ne revient jamais à sa position initiale : il revient à sa forme translatée. C’est ce que capture la comparaison evolve glider 4 contre translate 1 1 glider plutôt que contre glider lui-même.
Cette translation diagonale est la signature du planeur : 4 pas pour avancer d’une case, soit une « vitesse » de c/4 diagonale (le maximum pour un vaisseau de cette taille dans le Game of Life). C’est ce motif que les calculateurs à planeurs exploitent pour faire circuler de l’information.
8. Petits faits certifiés
L’avantage d’un noyau formel : on prouve, pas seulement on observe. decide demande à Lean si un énoncé décidable clos est vrai ; native_decide fait de même via le code natif (plus rapide sur des domaines plus grands).
-- Le centre du clignoteur a bien 2 voisins vivants
theorem blinker_center_has_two_neighbors :
liveNeighborCount blinker (0, 1) = 2 := by decide
-- Une cellule vivante du bloc a 3 voisins => elle survit (S23)
theorem block_cell_survives :
aliveNext block (0, 0) = true := by decide
-- Une cellule isolée n'a aucun voisin => elle meurt (0 ≠ 2, 0 ≠ 3)
theorem lonely_cell_dies :
aliveNext [(5, 5)] (5, 5) = false := by decide
-- Le clignoteur est un oscillateur de période 2 (set-égalité, native_decide)
theorem blinker_period_two :
sameSet (evolve blinker 2) blinker = true := by native_decide
-- Le centre du clignoteur a bien 2 voisins vivants
theoremblinker_center_has_two_neighbors:
liveNeighborCountblinker(0,1)=2:=bydecide
-- Une cellule vivante du bloc a 3 voisins => elle survit (S23)
theoremblock_cell_survives:
aliveNextblock(0,0)=true:=bydecide
-- Une cellule isolée n'a aucun voisin => elle meurt (0 ≠ 2, 0 ≠ 3)
theoremlonely_cell_dies:
aliveNext[(5,5)](5,5)=false:=bydecide
-- Le clignoteur est un oscillateur de période 2 (set-égalité, native_decide)
Raw input{"cmd": "-- Le centre du clignoteur a bien 2 voisins vivants\ntheorem blinker_center_has_two_neighbors :\n liveNeighborCount blinker (0, 1) = 2 := by decide\n\n-- Une cellule vivante du bloc a 3 voisins => elle survit (S23)\ntheorem block_cell_survives :\n aliveNext block (0, 0) = true := by decide\n\n-- Une cellule isol\u00e9e n'a aucun voisin => elle meurt (0 \u2260 2, 0 \u2260 3)\ntheorem lonely_cell_dies :\n aliveNext [(5, 5)] (5, 5) = false := by decide\n\n-- Le clignoteur est un oscillateur de p\u00e9riode 2 (set-\u00e9galit\u00e9, native_decide)\ntheorem blinker_period_two :\n sameSet (evolve blinker 2) blinker = true := by native_decide\n", "env": 6}Raw output{"env": 7}
Interprétation. Quatre faits, quatre verdicts du noyau : le centre du clignoteur a bien 2 voisins (decide évalue la définition), la cellule du bloc survit (S23), la cellule isolée meurt (0 voisins, ni 2 ni 3), et le clignoteur revient à son ensemble initial après 2 pas (native_decide, car l’évaluation littérale de deux step imbriqués dépasse le budget de decide).
La lecture pédagogique : le même moteur qui exécute prouve. Aucun de ces théorèmes n’est long, mais chacun transforme une observation expérimentale (#eval renvoie true) en énoncé vérifié pour toutes les exécutions — la valeur de aliveNext [(5,5)] (5,5) est falsepar construction mathématique, pas par chance d’un tirage. C’est le seuil de différence entre simuler le Game of Life et le formaliser. La section suivante interroge ce que ces preuves ne couvrent pas — et le rend visible.
9. La frontière du prouvé : transparence
Les théorèmes ci-dessus ne dépendent d’aucun axiome caché — en particulier pas de sorry. Vérifions-le :
Pourquoi cela compte-t-il ? En Lean, une tactique comme decide ou native_decide ferme un but en évaluant un terme, mais la confiance que l’on accorde au résultat repose sur ce que le noyau accepte. La commande #print axioms est l’ancre de confiance : elle liste tous les axiomes dont un théorème dépend transitivement. Si elle renvoie une liste vide, le théorème est auto-contenu — prouvé à partir des seules règles du calcul des constructions. Une preuve qui admettrait un axiome ad hoc (ou un sorry masqué) apparaîtrait ici. C’est précisément ce qui distingue une formalisation (le noyau certifie, tous les cas couverts) d’un test (on a vérifié un échantillon d’entrées) : le test peut rater un contre-exemple, la formalization ne le peut pas.
Raw input{"cmd": "#print axioms blinker_center_has_two_neighbors\n#print axioms blinker_period_two\n", "env": 7}Raw output{"messages":
[{"severity": "info",
"pos": {"line": 1, "column": 0},
"endPos": {"line": 1, "column": 6},
"data": "'blinker_center_has_two_neighbors' does not depend on any axioms"},
{"severity": "info",
"pos": {"line": 2, "column": 0},
"endPos": {"line": 2, "column": 6},
"data":
"'blinker_period_two' depends on axioms: [blinker_period_two._native.native_decide.ax_1]"}],
"env": 8}
Les théorèmes ci-dessus ne sont pas une formalisation complète du Game of Life : ils certifient des faits locaux (comptages, survie) sur des petites grilles. La formalisation complète — avec l’optimisation Hashlife de Gosper (1984), le découpage en macro-cellules, et la preuve que hashlifeJump simule correctement \(2^k\) générations — vit dans le projet conway_lean/ du dépôt (modules Conway.Life.Hashlife, Conway.Life.HashlifeCorrectness).
Frontière assumée : HashlifeCorrectness.lean contient encore des lemmes admis (sorry), sur la partie la plus subtile du résultat (composition inductive des macro-cellules). C’est la limite actuelle, ouvertement documentée, du travail de formalisation. Ce notebook se concentre sur ce qui est prouvé de bout en bout.
10. Exercices
Trois exercices pour manipuler directement le moteur. Chaque cellule ci-dessous compile et s’exécute dans l’état (corps de substitution) ; à vous de remplacer la partie signalée -- TODO.
Exercice 1 — Compter sans countP
Réécrivez liveNeighborCount à l’aide d’un foldl sur la liste des voisins, sans utiliser countP. Indice : accumuler un Nat qui s’incrémente de 1 pour chaque voisin vivant.
-- Exercice 1 : compter les voisins vivants de `p` dans `g` via foldl
-- TODO étudiant : remplacer le corps ci-dessous
def liveNeighborCountFold (g : Grid) (p : Int × Int) : Nat :=
0
-- Doit donner 2 pour le centre du clignoteur
#eval liveNeighborCountFold blinker (0, 1)
-- Exercice 1 : compter les voisins vivants de `p` dans `g` via foldl
Raw input{"cmd": "-- Exercice 1 : compter les voisins vivants de `p` dans `g` via foldl\n-- TODO \u00e9tudiant : remplacer le corps ci-dessous\ndef liveNeighborCountFold (g : Grid) (p : Int \u00d7 Int) : Nat :=\n 0\n\n-- Doit donner 2 pour le centre du clignoteur\n#eval liveNeighborCountFold blinker (0, 1)\n", "env": 8}Raw output{"messages":
[{"severity": "warning",
"pos": {"line": 3, "column": 27},
"endPos": {"line": 3, "column": 28},
"data":
"unused variable `g`\n\nNote: This linter can be disabled with `set_option linter.unusedVariables false`"},
{"severity": "warning",
"pos": {"line": 3, "column": 38},
"endPos": {"line": 3, "column": 39},
"data":
"unused variable `p`\n\nNote: This linter can be disabled with `set_option linter.unusedVariables false`"},
{"severity": "info",
"pos": {"line": 7, "column": 0},
"endPos": {"line": 7, "column": 5},
"data": "0"}],
"env": 9}
Attendu et anti-piège. Après correction, le #eval doit renvoyer 2. Deux pièges classiques : (1) tester isAlive g p au lieu de isAlive g q sur l’élément courant q du foldl — on compte alors la cellule elle-même si elle est vivante ; (2) partir d’un accumulateur initialisé à 1. La signature imposée (g : Grid) (p : Int × Int) : Nat vous interdit de rendre le foldl sur g : c’est bien la liste mooreNeighbors p qu’il faut plier.
Exercice 2 — Prouver un fait local
Démontrez que la cellule morte (1, 1) naît bien à la génération suivante du clignoteur (elle a 3 voisins vivants). Remplacez sorry par une tactique. Indice : decide ou native_decide.
-- Exercice 2 : (1,1) est morte dans blinker, a 3 voisins vivants => naît
-- TODO étudiant : remplacer sorry
theorem blinker_birth_at_11 :
aliveNext blinker (1, 1) = true := by sorry
-- Exercice 2 : (1,1) est morte dans blinker, a 3 voisins vivants => naît
Attendu et anti-piège. L’énoncé parle d’une naissance — mais le théorème demandé porte sur aliveNext, pas sur un constructeur « naît ». Avec decide, Lean évalue aliveNext blinker (1,1) littéralement : la cellule est morte dans blinker, elle a 3 voisins vivants, la branche B3 s’applique. Si vous remplacez par native_decide, vérifiez que le #eval de l’exercice 1 ci-dessus est déjà corrigé : la preuve utilise vos définitions, pas celles d’un voisin.
Exercice 3 — Un nouvel oscillateur
Définissez le pulsar miniature ci-dessous et vérifiez qu’il est de période 2 (set-égalité avec sameSet). Indice : inspirez-vous des définitions de blinker/toad.
-- Exercice 3 : définir un oscillateur et vérifier sa période
-- TODO étudiant : remplacer le corps ci-dessous par une vraie figure
def myOscillator : Grid := [(0, 0)]
#eval sameSet (evolve myOscillator 2) myOscillator
-- Exercice 3 : définir un oscillateur et vérifier sa période
-- TODO étudiant : remplacer le corps ci-dessous par une vraie figure
defmyOscillator:Grid:=[(0,0)]
false
--% env 11
Raw input{"cmd": "-- Exercice 3 : d\u00e9finir un oscillateur et v\u00e9rifier sa p\u00e9riode\n-- TODO \u00e9tudiant : remplacer le corps ci-dessous par une vraie figure\ndef myOscillator : Grid := [(0, 0)]\n\n#eval sameSet (evolve myOscillator 2) myOscillator\n", "env": 10}Raw output{"messages":
[{"severity": "info",
"pos": {"line": 5, "column": 0},
"endPos": {"line": 5, "column": 5},
"data": "false"}],
"env": 11}
Attendu et anti-piège. Le corps de substitution [(0, 0)] rend false (une cellule isolée meurt au premier pas) : votre figure doit être un vrai oscillateur. Le pulsar miniature (période 3 !) ne satisfera pas evolve _ 2 — testez d’abord avec #eval sameSet (evolve myOscillator 2) myOscillator sur le toad de la section 6 pour valider votre méthode, puis construisez la figure demandée. Anti-piège : sameSet compare des ensembles ; garder des doublons dans votre liste ne changera pas le verdict — mais faussera votre lecture du nombre de cellules.
Conclusion
Sur le kernel Lean natif, l’étudiant a : (1) défini le Game of Life (grille, voisinage, règle B3/S23, moteur step/evolve) en pur Lean 4 ; (2) observé les motifs canoniques (bloc, clignoteur, planeur) via #eval ; (3) certifié des faits locaux par decide/native_decide sans axiome sorry.
Pour la formalisation complète (Hashlife, macro-cellules, universalité), voir le projet conway_lean/ et le notebook compagnon Lean-16b. La frontière du prouvé y est assumée : des lemmes de HashlifeCorrectness restent admis, et font l’objet d’un travail de preuve en cours.
Suite logique : Lean-13 Kochen-Specker — un autre résultat de Conway (le théorème du libre arbitre), cette fois-ci entièrement prouvé (sans sorry).