# Dependances : stdlib uniquement pour le calcul (fractions, itertools).
# Les deux figures utilisent matplotlib, fourni par le noyau.
from fractions import Fraction
from itertools import combinations
print("imports OK")imports OK
Série Serre 100, voie décorrelée (EPIC #16334). Ce notebook est le miroir exécutable du module SheafCohomology/Cech.lean du lake grothendieck_lean : là-bas, le théorème est démontré en Lean ; ici, il est calculé sur des espaces à la main.
Le contexte historique : en 1955, Serre publie FAC (Faisceaux algébriques cohérents) et transporte la cohomologie des faisceaux — née en analyse — vers la géométrie algébrique ; Grothendieck généralisera ce langage jusqu’aux sites et aux topologies qui portent son nom. L’idée de ce notebook : prendre des espaces topologiques finis (des posets), le faisceau constant \(\mathbb{Z}\), et calculer effectivement \(H^0\), \(H^1\), \(H^2\) par le complexe de Čech — avec pour résultats mesurés :
Chaque résultat est croisé par une machine de contrôle indépendante (cohomologie du complexe d’ordre). Le même énoncé, démontré en Lean dans le lake, calculé deux fois ici.
Soit \((P, \leq)\) un ordre partiel fini. En déclarant ouverts les parties croissantes (\(x \in U\), \(x \leq y \Rightarrow y \in U\)), on obtient la topologie d’Alexandrov : le point clé est que chaque \(x\) possède un plus petit ouvert \(U_x = \{y : x \leq y\}\), et ces \(U_x\) forment une base de la topologie. Tout l’espace tient dans une relation d’ordre.
def espace(elements, comparabilites):
"""Espace d'Alexandrov : ordre reflechi x <= y comme dict x -> {y}."""
order = {x: {x} for x in elements}
for a, b in comparabilites:
order[a].add(b)
return {"elements": list(elements), "order": order}
def ouvert_minimal(E, x):
"""U_x = {y : x <= y} : plus petit ouvert contenant x."""
return frozenset(E["order"][x])
# L'espace de Sierpinski : a < b. Ouverts : vide, {b}, {a,b}.
sierpinski = espace(["a", "b"], [("a", "b")])
print("U_a =", sorted(ouvert_minimal(sierpinski, "a")),
" U_b =", sorted(ouvert_minimal(sierpinski, "b")))
print("les U_x forment une base : {U_a, U_b} engendre les 3 ouverts")U_a = ['a', 'b'] U_b = ['b']
les U_x forment une base : {U_a, U_b} engendre les 3 ouverts
Le faisceau constant \(\mathbb{Z}\) associe à un ouvert \(U\) les fonctions localement constantes \(U \to \mathbb{Z}\) — un groupe libre de rang le nombre de composantes connexes \(\pi_0(U)\). Sur un espace fini, la connexité se calcule : deux points de \(U\) sont reliés si leurs ouverts minimaux se rencontrent dans \(U\) (union-find). Les applications de restriction \(\mathbb{Z}^{\pi_0(U)} \to \mathbb{Z}^{\pi_0(V)}\) suivent l’inclusion des composantes.
def composantes(E, U):
"""Composantes connexes d'un ouvert U (union-find sur le graphe
d'intersection des ouverts minimaux, restreint a U)."""
pts = sorted(U)
parent = {p: p for p in pts}
def find(a):
while parent[a] != a:
parent[a] = parent[parent[a]]
a = parent[a]
return a
for a, b in combinations(pts, 2):
if (ouvert_minimal(E, a) & ouvert_minimal(E, b)) & U:
ra, rb = find(a), find(b)
if ra != rb:
parent[ra] = rb
groupes = {}
for p in pts:
groupes.setdefault(find(p), []).append(p)
return sorted((frozenset(g) for g in groupes.values()), key=lambda g: min(g))
def restriction(E, U, V):
"""Matrice Z^{pi0(U)} -> Z^{pi0(V)} du faisceau constant (0/1,
ligne = composante de V, colonne = composante de U qui la contient)."""
cu, cv = composantes(E, U), composantes(E, V)
return [[1 if cvi <= cuj else 0 for cuj in cu] for cvi in cv]
# Sur le cercle a 4 points (modele minimal de McCord de S^1) :
cercle4 = espace(["a1", "a2", "a3", "a4"],
[("a1", "a2"), ("a1", "a4"), ("a3", "a2"), ("a3", "a4")])
tout = frozenset(cercle4["elements"])
print("pi0(cercle4 entier) :", [sorted(c) for c in composantes(cercle4, tout)])
inter = ouvert_minimal(cercle4, "a1") & ouvert_minimal(cercle4, "a3")
print("U_a1 & U_a3 =", sorted(inter), "-> pi0 :", [sorted(c) for c in composantes(cercle4, inter)])pi0(cercle4 entier) : [['a1', 'a2', 'a3', 'a4']]
U_a1 & U_a3 = ['a2', 'a4'] -> pi0 : [['a2'], ['a4']]
On recouvre l’espace par ses ouverts minimaux \(\mathcal{U} = (U_x)_x\). Le complexe de Čech à coefficients dans le faisceau \(F\) :
\[C^k = \bigoplus_{x_0 < \cdots < x_k} F(U_{x_0} \cap \cdots \cap U_{x_k}), \qquad (\delta c)_{x_0 \cdots x_{k+1}} = \sum_{j} (-1)^j \, c_{x_0 \cdots \hat{x}_j \cdots x_{k+1}}\Big|_{\text{intersection}}\]
et \(H^k = \ker \delta^k / \mathrm{im}\,\delta^{k-1}\). Chaque groupe est libre de rang fini (nombre de composantes des intersections), chaque application une matrice \(0/1\) — tout se calcule par élimination exacte sur \(\mathbb{Q}\) (module fractions), donc les dimensions obtenues sont des nombres de Betti \(b_k = \dim_{\mathbb{Q}} H^k(-; \mathbb{Q})\).
Portée de l’instrument. L’élimination sur \(\mathbb{Q}\) voit les nombres de Betti — la dimension du gradué associé de \(H^k(;\mathbb{Z})\) après tensorisation par \(\mathbb{Q}\). Elle ne voit pas la torsion : un groupe \(H^k(;\mathbb{Z})\) de torsion \(T\) a \(\dim_{\mathbb{Q}} T \otimes \mathbb{Q} = 0\). Pour ces espaces-ci (cercle McCord, sphère-tétraèdre), McCord 1967 montre \(H^*(;\mathbb{Z})\) sans torsion ; les rangs coïncident donc avec le groupe entier. La séparation nette — ce que l’algorithme certifie vs ce que la théorie donne — est ce qu’on déclare ici pour ne pas enseigner qu’une élimination sur \(\mathbb{Q}\) décide une cohomologie entière.
Témoignage de Serre — Tohoku et les axiomes (« À propos de la correspondance Grothendieck-Serre », dialogue Serre–Connes, Fondation Hugot du Collège de France, 2019 (YouTube
pOv-ygSynRI), 07:05) :« il y avait quand même quelque chose qui m’a beaucoup frappé là-dedans. Il y avait des axiomes sur les catégories abéliennes. […] mais pas seulement. Il y avait au niveau des exemples. C’est-à-dire, bien sûr, l’exemple principal, c’était les faisceaux de groupes abéliens. »
(transcription automatique, noms propres corrigés). La cohomologie de Čech calculée ici vit exactement dans ce cadre : des axiomes (catégories, faisceaux) et des exemples calculables jusqu’au bout.
def rang(M):
"""Rang exact d'une matrice d'entiers (elimination sur Q)."""
if not M or not M[0]:
return 0
A = [[Fraction(v) for v in r] for r in M]
rows, cols, r = len(A), len(A[0]), 0
for c in range(cols):
p = next((i for i in range(r, rows) if A[i][c] != 0), None)
if p is None:
continue
A[r], A[p] = A[p], A[r]
A[r] = [v / A[r][c] for v in A[r]]
for i in range(rows):
if i != r and A[i][c] != 0:
f = A[i][c]
A[i] = [a - f * b for a, b in zip(A[i], A[r])]
r += 1
return r
def produit(A, B):
if not A or not B:
return []
return [[sum(Fraction(A[i][k]) * Fraction(B[k][j]) for k in range(len(B)))
for j in range(len(B[0]))] for i in range(len(A))]
def complexe_cech(E, degre_max=2):
"""Bases explicites de C^k et differentielles (matrices d'entiers)."""
pts = E["elements"]
n = len(pts)
ouvs = [ouvert_minimal(E, x) for x in pts]
def cells(k):
out = []
for idx in combinations(range(n), k + 1):
inter = frozenset.intersection(*[ouvs[i] for i in idx])
if inter:
out.append((idx, inter))
return out
bases = {k: [(idx, ci) for idx, o in cells(k)
for ci in range(len(composantes(E, o)))]
for k in range(degre_max + 2)}
col_of = {k: {b: j for j, b in enumerate(bases[k])} for k in bases}
diffs = {}
for k in range(degre_max + 1):
M = [[0] * len(bases[k]) for _ in bases[k + 1]]
for tgt_idx, o_tgt in cells(k + 1):
for pos, i in enumerate(tgt_idx):
src_idx = tuple(x for x in tgt_idx if x != i)
src = next(((ix, o) for ix, o in cells(k) if ix == src_idx), None)
if src is None:
continue # intersection vide -> groupe nul
R = restriction(E, src[1], o_tgt)
sgn = 1 if pos % 2 == 0 else -1
for li, row in enumerate(R):
for lj, v in enumerate(row):
if v:
M[col_of[k + 1][(tgt_idx, li)]][col_of[k][(src_idx, lj)]] += sgn * v
diffs[k] = M
return bases, diffs
def cohomologie_cech(E, degre_max=2):
"""Dimensions de H^0..H^{degre_max} + verification exacte d^{k+1}.d^k = 0."""
bases, diffs = complexe_cech(E, degre_max)
compose_nul = all(all(v == 0 for row in produit(diffs[k + 1], diffs[k]) for v in row)
for k in range(degre_max) if diffs[k] and diffs[k + 1])
H = {}
for k in range(degre_max + 1):
H[k] = (len(bases[k]) - rang(diffs[k])
- (rang(diffs[k - 1]) if k >= 1 else 0))
return H, compose_nulLe modèle minimal de McCord de \(S^1\) : quatre points, deux minimaux (\(a_1, a_3\)), deux maximaux (\(a_2, a_4\)), chacun sous deux maximaux. McCord (1967) a démontré que cet espace est faiblement équivalent à \(S^1\) — la machine va le voir : \(H^1 = \mathbb{Z}\), un seul générateur, la classe fondamentale.
Témoignage de Serre — la montée H0, H1, H2 (« À propos de la correspondance Grothendieck-Serre », dialogue Serre–Connes, Fondation Hugot du Collège de France, 2019 (YouTube
pOv-ygSynRI), 12:36) :« je fais un H0, je fais le H1. Voilà, toi c’était le H1. Mais je n’ai pas eu le courage intellectuel de dire que ça pourrait faire un H2. Tandis que lui, il a dit tout de suite, instantanément : ça, c’est parfaitement correct. »
(transcription automatique, noms propres corrigés). Les \(H^n\) de ce carnet sont cette échelle : le calcul montre que la tour ne s’arrête pas au premier étage — et c’est Grothendieck qui a vu la tour entière avant de la calculer.
H, ok = cohomologie_cech(cercle4)
print("Cercle a 4 points (McCord) -- nombres de Betti : ", H,
"| d.d = 0 verifie :", ok)
print("dim_Q H^1 = 1 (calcule par elimination) ; par McCord 1967, ")
print("le cercle a 4 points est faiblement equivalent a S^1, donc H^1 = Z ; ")
print("cohomologie sans torsion ici, les rangs = groupe entier.")Cercle a 4 points (McCord) -- nombres de Betti : {0: 1, 1: 1, 2: 0} | d.d = 0 verifie : True
dim_Q H^1 = 1 (calcule par elimination) ; par McCord 1967,
le cercle a 4 points est faiblement equivalent a S^1, donc H^1 = Z ;
cohomologie sans torsion ici, les rangs = groupe entier.
La sphère \(S^2\) comme poset des faces propres d’un 3-simplexe : 4 sommets < 6 arêtes < 4 triangles (14 points, la cellule pleine retirée — c’est la frontière). Le complexe d’ordre de ce poset est la subdivision barycentrique de \(\partial\Delta^3\), homéomorphe à \(S^2\). C’est le geste catégorique : l’objet géométrique devient un poset, le poset suffit.
# Poset des faces propres du tetraedre : V(4) < E(6) < F(4)
sommets = ['V1', 'V2', 'V3', 'V4']
aretes = ['E' + ''.join(c) for c in combinations('1234', 2)]
faces = ['F' + ''.join(c) for c in combinations('1234', 3)]
rel = []
for c in combinations('1234', 2):
for i in c:
rel.append((f'V{i}', 'E' + ''.join(c)))
for c in combinations('1234', 3):
for i in c:
rel.append((f'V{i}', 'F' + ''.join(c)))
for j in c:
if i < j:
rel.append(('E' + i + j, 'F' + ''.join(c)))
tetra = espace(sommets + aretes + faces, rel)
print(f"Tetraedre : {len(tetra['elements'])} points "
f"({len(sommets)} V + {len(aretes)} E + {len(faces)} F)")
H, ok = cohomologie_cech(tetra)
print("Sphere-tetraedre -- nombres de Betti : ", H, "| d.d = 0 verifie :", ok)
print("dim_Q H^2 = 1 (calcule) ; cohomologie S^2 est Z, pas de torsion, ")
print("rangs = groupe entier (theorie homologie sphere). ")
print("NB : l'elimination ne suffit pas pour declarer H^n = Z -- la theorie ")
print("dit que S^2 et le tetraedre ont la meme cohomologie (McCord).")Tetraedre : 14 points (4 V + 6 E + 4 F)
Sphere-tetraedre -- nombres de Betti : {0: 1, 1: 0, 2: 1} | d.d = 0 verifie : True
dim_Q H^2 = 1 (calcule) ; cohomologie S^2 est Z, pas de torsion,
rangs = groupe entier (theorie homologie sphere).
NB : l'elimination ne suffit pas pour declarer H^n = Z -- la theorie
dit que S^2 et le tetraedre ont la meme cohomologie (McCord).
Le théorème de McCord relie ces espaces finis à leurs complexes d’ordre (un \(k\)-simplexe par chaîne \(x_0 < \cdots < x_k\)). Une deuxième machine, complètement indépendante de la première — cohomologie simpliciale, pas de faisceau, pas de recouvrement — doit donner les mêmes nombres. Si les deux machines s’accordent, le résultat n’est pas un artefact d’implémentation.
def ordre_complexe(E, degre_max=2):
"""Squelette du complexe d'ordre : sk[k] = k-simplexes = chaines croissantes."""
pts, ordre = E["elements"], E["order"]
chaines = {k: set() for k in range(degre_max + 2)}
for x in pts:
pile = [(x,)]
chaines[0].add((x,))
while pile:
ch = pile.pop()
for y in ordre[ch[-1]]:
if y != ch[-1]:
ch2 = ch + (y,)
chaines[len(ch2) - 1].add(ch2)
pile.append(ch2)
return {k: sorted(chaines[k]) for k in chaines}
def cobords_ordre(sk, degre_max=2):
"""Matrices de cobord : delta^k a pour lignes les (k+1)-simplexes."""
deltas = {}
for k in range(degre_max):
M = [[0] * len(sk[k]) for _ in sk[k + 1]]
for ti, t in enumerate(sk[k + 1]):
for pos in range(len(t)):
s = t[:pos] + t[pos + 1:]
if s in set(sk[k]):
M[ti][sk[k].index(s)] += 1 if pos % 2 == 0 else -1
deltas[k] = M
return deltas
def cohomologie_ordre(E, degre_max=2):
"""Cohomologie du complexe d'ordre sur Q : dimensions par elimination exacte."""
sk = ordre_complexe(E, degre_max)
deltas = cobords_ordre(sk, degre_max)
H = {}
for k in range(degre_max + 1):
H[k] = (len(sk[k]) - rang(deltas[k] if k in deltas else [])
- (rang(deltas[k - 1]) if k >= 1 else 0))
return H
# Tableau croise : les deux machines doivent s'accorder sur les trois espaces.
lignes = []
for nom, E in [("Sierpinski", sierpinski), ("Cercle 4 pts", cercle4), ("Sphere-tetraedre", tetra)]:
Hc, ok = cohomologie_cech(E)
Hs = cohomologie_ordre(E)
lignes.append((nom, Hc, Hs, "ACCORD" if Hc == Hs else "DIVERGENCE"))
print(f"{'espace':16s} {'Cech (faisceaux)':24s} {'ordre (simplicial)':22s} verdict")
for nom, Hc, Hs, v in lignes:
print(f"{nom:16s} {str(Hc):24s} {str(Hs):22s} {v}")espace Cech (faisceaux) ordre (simplicial) verdict
Sierpinski {0: 1, 1: 0, 2: 0} {0: 1, 1: 0, 2: 0} ACCORD
Cercle 4 pts {0: 1, 1: 1, 2: 0} {0: 1, 1: 1, 2: 0} ACCORD
Sphere-tetraedre {0: 1, 1: 0, 2: 1} {0: 1, 1: 0, 2: 1} ACCORD
Lecture : les deux colonnes coïncident sur les trois espaces — Sierpiński contractible, le cercle et son \(b_1 = 1\), la sphère et son \(b_2 = 1\). La colonne Čech mesure les nombres de Betti \(b_k = \dim_{\mathbb{Q}} H^k\) par élimination exacte ; la colonne complexe d’ordre fait la même mesure par voie simpliciale. Pour ces trois espaces, McCord 1967 garantit que la cohomologie entière n’a pas de torsion, donc \(b_k = \mathrm{rang}\, H^k(;\mathbb{Z})\) — et l’on note les groupes par \(\mathbb{Z}\) dans la conclusion. L’élimination sur \(\mathbb{Q}\) ne décide pas de \(H^k\) en général : les nombres de Betti ne déterminent pas le groupe, et deux espaces peuvent les partager sans partager leur cohomologie entière. La section suivante en donne le témoin — \(\mathbb{RP}^2\), dont \(H^2(;\mathbb{Z}) = \mathbb{Z}/2\) reste invisible aux dimensions rationnelles — et y ajoute la machine sur \(\mathbb{Z}\) (forme normale de Smith du cobord) qui, elle, le calcule. Dans le lake grothendieck_lean, le même énoncé est démontré en Lean.
Les versions antérieures de ce notebook exhibaient ici un préordre à trois points présenté comme un modèle de \(\mathbb{RP}^2\). Ce n’en était pas un : dans un préordre où chaque paire est comparable dans les deux sens, le quotient \(T_0\) est un point unique, donc l’espace est contractile et la cohomologie calculée — \(\{0{:}1,\ 1{:}0,\ 2{:}0\}\) — est celle d’un point. Le calcul était exact, l’étiquette était fausse ; elle est retirée.
Le vrai modèle tient en dix triangles. La triangulation minimale de \(\mathbb{RP}^2\) a 6 sommets, 15 arêtes et 10 faces, et se décrit à la main :
\[\{012,\ 013,\ 024,\ 035,\ 045,\ 125,\ 134,\ 145,\ 234,\ 235\}\]
Trois propriétés la caractérisent, et chacune se vérifie — c’est la cellule suivante :
Le poset des faces — 6 sommets \(<\) 15 arêtes \(<\) 10 triangles, ordonné par inclusion, soit 31 éléments — est l’espace d’Alexandrov associé à la triangulation. Son complexe d’ordre est la subdivision barycentrique de cette triangulation : ce n’est pas seulement l’équivalence faible de McCord, c’est un homéomorphisme. La théorie dit alors, avant tout calcul :
\[H^0(\mathbb{RP}^2;\mathbb{Z}) = \mathbb{Z}, \qquad H^1(\mathbb{RP}^2;\mathbb{Z}) = 0, \qquad H^2(\mathbb{RP}^2;\mathbb{Z}) = \mathbb{Z}/2\]
# Triangulation minimale de RP^2 : 6 sommets, 10 triangles, 15 aretes.
faces_rp2 = [(0, 1, 2), (0, 1, 3), (0, 2, 4), (0, 3, 5), (0, 4, 5),
(1, 2, 5), (1, 3, 4), (1, 4, 5), (2, 3, 4), (2, 3, 5)]
aretes_rp2 = sorted({a for f in faces_rp2 for a in combinations(f, 2)})
# Controle 1 : surface close -- chaque arete dans exactement deux triangles.
mult = {a: sum(1 for f in faces_rp2 if set(a) <= set(f)) for a in aretes_rp2}
print("aretes :", len(aretes_rp2), "| multiplicites :", sorted(set(mult.values())))
# Controle 2 : le lien de chaque sommet est un cercle -- pas de cone, pas de bord.
# Le lien de v a pour sommets les voisins de v et pour aretes les triangles
# contenant v : degre 2 partout sur 5 voisins => un 5-cycle.
for v in range(6):
degres = {}
for f in faces_rp2:
if v in f:
for w in f:
if w != v:
degres[w] = degres.get(w, 0) + 1
assert len(degres) == 5 and set(degres.values()) == {2}, (v, degres)
print("liens des 6 sommets : 5 voisins, degre 2 partout -- des cercles")
# Controle 2b : le 1-squelette est connexe (une seule surface, pas deux).
vus, pile = {0}, [0]
while pile:
v = pile.pop()
for e in aretes_rp2:
if v in e:
for w in e:
if w not in vus:
vus.add(w)
pile.append(w)
assert len(vus) == 6, vus
print("1-squelette connexe :", len(vus), "sommets en une seule composante")
# Controle 3 : caracteristique d'Euler -> identification par classification.
chi = 6 - len(aretes_rp2) + len(faces_rp2)
print("chi = 6 -", len(aretes_rp2), "+", len(faces_rp2), "=", chi,
"| impair, donc non orientable, k = 2 - chi =", 2 - chi)
# Le poset des faces : sommets < aretes < triangles (ordre d'inclusion).
noms_s = ['s%d' % v for v in range(6)]
noms_a = ['a' + str(a) for a in aretes_rp2]
noms_f = ['t%d' % i for i in range(len(faces_rp2))]
relations = [(noms_s[v], 'a' + str(a)) for a in aretes_rp2 for v in a]
relations += [(noms_s[v], 't%d' % i) for i, f in enumerate(faces_rp2) for v in f]
relations += [('a' + str(a), 't%d' % i) for i, f in enumerate(faces_rp2)
for a in combinations(f, 2)]
rp2 = espace(noms_s + noms_a + noms_f, relations)
print("poset des faces :", len(rp2['elements']), "elements")aretes : 15 | multiplicites : [2]
liens des 6 sommets : 5 voisins, degre 2 partout -- des cercles
1-squelette connexe : 6 sommets en une seule composante
chi = 6 - 15 + 10 = 1 | impair, donc non orientable, k = 2 - chi = 1
poset des faces : 31 elements
Le compte \(6,\ 15,\ 10\) n’est pas un fait isolé : c’est la moitié de l’icosaèdre. L’icosaèdre a 12 sommets, 30 arêtes et 20 faces ; l’antipode \(x \mapsto -x\) les apparie deux à deux, et le quotient a donc exactement 6 sommets, 15 arêtes et 10 faces. La cellule suivante le vérifie au lieu de l’affirmer, puis dessine trois choses : l’icosaèdre et ses six axes antipodaux, le quotient, et le lien d’un sommet — le cycle qui témoigne que la surface est localement un disque.
# --- Figure : le modele, et pourquoi il a 6 sommets --------------------------
# La triangulation ci-dessus n'est pas posee au hasard : c'est la moitie de
# l'icosaedre. Le voir demande trois etapes, et chacune se verifie.
import math
from itertools import permutations
import matplotlib.pyplot as plt
from matplotlib.patches import Polygon
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
phi = (1 + 5 ** 0.5) / 2
ico = [(0, 1, phi), (0, 1, -phi), (0, -1, phi), (0, -1, -phi),
(1, phi, 0), (1, -phi, 0), (-1, phi, 0), (-1, -phi, 0),
(phi, 0, 1), (-phi, 0, 1), (phi, 0, -1), (-phi, 0, -1)]
# Etape 1 : les faces d'un icosaedre sont les 3-cliques de son 1-squelette,
# le graphe des aretes les plus courtes -- inutile d'en recopier la liste.
cote = min(math.dist(ico[i], ico[j]) for i, j in combinations(range(12), 2))
aretes_ico = {(i, j) for i, j in combinations(range(12), 2)
if abs(math.dist(ico[i], ico[j]) - cote) < 1e-9}
faces_ico = [f for f in combinations(range(12), 3)
if all(a in aretes_ico for a in combinations(f, 2))]
assert (len(aretes_ico), len(faces_ico)) == (30, 20), (len(aretes_ico), len(faces_ico))
# Etape 2 : l'antipode x -> -x apparie les 12 sommets en 6 classes et envoie
# chaque face sur une autre face : au quotient, 6 sommets, 15 aretes, 10 faces.
antipode = {i: next(j for j in range(12)
if all(abs(ico[i][k] + ico[j][k]) < 1e-9 for k in range(3)))
for i in range(12)}
classe = {i: min(i, antipode[i]) for i in range(12)}
classes = sorted(set(classe.values()))
quotient_ico = sorted({tuple(sorted(classe[v] for v in f)) for f in faces_ico})
# Etape 3 : ce n'est pas seulement le bon compte -- le quotient EST faces_rp2
# a renommage des 6 sommets pres. On cherche le renommage parmi les 720
# permutations au lieu de le postuler.
cible = sorted(tuple(sorted(f)) for f in faces_rp2)
renommage = next(p for p in permutations(range(6))
if sorted(tuple(sorted(p[classes.index(x)] for x in f))
for f in quotient_ico) == cible)
print("icosaedre :", len(aretes_ico), "aretes,", len(faces_ico), "faces ->",
len(quotient_ico), "triangles au quotient")
print("le quotient redonne faces_rp2 a renommage pres ; renommage trouve :", renommage)
fig = plt.figure(figsize=(15.5, 5.0))
# Panneau 1 : l'icosaedre et les 6 axes antipodaux.
ax_ico = fig.add_subplot(1, 3, 1, projection="3d")
ax_ico.add_collection3d(Poly3DCollection(
[[ico[i] for i in f] for f in faces_ico],
alpha=0.10, facecolor="tab:blue", edgecolor="0.72", linewidths=0.4))
for i in range(12):
ax_ico.scatter(*ico[i], s=55, color="white", edgecolor="0.2", depthshade=False)
for i in range(12):
if i < antipode[i]:
ax_ico.plot([ico[i][0], ico[antipode[i]][0]],
[ico[i][1], ico[antipode[i]][1]],
[ico[i][2], ico[antipode[i]][2]],
color="tab:red", lw=1.5, ls="--")
ax_ico.set_title("L'icosaedre : 12 sommets, 30 aretes, 20 faces\n"
"les 6 axes rouges identifient $x \\sim -x$", fontsize=10)
ax_ico.set_box_aspect((1, 1, 1))
ax_ico.set_axis_off()
# Panneau 2 : le quotient, dessine depuis le lien de s0 (centre + pentagone).
pos = {0: (0.0, 0.0)}
for j in range(5):
ang = math.pi / 2 + 2 * math.pi * j / 5
pos[j + 1] = (math.cos(ang), math.sin(ang))
ax_q = fig.add_subplot(1, 3, 2)
for i, f in enumerate(faces_rp2):
ax_q.add_patch(Polygon([pos[v] for v in f], closed=True,
facecolor=plt.cm.tab20(i % 20), alpha=0.30, lw=0))
for a in aretes_rp2:
ax_q.plot([pos[a[0]][0], pos[a[1]][0]], [pos[a[0]][1], pos[a[1]][1]],
color="0.1", lw=1.4)
ax_q.scatter([p[0] for p in pos.values()], [p[1] for p in pos.values()],
s=210, color="white", edgecolor="0.1", zorder=4)
for v, (x, y) in pos.items():
ax_q.text(x, y, "s%d" % v, ha="center", va="center", fontsize=9, zorder=5)
ax_q.set_title("Le quotient : 6 sommets, 15 aretes, 10 triangles\n"
"la moitie des 12/30/20 de l'icosaedre", fontsize=10)
ax_q.set_aspect("equal")
ax_q.axis("off")
# Panneau 3 : le lien de s0 (controle 2 de la cellule precedente).
ax_l = fig.add_subplot(1, 3, 3)
v0 = 0
voisins = sorted({w for f in faces_rp2 if v0 in f for w in f if w != v0})
pos_l = {v0: (0.0, 0.0)}
for j, w in enumerate(voisins):
ang = math.pi / 2 + 2 * math.pi * j / len(voisins)
pos_l[w] = (math.cos(ang), math.sin(ang))
for j, w in enumerate(voisins):
suite = voisins[(j + 1) % len(voisins)]
ax_l.plot([pos_l[w][0], pos_l[suite][0]], [pos_l[w][1], pos_l[suite][1]],
color="tab:red", lw=1.8)
for w in voisins:
ax_l.plot([pos_l[v0][0], pos_l[w][0]], [pos_l[v0][1], pos_l[w][1]],
color="0.62", lw=0.9, ls="--")
ax_l.scatter([p[0] for p in pos_l.values()], [p[1] for p in pos_l.values()],
s=210, color="white", edgecolor="0.1", zorder=4)
for v, (x, y) in pos_l.items():
ax_l.text(x, y, "s%d" % v, ha="center", va="center", fontsize=9, zorder=5)
ax_l.set_title("Le lien de $s_0$ : 5 voisins, degre 2 partout\n"
"le 5-cycle en rouge -- autour de $s_0$, un disque", fontsize=10)
ax_l.set_aspect("equal")
ax_l.axis("off")
plt.tight_layout()
plt.show()icosaedre : 30 aretes, 20 faces -> 10 triangles au quotient
le quotient redonne faces_rp2 a renommage pres ; renommage trouve : (0, 1, 4, 5, 2, 3)

Lecture : le renommage trouvé confirme que la triangulation décrite plus haut est le quotient de l’icosaèdre — les dix triangles ne sont pas une liste choisie parmi d’autres, c’est la structure que l’antipode laisse sur la sphère. Le panneau du milieu donne à voir les 15 arêtes et les 10 faces du modèle, et \(\chi = 6 - 15 + 10 = 1\) s’y lit d’un coup d’œil. Le panneau de droite détaille le contrôle de régularité sur un sommet : ses cinq voisins forment un 5-cycle, donc autour de \(s_0\) la surface est un disque.
Čech-sur-\(\mathbb{Q}\) mesure des dimensions ; pour décider \(H^k(;\mathbb{Z})\) il faut calculer sur \(\mathbb{Z}\). Trois gestes exacts suffisent — aucun flottant, aucune heuristique, et le notebook reste dans sa contrainte stdlib (les bibliothèques de calcul formel, SymPy ou SageMath, en fournissent une implémentation : ici on l’écrit, parce que c’est elle qu’on veut montrer) :
C’est la diagonalisation qui rend la torsion visible : dans le complexe d’ordre de \(\mathbb{RP}^2\), \(\delta^1\) est une application \(\mathbb{Z}^{90} \to \mathbb{Z}^{60}\) ; son conoyau est \(H^2\), et c’est sa forme normale de Smith — un unique facteur invariant égal à 2, tous les autres valant 1 — qui porte le \(\mathbb{Z}/2\).
# --- 1. Noyau entier : forme normale de Smith -------------------------------
def smith(M):
"""Forme normale de Smith entiere : rend (D, U, V) avec U M V = D,
U et V unimodulaires, D diagonale (facteurs invariants)."""
A = [[int(v) for v in row] for row in M]
m, n = len(A), (len(A[0]) if A else 0)
U = [[1 if i == j else 0 for j in range(m)] for i in range(m)]
V = [[1 if i == j else 0 for j in range(n)] for i in range(n)]
if m == 0 or n == 0:
return A, U, V
def echange_lignes(i, j):
A[i], A[j] = A[j], A[i]
U[i], U[j] = U[j], U[i]
def echange_colonnes(i, j):
for r in range(m):
A[r][i], A[r][j] = A[r][j], A[r][i]
for r in range(n):
V[r][i], V[r][j] = V[r][j], V[r][i]
def retranche_ligne(i, j, q):
if q:
for c in range(n):
A[i][c] -= q * A[j][c]
for c in range(m):
U[i][c] -= q * U[j][c]
def retranche_colonne(i, j, q):
if q:
for r in range(m):
A[r][i] -= q * A[r][j]
for r in range(n):
V[r][i] -= q * V[r][j]
t = 0
while t < min(m, n):
pivot = None
for i in range(t, m):
for j in range(t, n):
if A[i][j] != 0:
pivot = (i, j)
break
if pivot:
break
if pivot is None:
break
echange_lignes(t, pivot[0])
echange_colonnes(t, pivot[1])
while True:
change = False
for i in range(t + 1, m):
if A[i][t] != 0:
retranche_ligne(i, t, A[i][t] // A[t][t])
if A[i][t] != 0: # reste non nul : on echange les lignes
echange_lignes(t, i)
change = True
for j in range(t + 1, n):
if A[t][j] != 0:
retranche_colonne(j, t, A[t][j] // A[t][t])
if A[t][j] != 0:
echange_colonnes(t, j)
change = True
if not change:
break
t += 1
return A, U, V
def facteurs(D):
"""Facteurs invariants non nuls d'une matrice diagonale de Smith."""
n = min(len(D), len(D[0]) if D else 0)
return [abs(D[i][i]) for i in range(n) if D[i][i] != 0]
def noyau_colonnes(M):
"""Base entiere de ker M : les colonnes de V situees au-dela du rang."""
D, _, V = smith(M)
r = len(facteurs(D))
n = len(V)
return [[V[i][j] for i in range(n)] for j in range(r, n)]
# Auto-test de la machine : la convention U M V = D, et U unimodulaire.
for _M in ([[2]], [[1, 2, 3], [4, 5, 6]], [[2, 4], [6, 8]]):
_D, _U, _V = smith(_M)
assert produit(produit(_U, _M), _V) == [[Fraction(v) for v in r] for r in _D], \
"convention U M V = D violee sur %s" % (_M,)
assert facteurs(smith(_U)[0]) == [1] * len(_U), "U n'est pas unimodulaire"
print("auto-test Smith : U M V = D verifie, U unimodulaire (3 matrices)")
# --- 2 et 3. Coordonnees exactes, puis quotient -----------------------------
def resoudre_exact(K, g):
"""Resout K x = g exactement, K a colonnes independantes (Gauss-Jordan sur Q)."""
m = len(K)
p = len(K[0]) if m else 0
aug = [[Fraction(K[i][j]) for j in range(p)] + [Fraction(g[i])] for i in range(m)]
ligne = 0
for col in range(p):
pivot = next((i for i in range(ligne, m) if aug[i][col] != 0), None)
if pivot is None:
continue
aug[ligne], aug[pivot] = aug[pivot], aug[ligne]
pv = aug[ligne][col]
aug[ligne] = [v / pv for v in aug[ligne]]
for i in range(m):
if i != ligne and aug[i][col] != 0:
f = aug[i][col]
aug[i] = [a - f * b for a, b in zip(aug[i], aug[ligne])]
ligne += 1
x = [Fraction(0)] * p
ligne = 0
for col in range(p):
pivot = next((i for i in range(ligne, m) if aug[i][col] != 0), None)
if pivot is not None:
x[col] = aug[pivot][p]
ligne += 1
return x
def quotient_invariants(base_noyau, generateurs):
"""Z^p / <coordonnees des generateurs dans base_noyau> -> (rang libre, torsion).
base_noyau est une liste de COLONNES ; on en fait la matrice avant de resoudre.
"""
p = len(base_noyau)
if p == 0:
assert not generateurs, "l'image deborde d'un noyau nul"
return 0, []
K = [[base_noyau[j][i] for j in range(p)] for i in range(len(base_noyau[0]))]
X = []
for g in generateurs:
x = resoudre_exact(K, g)
assert all(v.denominator == 1 for v in x), "coordonnee non entiere"
assert all(sum(K[i][j] * x[j] for j in range(p)) == g[i]
for i in range(len(K))), "resolution fausse"
X.append([int(v) for v in x])
if not X:
return p, []
Xc = [[X[j][i] for j in range(len(X))] for i in range(p)]
inv = facteurs(smith(Xc)[0])
return p - len(inv), [d for d in inv if d > 1]
def cohomologie_ordre_Z(E, degre_max=2):
"""H^k du complexe d'ordre sur Z : (rang libre, facteurs de torsion) par degre."""
sk = ordre_complexe(E, degre_max)
deltas = cobords_ordre(sk, degre_max)
groupes = {}
for k in range(degre_max + 1):
if k in deltas and deltas[k]:
noyau = noyau_colonnes(deltas[k])
else:
# degre maximal : pas de (k+1)-simplexes, donc ker delta^k = C^k entier
noyau = [[1 if i == j else 0 for i in range(len(sk[k]))]
for j in range(len(sk[k]))]
image = []
if k - 1 in deltas and deltas[k - 1] and deltas[k - 1][0]:
M = deltas[k - 1]
image = [[M[i][j] for i in range(len(M))] for j in range(len(M[0]))]
groupes[k] = quotient_invariants(noyau, image)
return groupes, sk, deltas
def fmt_groupes(groupes):
"""Rend 'H^0 = Z ; H^1 = Z + Z/2' pour un dict degre -> (rang libre, torsion)."""
morceaux = []
for k in sorted(groupes):
libre, torsion = groupes[k]
termes = []
if libre:
termes.append("Z" if libre == 1 else "Z^%d" % libre)
termes.extend("Z/%d" % d for d in torsion)
morceaux.append("H^%d = %s" % (k, " + ".join(termes) if termes else "0"))
return " ; ".join(morceaux)
print("Z --x2--> Z :", quotient_invariants([[1]], [[2]]), "(cokernel attendu (0, [2]))")auto-test Smith : U M V = D verifie, U unimodulaire (3 matrices)
Z --x2--> Z : (0, [2]) (cokernel attendu (0, [2]))
Une machine neuve ne se croit pas sur parole. La cellule suivante l’exécute d’abord sur les deux espaces dont la réponse est déjà connue — le cercle à 4 points (\(H^1 = \mathbb{Z}\), une classe fondamentale, aucune torsion) et la sphère-tétraèdre (\(H^1 = 0\), \(H^2 = \mathbb{Z}\), aucune torsion) — et seulement ensuite sur \(\mathbb{RP}^2\). Les attendus sont écrits avant la mesure : \(H^0 = \mathbb{Z}\), \(H^1 = 0\), \(H^2 = \mathbb{Z}/2\).
Les trois dernières lignes superposent les deux instruments sur le même espace : les dimensions rationnelles et les groupes entiers ne disent pas la même chose, et c’est exactement dans cet écart que la torsion se tient.
# Protocole : temoins d'abord (reponses connues), puis le cas a decider.
H_cercle, _, _ = cohomologie_ordre_Z(cercle4)
H_sphere, _, _ = cohomologie_ordre_Z(tetra)
H_rp2, sk_rp2, deltas_rp2 = cohomologie_ordre_Z(rp2)
print("cercle a 4 points (S^1) :", fmt_groupes(H_cercle))
print("sphere-tetraedre (S^2) :", fmt_groupes(H_sphere))
print("RP^2, poset des faces :", fmt_groupes(H_rp2))
# La torsion se lit sur le cobord lui-meme : les facteurs invariants de delta^1.
print()
print("complexe d'ordre de RP^2 :",
{k: len(sk_rp2[k]) for k in sk_rp2}, "simplexes")
fact = facteurs(smith(deltas_rp2[1])[0])
print("facteurs invariants de delta^1 :", len(fact), "facteurs,",
fact.count(1), "valent 1, la torsion est portee par", [d for d in fact if d != 1])
# Contraste : le meme espace, deux instruments.
print()
print("le meme espace, deux instruments :")
print(" dim_Q H^k (elimination) :", cohomologie_ordre(rp2))
print(" H^k sur Z (Smith) :", fmt_groupes(H_rp2))cercle a 4 points (S^1) : H^0 = Z ; H^1 = Z ; H^2 = 0
sphere-tetraedre (S^2) : H^0 = Z ; H^1 = 0 ; H^2 = Z
RP^2, poset des faces : H^0 = Z ; H^1 = 0 ; H^2 = Z/2
complexe d'ordre de RP^2 : {0: 31, 1: 90, 2: 60, 3: 0} simplexes
facteurs invariants de delta^1 : 60 facteurs, 59 valent 1, la torsion est portee par [2]
le meme espace, deux instruments :
dim_Q H^k (elimination) : {0: 1, 1: 0, 2: 0}
H^k sur Z (Smith) : H^0 = Z ; H^1 = 0 ; H^2 = Z/2
La phrase qui suit — « un unique facteur invariant égal à 2, tous les autres valant 1 » — décrit une liste de 60 nombres que la cellule précédente a calculée et que personne ne voit. La figure la donne telle quelle : \(\delta^1\) a 60 facteurs invariants, 59 valent 1, et un seul vaut 2. C’est ce 2, et rien d’autre, qui fait \(H^2(\mathbb{RP}^2;\mathbb{Z}) = \mathbb{Z}/2\).
# --- Figure : les memes 60 facteurs invariants, rendus visibles --------------
# La ligne precedente le dit en toutes lettres ; la figure montre la liste.
fig, ax = plt.subplots(figsize=(9.6, 3.2))
ax.bar(range(len(fact)), fact, width=0.9,
color=["tab:red" if d != 1 else "0.72" for d in fact])
ax.set_xlabel("indice du facteur invariant de $\\delta^1$")
ax.set_ylabel("valeur")
ax.set_yticks(range(max(fact) + 1))
ax.set_title("Les %d facteurs invariants de $\\delta^1$ : %d valent 1, %s porte la torsion"
% (len(fact), fact.count(1), [d for d in fact if d != 1]), fontsize=10)
ax.grid(axis="y", alpha=0.3)
plt.tight_layout()
plt.show()
Lecture : les deux témoins rendent ce qu’ils doivent rendre — \(H^1(S^1) = \mathbb{Z}\), \(H^2(S^2) = \mathbb{Z}\) sans torsion — et \(\mathbb{RP}^2\) livre ce que Čech-sur-\(\mathbb{Q}\) ne pouvait pas décider : \(H^2(\mathbb{RP}^2;\mathbb{Z}) = \mathbb{Z}/2\). Le \(\mathbb{Z}/2\) est lu sur le cobord lui-même — un unique facteur invariant égal à 2 dans la forme normale de Smith de \(\delta^1\), tous les autres valant 1 : la torsion n’est pas postulée, elle est le défaut de surjectivité du cobord, mesuré.
Le contraste final est le théorème des coefficients universels rendu tangible : \(b_2 = \dim_{\mathbb{Q}} H^2(;\mathbb{Q}) = 0\) et \(H^2(;\mathbb{Z}) = \mathbb{Z}/2\) sur le même espace — \(H^n(X;\mathbb{Z}) = \mathrm{Hom}(H_n, \mathbb{Z}) \oplus \mathrm{Ext}(H_{n-1}, \mathbb{Z})\), dont le second terme est précisément la part que les dimensions rationnelles laissent échapper.
Le modèle, enfin, n’est pas supposé : la triangulation à 6 sommets est vérifiée close, sans singularité et d’Euler impair, donc identifiée à \(\mathbb{RP}^2\) par le théorème de classification — et son poset des faces, dont le complexe d’ordre est la subdivision barycentrique, la représente exactement.
Construisez l’union disjointe de deux espaces d’Alexandrov (concaténer les éléments et les relations, en préfixant les noms du second pour éviter les collisions), puis vérifiez sur deux copies du cercle à 4 points que \(H^0 = 2\) (deux composantes) et \(H^1 = 2\) (une classe fondamentale par cercle).
# Exercice 1 : union disjointe de deux espaces d'Alexandrov.
def union_disjointe(E1, E2):
"""Espace union : elements de E1 (tels quels) + elements de E2
prefixes '2_', relations concatenees. Renvoie un espace."""
# Etape 1 : renommer les elements de E2 avec le prefixe '2_'
# Etape 2 : traduire les relations de E2 (via E2['order'] sans les reflexivites)
# Indice : espace(elements1 + elements2_prefixes, relations1 + relations2)
return None # TODO etudiantLe poset à 6 points \(x_1, x_2, x_3\) minimaux, \(y_1, y_2, y_3\) maximaux, \(x_i < y_j\) dès que \(i \neq j\) (un hexagone vu comme poset de hauteur 2) est lui aussi un modèle de \(S^1\). Construisez-le et vérifiez \(H^1 = 1\) — deux modèles différents, la même classe fondamentale.
Le produit de deux posets \(E_1 \times E_2\) — éléments = couples, ordre composante par composante — est le modèle fini de l’espace produit. Construisez-le, puis vérifiez l’invariance d’homotopie sur le cas le plus simple : le produit de deux cercles à 4 points est un poset à 16 points dont la cohomologie de Čech doit rendre celle du tore, \(b_0 = 1\), \(b_1 = 2\), \(b_2 = 1\) — les deux classes de degré 1 étant celles des deux facteurs. Le contrôle structurel \(\delta^2 \delta^1 = 0\) doit rester vrai.
# Exercice 3 : produit de posets et invariance d'homotopie.
def produit_posets(E1, E2):
"""Produit cartesiens : elements = couples (p, q), ordre composante
par composante. Renvoie un espace (elements = tuples)."""
# Etape 1 : elements = [(p, q) pour p dans E1 pour q dans E2]
# Etape 2 : (p,q) <= (p',q') ssi p' dans order1[p] et q' dans order2[q]
# Indice : poser seulement les relations STRICTES (a != b) pour rester leger
return None # TODO etudiantCe qu’on a distillé :
C’est le pont Serre–Grothendieck de l’EPIC #16334 : le langage de FAC — faisceaux, recouvrements, cohomologie — démontré en Lean dans grothendieck_lean, calculé ici en Python pur, sur les mêmes objets.
grothendieck_lean — la démonstration formelle (modules SheafCohomology/Basic, SheafCohomology/Cech, MayerVietoris).