Spaces:
Running
sprint34: Phase 0.3 — registre typé de métriques (clôture Phase 0)
Browse filesTroisiè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 +29 -1
- CLAUDE.md +2 -1
- picarones/core/builtin_metrics.py +163 -0
- picarones/core/metric_registry.py +263 -0
- tests/test_sprint34_metric_registry.py +288 -0
|
@@ -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 →
|
|
|
|
| 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 |
|
|
@@ -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** :
|
| 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** :
|
|
@@ -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 |
+
]
|
|
@@ -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 |
+
]
|
|
@@ -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
|