Claude commited on
Commit
9b01b52
·
unverified ·
1 Parent(s): 52be96b

sprint34: Phase 0.3 — registre typé de métriques (clôture Phase 0)

Browse files

Troisième et dernier sprint de la Phase 0 du plan d'évolution 2026.
Permet à un runner de pipeline composée de calculer automatiquement la
métrique adéquate à chaque jonction de son DAG selon les types
d'artefacts.

Nouveaux modules :
- picarones/core/metric_registry.py : MetricSpec (dataclass figée),
décorateur @register_metric(name, input_types, ...), select_metrics
par signature exacte, compute_at_junction qui orchestre toutes les
métriques applicables et tolère les erreurs unitaires (logger.warning).
- picarones/core/builtin_metrics.py : enregistre cer/wer/mer/wil sur
(TEXT, TEXT) plus le stub text_preservation_after_reconstruction sur
(TEXT, ALTO) comme preuve de concept de jonction hétérogène.

Approche strictement additive : ni metrics.py ni compute_metrics ne
sont modifiés. Le rapport HTML reste identique octet par octet (critère
de la Phase 0.3).

Tests : +21 dans test_sprint34_metric_registry.py couvrant
l'enregistrement, la sélection par signature, la résilience aux
erreurs (skip_on_error), les garde-fous (double enregistrement, arité),
le stub TEXT→ALTO et — point critique — la parité numérique
CER/WER/MER/WIL avec compute_metrics legacy à 1e-9 près sur 4 paires
de textes.
Suite complète : 1518 → 1539 passed, 2 skipped, 0 failed.

Phase 0 du plan d'évolution 2026 close. Les 3 sprints (32 GT
multi-niveaux, 33 BaseModule générique, 34 registre de métriques)
constituent la fondation commune des axes A et B. Prochaine étape :
Étape 2 du plan — premier livrable de l'axe A (NER, calibration,
divergence taxonomique, médiane par défaut, stratification script_type).

CHANGELOG.md CHANGED
@@ -16,6 +16,33 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
16
 
17
  ### Ajouté
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  - **Sprint 33 — Phase 0.2 : interface module générique.** Création de
20
  `picarones/core/modules.py` :
21
  - Enum `ArtifactType` (IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER) —
@@ -56,7 +83,8 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
56
 
57
  ### Tests
58
 
59
- - 1478 → 1518 tests (+17 Sprint 32, +23 Sprint 33). Aucune régression.
 
60
 
61
  ---
62
 
 
16
 
17
  ### Ajouté
18
 
19
+ - **Sprint 34 — Phase 0.3 : registre typé de métriques (clôture Phase 0).**
20
+ Nouveaux modules `picarones/core/metric_registry.py` et
21
+ `picarones/core/builtin_metrics.py` :
22
+ - `MetricSpec` (dataclass figée) déclare `name`, `func`,
23
+ `input_types: tuple[ArtifactType, ArtifactType]`, `description`,
24
+ `higher_is_better`, `tags`
25
+ - Décorateur `@register_metric(name=..., input_types=..., ...)`
26
+ enregistre une métrique dans un registre global ; double
27
+ enregistrement avec le même nom interdit, signature non-paire rejetée
28
+ - `select_metrics(input_types)` retourne les métriques applicables à
29
+ une jonction
30
+ - `compute_at_junction(reference, hypothesis, input_types)` calcule
31
+ toutes les métriques sélectionnées et tolère les erreurs unitaires
32
+ (`logger.warning`, jamais `except: pass`)
33
+ - `builtin_metrics.py` enregistre `cer`, `wer`, `mer`, `wil` sur
34
+ `(TEXT, TEXT)` plus le stub `text_preservation_after_reconstruction`
35
+ sur `(TEXT, ALTO)` comme preuve de concept de jonction hétérogène
36
+ - **Approche additive stricte** : ni `metrics.py` ni `compute_metrics`
37
+ ne sont modifiés ; le rapport HTML existant reste strictement
38
+ identique octet par octet
39
+ - +21 tests dans `tests/test_sprint34_metric_registry.py` couvrant
40
+ l'enregistrement, la sélection par signature exacte, la résilience
41
+ aux erreurs (`skip_on_error`), la **parité numérique** avec
42
+ `compute_metrics` legacy sur 4 paires de textes (CER/WER/MER/WIL
43
+ identiques à 1e-9 près), les garde-fous (double enregistrement,
44
+ arité), et le stub TEXT→ALTO
45
+
46
  - **Sprint 33 — Phase 0.2 : interface module générique.** Création de
47
  `picarones/core/modules.py` :
48
  - Enum `ArtifactType` (IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER) —
 
83
 
84
  ### Tests
85
 
86
+ - 1478 → 1539 tests (+17 Sprint 32, +23 Sprint 33, +21 Sprint 34). Aucune
87
+ régression sur la suite existante. **Phase 0 du plan d'évolution close.**
88
 
89
  ---
90
 
CLAUDE.md CHANGED
@@ -205,6 +205,7 @@ AZURE_DOC_INTEL_KEY=...
205
  | 23-31 | Sprints intermédiaires : anti-hallucination, sécurité institutionnelle, refactor frontend Jinja2, persistance SQLite des jobs, snapshots reproductibilité, save/load config + comparaison de runs, registre déclaratif des détecteurs, polish/a11y/DX, couverture des modules sous-testés. Voir `CHANGELOG.md` [1.1.x] pour le détail. |
