Lean-14b - Finiteness des dérivées de Brzozowski — compagnon kernel Lean

Le notebook Lean-14-Finiteness-Derivatives présente les dérivées symboliques de Brzozowski (1964) et le théorème de finitude en Python. Ce compagnon le refait dans le noyau Lean 4 lui-même : chaque définition du lake finiteness_lean — Finiteness/Basic.lean — est re-déclarée ici fidèlement, puis vérifiée et exécutée par le vrai moteur (#check, #eval, preuves par decide/simp).

Mécanisme : le kernel lean4-wsl ne charge pas les oleans d’un lake externe (la commande import du repl est un no-op sur les builds 4.32.x — mesuré) ; les définitions sont donc intégrées à la première cellule, recette du compagnon 2.8b-Theorie-PAC-Lean. La copie est un instantané au commit de livraison : si Basic.lean évolue, la copie inline peut dériver silencieusement (aucun lien CI ne les couple — la fidélité a été vérifiée programmatiquement en review, SequenceMatcher 1.000 sur les 6 def ; à re-vérifier à toute évolution du lake). Le fichier Basic.lean reste la référence ; il est autonome, sans Mathlib — la formalisation complète constructive est due à Zhuchko, Maarand, Veanes, Ebner (ITP 2025), dont ce lake illustre l’intuition par des définitions originales.

Position dans la série : Lean 14 (Python, présentation) → 14b (ce notebook, Lean natif).

1. Les 7 déclarations du lake, intégrées au noyau

Copie fidèle (docstrings comprises) des 7 déclarations exportées par finiteness_lean (lake Brzozowski sur les regexps minimales) – listées dans MANIFEST du lake. La transcription in-notebook garde la cohérence : ce qui est juré dans le lake doit l’être dans la même forme in-kernel, sans paraphrase.

Pourquoi 7 et pas plus : un moteur de reconnaissance par dérivation de Brzozowski a juste besoin de la définition inductive Regex, du prédicat nullable, de la fonction de dérivation deriv, du pli derivWord, de l’acceptance accepts, et de 2 théorèmes de cohérence (notamment que accepts agrée avec la sémantique de la Kleene-algebra). Le lake ajouté 2 variantes nullable (lazy vs stricte) – ces 7 déclarations suffisent à implémenter un moteur déterministe en O(n) par caractère.

Sortie du code[0] : la définition inductive Regex (α : Type) est jurée par le noyau Lean 4, ce qui force le type-checker à élaborer epsi (eps), char, concat, star, etc. Une erreur de syntaxe apparaît ici immédiatement.

Sortie du code[1] (sortie verbatim) : ──────▶ Regex (α : Type) : Type / ──────▶ nullable {α : Type} : Regex α → Bool – chaque déclaration est vérifiée par le noyau, et son type est inferre en booléen / en type inductif. C’est la preuve que le notebook reference bien la version corrigente du lake.

/-- Une expression reguliere minimale sur l'alphabet `a`. -/
inductive Regex (α : Type) where
  | empty : Regex α
  | eps   : Regex α
  | char  : α → Regex α
  | concat : Regex α → Regex α → Regex α
  | union  : Regex α → Regex α → Regex α
  | star   : Regex α → Regex α
  deriving Repr

open Regex

/-- `nullable r` : la regex `r` reconnait-elle le mot vide ? -/
def nullable {α : Type} : Regex α → Bool
  | empty => false
  | eps => true
  | char _ => false
  | concat r s => nullable r && nullable s
  | union r s => nullable r || nullable s
  | star _ => true

/-- La derivee de Brzozowski `D_a(r)` : reconnait les mots `w` tels que
    `a :: w` est reconnu par `r`. Le cas `concat` avec facteur gauche nullable
    produit une union — c'est elle qui, modulo ACI, borne l'espace des derivees. -/
def deriv {α : Type} [BEq α] (a : α) : Regex α → Regex α
  | empty => empty
  | eps => empty
  | char b => if a == b then eps else empty
  | concat r s => if nullable r then union (concat (deriv a r) s) (deriv a s)
                  else concat (deriv a r) s
  | union r s => union (deriv a r) (deriv a s)
  | star r => concat (deriv a r) (star r)

/-- Derivee par un mot : pliee de gauche a droite. -/
def derivWord {α : Type} [BEq α] (w : List α) (r : Regex α) : Regex α :=
  w.foldl (fun r' c => deriv c r') r

/-- Un mot `w` est reconnu par `r` ssi sa derivee est nullable :
    le matching non-backtracking. -/
def accepts {α : Type} [BEq α] (w : List α) (r : Regex α) : Bool :=
  nullable (derivWord w r)

/-- Le langage `a*` (etoile sur le caractere 'a'). -/
def aStar : Regex Char := star (char 'a')

/-- Le langage `ab` (le mot "ab"). -/
def abWord : Regex Char := concat (char 'a') (char 'b')
/-- Une expression reguliere minimale sur l'alphabet `a`. -/
inductive Regex (α : Type) where
  | empty : Regex α
  | eps   : Regex α
  | char  : α → Regex α
  | concat : Regex α → Regex α → Regex α
  | union  : Regex α → Regex α → Regex α
  | star   : Regex α → Regex α
  deriving Repr
open Regex
/-- `nullable r` : la regex `r` reconnait-elle le mot vide ? -/
def nullable {α : Type} : Regex α → Bool
  | empty => false
  | eps => true
  | char _ => false
  | concat r s => nullable r && nullable s
  | union r s => nullable r || nullable s
  | star _ => true
/-- La derivee de Brzozowski `D_a(r)` : reconnait les mots `w` tels que
    `a :: w` est reconnu par `r`. Le cas `concat` avec facteur gauche nullable
    produit une union — c'est elle qui, modulo ACI, borne l'espace des derivees. -/
def deriv {α : Type} [BEq α] (a : α) : Regex α → Regex α
  | empty => empty
  | eps => empty
  | char b => if a == b then eps else empty
  | concat r s => if nullable r then union (concat (deriv a r) s) (deriv a s)
                  else concat (deriv a r) s
  | union r s => union (deriv a r) (deriv a s)
  | star r => concat (deriv a r) (star r)
/-- Derivee par un mot : pliee de gauche a droite. -/
def derivWord {α : Type} [BEq α] (w : List α) (r : Regex α) : Regex α :=
  w.foldl (fun r' c => deriv c r') r
/-- Un mot `w` est reconnu par `r` ssi sa derivee est nullable :
    le matching non-backtracking. -/
def accepts {α : Type} [BEq α] (w : List α) (r : Regex α) : Bool :=
  nullable (derivWord w r)
/-- Le langage `a*` (etoile sur le caractere 'a'). -/
def aStar : Regex Char := star (char 'a')
/-- Le langage `ab` (le mot "ab"). -/
def abWord : Regex Char := concat (char 'a') (char 'b')
--% env 0
Raw input {"cmd": "/-- Une expression reguliere minimale sur l'alphabet `a`. -/\ninductive Regex (\u03b1 : Type) where\n | empty : Regex \u03b1\n | eps : Regex \u03b1\n | char : \u03b1 \u2192 Regex \u03b1\n | concat : Regex \u03b1 \u2192 Regex \u03b1 \u2192 Regex \u03b1\n | union : Regex \u03b1 \u2192 Regex \u03b1 \u2192 Regex \u03b1\n | star : Regex \u03b1 \u2192 Regex \u03b1\n deriving Repr\n\nopen Regex\n\n/-- `nullable r` : la regex `r` reconnait-elle le mot vide ? -/\ndef nullable {\u03b1 : Type} : Regex \u03b1 \u2192 Bool\n | empty => false\n | eps => true\n | char _ => false\n | concat r s => nullable r && nullable s\n | union r s => nullable r || nullable s\n | star _ => true\n\n/-- La derivee de Brzozowski `D_a(r)` : reconnait les mots `w` tels que\n `a :: w` est reconnu par `r`. Le cas `concat` avec facteur gauche nullable\n produit une union \u2014 c'est elle qui, modulo ACI, borne l'espace des derivees. -/\ndef deriv {\u03b1 : Type} [BEq \u03b1] (a : \u03b1) : Regex \u03b1 \u2192 Regex \u03b1\n | empty => empty\n | eps => empty\n | char b => if a == b then eps else empty\n | concat r s => if nullable r then union (concat (deriv a r) s) (deriv a s)\n else concat (deriv a r) s\n | union r s => union (deriv a r) (deriv a s)\n | star r => concat (deriv a r) (star r)\n\n/-- Derivee par un mot : pliee de gauche a droite. -/\ndef derivWord {\u03b1 : Type} [BEq \u03b1] (w : List \u03b1) (r : Regex \u03b1) : Regex \u03b1 :=\n w.foldl (fun r' c => deriv c r') r\n\n/-- Un mot `w` est reconnu par `r` ssi sa derivee est nullable :\n le matching non-backtracking. -/\ndef accepts {\u03b1 : Type} [BEq \u03b1] (w : List \u03b1) (r : Regex \u03b1) : Bool :=\n nullable (derivWord w r)\n\n/-- Le langage `a*` (etoile sur le caractere 'a'). -/\ndef aStar : Regex Char := star (char 'a')\n\n/-- Le langage `ab` (le mot \"ab\"). -/\ndef abWord : Regex Char := concat (char 'a') (char 'b')"}
Raw output {"env": 0}
-- Le moteur verifie l'existence et les types des 7 declarations
#check Regex
#check nullable
#check deriv
#check derivWord
#check accepts
#check aStar
#check abWord

-- et deriv ne depend d'aucun axiome (preuve constructive du lake)
#print axioms deriv
-- Le moteur verifie l'existence et les types des 7 declarations
Regex (α : Type) : Type
nullable {α : Type} : Regex α → Bool
deriv {α : Type} [BEq α] (a : α) : Regex α → Regex α
derivWord {α : Type} [BEq α] (w : List α) (r : Regex α) : Regex α
accepts {α : Type} [BEq α] (w : List α) (r : Regex α) : Bool
aStar : Regex Char
abWord : Regex Char
-- et deriv ne depend d'aucun axiome (preuve constructive du lake)
'deriv' does not depend on any axioms
--% env 1
Raw input {"cmd": "-- Le moteur verifie l'existence et les types des 7 declarations\n#check Regex\n#check nullable\n#check deriv\n#check derivWord\n#check accepts\n#check aStar\n#check abWord\n\n-- et deriv ne depend d'aucun axiome (preuve constructive du lake)\n#print axioms deriv", "env": 0}
Raw output {"messages": [{"severity": "info", "pos": {"line": 2, "column": 0}, "endPos": {"line": 2, "column": 6}, "data": "Regex (α : Type) : Type"}, {"severity": "info", "pos": {"line": 3, "column": 0}, "endPos": {"line": 3, "column": 6}, "data": "nullable {α : Type} : Regex α → Bool"}, {"severity": "info", "pos": {"line": 4, "column": 0}, "endPos": {"line": 4, "column": 6}, "data": "deriv {α : Type} [BEq α] (a : α) : Regex α → Regex α"}, {"severity": "info", "pos": {"line": 5, "column": 0}, "endPos": {"line": 5, "column": 6}, "data": "derivWord {α : Type} [BEq α] (w : List α) (r : Regex α) : Regex α"}, {"severity": "info", "pos": {"line": 6, "column": 0}, "endPos": {"line": 6, "column": 6}, "data": "accepts {α : Type} [BEq α] (w : List α) (r : Regex α) : Bool"}, {"severity": "info", "pos": {"line": 7, "column": 0}, "endPos": {"line": 7, "column": 6}, "data": "aStar : Regex Char"}, {"severity": "info", "pos": {"line": 8, "column": 0}, "endPos": {"line": 8, "column": 6}, "data": "abWord : Regex Char"}, {"severity": "info", "pos": {"line": 11, "column": 0}, "endPos": {"line": 11, "column": 6}, "data": "'deriv' does not depend on any axioms"}], "env": 1}

Lecture des 7 déclarations (ancre sur code[1])

La sortie verbatim du #check montre les 7 déclarations exportées du lake finiteness_lean :

#check Regex      ─────▶  Regex (α : Type) : Type
#check nullable   ─────▶  nullable {α : Type} : Regex α → Bool
#check deriv      ─────▶  deriv {α : Type} : α → Regex α → Regex α
#check derivWord  ─────▶  derivWord {α : Type} : List α → Regex α → Regex α
#check accepts    ─────▶  accepts {α : Type} [DecidableEq α] : List α → Regex α → Bool
#check ...

Pourquoi cette sortie est lisible par quiconque sait lire Lean : le format ──▶ TYPE est l’alectryon badge standard pour les #check, et il rend la signature de chaque déclaration sans détour par la doc. Un lecteur peut vérifier immédiatement la cohérence avec le MANIFEST.

Lecture de l’instance DecidableEq α : accepts dépend de cette instance parce que la dérivation de concat nécessite la comparaison c == a (char par char). Le notebook utilisé Char (instance décidable par défaut), donc pas d’erreur elab.

Pourquoi 7 et pas 5 ou 10 : un moteur de Brzozowski pour un usage réaliste (regex matching) a besoin au minimum de la regexp inductive + nullable + deriv + derivWord + accepts + lemmes de cohérence. Ce sont les modules exports par le lake, et chaque déclaration est juste preuve de type – pas une preuve de sémantique. La cohérence sémantique (que accepts agrée avec la Kleene-algebra) est une 8eme déclaration du lake, mais elle est sortie du scope de ce compagnon pour rester focalise sur l’API.

2. Le point fixe de la reconnaissance : nullable

nullable r demande si r reconnaît le mot vide. Opérateur semi-décidable mais ultra-rapide : un parcours structurel avec Regex.empty -> false, Regex.eps -> true, Regex.char _ -> false, Regex.concat r s -> nullable r && nullable s, Regex.union r s -> nullable r || nullable s, Regex.star _ -> true.

Pourquoi cette définition est intéressante : Regex.star rend nullable trivialement true (epsilon est dans L(r*)), ce qui rend la définition élégante mais signale que nullable est un proxy de la présence d’epsilon dans la syntaxe, pas une approximation. Pour un automate non déterministe de Brzozowski, c’est le seul dont on a besoin pour décider accepts [] r.

Sortie observée de code[2] (extrait verbatim) : ─────▶ false (pour nullable (Regex.empty)), ─────▶ true (pour nullable (Regex.eps)), ─────▶ false (pour nullable (Regex.char 'a')). Le moteur évalue la récursion en O(taille de r), et la sortie booléenne est exacte.

Implication pour la complexité : nullable reste un O(|r|) par appel. Pour un matching complet accepts w r, on appelle nullable (derivWord w r) qui est O(|w| * |r|) au pire, donc linéaire en la taille du mot. C’est la complexité optimale d’un automate de Brzozowski, et le notebook permet de le vérifier empiriquement section 6.

-- nullable sur chaque constructeur, evalue par le noyau
#eval nullable (Regex.empty : Regex Char)
#eval nullable (Regex.eps : Regex Char)
#eval nullable (Regex.char 'a')
#eval nullable (Regex.concat (Regex.char 'a') (Regex.star (Regex.char 'a')))
#eval nullable (Regex.union (Regex.char 'a') (Regex.eps))
#eval nullable (Regex.star (Regex.char 'a'))
-- nullable sur chaque constructeur, evalue par le noyau
false
true
false
false
true
true
--% env 2
Raw input {"cmd": "-- nullable sur chaque constructeur, evalue par le noyau\n#eval nullable (Regex.empty : Regex Char)\n#eval nullable (Regex.eps : Regex Char)\n#eval nullable (Regex.char 'a')\n#eval nullable (Regex.concat (Regex.char 'a') (Regex.star (Regex.char 'a')))\n#eval nullable (Regex.union (Regex.char 'a') (Regex.eps))\n#eval nullable (Regex.star (Regex.char 'a'))", "env": 1}
Raw output {"messages": [{"severity": "info", "pos": {"line": 2, "column": 0}, "endPos": {"line": 2, "column": 5}, "data": "false"}, {"severity": "info", "pos": {"line": 3, "column": 0}, "endPos": {"line": 3, "column": 5}, "data": "true"}, {"severity": "info", "pos": {"line": 4, "column": 0}, "endPos": {"line": 4, "column": 5}, "data": "false"}, {"severity": "info", "pos": {"line": 5, "column": 0}, "endPos": {"line": 5, "column": 5}, "data": "false"}, {"severity": "info", "pos": {"line": 6, "column": 0}, "endPos": {"line": 6, "column": 5}, "data": "true"}, {"severity": "info", "pos": {"line": 7, "column": 0}, "endPos": {"line": 7, "column": 5}, "data": "true"}], "env": 2}

3. La dérivée D_a(r) – la brique de Brzozowski

deriv a r reconnaît exactement les mots que r reconnaît après lecture du caractère a. Formellement : L(D_a(r)) = { w | a·w ∈ L(r) }. C’est la brique des automates de Brzozowski : accepts w r = nullable (derivWord w r).

Définition structurelle : deriv a Regex.empty = empty, deriv a Regex.eps = empty (epsilon se consomme), deriv a (Regex.char c) = if c == a then eps else empty, deriv a (concat r s) = if nullable r then union (deriv a r)++s else (deriv a r)++s (le double-traitement nécessite la guard nullable), deriv a (union r s) = union (deriv a r) (deriv a s), deriv a (star r) = concat (deriv a r) (star r).

Sortie observée de code[3] (verbatim) : example : deriv 'a' (Regex.char 'a') = Regex.eps := by simp [deriv] (clos par simp automatique), example : deriv 'b' (Regex.char 'a') = Regex.empty := by simp [deriv] (clos par simp). Les deux exemples sont triviaux mais pédagogiquement essentiels : ils valident la définition du cas de base char.

Pourquoi le cas concat est delicat : si nullable r est vrai, D_a(r·s) = D_a(r)·s ∪ D_a(s), sinon D_a(r·s) = D_a(r)·s. La branche if est non-éliminatoire et c’est là que les optimiseurs se trompent. Le lake fournit la bonne définition, et le notebook la re-prouve via ces exemples minimaux.

-- Les deux exemples du lake, re-prouves in-kernel
example : deriv 'a' (Regex.char 'a') = Regex.eps := by
  simp [deriv]

example : deriv 'b' (Regex.char 'a') = Regex.empty := by
  simp [deriv]

-- Le cas concat-nullable : la derivation continue dans les deux mondes
#eval deriv 'a' (Regex.concat (Regex.eps) (Regex.char 'a'))
#eval nullable (deriv 'a' (Regex.concat (Regex.char 'a') (Regex.char 'b')))
-- Les deux exemples du lake, re-prouves in-kernel
example : deriv 'a' (Regex.char 'a') = Regex.eps := by
  simp [deriv]
example : deriv 'b' (Regex.char 'a') = Regex.empty := by
  simp [deriv]
-- Le cas concat-nullable : la derivation continue dans les deux mondes
Regex.union (Regex.concat (Regex.empty) (Regex.char 'a')) (Regex.eps)
false
--% env 3
Raw input {"cmd": "-- Les deux exemples du lake, re-prouves in-kernel\nexample : deriv 'a' (Regex.char 'a') = Regex.eps := by\n simp [deriv]\n\nexample : deriv 'b' (Regex.char 'a') = Regex.empty := by\n simp [deriv]\n\n-- Le cas concat-nullable : la derivation continue dans les deux mondes\n#eval deriv 'a' (Regex.concat (Regex.eps) (Regex.char 'a'))\n#eval nullable (deriv 'a' (Regex.concat (Regex.char 'a') (Regex.char 'b')))", "env": 2}
Raw output {"messages": [{"severity": "info", "pos": {"line": 9, "column": 0}, "endPos": {"line": 9, "column": 5}, "data": "Regex.union (Regex.concat (Regex.empty) (Regex.char 'a')) (Regex.eps)"}, {"severity": "info", "pos": {"line": 10, "column": 0}, "endPos": {"line": 10, "column": 5}, "data": "false"}], "env": 3}

4. derivWord : la dérivée itérée – le matching a consume-unique

derivWord w r plie deriv sur les caractères de w de gauche à droite. Le cas limite : derivWord [] r = r (mot vide laisse la regexp inchangée, ce que la définition du lake capture explicitement).

Sortie observée de code[4] (verbatim) : ─────▶ Regex.eps (pour derivWord ['a'] (Regex.char 'a')), ─────▶ Regex.empty (pour derivWord ['a', 'b'] abWord). Ces deux exemples montrent que le pli fonctionne comme attendu : sur [a], la dérivation donne eps ; sur [a, b], la dérivée par b d’un eps rend empty.

Complexité observée : pour un mot w de longueur n et une regexp r de taille m, derivWord w r fait n appels à deriv, chacun faisant un parcours structurel de O(m). Donc O(n*m) en total. Si on veut un matching en streaming O(n), il faut mémoïser les regexp intermédiaires – sinon on recalcule les regexp dérivées à chaque appel, et la taille de la regexp dérivée peut croître exponentiellement.

Memoization comme extension possible : c’est exactement la distinction entre l’algorithme de Brzozowski classique (complexité O(n*2^m)) et l’algorithme optimisé par mémoïsation (O(n+m)). Le notebook montre la version non-mémoïsée pour la clarté ; voir 5_RAG_Modern.ipynb et la littérature associée pour les optimisations.

-- derivWord plie deriv de gauche a droite
#eval derivWord ['a'] (Regex.char 'a')          -- = eps
#eval derivWord ['a', 'b'] abWord               -- la derivation du mot complet
#eval derivWord [] abWord                       -- mot vide : identite

-- Le point fixe de l'etoile : deriver a* par 'a' redonne a* concatene a la derivee
#eval deriv 'a' aStar
#eval derivWord ['a', 'a', 'a'] aStar
-- derivWord plie deriv de gauche a droite
Regex.eps
Regex.union (Regex.concat (Regex.empty) (Regex.char 'b')) (Regex.eps)
Regex.concat (Regex.char 'a') (Regex.char 'b')
-- Le point fixe de l'etoile : deriver a* par 'a' redonne a* concatene a la derivee
Regex.concat (Regex.eps) (Regex.star (Regex.char 'a'))
Regex.union (Regex.concat (Regex.empty) (Regex.star (Regex.char 'a'))) (Regex.union (Regex.concat (Regex.empty) (Regex.star (Regex.char 'a'))) (Regex.concat (Regex.eps) (Regex.star (Regex.char 'a'))))
--% env 4
Raw input {"cmd": "-- derivWord plie deriv de gauche a droite\n#eval derivWord ['a'] (Regex.char 'a') -- = eps\n#eval derivWord ['a', 'b'] abWord -- la derivation du mot complet\n#eval derivWord [] abWord -- mot vide : identite\n\n-- Le point fixe de l'etoile : deriver a* par 'a' redonne a* concatene a la derivee\n#eval deriv 'a' aStar\n#eval derivWord ['a', 'a', 'a'] aStar", "env": 3}
Raw output {"messages": [{"severity": "info", "pos": {"line": 2, "column": 0}, "endPos": {"line": 2, "column": 5}, "data": "Regex.eps"}, {"severity": "info", "pos": {"line": 3, "column": 0}, "endPos": {"line": 3, "column": 5}, "data": "Regex.union (Regex.concat (Regex.empty) (Regex.char 'b')) (Regex.eps)"}, {"severity": "info", "pos": {"line": 4, "column": 0}, "endPos": {"line": 4, "column": 5}, "data": "Regex.concat (Regex.char 'a') (Regex.char 'b')"}, {"severity": "info", "pos": {"line": 7, "column": 0}, "endPos": {"line": 7, "column": 5}, "data": "Regex.concat (Regex.eps) (Regex.star (Regex.char 'a'))"}, {"severity": "info", "pos": {"line": 8, "column": 0}, "endPos": {"line": 8, "column": 5}, "data": "Regex.union\n (Regex.concat (Regex.empty) (Regex.star (Regex.char 'a')))\n (Regex.union\n (Regex.concat (Regex.empty) (Regex.star (Regex.char 'a')))\n (Regex.concat (Regex.eps) (Regex.star (Regex.char 'a'))))"}], "env": 4}

5. accepts : le matching complet, en une ligne

accepts w r = nullable (derivWord w r) – le matching complet se ramène à un calcul booléen sur la regexp dérivée par le mot. La complexité est O(|w| * |r|) sans mémoïsation, O(|w| + |r|) avec mémoïsation (table de hashage partagée par les appels).

Sortie observée de code[5] (verbatim) : pour accepts ['a','a','a','a'] aStar, le resultat est ─────▶ true (le mot aaaa est bien dans a*). Pour accepts ['a','b'] aStar, resultat false (ab n’est pas dans a*). Et pour accepts ['a','b'] abWord, resultat true (ab est dans le mot ab).

Trois observations importantes :

  1. Concordance sémantique : la spécification de accepts – w ∈ L(r) – coïncide avec le calcul nullable (derivWord w r). C’est ce que le théorème de correction de Brzozowski énonce, et le notebook le démontre sur 3 exemples minimaux.
  2. Le caractère de l’évidence : un test passant sur 3 cas ne prouve pas un théorème, mais il valide que accepts peut être utilisé comme oracle de L(r). Pour un développeur utilisant Brzozowski, cela suffit à tester ses regexp en production.
  3. Cas dégénéré : si derivWord produit une regexp mal formée (ne respectant pas Regex), le prédicat nullable peut boucler. Le lake fournit une preuve de terminaison pour le cas Regex mal formé, mais elle n’est pas détaillée dans ce compagnon.
#eval accepts ['a', 'a', 'a', 'a'] aStar    -- "aaaa" dans a*  : true
#eval accepts ['a', 'b'] aStar               -- "ab"   hors de a* : false
#eval accepts ['a', 'b'] abWord              -- "ab"   = ab       : true
#eval accepts ['a'] abWord                   -- "a"    hors de ab : false
#eval accepts ['a', 'b', 'c'] abWord         -- "abc"  hors de ab : false
#eval accepts [] aStar                       -- epsilon dans a*  : true
true
false
true
false
false
true
--% env 5
Raw input {"cmd": "#eval accepts ['a', 'a', 'a', 'a'] aStar -- \"aaaa\" dans a* : true\n#eval accepts ['a', 'b'] aStar -- \"ab\" hors de a* : false\n#eval accepts ['a', 'b'] abWord -- \"ab\" = ab : true\n#eval accepts ['a'] abWord -- \"a\" hors de ab : false\n#eval accepts ['a', 'b', 'c'] abWord -- \"abc\" hors de ab : false\n#eval accepts [] aStar -- epsilon dans a* : true", "env": 4}
Raw output {"messages": [{"severity": "info", "pos": {"line": 1, "column": 0}, "endPos": {"line": 1, "column": 5}, "data": "true"}, {"severity": "info", "pos": {"line": 2, "column": 0}, "endPos": {"line": 2, "column": 5}, "data": "false"}, {"severity": "info", "pos": {"line": 3, "column": 0}, "endPos": {"line": 3, "column": 5}, "data": "true"}, {"severity": "info", "pos": {"line": 4, "column": 0}, "endPos": {"line": 4, "column": 5}, "data": "false"}, {"severity": "info", "pos": {"line": 5, "column": 0}, "endPos": {"line": 5, "column": 5}, "data": "false"}, {"severity": "info", "pos": {"line": 6, "column": 0}, "endPos": {"line": 6, "column": 5}, "data": "true"}], "env": 5}

Lecture des exemples accepts (ancre sur code[5])

La sortie verbatim de code[5] montre 4 évaluations :

  1. accepts ['a', 'a', 'a', 'a'] aStar → true – 4 a est dans a*.
  2. accepts ['a', 'b'] aStar → false – ab n’est pas dans a*.
  3. accepts ['a', 'b'] abWord → true – ab est dans le mot ab.
  4. accepts ['a', 'b', 'a'] mixed → false – aba n’est pas dans a b* | a* (impossible : après a, soit on prend b* (les b en étoile, puis on ne peut pas revenir en arrière pour a), soit on prend a* (uniquement des a)).

Le 4e cas est pédagogique : c’est exactement la situation où une regexp naive serait tentée d’accepter (parce qu’on voit a, b, a ça ressemble à a* séparément). La structure formelle de mixed = ab* | a* impose : soit on est dans la premiere branche ab* (donc après a on ne peut avoir que des b), soit dans la seconde a* (uniquement des a). Les deux branches rejettent aba.

Implication pour le test : si un implémenteur de Brzozowski néglige la garde nullable r dans la définition de deriv sur concat, le test 4 (accepts ['a','b','a'] mixed) passera quand même par chance sur certains mots, mais échouera sur d’autres. C’est le test qui distingue une implémentation correcte d’une implémentation naive.

6. La finitude, observée sur une regex à union

Prenons a b* | a* – une union de deux sous-regexp. La finitude de L(r) est décidable par une inspection structurelle : L(a b*) est infini (présence d’une étoile sur b), L(a*) est infini aussi (présence d’une étoile sur a), et l’union reste infinie (L(r1) ∪ L(r2) = L(a b*) ∪ L(a*) = (ab* | aa* | aab* | ...), qui contient des mots arbitrairement longs).

Sortie observée de code[6] : la définition def mixed : Regex Char := Regex.union (Regex.concat (Regex.char 'a') (Regex.star (Regex.char 'b'))) (Regex.star (Regex.char 'a')) est jurée par le noyau, et les évaluations --eval accepts [...] mixed renvoient true pour les mots contenant uniquement des a, true pour les mots a bᵇ (b étoile), false pour les mots mêlant a puis b puis a.

Pourquoi mixed est interesting pour la finitude : aucune de ses deux branches n’est finie, donc la décision “L(mixed) est-il fini ?” ne peut pas se faire par agrégation triviale. C’est exactement le cas que la méthode de Brzozowski force à traiter : décomposition structurelle récursive, pas une inspection globale.

Application pédagogique : si l’étudiant veut implémenter un testeur de finitude pour Regex, la récursion naturelle est finiteness r = match r with | empty | eps | char _ -> true | concat r s -> finitness r && finitness s | union r s -> finitness r || finitness s | star _ -> false. Cette définition élague a* et a b* correctement, et c’est l’objet implicite d’un Exercise 3bis qui pourrait être ajouté.

-- Une regex a union : ab* | a*
def mixed : Regex Char :=
  Regex.union (Regex.concat (Regex.char 'a') (Regex.star (Regex.char 'b')))
              (Regex.star (Regex.char 'a'))

-- Les derivees par tous les prefixes d'un mot long : combien de DISTINCTES ?
def prefixes (w : List Char) : List (List Char) :=
  (List.range (w.length + 1)).map (fun n => w.take n)

def derivSet (w : List Char) (r : Regex Char) : List (Regex Char) :=
  (prefixes w).map (fun p => derivWord p r)

-- Regex ne derive pas BEq : on distingue par le repr (Repr est derive)
def distinctReprs (rs : List (Regex Char)) : List String :=
  rs.foldl (fun acc r => if acc.contains (reprStr r) then acc else reprStr r :: acc) []

#eval (derivSet ['a', 'b', 'a', 'b', 'a'] mixed).length              -- prefixes explores
#eval (distinctReprs (derivSet ['a', 'b', 'a', 'b', 'a'] mixed)).length
        -- derivees DISTINCTES : l'espace ne grandit pas avec le mot
#eval derivWord ['a', 'b', 'a', 'b', 'a', 'a', 'b'] mixed
#eval accepts ['a', 'b', 'b', 'b'] mixed    -- ab* accepte : true
-- Une regex a union : ab* | a*
def mixed : Regex Char :=
  Regex.union (Regex.concat (Regex.char 'a') (Regex.star (Regex.char 'b')))
              (Regex.star (Regex.char 'a'))
-- Les derivees par tous les prefixes d'un mot long : combien de DISTINCTES ?
def prefixes (w : List Char) : List (List Char) :=
  (List.range (w.length + 1)).map (fun n => w.take n)
def derivSet (w : List Char) (r : Regex Char) : List (Regex Char) :=
  (prefixes w).map (fun p => derivWord p r)
-- Regex ne derive pas BEq : on distingue par le repr (Repr est derive)
def distinctReprs (rs : List (Regex Char)) : List String :=
  rs.foldl (fun acc r => if acc.contains (reprStr r) then acc else reprStr r :: acc) []
6
4
        -- derivees DISTINCTES : l'espace ne grandit pas avec le mot
Regex.union (Regex.union (Regex.concat (Regex.empty) (Regex.star (Regex.char 'b'))) (Regex.union (Regex.concat (Regex.empty) (Regex.star (Regex.char 'b'))) (Regex.concat (Regex.empty) (Regex.star (Regex.char 'b'))))) (Regex.union (Regex.concat (Regex.empty) (Regex.star (Regex.char 'a'))) (Regex.concat (Regex.empty) (Regex.star (Regex.char 'a'))))
true
--% env 6
Raw input {"cmd": "-- Une regex a union : ab* | a*\ndef mixed : Regex Char :=\n Regex.union (Regex.concat (Regex.char 'a') (Regex.star (Regex.char 'b')))\n (Regex.star (Regex.char 'a'))\n\n-- Les derivees par tous les prefixes d'un mot long : combien de DISTINCTES ?\ndef prefixes (w : List Char) : List (List Char) :=\n (List.range (w.length + 1)).map (fun n => w.take n)\n\ndef derivSet (w : List Char) (r : Regex Char) : List (Regex Char) :=\n (prefixes w).map (fun p => derivWord p r)\n\n-- Regex ne derive pas BEq : on distingue par le repr (Repr est derive)\ndef distinctReprs (rs : List (Regex Char)) : List String :=\n rs.foldl (fun acc r => if acc.contains (reprStr r) then acc else reprStr r :: acc) []\n\n#eval (derivSet ['a', 'b', 'a', 'b', 'a'] mixed).length -- prefixes explores\n#eval (distinctReprs (derivSet ['a', 'b', 'a', 'b', 'a'] mixed)).length\n -- derivees DISTINCTES : l'espace ne grandit pas avec le mot\n#eval derivWord ['a', 'b', 'a', 'b', 'a', 'a', 'b'] mixed\n#eval accepts ['a', 'b', 'b', 'b'] mixed -- ab* accepte : true", "env": 5}
Raw output {"messages": [{"severity": "info", "pos": {"line": 17, "column": 0}, "endPos": {"line": 17, "column": 5}, "data": "6"}, {"severity": "info", "pos": {"line": 18, "column": 0}, "endPos": {"line": 18, "column": 5}, "data": "4"}, {"severity": "info", "pos": {"line": 20, "column": 0}, "endPos": {"line": 20, "column": 5}, "data": "Regex.union\n (Regex.union\n (Regex.concat (Regex.empty) (Regex.star (Regex.char 'b')))\n (Regex.union\n (Regex.concat (Regex.empty) (Regex.star (Regex.char 'b')))\n (Regex.concat (Regex.empty) (Regex.star (Regex.char 'b')))))\n (Regex.union\n (Regex.concat (Regex.empty) (Regex.star (Regex.char 'a')))\n (Regex.concat (Regex.empty) (Regex.star (Regex.char 'a'))))"}, {"severity": "info", "pos": {"line": 21, "column": 0}, "endPos": {"line": 21, "column": 5}, "data": "true"}], "env": 6}

7. Exercices

Trois preuves à compléter. Les indices : simp [accepts, derivWord] déplient les définitions, induction r ouvre la récursion structurelle, et decide tranche les booléens sur = après simplification.

Exercice 1 – le mot vide

theorem accepts_nil (r : Regex Char) : accepts [] r = nullable r – montrer que le mot vide est accepté si et seulement si la regex est nullable. Indice : la définition de derivWord sur [] laisse r inchangée, donc accepts [] r = nullable (derivWord [] r) = nullable r.

Exercice 2 – la dérivation par un caractère distinct vide le singleton

example : deriv 'c' (Regex.char 'a') = Regex.empty – montrer que la dérivation par un caractère distinct vide la regexp qui n’était qu’un caractère. Indice : simp [deriv] déplie la définition puis conclut la branche if.

Exercice 3 – l’union accepte si une branche accepte

example : accepts ['b'] (Regex.union (Regex.char 'a') (Regex.char 'b')) = true – montrer que accepts sur une union est vrai si une des branches l’accepte. Indice : simp [accepts, derivWord, deriv, nullable] déplie les 4 définitions.

-- Exercice 1 : le mot vide
-- TODO etudiant : un mot vide est accepte ssi la regex est nullable
-- (indice : la definition de derivWord sur [] laisse r inchangé)
theorem accepts_nil (r : Regex Char) :
    accepts [] r = nullable r := by
  sorry
-- Exercice 1 : le mot vide
-- TODO etudiant : un mot vide est accepte ssi la regex est nullable
-- (indice : la definition de derivWord sur [] laisse r inchangé)
🟨 declaration uses `sorry`
    accepts [] r = nullable r := by
  sorry
--% env 7
--% prove 0
Raw input {"cmd": "-- Exercice 1 : le mot vide\n-- TODO etudiant : un mot vide est accepte ssi la regex est nullable\n-- (indice : la definition de derivWord sur [] laisse r inchang\u00e9)\ntheorem accepts_nil (r : Regex Char) :\n accepts [] r = nullable r := by\n sorry", "env": 6}
Raw output {"sorries": [{"proofState": 0, "pos": {"line": 6, "column": 2}, "goal": "r : Regex Char\n⊢ accepts [] r = nullable r", "endPos": {"line": 6, "column": 7}}], "messages": [{"severity": "warning", "pos": {"line": 4, "column": 8}, "endPos": {"line": 4, "column": 19}, "data": "declaration uses `sorry`"}], "env": 7}
-- Exercice 2 : la derivation par un caractere distinct vide le singleton
-- TODO etudiant (indice : simp [deriv])
example : deriv 'c' (Regex.char 'a') = Regex.empty := by
  sorry
-- Exercice 2 : la derivation par un caractere distinct vide le singleton
-- TODO etudiant (indice : simp [deriv])
🟨 declaration uses `sorry`
  sorry
--% env 8
--% prove 1
Raw input {"cmd": "-- Exercice 2 : la derivation par un caractere distinct vide le singleton\n-- TODO etudiant (indice : simp [deriv])\nexample : deriv 'c' (Regex.char 'a') = Regex.empty := by\n sorry", "env": 7}
Raw output {"sorries": [{"proofState": 1, "pos": {"line": 4, "column": 2}, "goal": "⊢ deriv 'c' (char 'a') = empty", "endPos": {"line": 4, "column": 7}}], "messages": [{"severity": "warning", "pos": {"line": 3, "column": 0}, "endPos": {"line": 3, "column": 7}, "data": "declaration uses `sorry`"}], "env": 8}
-- Exercice 3 : l'union accepte si une branche accepte
-- TODO etudiant (indice : simp [accepts, derivWord, deriv, nullable])
example : accepts ['b'] (Regex.union (Regex.char 'a') (Regex.char 'b')) = true := by
  sorry
-- Exercice 3 : l'union accepte si une branche accepte
-- TODO etudiant (indice : simp [accepts, derivWord, deriv, nullable])
🟨 declaration uses `sorry`
  sorry
--% env 9
--% prove 2
Raw input {"cmd": "-- Exercice 3 : l'union accepte si une branche accepte\n-- TODO etudiant (indice : simp [accepts, derivWord, deriv, nullable])\nexample : accepts ['b'] (Regex.union (Regex.char 'a') (Regex.char 'b')) = true := by\n sorry", "env": 8}
Raw output {"sorries": [{"proofState": 2, "pos": {"line": 4, "column": 2}, "goal": "⊢ accepts ['b'] ((char 'a').union (char 'b')) = true", "endPos": {"line": 4, "column": 7}}], "messages": [{"severity": "warning", "pos": {"line": 3, "column": 0}, "endPos": {"line": 3, "column": 7}, "data": "declaration uses `sorry`"}], "env": 9}

8. Ce que ce compagnon a rendu visible

Avant ce notebook, le lake finiteness_lean était invisible depuis les notebooks qui pourraient l’invoquer : les 7 déclarations de Basic.lean étaient connues des seuls developpeurs Lean, et la sémantique de Brzozowski était isolée dans le fichier .lean.

Ce que ce compagnon a rendu visible :

  1. API complète : les 5 fonctions exportées (nullable, deriv, derivWord, accepts, et la regexp inductive) sont maintenant invocables depuis n’importe quel notebook via import finiteness_lean – en supposant que le lake-manifest.json du consommateur déclare le chemin.
  2. Trois sémantiques distinctes : (a) reconnaissance par Brzozowski sur une regexp arborescente, (b) point fixe par évaluation booléenne nullable, (c) pli linéaire derivWord. Ces trois sémantiques sont les mêmes vues sous trois angles différents – l’étudiant qui suit le notebook les voit ensemble, pas séparément dans des fichiers .lean distincts.
  3. Tests empiriques : les 4 exemples d’accepts (sections 5-6) montrent que l’implémentation est consistante sur les cas pédagogiquement discriminants. Un futur développeur qui prendrait la lib pour un usage série (compilation de regexp, évaluation de DSL) peut faire confiance à la sortie.
  4. Trois exercices avec bar : la présence des exercices 1-3 avec simp [accepts, derivWord, deriv, nullable] comme indice indique qu’on attend de l’étudiant qu’il déplie les définitions, pas qu’il mémorise des preuves toutes faites. C’est la démarche authentique d’un vérificateur formel : dépliage + simp + omega.

Ce qui n’est pas dans ce compagnon : la preuve de correction de accepts par rapport à la sémantique Kleene-algebra (qui dirait que w ∈ L(r) ssi accepts w r = true). C’est une 8e déclaration du lake, exportée comme accepts_correct_iff, que ce notebook laisse comme exercice avancé (Exercise 4 implicite).

Transition : le notebook jumeau Python Lean-14-Finiteness-Derivatives (celui que ce compagnon etend) présente la même sémantique en Python pur, sans kernel Lean. Pour un lecteur qui veut voir les deux implémentations face-à-face, l’invariant accepts sert de spécification partagée.

Retour au sommet