206
  | 32 | **Sprint 1 du plan d'évolution 2026 — Phase 0.1 : GT multi-niveaux**. Refonte de `picarones/core/corpus.py` pour porter une vérité terrain à plusieurs niveaux (`GTLevel.{TEXT,ALTO,PAGE,ENTITIES,READING_ORDER}`), payloads typés (`TextGT`, `AltoGT`, `PageGT`, `EntitiesGT`, `ReadingOrderGT`) avec `source_path` traçable. Le champ `Document.ground_truth: str` reste la source de vérité historique et est synchronisé automatiquement avec `Document.ground_truths[GTLevel.TEXT]` — rétrocompatibilité stricte (1478 tests existants passent sans modification). Le loader détecte automatiquement `.gt.alto.xml`, `.gt.page.xml`, `.gt.entities.json`, `.gt.reading_order.json` à côté de l'image. `Corpus.gt_level_coverage()` et `Corpus.available_gt_levels` exposent la couverture. Erreurs de parse dégradées en `logger.warning` (jamais `except: pass`). +17 tests dans `test_sprint32_multi_level_gt.py`. **Verrou levé** : ce sprint débloque l'évaluation des modules qui produisent ou consomment ALTO/PAGE/entités (axe B du plan, à venir Sprint 35+) et plusieurs métriques de l'axe A (Layout F1, reading order F1, NER). |
207
  | 33 | **Sprint 2 du plan d'évolution 2026 — Phase 0.2 : interface module générique**. Nouveau module `picarones/core/modules.py` avec l'enum `ArtifactType` (IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER) et la classe abstraite `BaseModule` qui déclare `input_types`/`output_types`, `execution_mode` (`"io"`/`"cpu"`), une méthode `process(dict[ArtifactType, Any]) → dict[ArtifactType, Any]`, et des helpers `validate_inputs`/`validate_outputs`. `BaseOCREngine` (`picarones/engines/base.py`) hérite désormais de `BaseModule` avec `input_types=(IMAGE,)` et `output_types=(TEXT,)` ; sa nouvelle méthode `process` wrappe l'API historique `run()`. Aucun adaptateur OCR existant n'est touché — `test_engines.py` passe à 20/20 sans modification. +23 tests dans `test_sprint33_module_interface.py` (contrat, validation, MockModule TEXT→ALTO démonstratif comme demandé par le plan, délégation `BaseOCREngine.process → run`, cohérence ArtifactType/GTLevel). **Verrou levé** : un même runner peut maintenant exécuter un OCR (image→texte), un mappeur VLM→ALTO, un rewriter ALTO→ALTO, un module NER (texte→entités), etc. — fondation directe pour l'axe B du plan. |
 
208
 
209
  ---
210
 
@@ -251,7 +252,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
251
  ## Contexte développement
252
 
253
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
254
- - **Tests** : 1518 passed, 2 skipped (Sprints 32-33 — Phase 0.1 + 0.2 du plan d'évolution 2026)
255
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
256
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
257
  - **Transcript de la conversation de développement** :
 
205
  | 23-31 | Sprints intermédiaires : anti-hallucination, sécurité institutionnelle, refactor frontend Jinja2, persistance SQLite des jobs, snapshots reproductibilité, save/load config + comparaison de runs, registre déclaratif des détecteurs, polish/a11y/DX, couverture des modules sous-testés. Voir `CHANGELOG.md` [1.1.x] pour le détail. |
206
  | 32 | **Sprint 1 du plan d'évolution 2026 — Phase 0.1 : GT multi-niveaux**. Refonte de `picarones/core/corpus.py` pour porter une vérité terrain à plusieurs niveaux (`GTLevel.{TEXT,ALTO,PAGE,ENTITIES,READING_ORDER}`), payloads typés (`TextGT`, `AltoGT`, `PageGT`, `EntitiesGT`, `ReadingOrderGT`) avec `source_path` traçable. Le champ `Document.ground_truth: str` reste la source de vérité historique et est synchronisé automatiquement avec `Document.ground_truths[GTLevel.TEXT]` — rétrocompatibilité stricte (1478 tests existants passent sans modification). Le loader détecte automatiquement `.gt.alto.xml`, `.gt.page.xml`, `.gt.entities.json`, `.gt.reading_order.json` à côté de l'image. `Corpus.gt_level_coverage()` et `Corpus.available_gt_levels` exposent la couverture. Erreurs de parse dégradées en `logger.warning` (jamais `except: pass`). +17 tests dans `test_sprint32_multi_level_gt.py`. **Verrou levé** : ce sprint débloque l'évaluation des modules qui produisent ou consomment ALTO/PAGE/entités (axe B du plan, à venir Sprint 35+) et plusieurs métriques de l'axe A (Layout F1, reading order F1, NER). |
207
  | 33 | **Sprint 2 du plan d'évolution 2026 — Phase 0.2 : interface module générique**. Nouveau module `picarones/core/modules.py` avec l'enum `ArtifactType` (IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER) et la classe abstraite `BaseModule` qui déclare `input_types`/`output_types`, `execution_mode` (`"io"`/`"cpu"`), une méthode `process(dict[ArtifactType, Any]) → dict[ArtifactType, Any]`, et des helpers `validate_inputs`/`validate_outputs`. `BaseOCREngine` (`picarones/engines/base.py`) hérite désormais de `BaseModule` avec `input_types=(IMAGE,)` et `output_types=(TEXT,)` ; sa nouvelle méthode `process` wrappe l'API historique `run()`. Aucun adaptateur OCR existant n'est touché — `test_engines.py` passe à 20/20 sans modification. +23 tests dans `test_sprint33_module_interface.py` (contrat, validation, MockModule TEXT→ALTO démonstratif comme demandé par le plan, délégation `BaseOCREngine.process → run`, cohérence ArtifactType/GTLevel). **Verrou levé** : un même runner peut maintenant exécuter un OCR (image→texte), un mappeur VLM→ALTO, un rewriter ALTO→ALTO, un module NER (texte→entités), etc. — fondation directe pour l'axe B du plan. |
208
+ | 34 | **Sprint 3 du plan d'évolution 2026 — Phase 0.3 : registre typé de métriques (clôture Phase 0)**. Nouveaux modules `picarones/core/metric_registry.py` (`MetricSpec`, `@register_metric`, `select_metrics`, `compute_at_junction`) et `picarones/core/builtin_metrics.py` qui enregistre `cer`, `wer`, `mer`, `wil` sur `(TEXT, TEXT)` plus un stub `text_preservation_after_reconstruction` sur `(TEXT, ALTO)` comme preuve de concept de jonction hétérogène. **Approche strictement additive** : ni `metrics.py` ni `compute_metrics` ne sont modifiés, le rapport HTML reste identique octet par octet. La sélection par signature de types est exacte (pas de coercion). +21 tests dans `test_sprint34_metric_registry.py`, dont une parité numérique CER/WER/MER/WIL avec `compute_metrics` legacy à 1e-9 près sur 4 paires de textes. **Verrou levé** : le runner d'une pipeline composée peut maintenant calculer automatiquement la métrique adéquate à chaque jonction de son DAG selon les types d'artefacts produits/attendus — fondation directe pour la métrique d'absorption d'erreur (acte B.3) et toutes les métriques structurelles à venir (Layout F1, reading order F1, NER). |
209
 
210
  ---
211
 
 
252
  ## Contexte développement
253
 
254
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
255
+ - **Tests** : 1539 passed, 2 skipped (Sprints 32-34 — Phase 0 du plan d'évolution 2026 close)
256
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
257
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
258
  - **Transcript de la conversation de développement** :
picarones/core/builtin_metrics.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Métriques natives enregistrées dans le registre typé (Sprint 34).
2
+
3
+ Ce module est un démonstrateur d'enregistrement : il expose les
4
+ métriques scalaires existantes (CER, WER, MER, WIL) sous une forme
5
+ unitaire dans le registre, plus un stub typé hétérogène pour les
6
+ jonctions ``(TEXT, ALTO)``.
7
+
8
+ L'import du module suffit à peupler le registre — le décorateur
9
+ ``@register_metric`` s'exécute à l'import. Les sprints suivants (axe A
10
+ du plan d'évolution) ajouteront ici les métriques structurelles
11
+ (``reading_order_f1``, ``layout_f1``), philologiques (``unicode_block_*``,
12
+ ``mufi_coverage``), et de fiabilité (``ece``, ``mce``).
13
+
14
+ Important — pas de double calcul
15
+ -------------------------------
16
+ Ces wrappers ne **remplacent pas** ``compute_metrics`` du module
17
+ ``metrics.py``. Ils existent pour les nouveaux chemins (pipelines
18
+ composées qui calculent par jonction). Le rapport HTML existant
19
+ continue à passer par ``compute_metrics`` et reste donc strictement
20
+ identique octet par octet (critère de la Phase 0.3).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+
27
+ from picarones.core.metric_registry import register_metric
28
+ from picarones.core.modules import ArtifactType
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ try:
34
+ import jiwer
35
+ _JIWER_AVAILABLE = True
36
+ except ImportError:
37
+ _JIWER_AVAILABLE = False
38
+
39
+
40
+ # ──────────────────────────────────────────────────────────────────────────
41
+ # Métriques scalaires (TEXT, TEXT) — wrappers fins autour de jiwer
42
+ # ──────────────────────────────────────────────────────────────────────────
43
+
44
+
45
+ def _safe_jiwer_call(fn, reference: str, hypothesis: str) -> float:
46
+ """Wrapper qui gère les cas dégénérés (références ou hypothèses vides)."""
47
+ if not _JIWER_AVAILABLE:
48
+ raise RuntimeError(
49
+ "jiwer n'est pas installé — installer avec `pip install jiwer`"
50
+ )
51
+ if not reference:
52
+ return 0.0 if not hypothesis else 1.0
53
+ if not hypothesis:
54
+ return 1.0
55
+ return fn(reference, hypothesis)
56
+
57
+
58
+ @register_metric(
59
+ name="cer",
60
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
61
+ description="Character Error Rate (distance d'édition normalisée par la longueur de la GT).",
62
+ higher_is_better=False,
63
+ tags={"text", "edit_distance", "error_rate"},
64
+ )
65
+ def cer(reference: str, hypothesis: str) -> float:
66
+ """CER brut sur les caractères, via jiwer."""
67
+ return _safe_jiwer_call(jiwer.cer, reference, hypothesis)
68
+
69
+
70
+ @register_metric(
71
+ name="wer",
72
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
73
+ description="Word Error Rate.",
74
+ higher_is_better=False,
75
+ tags={"text", "edit_distance", "error_rate"},
76
+ )
77
+ def wer(reference: str, hypothesis: str) -> float:
78
+ """WER brut, via jiwer."""
79
+ return _safe_jiwer_call(jiwer.wer, reference, hypothesis)
80
+
81
+
82
+ @register_metric(
83
+ name="mer",
84
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
85
+ description="Match Error Rate (jiwer).",
86
+ higher_is_better=False,
87
+ tags={"text", "error_rate"},
88
+ )
89
+ def mer(reference: str, hypothesis: str) -> float:
90
+ return _safe_jiwer_call(jiwer.mer, reference, hypothesis)
91
+
92
+
93
+ @register_metric(
94
+ name="wil",
95
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
96
+ description="Word Information Lost (jiwer).",
97
+ higher_is_better=False,
98
+ tags={"text", "error_rate"},
99
+ )
100
+ def wil(reference: str, hypothesis: str) -> float:
101
+ return _safe_jiwer_call(jiwer.wil, reference, hypothesis)
102
+
103
+
104
+ # ──────────────────────────────────────────────────────────────────────────
105
+ # Métrique typée hétérogène (TEXT, ALTO) — stub démonstrateur
106
+ # ──────────────────────────────────────────────────────────────────────────
107
+
108
+
109
+ @register_metric(
110
+ name="text_preservation_after_reconstruction",
111
+ input_types=(ArtifactType.TEXT, ArtifactType.ALTO),
112
+ description=(
113
+ "Taux de tokens de la GT texte présents dans le texte extrait de "
114
+ "l'ALTO produit (preuve de concept ; remplaçable par une mesure "
115
+ "alignée par les sprints futurs)."
116
+ ),
117
+ higher_is_better=True,
118
+ tags={"structure", "preservation", "stub"},
119
+ )
120
+ def text_preservation_after_reconstruction(
121
+ reference_text: str,
122
+ hypothesis_alto: str,
123
+ ) -> float:
124
+ """Stub démonstrateur d'une jonction texte → ALTO.
125
+
126
+ Sprints à venir (axe A du plan d'évolution) remplaceront cette
127
+ implémentation par une vraie mesure de préservation : extraction
128
+ structurée du texte ALTO via le parser dédié, alignement, calcul
129
+ déterministe. Pour l'instant la mesure est volontairement simple
130
+ pour démontrer le mécanisme.
131
+
132
+ Parameters
133
+ ----------
134
+ reference_text:
135
+ Texte GT (niveau ``GTLevel.TEXT``).
136
+ hypothesis_alto:
137
+ ALTO XML brut produit par un module de reconstruction (niveau
138
+ ``ArtifactType.ALTO``).
139
+
140
+ Returns
141
+ -------
142
+ float
143
+ Taux de tokens uniques de ``reference_text`` apparaissant dans
144
+ ``hypothesis_alto`` (case-insensitive). ``1.0`` = tous les
145
+ tokens préservés.
146
+ """
147
+ if not reference_text:
148
+ return 1.0
149
+ ref_tokens = {tok.lower() for tok in reference_text.split() if tok}
150
+ if not ref_tokens:
151
+ return 1.0
152
+ alto_text = hypothesis_alto.lower()
153
+ preserved = sum(1 for tok in ref_tokens if tok in alto_text)
154
+ return preserved / len(ref_tokens)
155
+
156
+
157
+ __all__ = [
158
+ "cer",
159
+ "wer",
160
+ "mer",
161
+ "wil",
162
+ "text_preservation_after_reconstruction",
163
+ ]
picarones/core/metric_registry.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Registre typé de métriques (Sprint 34 — Phase 0.3 du plan d'évolution).
2
+
3
+ Pourquoi ce module
4
+ ------------------
5
+ Aujourd'hui ``compute_metrics`` (`picarones/core/metrics.py`) calcule un
6
+ ensemble fixe de métriques (CER, WER, MER, WIL) sur la paire ``(GT_text,
7
+ hypothesis_text)``. Cette signature implicite empêche d'évaluer les
8
+ sorties d'une pipeline composée à autre chose qu'à du texte : un
9
+ reconstructeur ALTO, un module NER, un mappeur VLM ne peuvent pas être
10
+ mesurés.
11
+
12
+ Le registre ci-dessous résout ce problème par typage : chaque métrique
13
+ déclare les types d'artefacts qu'elle consomme via ``@register_metric``,
14
+ et le runner d'une pipeline composée sélectionne automatiquement les
15
+ métriques applicables à chaque jonction de son DAG.
16
+
17
+ Approche additive
18
+ -----------------
19
+ Ce sprint **n'altère pas** le chemin de calcul existant. Le code legacy
20
+ (``compute_metrics`` → ``MetricsResult``) continue à fonctionner sans
21
+ modification, ce qui garantit le déterminisme bit-à-bit du rapport HTML.
22
+ Le registre est une couche supplémentaire utilisable par les nouveaux
23
+ chemins (pipelines composées, métriques typées contribuées par les
24
+ modules tiers).
25
+
26
+ Exemple d'usage
27
+ ---------------
28
+ >>> from picarones.core.modules import ArtifactType
29
+ >>> from picarones.core.metric_registry import (
30
+ ... register_metric, select_metrics, compute_at_junction,
31
+ ... )
32
+ >>>
33
+ >>> @register_metric(
34
+ ... name="my_word_count_ratio",
35
+ ... input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
36
+ ... description="Rapport du nombre de mots OCR / GT",
37
+ ... )
38
+ ... def word_count_ratio(reference: str, hypothesis: str) -> float:
39
+ ... ref = max(1, len(reference.split()))
40
+ ... return len(hypothesis.split()) / ref
41
+ >>>
42
+ >>> applicable = select_metrics((ArtifactType.TEXT, ArtifactType.TEXT))
43
+ >>> any(spec.name == "my_word_count_ratio" for spec in applicable)
44
+ True
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import logging
50
+ from dataclasses import dataclass, field
51
+ from typing import Any, Callable
52
+
53
+ from picarones.core.modules import ArtifactType
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+
58
+ # ──────────────────────────────────────────────────────────────────────────
59
+ # Spécification d'une métrique typée
60
+ # ──────────────────────────────────────────────────────────────────────────
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class MetricSpec:
65
+ """Description déclarative d'une métrique enregistrée.
66
+
67
+ Attributs
68
+ ---------
69
+ name:
70
+ Identifiant unique du registre (ex. ``"cer"``,
71
+ ``"reading_order_f1"``). Deux enregistrements avec le même
72
+ ``name`` lèvent ``ValueError`` à l'enregistrement.
73
+ func:
74
+ Fonction de calcul ``f(reference, hypothesis) -> Any``. Le type
75
+ des deux arguments doit correspondre à ``input_types``.
76
+ input_types:
77
+ Couple ``(reference_type, hypothesis_type)`` indiquant ce que la
78
+ métrique attend. Le runner sélectionne par cette signature.
79
+ description:
80
+ Phrase courte affichée dans le rapport / le glossaire.
81
+ higher_is_better:
82
+ ``True`` si une valeur plus élevée signale une meilleure qualité
83
+ (ex : F1, recall) ; ``False`` pour les métriques d'erreur (CER,
84
+ WER). Utilisé par le moteur narratif pour orienter ses
85
+ comparaisons.
86
+ tags:
87
+ Étiquettes libres pour grouper les métriques (ex. ``{"text",
88
+ "edit_distance"}`` ou ``{"structure", "icdar"}``).
89
+ """
90
+
91
+ name: str
92
+ func: Callable[..., Any]
93
+ input_types: tuple[ArtifactType, ArtifactType]
94
+ description: str = ""
95
+ higher_is_better: bool = False
96
+ tags: frozenset[str] = field(default_factory=frozenset)
97
+
98
+
99
+ # ──────────────────────────────────────────────────────────────────────────
100
+ # Registre global
101
+ # ──────────────────────────────────────────────────────────────────────────
102
+
103
+
104
+ _METRIC_REGISTRY: dict[str, MetricSpec] = {}
105
+
106
+
107
+ def register_metric(
108
+ *,
109
+ name: str,
110
+ input_types: tuple[ArtifactType, ArtifactType],
111
+ description: str = "",
112
+ higher_is_better: bool = False,
113
+ tags: frozenset[str] | set[str] | None = None,
114
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
115
+ """Décorateur d'enregistrement d'une métrique typée.
116
+
117
+ Parameters
118
+ ----------
119
+ name:
120
+ Identifiant unique.
121
+ input_types:
122
+ Couple ``(reference_artifact_type, hypothesis_artifact_type)``.
123
+ description:
124
+ Aide courte (≤ une phrase).
125
+ higher_is_better:
126
+ ``True`` pour les métriques de qualité, ``False`` pour les
127
+ métriques d'erreur.
128
+ tags:
129
+ Étiquettes pour grouper.
130
+
131
+ Raises
132
+ ------
133
+ ValueError
134
+ Si ``name`` est déjà enregistré ou si ``input_types`` n'a pas
135
+ exactement deux éléments.
136
+ """
137
+ if len(input_types) != 2:
138
+ raise ValueError(
139
+ f"input_types doit être un couple (ref, hyp) — reçu {input_types!r}"
140
+ )
141
+
142
+ frozen_tags = frozenset(tags) if tags is not None else frozenset()
143
+
144
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
145
+ if name in _METRIC_REGISTRY:
146
+ existing = _METRIC_REGISTRY[name]
147
+ if existing.func is func:
148
+ # Ré-import du module : on tolère silencieusement.
149
+ return func
150
+ raise ValueError(
151
+ f"Métrique '{name}' déjà enregistrée par "
152
+ f"{existing.func.__module__}.{existing.func.__qualname__}"
153
+ )
154
+ spec = MetricSpec(
155
+ name=name,
156
+ func=func,
157
+ input_types=input_types,
158
+ description=description,
159
+ higher_is_better=higher_is_better,
160
+ tags=frozen_tags,
161
+ )
162
+ _METRIC_REGISTRY[name] = spec
163
+ return func
164
+
165
+ return decorator
166
+
167
+
168
+ def get_metric(name: str) -> MetricSpec:
169
+ """Retourne la spec enregistrée pour ``name``.
170
+
171
+ Raises
172
+ ------
173
+ KeyError
174
+ Si la métrique n'est pas enregistrée.
175
+ """
176
+ if name not in _METRIC_REGISTRY:
177
+ raise KeyError(f"Métrique '{name}' non enregistrée")
178
+ return _METRIC_REGISTRY[name]
179
+
180
+
181
+ def all_metrics() -> list[MetricSpec]:
182
+ """Liste toutes les métriques enregistrées (ordre d'enregistrement)."""
183
+ return list(_METRIC_REGISTRY.values())
184
+
185
+
186
+ def select_metrics(
187
+ input_types: tuple[ArtifactType, ArtifactType],
188
+ ) -> list[MetricSpec]:
189
+ """Retourne les métriques applicables à une jonction donnée.
190
+
191
+ Parameters
192
+ ----------
193
+ input_types:
194
+ Couple ``(reference_type, hypothesis_type)`` à la jonction.
195
+
196
+ Returns
197
+ -------
198
+ list[MetricSpec]
199
+ Liste (potentiellement vide) des métriques dont la signature
200
+ correspond exactement.
201
+ """
202
+ return [spec for spec in _METRIC_REGISTRY.values() if spec.input_types == input_types]
203
+
204
+
205
+ def compute_at_junction(
206
+ reference: Any,
207
+ hypothesis: Any,
208
+ input_types: tuple[ArtifactType, ArtifactType],
209
+ *,
210
+ skip_on_error: bool = True,
211
+ ) -> dict[str, Any]:
212
+ """Calcule toutes les métriques applicables à une jonction.
213
+
214
+ Parameters
215
+ ----------
216
+ reference:
217
+ Artefact de référence (typiquement la GT au niveau attendu).
218
+ hypothesis:
219
+ Artefact à évaluer (sortie d'un module).
220
+ input_types:
221
+ Signature de la jonction. Détermine quelles métriques sont
222
+ sélectionnées.
223
+ skip_on_error:
224
+ Si ``True`` (défaut), une exception levée par une métrique est
225
+ loggée en warning et la métrique est absente du résultat. Si
226
+ ``False``, l'exception est propagée — utile pour les tests.
227
+
228
+ Returns
229
+ -------
230
+ dict[str, Any]
231
+ Dictionnaire ``{metric_name: value}`` pour chaque métrique
232
+ applicable qui s'est calculée sans erreur.
233
+ """
234
+ selected = select_metrics(input_types)
235
+ results: dict[str, Any] = {}
236
+ for spec in selected:
237
+ try:
238
+ results[spec.name] = spec.func(reference, hypothesis)
239
+ except Exception as exc: # noqa: BLE001
240
+ if skip_on_error:
241
+ logger.warning(
242
+ "[metric_registry] '%s' a échoué : %s — métrique ignorée",
243
+ spec.name, exc,
244
+ )
245
+ else:
246
+ raise
247
+ return results
248
+
249
+
250
+ def _reset_registry_for_tests() -> None:
251
+ """Vide le registre global. **Réservé aux tests** — ne pas appeler
252
+ en production sous peine de désactiver toutes les métriques."""
253
+ _METRIC_REGISTRY.clear()
254
+
255
+
256
+ __all__ = [
257
+ "MetricSpec",
258
+ "register_metric",
259
+ "get_metric",
260
+ "all_metrics",
261
+ "select_metrics",
262
+ "compute_at_junction",
263
+ ]
tests/test_sprint34_metric_registry.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests Sprint 34 — registre typé de métriques (Phase 0.3).
2
+
3
+ Vérifie :
4
+
5
+ 1. ``register_metric`` accepte les métriques typées et les expose via
6
+ ``all_metrics`` / ``get_metric`` / ``select_metrics``.
7
+ 2. La sélection par signature de types est exacte (pas de coercion).
8
+ 3. ``compute_at_junction`` calcule toutes les métriques applicables et
9
+ tolère les erreurs d'une métrique sans casser les autres.
10
+ 4. Les métriques natives (``builtin_metrics``) produisent les mêmes
11
+ valeurs que ``jiwer`` directement (parité numérique avec
12
+ ``compute_metrics`` legacy).
13
+ 5. Le double enregistrement avec le même nom est interdit.
14
+ 6. Une signature à 1 ou 3 éléments est rejetée.
15
+ 7. Le stub typé hétérogène ``(TEXT, ALTO)`` se calcule sans erreur.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import pytest
21
+
22
+ from picarones.core.metric_registry import (
23
+ MetricSpec,
24
+ all_metrics,
25
+ compute_at_junction,
26
+ get_metric,
27
+ register_metric,
28
+ select_metrics,
29
+ )
30
+ from picarones.core.modules import ArtifactType
31
+
32
+
33
+ # Force l'import du module qui enregistre les métriques natives. Les
34
+ # tests s'exécutent avec ce registre peuplé ; on n'utilise pas
35
+ # ``_reset_registry_for_tests`` parce qu'on veut justement tester l'état
36
+ # par défaut visible par le runner en production.
37
+ import picarones.core.builtin_metrics # noqa: F401
38
+
39
+
40
+ # ──────────────────────────────────────────────────────────────────────────
41
+ # 1 & 2. Enregistrement et sélection par signature
42
+ # ──────────────────────────────────────────────────────────────────────────
43
+
44
+
45
+ class TestRegistryBasics:
46
+ def test_builtin_metrics_loaded(self) -> None:
47
+ names = {spec.name for spec in all_metrics()}
48
+ assert {"cer", "wer", "mer", "wil"} <= names
49
+
50
+ def test_get_metric_returns_spec(self) -> None:
51
+ spec = get_metric("cer")
52
+ assert isinstance(spec, MetricSpec)
53
+ assert spec.input_types == (ArtifactType.TEXT, ArtifactType.TEXT)
54
+ assert spec.higher_is_better is False
55
+
56
+ def test_get_metric_unknown_raises(self) -> None:
57
+ with pytest.raises(KeyError):
58
+ get_metric("definitely_not_registered_42")
59
+
60
+ def test_select_text_text_includes_cer_wer(self) -> None:
61
+ selected = select_metrics((ArtifactType.TEXT, ArtifactType.TEXT))
62
+ names = {spec.name for spec in selected}
63
+ assert "cer" in names
64
+ assert "wer" in names
65
+
66
+ def test_select_alto_alto_excludes_text_metrics(self) -> None:
67
+ selected = select_metrics((ArtifactType.ALTO, ArtifactType.ALTO))
68
+ names = {spec.name for spec in selected}
69
+ assert "cer" not in names
70
+ assert "wer" not in names
71
+
72
+ def test_select_text_alto_returns_heterogeneous_metric(self) -> None:
73
+ selected = select_metrics((ArtifactType.TEXT, ArtifactType.ALTO))
74
+ names = {spec.name for spec in selected}
75
+ assert "text_preservation_after_reconstruction" in names
76
+
77
+ def test_select_returns_empty_when_no_match(self) -> None:
78
+ # ENTITIES → READING_ORDER : aucune métrique enregistrée à ce jour
79
+ assert select_metrics((ArtifactType.ENTITIES, ArtifactType.READING_ORDER)) == []
80
+
81
+
82
+ # ──────────────────────────────────────────────────────────────────────────
83
+ # 3. compute_at_junction — calcul orchestré et résilience
84
+ # ──────────────────────────────────────────────────────────────────────────
85
+
86
+
87
+ class TestComputeAtJunction:
88
+ def test_returns_all_applicable_metrics(self) -> None:
89
+ out = compute_at_junction(
90
+ "hello world",
91
+ "hello wrld",
92
+ (ArtifactType.TEXT, ArtifactType.TEXT),
93
+ )
94
+ # Au moins les 4 métriques natives doivent être présentes
95
+ for name in ("cer", "wer", "mer", "wil"):
96
+ assert name in out
97
+ assert isinstance(out[name], float)
98
+ assert 0.0 <= out[name] <= 1.0
99
+
100
+ def test_empty_dict_when_no_metric_applies(self) -> None:
101
+ # Un type d'artefact sans métrique enregistrée
102
+ out = compute_at_junction(
103
+ [], [],
104
+ (ArtifactType.ENTITIES, ArtifactType.READING_ORDER),
105
+ )
106
+ assert out == {}
107
+
108
+ def test_skip_on_error_default_true(self) -> None:
109
+ """Une métrique qui lève est ignorée, les autres tournent."""
110
+
111
+ @register_metric(
112
+ name="_test_always_raises",
113
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
114
+ description="Test only",
115
+ )
116
+ def _broken(ref: str, hyp: str) -> float:
117
+ raise RuntimeError("intentional failure")
118
+
119
+ try:
120
+ out = compute_at_junction(
121
+ "abc", "abd",
122
+ (ArtifactType.TEXT, ArtifactType.TEXT),
123
+ )
124
+ assert "_test_always_raises" not in out
125
+ # Les natives sont toujours là
126
+ assert "cer" in out
127
+ finally:
128
+ # Nettoyage manuel — pas d'API publique, on écrit dans le dict.
129
+ from picarones.core.metric_registry import _METRIC_REGISTRY
130
+
131
+ _METRIC_REGISTRY.pop("_test_always_raises", None)
132
+
133
+ def test_skip_on_error_false_propagates(self) -> None:
134
+ @register_metric(
135
+ name="_test_propagates",
136
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
137
+ )
138
+ def _broken(ref: str, hyp: str) -> float:
139
+ raise RuntimeError("propagate me")
140
+
141
+ try:
142
+ with pytest.raises(RuntimeError, match="propagate me"):
143
+ compute_at_junction(
144
+ "x", "y",
145
+ (ArtifactType.TEXT, ArtifactType.TEXT),
146
+ skip_on_error=False,
147
+ )
148
+ finally:
149
+ from picarones.core.metric_registry import _METRIC_REGISTRY
150
+
151
+ _METRIC_REGISTRY.pop("_test_propagates", None)
152
+
153
+
154
+ # ──────────────────────────────────────────────────────────────────────────
155
+ # 4. Parité numérique avec compute_metrics legacy
156
+ # ──────────────────────────────────────────────────────────────────────────
157
+
158
+
159
+ class TestParityWithLegacy:
160
+ """Le critère « rapport identique octet par octet » du Sprint 34
161
+ se traduit en : les métriques enregistrées produisent les mêmes
162
+ chiffres que ``compute_metrics`` historique sur les mêmes paires."""
163
+
164
+ @pytest.mark.parametrize(
165
+ "ref,hyp",
166
+ [
167
+ ("hello world", "hello wrld"),
168
+ ("Le manuscrit médiéval", "Le manuscript medieval"),
169
+ ("abcdef", "abcdef"), # cas parfait
170
+ ("a", "b"),
171
+ ],
172
+ )
173
+ def test_cer_matches_compute_metrics(self, ref: str, hyp: str) -> None:
174
+ from picarones.core.metrics import compute_metrics
175
+
176
+ legacy = compute_metrics(ref, hyp)
177
+ registered = compute_at_junction(
178
+ ref, hyp,
179
+ (ArtifactType.TEXT, ArtifactType.TEXT),
180
+ )
181
+ # On compare au CER brut, pas aux variantes (NFC, caseless,
182
+ # diplomatic) qui sont des métriques distinctes non encore
183
+ # enregistrées.
184
+ assert registered["cer"] == pytest.approx(legacy.cer, abs=1e-9)
185
+ assert registered["wer"] == pytest.approx(legacy.wer, abs=1e-9)
186
+ assert registered["mer"] == pytest.approx(legacy.mer, abs=1e-9)
187
+ assert registered["wil"] == pytest.approx(legacy.wil, abs=1e-9)
188
+
189
+
190
+ # ──────────────────────────────────────────────────────────────────────────
191
+ # 5 & 6. Garde-fous d'enregistrement
192
+ # ──────────────────────────────────────────────────────────────────────────
193
+
194
+
195
+ class TestRegistrationGuards:
196
+ def test_double_register_same_name_raises(self) -> None:
197
+ @register_metric(
198
+ name="_test_duplicate",
199
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
200
+ )
201
+ def _first(ref: str, hyp: str) -> float:
202
+ return 0.0
203
+
204
+ try:
205
+ with pytest.raises(ValueError, match="déjà enregistrée"):
206
+
207
+ @register_metric(
208
+ name="_test_duplicate",
209
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
210
+ )
211
+ def _second(ref: str, hyp: str) -> float:
212
+ return 1.0
213
+ finally:
214
+ from picarones.core.metric_registry import _METRIC_REGISTRY
215
+
216
+ _METRIC_REGISTRY.pop("_test_duplicate", None)
217
+
218
+ def test_re_register_same_function_tolerated(self) -> None:
219
+ """Ré-importer le module ne doit pas lever (cas réel : pytest
220
+ recharge un module entre fichiers de tests)."""
221
+
222
+ def _func(ref: str, hyp: str) -> float:
223
+ return 0.0
224
+
225
+ register_metric(
226
+ name="_test_idempotent",
227
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
228
+ )(_func)
229
+ # Second appel avec la même fonction → tolérance
230
+ register_metric(
231
+ name="_test_idempotent",
232
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
233
+ )(_func)
234
+
235
+ from picarones.core.metric_registry import _METRIC_REGISTRY
236
+
237
+ _METRIC_REGISTRY.pop("_test_idempotent", None)
238
+
239
+ def test_input_types_must_be_pair(self) -> None:
240
+ with pytest.raises(ValueError, match="couple"):
241
+
242
+ @register_metric(
243
+ name="_bad_arity_3",
244
+ input_types=( # type: ignore[arg-type]
245
+ ArtifactType.TEXT,
246
+ ArtifactType.TEXT,
247
+ ArtifactType.TEXT,
248
+ ),
249
+ )
250
+ def _f(a, b, c):
251
+ return 0.0
252
+
253
+
254
+ # ──────────────────────────────────────────────────────────────────────────
255
+ # 7. Stub TEXT → ALTO opérationnel
256
+ # ──────────────────────────────────────────────────────────────────────────
257
+
258
+
259
+ class TestHeterogeneousJunction:
260
+ def test_text_preservation_runs(self) -> None:
261
+ ref = "le manuscrit médiéval"
262
+ alto = (
263
+ '<?xml version="1.0"?><alto>'
264
+ '<String CONTENT="le"/><String CONTENT="manuscrit"/>'
265
+ '<String CONTENT="médiéval"/></alto>'
266
+ )
267
+
268
+ out = compute_at_junction(
269
+ ref, alto,
270
+ (ArtifactType.TEXT, ArtifactType.ALTO),
271
+ )
272
+ assert "text_preservation_after_reconstruction" in out
273
+ assert out["text_preservation_after_reconstruction"] == pytest.approx(1.0)
274
+
275
+ def test_text_preservation_partial(self) -> None:
276
+ ref = "alpha beta gamma"
277
+ alto = '<?xml version="1.0"?><alto><String CONTENT="alpha"/></alto>'
278
+
279
+ score = compute_at_junction(
280
+ ref, alto,
281
+ (ArtifactType.TEXT, ArtifactType.ALTO),
282
+ )["text_preservation_after_reconstruction"]
283
+ # 1 token sur 3 préservé
284
+ assert score == pytest.approx(1 / 3, abs=1e-9)
285
+
286
+ def test_text_preservation_metric_marked_higher_is_better(self) -> None:
287
+ spec = get_metric("text_preservation_after_reconstruction")
288
+ assert spec.higher_is_better is True