Claude commited on
Commit
503d263
·
unverified ·
1 Parent(s): 4287328

feat(migration): Phase 5.C batch 7 — pré-requis + 2 derniers renderers

Browse files

Septième et dernière vague de Phase 5.C. Migre d'abord les
modules de mesure dont dépendent les renderers
``numerical_sequences`` et ``pipeline``, puis migre ces 2 derniers
renderers vers ``reports_v2/html/renderers/``.

Pré-requis migrés (modules de mesure)
-------------------------------------
| Source legacy | Destination canonique |
|------------------------------------------------|------------------------------------------------|
| ``measurements/roman_numerals.py`` (478) | ``evaluation/metrics/roman_numerals.py`` |
| ``measurements/numerical_sequences.py`` (422) | ``evaluation/metrics/numerical_sequences.py`` |
| ``measurements/pipeline_benchmark.py`` (367) | ``evaluation/pipeline_benchmark.py`` |
| ``measurements/pipeline_comparison.py`` (301) | ``evaluation/pipeline_comparison.py`` |
| ``core/pipeline.py`` (607) | ``evaluation/pipeline.py`` |

Puis les 2 derniers renderers
-----------------------------
| Source legacy | Destination canonique |
|------------------------------------------------|------------------------------------------------------|
| ``report/numerical_sequences_render.py`` (149) | ``reports_v2/html/renderers/numerical_sequences.py`` |
| ``report/pipeline_render.py`` (707) | ``reports_v2/html/renderers/pipeline.py`` |

Total : ~3031 lignes relocalisées. 7 nouveaux shims minimaux.

État final de ``picarones/core/``
---------------------------------
Le répertoire ``picarones/core/`` est désormais **entièrement
constitué de shims** (10 fichiers, tous < 30 lignes). Aucun
module Cercle 1 réel ne subsiste — les abstractions vivent dans
``domain/`` (Pydantic immutable) et ``evaluation/`` (riche en
behavior). ``EXPECTED_CERCLE1`` du test
``test_public_api.py::TestCercle1IsLean`` est désormais un set
vide, documentant explicitement que la Phase 1 du retrait du
legacy est complète au niveau ``core/``.

Adaptations transverses
-----------------------
- Imports internes mis à jour entre modules canoniques.
- ``test_module_coverage.py::TEST_ONLY_BASELINE`` étendu à 4
modules supplémentaires.
- ``test_file_budgets.py`` : 4 entrées legacy retirées,
remplacées par les chemins canoniques.
- ``docs/tutorials/writing-a-pipeline-module.md`` : tous les
imports mis à jour.

Cumul Phase 5.C
---------------
**29 / 29 renderers migrés** (~8263 lignes au total) à travers
les 7 batches. Phase 5.C est terminée.

Acceptance
----------
5019 tests passent, lint vert, architecture vérifiée.

Restantes pour Phase 5
----------------------
- Phase 5.D : 5 vues (``views/*.py``).
- Phase 5.E : ``generator.py``, ``comparison.py``,
``snapshot.py``, ``report_data/``, templates Jinja2.

https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP

Files changed (39) hide show
  1. docs/migration/legacy-retirement-plan.md +72 -6
  2. docs/tutorials/writing-a-pipeline-module.md +6 -6
  3. picarones/__init__.py +1 -1
  4. picarones/cli/_pipeline.py +4 -4
  5. picarones/core/pipeline.py +11 -600
  6. picarones/evaluation/metrics/numerical_sequences.py +428 -0
  7. picarones/evaluation/metrics/roman_numerals.py +484 -0
  8. picarones/evaluation/pipeline.py +622 -0
  9. picarones/evaluation/pipeline_benchmark.py +373 -0
  10. picarones/evaluation/pipeline_comparison.py +307 -0
  11. picarones/measurements/builtin_hooks.py +2 -2
  12. picarones/measurements/numerical_sequences.py +10 -414
  13. picarones/measurements/numerical_sequences_hooks.py +1 -1
  14. picarones/measurements/philological_hooks.py +2 -2
  15. picarones/measurements/pipeline_benchmark.py +10 -359
  16. picarones/measurements/pipeline_comparison.py +11 -294
  17. picarones/measurements/pipeline_spec_loader.py +1 -1
  18. picarones/measurements/roman_numerals.py +10 -470
  19. picarones/report/generator.py +1 -1
  20. picarones/report/numerical_sequences_render.py +11 -142
  21. picarones/report/pipeline_render.py +11 -700
  22. picarones/report/views/pipeline.py +1 -1
  23. picarones/reports_v2/html/renderers/numerical_sequences.py +155 -0
  24. picarones/reports_v2/html/renderers/pipeline.py +713 -0
  25. tests/architecture/test_file_budgets.py +14 -4
  26. tests/architecture/test_module_coverage.py +11 -0
  27. tests/core/test_public_api.py +20 -19
  28. tests/core/test_sprint63_pipeline_runner.py +1 -1
  29. tests/core/test_sprint66_dag_branching.py +1 -1
  30. tests/integration/test_alto_baseline.py +1 -1
  31. tests/integration/test_pipeline_ocr_to_alto.py +1 -1
  32. tests/integration/test_sprint69_user_doc.py +4 -4
  33. tests/measurements/test_sprint60_roman_numerals.py +1 -1
  34. tests/measurements/test_sprint64_pipeline_benchmark.py +2 -2
  35. tests/measurements/test_sprint65_pipeline_comparison.py +2 -2
  36. tests/measurements/test_sprint85_numerical_sequences.py +1 -1
  37. tests/report/test_sprint67_pipeline_html.py +2 -2
  38. tests/report/test_sprint68_pipeline_comparison_html.py +3 -3
  39. tests/report/test_sprint86_aii5_html.py +1 -1
docs/migration/legacy-retirement-plan.md CHANGED
@@ -696,12 +696,11 @@ architecture vérifiée.
696
  - Batch 4 ✅ (cf. ci-dessous) — 5 renderers (188-321 LOC).
697
  - Batch 5 ✅ (cf. ci-dessous) — 5 renderers (148-314 LOC).
698
  - Batch 6 ✅ (cf. ci-dessous) — 2 renderers (``levers``, ``philological``).
699
- - Batch 7 (final) : ``pipeline_render`` (707 l) +
700
- ``numerical_sequences_render`` (149 l).
701
- Pré-requis : migration de ``measurements/pipeline_benchmark``,
702
- ``measurements/pipeline_comparison``,
703
- ``measurements/numerical_sequences``,
704
- ``measurements/roman_numerals`` vers ``evaluation/metrics/``.
705
  - Phase 5.D : 5 vues (``views/*.py``).
706
  - Phase 5.E : ``generator.py``, ``comparison.py``,
707
  ``snapshot.py``, ``report_data/``, templates Jinja2.
@@ -886,6 +885,73 @@ Total : ~776 lignes relocalisées.
886
  **Acceptance batch 6** : 5019 tests passent, lint vert,
887
  architecture vérifiée.
888
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
889
  ### Phase 6 — Pipelines OCR+LLM (`pipelines/`)
890
 
891
  **Modules** : `pipelines/base.OCRLLMPipeline` (3 modes), `pipelines/
 
696
  - Batch 4 ✅ (cf. ci-dessous) — 5 renderers (188-321 LOC).
697
  - Batch 5 ✅ (cf. ci-dessous) — 5 renderers (148-314 LOC).
698
  - Batch 6 ✅ (cf. ci-dessous) — 2 renderers (``levers``, ``philological``).
699
+ - Batch 7 (cf. ci-dessous) — pré-requis migrés
700
+ (``roman_numerals``, ``numerical_sequences``,
701
+ ``pipeline_benchmark``, ``pipeline_comparison``,
702
+ ``core/pipeline``) puis 2 renderers
703
+ (``numerical_sequences``, ``pipeline``).
 
704
  - Phase 5.D : 5 vues (``views/*.py``).
705
  - Phase 5.E : ``generator.py``, ``comparison.py``,
706
  ``snapshot.py``, ``report_data/``, templates Jinja2.
 
885
  **Acceptance batch 6** : 5019 tests passent, lint vert,
886
  architecture vérifiée.
887
 
888
+ #### Phase 5.C.batch7 — Lot 7 : pré-requis + 2 derniers renderers (2026-05)
889
+
890
+ Le batch 7 finalise Phase 5.C en migrant **d'abord** les
891
+ modules de mesure dont dépendent les renderers
892
+ ``numerical_sequences`` et ``pipeline`` :
893
+
894
+ | Source legacy | Destination canonique |
895
+ |------------------------------------------------|------------------------------------------------|
896
+ | ``measurements/roman_numerals.py`` (478) | ``evaluation/metrics/roman_numerals.py`` |
897
+ | ``measurements/numerical_sequences.py`` (422) | ``evaluation/metrics/numerical_sequences.py`` |
898
+ | ``measurements/pipeline_benchmark.py`` (367) | ``evaluation/pipeline_benchmark.py`` |
899
+ | ``measurements/pipeline_comparison.py`` (301) | ``evaluation/pipeline_comparison.py`` |
900
+ | ``core/pipeline.py`` (607) | ``evaluation/pipeline.py`` |
901
+
902
+ Puis les 2 derniers renderers :
903
+
904
+ | Source legacy | Destination canonique |
905
+ |------------------------------------------------|------------------------------------------------------|
906
+ | ``report/numerical_sequences_render.py`` (149) | ``reports_v2/html/renderers/numerical_sequences.py`` |
907
+ | ``report/pipeline_render.py`` (707) | ``reports_v2/html/renderers/pipeline.py`` |
908
+
909
+ Total : ~3031 lignes relocalisées dans ce batch. 7 nouveaux
910
+ shims minimaux (< 25 lignes) avec ``DeprecationWarning``.
911
+
912
+ État final de ``picarones/core/``
913
+ ---------------------------------
914
+
915
+ Le répertoire ``picarones/core/`` est désormais **entièrement
916
+ constitué de shims** (10 fichiers, tous < 30 lignes). Aucun
917
+ module Cercle 1 réel ne subsiste — les abstractions vivent dans
918
+ ``domain/`` (Pydantic immutable) et ``evaluation/`` (riche en
919
+ behavior). ``EXPECTED_CERCLE1`` du test
920
+ ``test_public_api.py::TestCercle1IsLean`` est désormais un set
921
+ vide, documentant explicitement que la Phase 1 du retrait du
922
+ legacy est complète au niveau ``core/``.
923
+
924
+ Adaptations transverses
925
+ -----------------------
926
+
927
+ - Imports internes mis à jour entre modules canoniques
928
+ (``evaluation/metrics/numerical_sequences.py`` → canonique
929
+ ``roman_numerals``, ``evaluation/pipeline_comparison.py`` →
930
+ canonique ``pipeline_benchmark``, etc.).
931
+ - ``test_module_coverage.py::TEST_ONLY_BASELINE`` étendu à
932
+ ``"numerical_sequences"``, ``"numerical_sequences_hooks"``,
933
+ ``"pipeline_benchmark"``, ``"pipeline_comparison"``.
934
+ - ``test_file_budgets.py`` : 4 entrées legacy retirées,
935
+ remplacées par les chemins canoniques.
936
+ - ``test_public_api.py::EXPECTED_CERCLE1`` : ``pipeline.py``
937
+ retiré (set désormais vide).
938
+ - ``docs/tutorials/writing-a-pipeline-module.md`` : tous les
939
+ imports mis à jour vers les chemins canoniques.
940
+
941
+ **Cumul Phase 5.C** (batches 1-7) : **29 / 29 renderers migrés**
942
+ (~8263 lignes au total). Phase 5.C est terminée.
943
+
944
+ **Acceptance batch 7** : 5019 tests passent, lint vert,
945
+ architecture vérifiée (anti-cycles, file budgets,
946
+ EXPECTED_CERCLE1 vide).
947
+
948
+ Restantes pour Phase 5
949
+ ----------------------
950
+
951
+ - Phase 5.D : 5 vues (``views/*.py``).
952
+ - Phase 5.E : ``generator.py``, ``comparison.py``,
953
+ ``snapshot.py``, ``report_data/``, templates Jinja2.
954
+
955
  ### Phase 6 — Pipelines OCR+LLM (`pipelines/`)
956
 
957
  **Modules** : `pipelines/base.OCRLLMPipeline` (3 modes), `pipelines/
docs/tutorials/writing-a-pipeline-module.md CHANGED
@@ -18,7 +18,7 @@
18
 
19
  ```python
20
  from picarones.core.modules import BaseModule, ArtifactType
21
- from picarones.core.pipeline import (
22
  PipelineRunner, PipelineSpec, PipelineStep,
23
  )
24
 
@@ -150,7 +150,7 @@ class NERExtractor(BaseModule):
150
  ### 3.a Mono-document (Sprint 63)
151
 
152
  ```python
153
- from picarones.core.pipeline import (
154
  PipelineRunner, PipelineSpec, PipelineStep,
155
  )
156
 
@@ -178,7 +178,7 @@ que `Document.ground_truths` porte une `TextGT` (ou `AltoGT`,
178
  ### 3.b Corpus complet (Sprint 64)
179
 
180
  ```python
181
- from picarones.measurements.pipeline_benchmark import run_pipeline_benchmark
182
 
183
  bench = run_pipeline_benchmark(spec, my_corpus)
184
  print(bench.n_pipelines_succeeded, "/", bench.n_docs)
@@ -203,7 +203,7 @@ bench = run_pipeline_benchmark(spec, corpus, initial_inputs_factory=my_factory)
203
  ### 3.c Comparer N pipelines (Sprint 65)
204
 
205
  ```python
206
- from picarones.measurements.pipeline_comparison import compare_pipelines
207
 
208
  comparison = compare_pipelines(
209
  [spec_baseline, spec_with_correcteur_a, spec_with_correcteur_b],
@@ -259,7 +259,7 @@ Sans `inputs_from`, `correct_b` aurait reçu la sortie de
259
 
260
  ```python
261
  from pathlib import Path
262
- from picarones.report.pipeline_render import build_pipeline_report_html
263
 
264
  bench = run_pipeline_benchmark(spec, corpus)
265
  Path("rapport_pipeline.html").write_text(
@@ -271,7 +271,7 @@ Path("rapport_pipeline.html").write_text(
271
 
272
  ```python
273
  from picarones.core.modules import ArtifactType
274
- from picarones.report.pipeline_render import (
275
  RankingSpec, build_pipeline_comparison_report_html,
276
  )
277
 
 
18
 
19
  ```python
20
  from picarones.core.modules import BaseModule, ArtifactType
21
+ from picarones.evaluation.pipeline import (
22
  PipelineRunner, PipelineSpec, PipelineStep,
23
  )
24
 
 
150
  ### 3.a Mono-document (Sprint 63)
151
 
152
  ```python
153
+ from picarones.evaluation.pipeline import (
154
  PipelineRunner, PipelineSpec, PipelineStep,
155
  )
156
 
 
178
  ### 3.b Corpus complet (Sprint 64)
179
 
180
  ```python
181
+ from picarones.evaluation.pipeline_benchmark import run_pipeline_benchmark
182
 
183
  bench = run_pipeline_benchmark(spec, my_corpus)
184
  print(bench.n_pipelines_succeeded, "/", bench.n_docs)
 
203
  ### 3.c Comparer N pipelines (Sprint 65)
204
 
205
  ```python
206
+ from picarones.evaluation.pipeline_comparison import compare_pipelines
207
 
208
  comparison = compare_pipelines(
209
  [spec_baseline, spec_with_correcteur_a, spec_with_correcteur_b],
 
259
 
260
  ```python
261
  from pathlib import Path
262
+ from picarones.reports_v2.html.renderers.pipeline import build_pipeline_report_html
263
 
264
  bench = run_pipeline_benchmark(spec, corpus)
265
  Path("rapport_pipeline.html").write_text(
 
271
 
272
  ```python
273
  from picarones.core.modules import ArtifactType
274
+ from picarones.reports_v2.html.renderers.pipeline import (
275
  RankingSpec, build_pipeline_comparison_report_html,
276
  )
277
 
picarones/__init__.py CHANGED
@@ -69,7 +69,7 @@ from picarones.domain.facts import (
69
  FactImportance,
70
  FactType,
71
  )
72
- from picarones.core.pipeline import (
73
  PipelineResult,
74
  PipelineRunner,
75
  PipelineSpec,
 
69
  FactImportance,
70
  FactType,
71
  )
72
+ from picarones.evaluation.pipeline import (
73
  PipelineResult,
74
  PipelineRunner,
75
  PipelineSpec,
picarones/cli/_pipeline.py CHANGED
@@ -66,7 +66,7 @@ def pipeline_run_cmd(
66
  import json as _json
67
 
68
  from picarones.evaluation.corpus import load_corpus_from_directory
69
- from picarones.measurements.pipeline_benchmark import run_pipeline_benchmark
70
  from picarones.measurements.pipeline_spec_loader import load_pipeline_spec_from_yaml
71
 
72
  spec = load_pipeline_spec_from_yaml(spec_path)
@@ -114,7 +114,7 @@ def pipeline_run_cmd(
114
  )
115
  click.echo(f"JSON exporté : {output_json}")
116
  if output_html is not None:
117
- from picarones.report.pipeline_render import build_pipeline_report_html
118
  Path(output_html).write_text(
119
  build_pipeline_report_html(bench, lang=lang),
120
  encoding="utf-8",
@@ -163,7 +163,7 @@ def pipeline_compare_cmd(
163
  """Compare N pipelines décrites dans SPECS_PATH sur le même corpus."""
164
  from picarones.evaluation.corpus import load_corpus_from_directory
165
  from picarones.domain.artifacts import ArtifactType
166
- from picarones.measurements.pipeline_comparison import compare_pipelines
167
  from picarones.measurements.pipeline_spec_loader import (
168
  load_comparison_specs_from_yaml,
169
  )
@@ -187,7 +187,7 @@ def pipeline_compare_cmd(
187
  shown = f"{value:.4f}" if value is not None else "N/A"
188
  click.echo(f" {i}. {name}: {shown}")
189
  if output_html is not None:
190
- from picarones.report.pipeline_render import (
191
  RankingSpec,
192
  build_pipeline_comparison_report_html,
193
  )
 
66
  import json as _json
67
 
68
  from picarones.evaluation.corpus import load_corpus_from_directory
69
+ from picarones.evaluation.pipeline_benchmark import run_pipeline_benchmark
70
  from picarones.measurements.pipeline_spec_loader import load_pipeline_spec_from_yaml
71
 
72
  spec = load_pipeline_spec_from_yaml(spec_path)
 
114
  )
115
  click.echo(f"JSON exporté : {output_json}")
116
  if output_html is not None:
117
+ from picarones.reports_v2.html.renderers.pipeline import build_pipeline_report_html
118
  Path(output_html).write_text(
119
  build_pipeline_report_html(bench, lang=lang),
120
  encoding="utf-8",
 
163
  """Compare N pipelines décrites dans SPECS_PATH sur le même corpus."""
164
  from picarones.evaluation.corpus import load_corpus_from_directory
165
  from picarones.domain.artifacts import ArtifactType
166
+ from picarones.evaluation.pipeline_comparison import compare_pipelines
167
  from picarones.measurements.pipeline_spec_loader import (
168
  load_comparison_specs_from_yaml,
169
  )
 
187
  shown = f"{value:.4f}" if value is not None else "N/A"
188
  click.echo(f" {i}. {name}: {shown}")
189
  if output_html is not None:
190
+ from picarones.reports_v2.html.renderers.pipeline import (
191
  RankingSpec,
192
  build_pipeline_comparison_report_html,
193
  )
picarones/core/pipeline.py CHANGED
@@ -1,607 +1,18 @@
1
- """Banc d'essai de pipelines composées Sprint 63 (axe B).
2
 
3
- Sprint 63 — Étape 4 / axe B du plan d'évolution 2026 : démarrage du
4
- banc d'essai de pipelines.
5
-
6
- Philosophie
7
- -----------
8
- Picarones est un **banc d'essai**, pas un atelier de production.
9
- Cette infrastructure permet d'**évaluer des pipelines composées de
10
- modules tiers** que l'utilisateur amène — par exemple :
11
-
12
- - ``[OCR(image→texte)] → [reconstructeur ALTO tiers(texte→ALTO)]``
13
- - ``[VLM(image→ALTO)] → [post-processing tiers(ALTO→ALTO)]``
14
- - ``[OCR(image→texte)] → [LLM correcteur(texte→texte)]``
15
-
16
- Picarones **ne fournit aucun module métier** (pas de
17
- reconstructeur ALTO, pas de correcteur, pas de re-segmenteur).
18
- L'utilisateur branche ses propres ``BaseModule`` (Sprint 33), le
19
- runner orchestre l'exécution séquentielle, valide les types aux
20
- jonctions et **évalue automatiquement** chaque artefact produit
21
- contre la GT du même niveau (Sprint 32) en sélectionnant les
22
- métriques pertinentes du registre typé (Sprint 34).
23
-
24
- Périmètre Sprint 63
25
- -------------------
26
- Inclus :
27
-
28
- - Spécification déclarative d'une pipeline séquentielle.
29
- - Exécution sur un seul document avec passage typé d'artefacts.
30
- - Validation des types aux jonctions inter-modules.
31
- - Évaluation automatique aux jonctions GT-vs-sortie pour chaque
32
- niveau de GT disponible sur le document.
33
- - Mesure du temps par étape.
34
- - Capture gracieuse des erreurs (un module qui lève n'arrête pas
35
- les étapes suivantes — leur entrée manquante est rapportée
36
- comme erreur explicite).
37
-
38
- Reporté à des sprints dédiés :
39
-
40
- - DAG branchant non séquentiel (1 → {2, 3} → 4) — Sprint 64+.
41
- - Orchestration corpus-wide + agrégation par pipeline — Sprint 65+.
42
- - Vue HTML dédiée aux pipelines composées — Sprint 66+.
43
- - Cache d'artefacts intermédiaires — non prévu.
44
- - Parallélisation inter-étapes — non prévue (les modules
45
- ``execution_mode`` sont déjà respectés par le runner historique
46
- pour le bench OCR mono-étage).
47
  """
48
 
49
  from __future__ import annotations
50
 
51
- import logging
52
- import time
53
- from dataclasses import dataclass, field
54
- from typing import Any, Optional
55
-
56
- from picarones.evaluation.corpus import Document, GTLevel
57
- from picarones.evaluation.metric_registry import compute_at_junction
58
- from picarones.domain.artifacts import ArtifactType
59
- from picarones.domain.module_protocol import BaseModule
60
-
61
- # Sprint A3 (renforce la règle Cercle 1 → Cercle 1 uniquement) — la
62
- # cérémonie d'eager-load des métriques typées (Sprint 34) qui vivait
63
- # ici a été déplacée dans ``picarones/measurements/__init__.py``. Tout
64
- # consommateur de ``compute_at_junction`` (typiquement la classe
65
- # ``PipelineRunner`` ci-dessous) doit avoir importé
66
- # ``picarones.measurements`` au moins une fois — c'est le cas dans
67
- # l'API publique via ``picarones.__init__`` qui déclenche le trigger.
68
-
69
- logger = logging.getLogger(__name__)
70
-
71
-
72
- # ──────────────────────────────────────────────────────────────────────────
73
- # Conversion ArtifactType <-> GTLevel
74
- # ──────────────────────────────────────────────────────────────────────────
75
-
76
-
77
- #: Map ``ArtifactType`` canonique → ``GTLevel`` legacy. Phase 4-bis :
78
- #: ``ArtifactType`` a été migré vers ``domain/artifacts.py`` qui
79
- #: distingue ``RAW_TEXT``/``CORRECTED_TEXT`` (vs ``TEXT`` legacy) et
80
- #: ``ALTO_XML``/``PAGE_XML`` (vs ``ALTO``/``PAGE`` legacy). Les
81
- #: valeurs canoniques ne matchent donc plus celles de ``GTLevel``.
82
- #: Ce mapping explicite fait le pont — sera retiré en 2.0 quand
83
- #: ``GTLevel`` aura aussi été retiré au profit de la projection
84
- #: ``ArtifactType → niveau d'évaluation`` du rewrite.
85
- _ARTIFACT_TO_GT_LEVEL: dict[ArtifactType, GTLevel] = {
86
- ArtifactType.RAW_TEXT: GTLevel.TEXT,
87
- ArtifactType.CORRECTED_TEXT: GTLevel.TEXT,
88
- ArtifactType.ALTO_XML: GTLevel.ALTO,
89
- ArtifactType.PAGE_XML: GTLevel.PAGE,
90
- ArtifactType.ENTITIES: GTLevel.ENTITIES,
91
- ArtifactType.READING_ORDER: GTLevel.READING_ORDER,
92
- }
93
-
94
-
95
- def _artifact_type_to_gt_level(at: ArtifactType) -> Optional[GTLevel]:
96
- """Retourne le ``GTLevel`` correspondant à un ``ArtifactType``.
97
-
98
- ``IMAGE`` n'a pas de correspondance GT (on n'évalue pas une
99
- image en sortie d'un module — c'est typiquement une entrée).
100
- Les types ``CONFIDENCES``, ``ALIGNMENT``, ``CANONICAL_DOCUMENT``
101
- n'ont pas non plus de niveau de GT direct dans le legacy.
102
- """
103
- return _ARTIFACT_TO_GT_LEVEL.get(at)
104
-
105
-
106
- # ──────────────────────────────────────────────────────────────────────────
107
- # PipelineStep + PipelineSpec
108
- # ──────────────────────────────────────────────────────────────────────────
109
-
110
-
111
- @dataclass
112
- class PipelineStep:
113
- """Une étape dans une pipeline composée.
114
-
115
- L'étape porte un nom lisible (utile pour le rapport et le
116
- diagnostic) et une instance de ``BaseModule`` fournie par
117
- l'utilisateur. Les types d'entrée et de sortie ne sont pas
118
- redéclarés ici : ils sont lus depuis le module lui-même
119
- (``module.input_types`` / ``module.output_types``).
120
-
121
- Sprint 66 — DAG branchant
122
- -------------------------
123
- ``inputs_from`` permet de désigner explicitement, pour chaque
124
- type d'entrée, l'étape source dont on veut consommer l'artefact.
125
- Utile quand plusieurs étapes antérieures produisent le même
126
- type et qu'on veut éviter l'écrasement implicite (par exemple
127
- deux correcteurs LLM en parallèle qui partent du même OCR).
128
-
129
- - ``inputs_from = {}`` (défaut) : pour chaque type d'entrée,
130
- le runner prend la version **la plus récente** disponible
131
- dans le bag (comportement Sprint 63, rétrocompat stricte).
132
- - ``inputs_from = {ArtifactType.TEXT: "ocr"}`` : exige la
133
- version du ``TEXT`` produite par l'étape nommée ``"ocr"``.
134
- Si cette étape n'existe pas ou n'a pas produit ce type,
135
- ``PipelineSpec.validate`` remonte un problème explicite et
136
- le runner remonte une erreur d'entrée manquante.
137
-
138
- La chaîne spéciale ``"__initial__"`` désigne les artefacts
139
- fournis dans ``initial_inputs`` (par exemple ``IMAGE``).
140
- """
141
-
142
- name: str
143
- module: BaseModule
144
- inputs_from: dict[ArtifactType, str] = field(default_factory=dict)
145
-
146
- @property
147
- def input_types(self) -> tuple[ArtifactType, ...]:
148
- return tuple(self.module.input_types)
149
-
150
- @property
151
- def output_types(self) -> tuple[ArtifactType, ...]:
152
- return tuple(self.module.output_types)
153
-
154
- def __repr__(self) -> str:
155
- ins = ",".join(t.value for t in self.input_types) or "·"
156
- outs = ",".join(t.value for t in self.output_types) or "·"
157
- if self.inputs_from:
158
- refs = ",".join(
159
- f"{t.value}@{src}" for t, src in self.inputs_from.items()
160
- )
161
- return f"PipelineStep({self.name}: [{refs}] → {outs})"
162
- return f"PipelineStep({self.name}: {ins} → {outs})"
163
-
164
-
165
- @dataclass
166
- class PipelineSpec:
167
- """DAG séquentiel de ``PipelineStep``.
168
-
169
- Sprint 63 — séquentiel uniquement : l'étape ``i+1`` consomme
170
- les artefacts produits par l'étape ``i`` (et tous les artefacts
171
- initiaux fournis au runner, par exemple l'image source).
172
-
173
- Le DAG branchant arrive dans un sprint dédié.
174
- """
175
-
176
- name: str
177
- steps: list[PipelineStep] = field(default_factory=list)
178
-
179
- def validate(self, initial_inputs: tuple[ArtifactType, ...]) -> list[str]:
180
- """Vérifie que les types s'enchaînent et retourne la liste
181
- des problèmes détectés (vide si la pipeline est valide).
182
-
183
- Une pipeline est valide si, pour chaque étape, tous les
184
- ``input_types`` sont disponibles : soit dans les
185
- ``initial_inputs`` (typiquement ``IMAGE``), soit produits
186
- par une étape antérieure.
187
-
188
- Sprint 66 — validation des références ``inputs_from`` :
189
- si une étape déclare ``inputs_from[type] = "foo"``,
190
- l'étape ``foo`` doit exister parmi les étapes antérieures
191
- et avoir ce type dans ses ``output_types``. La chaîne
192
- spéciale ``"__initial__"`` désigne les entrées initiales.
193
- """
194
- problems: list[str] = []
195
- if not self.steps:
196
- problems.append("pipeline vide : au moins une étape est requise")
197
- return problems
198
- # Map type → set des steps qui ont produit ce type
199
- # ("__initial__" pour les entrées initiales) — utilisé pour
200
- # valider les références ``inputs_from``.
201
- producers: dict[ArtifactType, set[str]] = {
202
- t: {"__initial__"} for t in initial_inputs
203
- }
204
- # Map step_name → set des types produits, pour la validation
205
- # des références.
206
- step_outputs: dict[str, set[ArtifactType]] = {
207
- "__initial__": set(initial_inputs),
208
- }
209
- # Set des types disponibles à un instant t (latest seulement).
210
- available: set[ArtifactType] = set(initial_inputs)
211
-
212
- for i, step in enumerate(self.steps):
213
- # 1. Toutes les entrées doivent être disponibles
214
- missing = [t for t in step.input_types if t not in available]
215
- if missing:
216
- miss_str = ",".join(t.value for t in missing)
217
- problems.append(
218
- f"étape {i} ({step.name}) demande {miss_str} "
219
- f"qui n'est ni dans les entrées initiales "
220
- f"ni produit par une étape antérieure"
221
- )
222
- # 2. Vérification des références ``inputs_from``
223
- for ref_type, ref_step in step.inputs_from.items():
224
- if ref_type not in step.input_types:
225
- problems.append(
226
- f"étape {i} ({step.name}) déclare "
227
- f"inputs_from[{ref_type.value}]={ref_step!r} "
228
- f"mais le module ne consomme pas ce type"
229
- )
230
- continue
231
- if ref_step not in step_outputs:
232
- problems.append(
233
- f"étape {i} ({step.name}) référence "
234
- f"inputs_from[{ref_type.value}]={ref_step!r} "
235
- f"qui n'est pas une étape antérieure connue"
236
- )
237
- continue
238
- if ref_type not in step_outputs[ref_step]:
239
- problems.append(
240
- f"étape {i} ({step.name}) référence "
241
- f"inputs_from[{ref_type.value}]={ref_step!r} "
242
- f"mais cette étape ne produit pas ce type"
243
- )
244
- # 3. Mise à jour pour les étapes suivantes
245
- available.update(step.output_types)
246
- step_outputs[step.name] = set(step.output_types)
247
- for out_type in step.output_types:
248
- producers.setdefault(out_type, set()).add(step.name)
249
- return problems
250
-
251
- def is_valid(self, initial_inputs: tuple[ArtifactType, ...]) -> bool:
252
- return not self.validate(initial_inputs)
253
-
254
- def __repr__(self) -> str:
255
- chain = " → ".join(str(s) for s in self.steps)
256
- return f"PipelineSpec({self.name}: {chain})"
257
-
258
-
259
- # ──────────────────────────────────────────────────────────────────────────
260
- # StepResult + PipelineResult
261
- # ──────────────────────────────────────────────────────────────────────────
262
-
263
-
264
- @dataclass
265
- class StepResult:
266
- """Résultat de l'exécution d'une étape sur un document.
267
-
268
- Champs
269
- ------
270
- step_name:
271
- Nom de l'étape (cf. ``PipelineStep.name``).
272
- duration_seconds:
273
- Temps d'exécution de ``module.process`` mesuré en wall-clock.
274
- output_types:
275
- Types effectivement présents dans la sortie (peut être un
276
- sous-ensemble de ``module.output_types`` si le module a
277
- omis un type — cas reporté ici comme info pour diagnostic).
278
- junction_metrics:
279
- Pour chaque type produit qui correspond à un ``GTLevel``
280
- dont le document porte une GT : dictionnaire ``{type: dict
281
- métriques}`` retourné par ``compute_at_junction``.
282
- error:
283
- ``None`` si l'étape s'est bien déroulée ; sinon message
284
- d'erreur (le module a levé, l'entrée est manquante, ou la
285
- validation des types a échoué).
286
- """
287
-
288
- step_name: str
289
- duration_seconds: float
290
- output_types: tuple[ArtifactType, ...]
291
- junction_metrics: dict[str, dict[str, Any]] = field(default_factory=dict)
292
- """Map ``{artifact_type_value: {metric_name: value}}``.
293
-
294
- La clé est la valeur string du ``ArtifactType`` (ex. ``"text"``,
295
- ``"alto"``) et non l'enum lui-même, pour faciliter la
296
- sérialisation JSON.
297
- """
298
- error: Optional[str] = None
299
-
300
-
301
- @dataclass
302
- class PipelineResult:
303
- """Résultat complet d'une exécution de pipeline sur un document.
304
-
305
- On capture la durée totale, la durée par étape et les
306
- métriques aux jonctions pour chaque artefact produit qui a une
307
- GT correspondante.
308
- """
309
-
310
- pipeline_name: str
311
- doc_id: str
312
- steps: list[StepResult] = field(default_factory=list)
313
- total_duration_seconds: float = 0.0
314
- error: Optional[str] = None
315
- """Erreur fatale au niveau pipeline (ex. validation des types
316
- en amont avant la première étape). ``None`` n'implique pas
317
- qu'aucune étape n'a échoué — voir ``StepResult.error`` pour le
318
- détail par étape."""
319
-
320
- @property
321
- def succeeded(self) -> bool:
322
- """Vrai si la pipeline s'est exécutée jusqu'au bout sans
323
- qu'aucune étape ne lève d'erreur."""
324
- if self.error is not None:
325
- return False
326
- return all(s.error is None for s in self.steps)
327
-
328
- @property
329
- def failing_steps(self) -> list[str]:
330
- """Noms des étapes ayant levé une erreur."""
331
- return [s.step_name for s in self.steps if s.error is not None]
332
-
333
- def junction_metrics_for(
334
- self, artifact_type: ArtifactType,
335
- ) -> Optional[dict[str, Any]]:
336
- """Retourne les métriques de la **dernière** étape qui a
337
- produit ``artifact_type``, ou ``None`` si aucune étape ne
338
- l'a produit avec succès.
339
-
340
- Utile pour comparer plusieurs pipelines qui produisent in
341
- fine le même type (ex. deux DAG aboutissant à du texte
342
- corrigé).
343
- """
344
- from picarones.domain.artifacts import LEGACY_VALUE_ALIASES
345
- legacy_alias = LEGACY_VALUE_ALIASES.get(artifact_type.value)
346
- for step in reversed(self.steps):
347
- if step.error is not None:
348
- continue
349
- metrics = step.junction_metrics.get(artifact_type.value)
350
- if metrics is None and legacy_alias is not None:
351
- # Phase 4-bis : un caller legacy peut avoir construit
352
- # le dict avec la clé pré-rewrite ("text" au lieu de
353
- # "raw_text"). expand_legacy_keys synchronise les deux
354
- # côtés sur les sites d'écriture du runner, mais des
355
- # StepResult construits à la main par les tests ou par
356
- # un caller externe peuvent encore avoir une seule
357
- # clé — on tolère.
358
- metrics = step.junction_metrics.get(legacy_alias)
359
- if metrics is not None:
360
- return metrics
361
- return None
362
-
363
-
364
- # ──────────────────────────────────────────────────────────────────────────
365
- # Exécuteur
366
- # ──────────────────────────────────────────────────────────────────────────
367
-
368
-
369
- class PipelineRunner:
370
- """Exécute une ``PipelineSpec`` sur un document.
371
-
372
- Sprint 63 — un seul document à la fois. L'orchestration
373
- corpus-wide et l'agrégation par pipeline sont reportées à un
374
- sprint dédié.
375
-
376
- Usage typique
377
- -------------
378
-
379
- >>> spec = PipelineSpec(
380
- ... name="ocr_then_rewrite",
381
- ... steps=[
382
- ... PipelineStep("ocr", my_ocr_module),
383
- ... PipelineStep("rewrite", my_llm_rewriter),
384
- ... ],
385
- ... )
386
- >>> runner = PipelineRunner()
387
- >>> result = runner.run(spec, document, {ArtifactType.IMAGE: "/path/img.png"})
388
- >>> result.succeeded
389
- True
390
- >>> result.junction_metrics_for(ArtifactType.TEXT)
391
- {'cer': 0.05, 'wer': 0.12, ...}
392
- """
393
-
394
- @staticmethod
395
- def run(
396
- spec: PipelineSpec,
397
- document: Document,
398
- initial_inputs: dict[ArtifactType, Any],
399
- ) -> PipelineResult:
400
- """Exécute ``spec`` sur ``document`` à partir de
401
- ``initial_inputs``.
402
-
403
- Parameters
404
- ----------
405
- spec:
406
- Spécification de la pipeline.
407
- document:
408
- Document du corpus, porteur de zéro ou plusieurs niveaux
409
- de GT (Sprint 32).
410
- initial_inputs:
411
- Artefacts initiaux par type — typiquement
412
- ``{ArtifactType.IMAGE: "/path/img.png"}`` pour une
413
- pipeline qui démarre par un OCR.
414
-
415
- Returns
416
- -------
417
- PipelineResult
418
- Résultat complet : durée totale, résultat par étape,
419
- métriques aux jonctions évaluées contre la GT.
420
- """
421
- result = PipelineResult(
422
- pipeline_name=spec.name, doc_id=document.doc_id,
423
- )
424
-
425
- # Validation amont : si la pipeline est statiquement
426
- # invalide, on n'exécute aucune étape.
427
- problems = spec.validate(tuple(initial_inputs.keys()))
428
- if problems:
429
- result.error = " ; ".join(problems)
430
- return result
431
-
432
- # Sprint 66 — bag versionné : ``versioned[(type, src_step)]``
433
- # contient l'artefact produit par ``src_step`` pour ``type``.
434
- # ``src_step`` vaut ``"__initial__"`` pour les entrées
435
- # initiales fournies par l'utilisateur. ``latest[type]``
436
- # désigne le nom de l'étape qui a produit la version la plus
437
- # récente du type — utilisé en l'absence d'``inputs_from``
438
- # explicite (rétrocompat Sprint 63).
439
- versioned: dict[tuple[ArtifactType, str], Any] = {
440
- (t, "__initial__"): v for t, v in initial_inputs.items()
441
- }
442
- latest: dict[ArtifactType, str] = {
443
- t: "__initial__" for t in initial_inputs
444
- }
445
-
446
- pipeline_t0 = time.monotonic()
447
- for step in spec.steps:
448
- step_result = PipelineRunner._run_step(
449
- step, versioned, latest, document,
450
- )
451
- result.steps.append(step_result)
452
- result.total_duration_seconds = time.monotonic() - pipeline_t0
453
- return result
454
-
455
- @staticmethod
456
- def _run_step(
457
- step: PipelineStep,
458
- versioned: dict[tuple[ArtifactType, str], Any],
459
- latest: dict[ArtifactType, str],
460
- document: Document,
461
- ) -> StepResult:
462
- # Sprint 66 — résolution des entrées : pour chaque type
463
- # demandé, on consulte ``inputs_from`` ; sinon on prend la
464
- # dernière version disponible (rétrocompat Sprint 63).
465
- resolved: dict[ArtifactType, Any] = {}
466
- missing: list[str] = []
467
- for t in step.input_types:
468
- src = step.inputs_from.get(t, latest.get(t))
469
- if src is None:
470
- missing.append(t.value)
471
- continue
472
- key = (t, src)
473
- if key not in versioned:
474
- # Référence explicite vers une étape qui n'a pas
475
- # produit cet artefact (ex. l'étape source a échoué).
476
- missing.append(f"{t.value}@{src}")
477
- continue
478
- resolved[t] = versioned[key]
479
- if missing:
480
- miss_str = ",".join(missing)
481
- return StepResult(
482
- step_name=step.name,
483
- duration_seconds=0.0,
484
- output_types=(),
485
- error=f"entrée manquante : {miss_str}",
486
- )
487
- inputs_for_module = resolved
488
- # Exécution chronométrée
489
- t0 = time.monotonic()
490
- try:
491
- outputs = step.module.process(inputs_for_module)
492
- except Exception as exc: # noqa: BLE001
493
- duration = time.monotonic() - t0
494
- logger.warning(
495
- "[pipeline_runner] étape '%s' a levé : %s",
496
- step.name, exc,
497
- )
498
- return StepResult(
499
- step_name=step.name,
500
- duration_seconds=duration,
501
- output_types=(),
502
- error=f"{type(exc).__name__}: {exc}",
503
- )
504
- duration = time.monotonic() - t0
505
-
506
- # Validation des sorties : le module est censé déclarer ses
507
- # output_types, on vérifie qu'il les a tous produits. Si
508
- # ce n'est pas le cas, on remonte une erreur explicite mais
509
- # on conserve les sorties effectivement présentes (utile
510
- # pour le diagnostic).
511
- if not isinstance(outputs, dict):
512
- return StepResult(
513
- step_name=step.name,
514
- duration_seconds=duration,
515
- output_types=(),
516
- error=(
517
- f"le module a retourné {type(outputs).__name__}, "
518
- f"un dict[ArtifactType, Any] est attendu"
519
- ),
520
- )
521
- produced = tuple(t for t in step.output_types if t in outputs)
522
- missing_outputs = [t for t in step.output_types if t not in outputs]
523
- error: Optional[str] = None
524
- if missing_outputs:
525
- miss_str = ",".join(t.value for t in missing_outputs)
526
- error = f"sortie manquante : {miss_str}"
527
-
528
- # Mise à jour du bag versionné : on stocke la sortie sous
529
- # une clé (type, step.name) ET on met à jour ``latest`` pour
530
- # que les étapes suivantes la récupèrent par défaut.
531
- for t in produced:
532
- versioned[(t, step.name)] = outputs[t]
533
- latest[t] = step.name
534
-
535
- # Évaluation aux jonctions : pour chaque type produit, si
536
- # la GT du même niveau existe, on calcule les métriques.
537
- junction_metrics: dict[str, dict[str, Any]] = {}
538
- for at in produced:
539
- gt_level = _artifact_type_to_gt_level(at)
540
- if gt_level is None:
541
- continue
542
- gt_payload = document.get_gt(gt_level)
543
- if gt_payload is None:
544
- continue
545
- try:
546
- metrics = compute_at_junction(
547
- _gt_payload_to_value(gt_payload),
548
- outputs[at],
549
- (at, at),
550
- )
551
- except Exception as exc: # noqa: BLE001
552
- logger.warning(
553
- "[pipeline_runner] évaluation à la jonction %s "
554
- "a levé : %s",
555
- at.value, exc,
556
- )
557
- continue
558
- if metrics:
559
- junction_metrics[at.value] = metrics
560
-
561
- # Phase 4-bis : double-clé pour rétrocompat. Les tests
562
- # legacy cherchent junction_metrics["text"] mais le runner
563
- # peut produire junction_metrics["raw_text"] si l'enum est
564
- # migré (ArtifactType.TEXT alias de RAW_TEXT, valeur
565
- # "raw_text"). expand_legacy_keys ajoute la clé legacy
566
- # ("text") à côté de la canonique ("raw_text") sans écraser.
567
- from picarones.domain.artifacts import expand_legacy_keys
568
- expand_legacy_keys(junction_metrics)
569
-
570
- return StepResult(
571
- step_name=step.name,
572
- duration_seconds=duration,
573
- output_types=produced,
574
- junction_metrics=junction_metrics,
575
- error=error,
576
- )
577
-
578
-
579
- def _gt_payload_to_value(payload: Any) -> Any:
580
- """Extrait la valeur exploitable d'un ``GTPayload`` typé.
581
-
582
- Pour ``TextGT`` on veut juste la chaîne ; pour les autres
583
- payloads on retourne le payload entier (la métrique sait quoi
584
- en faire selon sa signature de types).
585
- """
586
- # Import paresseux pour éviter une dépendance cyclique
587
- from picarones.evaluation.corpus import (
588
- AltoGT, EntitiesGT, PageGT, ReadingOrderGT, TextGT,
589
- )
590
- if isinstance(payload, TextGT):
591
- return payload.text
592
- if isinstance(payload, EntitiesGT):
593
- return payload.entities
594
- if isinstance(payload, ReadingOrderGT):
595
- return payload.region_order
596
- if isinstance(payload, (AltoGT, PageGT)):
597
- return payload
598
- return payload
599
 
 
600
 
601
- __all__ = [
602
- "PipelineRunner",
603
- "PipelineResult",
604
- "PipelineSpec",
605
- "PipelineStep",
606
- "StepResult",
607
- ]
 
1
+ """``picarones.core.pipeline``shim re-export (déprécié, suppression 2.0).
2
 
3
+ Canonique : :mod:`picarones.evaluation.pipeline`. Phase 5.C.batch7
4
+ du retrait du legacy.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
9
+ import warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ from picarones.evaluation.pipeline import * # noqa: F401, F403
12
 
13
+ warnings.warn(
14
+ "picarones.core.pipeline is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.evaluation.pipeline instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
18
+ )
 
picarones/evaluation/metrics/numerical_sequences.py ADDED
@@ -0,0 +1,428 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Précision sur séquences numériques — Sprint 85 (A.II.5b).
2
+
3
+ Phase 5.C.batch7 — module relocalisé depuis
4
+ ``picarones.measurements.numerical_sequences`` vers
5
+ ``picarones.evaluation.metrics.numerical_sequences``. Le chemin
6
+ legacy reste disponible via un shim avec ``DeprecationWarning`` ;
7
+ suppression prévue en 2.0.
8
+
9
+ Sprint 85 — A.II.5b du plan d'évolution 2026.
10
+
11
+ Pourquoi ce module
12
+ ------------------
13
+ Pour un économiste-historien, un éditeur de chartes ou un
14
+ archiviste, la **fidélité aux séquences numériques** est un
15
+ proxy direct de la qualité éditoriale. Un OCR qui rate
16
+ *« 1789 »* dans une charte révolutionnaire ou *« f. 12v »*
17
+ dans une cote d'archives produit un corpus inutilisable pour la
18
+ recherche fine, même si le CER global est respectable.
19
+
20
+ Catégories couvertes
21
+ --------------------
22
+ 1. **Dates arabes** : ``1789``, ``1450``, ``1ᵉʳ janvier 1789``
23
+ (le module détecte les **années** sur 4 chiffres dans la
24
+ plage [1000-2099]).
25
+ 2. **Numéraux romains** : ``MDCLXVIII``, ``XIV``, ``Tome IV``.
26
+ Réutilise ``picarones.measurements.roman_numerals`` (Sprint 60).
27
+ 3. **Foliotation** : ``f. 12``, ``f. 12r``, ``fol. 24v``,
28
+ ``p. 5``, ``pp. 12-15``, ``n° 42``.
29
+ 4. **Montants** : ``12 livres``, ``5 sols``, ``8 deniers``,
30
+ ``100 £``, ``50 ₣``, ``20 €``, formes Ancien Régime
31
+ (``l.``, ``s.``, ``d.``).
32
+ 5. **Années régnales** : ``an III``, ``l'an V``, ``an de
33
+ grâce 1450``, ``an de la République``.
34
+
35
+ Méthode
36
+ -------
37
+ Pour chaque catégorie, on extrait les occurrences (regex
38
+ spécialisée) en GT et en hypothèse. On classe ensuite chaque
39
+ GT en **3 statuts** :
40
+
41
+ - ``strict_preserved`` : forme exacte présente dans
42
+ l'hypothèse (sensible à la casse seulement pour la
43
+ foliotation, sinon la convention est documentée par
44
+ catégorie) ;
45
+ - ``value_preserved`` : la **valeur** apparaît même si la
46
+ forme diffère (ex. ``XIV`` GT et ``14`` hypothèse —
47
+ considéré comme valeur préservée mais forme non) ;
48
+ - ``lost`` : aucune trace exploitable.
49
+
50
+ Sortie
51
+ ------
52
+ ``compute_numerical_sequence_metrics(reference, hypothesis)``
53
+ retourne :
54
+
55
+ ```
56
+ {
57
+ "global_strict_score": float, # ∈ [0, 1]
58
+ "global_value_score": float, # ∈ [0, 1]
59
+ "n_total": int,
60
+ "per_category": {
61
+ "year": {"n_total": int, "strict": int, "value": int,
62
+ "strict_score": float, "value_score": float,
63
+ "lost_items": list[str]},
64
+ "roman": {...},
65
+ "foliation": {...},
66
+ "currency": {...},
67
+ "regnal": {...},
68
+ },
69
+ }
70
+ ```
71
+
72
+ Limites
73
+ -------
74
+ - Les regex sont **conservatrices** : on rate quelques
75
+ formes rares plutôt que de produire des faux positifs (par
76
+ exemple, ``mil cinq cens`` en français médiéval n'est pas
77
+ détecté comme année — la couche calcul s'en tient aux
78
+ formes les plus reconnaissables). Pour un corpus
79
+ spécifique, l'utilisateur peut composer ses propres
80
+ détecteurs et les passer via ``custom_detectors``.
81
+ - ``value_preserved`` exige une équivalence de **valeur
82
+ numérique** : ``XIV`` ↔ ``14`` est OK pour les romains ;
83
+ ``f. 12v`` ↔ ``f. 12r`` n'est **pas** OK pour la
84
+ foliotation (recto/verso est une information distincte).
85
+ """
86
+
87
+ from __future__ import annotations
88
+
89
+ import logging
90
+ import re
91
+ from typing import Optional
92
+
93
+ from picarones.evaluation.metric_registry import register_metric
94
+ from picarones.domain.artifacts import ArtifactType
95
+ from picarones.evaluation.metrics.roman_numerals import (
96
+ detect_roman_numerals,
97
+ roman_to_int,
98
+ )
99
+
100
+ logger = logging.getLogger(__name__)
101
+
102
+
103
+ # ──────────────────────────────────────────────────────────────────────────
104
+ # Constantes / catégories
105
+ # ──────────────────────────────────────────────────────────────────────────
106
+
107
+
108
+ CATEGORIES = ("year", "roman", "foliation", "currency", "regnal")
109
+
110
+
111
+ # Dates arabes — 4 chiffres dans la plage [1000-2099].
112
+ # On exige une frontière de mot pour ne pas attraper
113
+ # « 12345 » (volume) ou « 0001 » (numéro de page).
114
+ _RE_YEAR = re.compile(r"\b(1[0-9]{3}|20[0-9]{2})\b")
115
+
116
+
117
+ # Foliotation : f. 12, f. 12r, fol. 24v, p. 5, pp. 12-15, n° 42
118
+ # La capture conserve la forme intégrale (avec ponctuation et
119
+ # r/v) parce que recto/verso est une information distincte.
120
+ _RE_FOLIATION = re.compile(
121
+ r"\b(?:fol\.?|f\.|pp\.|p\.|n\.°|n°)\s*" # préfixe : fol., f., pp., p., n°
122
+ r"(\d+(?:\s*-\s*\d+)?)" # nombre ou plage (12 / 12-15)
123
+ r"\s*([rvRV])?", # suffixe optionnel r/v
124
+ re.UNICODE,
125
+ )
126
+
127
+
128
+ # Montants : nombre suivi d'une unité monétaire.
129
+ # On accepte espaces multiples mais pas de saut de ligne.
130
+ _RE_CURRENCY = re.compile(
131
+ r"\b(\d+(?:[.,]\d+)?)\s*" # montant (entier ou décimal)
132
+ r"(livres?|sols?|deniers?|écus?|florins?|francs?|"
133
+ r"l\.|s\.|d\.|£|€|₣)" # unité
134
+ r"(?=\b|[\s,;.!?:]|$)", # frontière souple post-symbole
135
+ re.UNICODE | re.IGNORECASE,
136
+ )
137
+
138
+
139
+ # Années régnales : « an III », « an de grâce 1450 »,
140
+ # « l'an V de la République ».
141
+ # Capture le numéral (romain ou arabe).
142
+ _RE_REGNAL = re.compile(
143
+ r"\b(?:l['’]\s*)?an\s+(?:de\s+(?:grâce|la\s+R[eé]publique)\s+)?"
144
+ r"([IVXLCDMivxlcdm]+|\d{1,4})\b",
145
+ re.UNICODE,
146
+ )
147
+
148
+
149
+ # ──────────────────────────────────────────────────────────────────────────
150
+ # Détection par catégorie
151
+ # ──────────────────────────────────────────────────────────────────────────
152
+
153
+
154
+ def _detect_years(text: str) -> list[tuple[str, int]]:
155
+ """Retourne [(forme, valeur)] pour chaque année 4 chiffres."""
156
+ if not text:
157
+ return []
158
+ return [(m.group(0), int(m.group(0))) for m in _RE_YEAR.finditer(text)]
159
+
160
+
161
+ def _detect_romans_with_values(text: str) -> list[tuple[str, int]]:
162
+ """Numéraux romains accompagnés de leur valeur entière.
163
+ Délègue à ``roman_numerals.detect_roman_numerals`` (Sprint 60),
164
+ qui retourne ``(start, form, value)``.
165
+ """
166
+ if not text:
167
+ return []
168
+ out: list[tuple[str, int]] = []
169
+ for _start, form, value in detect_roman_numerals(text, min_length=2):
170
+ if value is not None:
171
+ out.append((form, value))
172
+ return out
173
+
174
+
175
+ def _detect_foliations(text: str) -> list[tuple[str, str]]:
176
+ """Foliotation. Retourne [(forme_complète, clé_normalisée)] où la
177
+ clé inclut le suffixe r/v normalisé (recto/verso).
178
+ """
179
+ if not text:
180
+ return []
181
+ out: list[tuple[str, str]] = []
182
+ for m in _RE_FOLIATION.finditer(text):
183
+ full = m.group(0).strip()
184
+ nums = re.sub(r"\s+", "", m.group(1)) # ex : "12-15"
185
+ suffix = (m.group(2) or "").lower()
186
+ key = f"{nums}{suffix}"
187
+ out.append((full, key))
188
+ return out
189
+
190
+
191
+ def _detect_currencies(text: str) -> list[tuple[str, tuple[str, str]]]:
192
+ """Montants. Clé = (montant_normalisé, unité_canonique).
193
+
194
+ L'unité canonique compresse les variantes (« livres » et
195
+ « livre » → « livre » ; « £ » reste « £ »).
196
+ """
197
+ if not text:
198
+ return []
199
+ canon = {
200
+ "livre": "livre", "livres": "livre", "l.": "livre",
201
+ "sol": "sol", "sols": "sol", "s.": "sol",
202
+ "denier": "denier", "deniers": "denier", "d.": "denier",
203
+ "écu": "écu", "écus": "écu",
204
+ "florin": "florin", "florins": "florin",
205
+ "franc": "franc", "francs": "franc",
206
+ "£": "£", "€": "€", "₣": "₣",
207
+ }
208
+ out: list[tuple[str, tuple[str, str]]] = []
209
+ for m in _RE_CURRENCY.finditer(text):
210
+ amount = m.group(1).replace(",", ".")
211
+ unit_raw = m.group(2).lower()
212
+ unit = canon.get(unit_raw, unit_raw)
213
+ out.append((m.group(0), (amount, unit)))
214
+ return out
215
+
216
+
217
+ def _detect_regnal(text: str) -> list[tuple[str, int]]:
218
+ """Années régnales. Retourne [(forme, valeur_int)] avec la
219
+ valeur extraite (romain → int ou arabe → int).
220
+ """
221
+ if not text:
222
+ return []
223
+ out: list[tuple[str, int]] = []
224
+ for m in _RE_REGNAL.finditer(text):
225
+ numeral = m.group(1)
226
+ value: Optional[int]
227
+ if numeral.isdigit():
228
+ value = int(numeral)
229
+ else:
230
+ value = roman_to_int(numeral)
231
+ if value is not None:
232
+ out.append((m.group(0), value))
233
+ return out
234
+
235
+
236
+ _DETECTORS = {
237
+ "year": _detect_years,
238
+ "roman": _detect_romans_with_values,
239
+ "foliation": _detect_foliations,
240
+ "currency": _detect_currencies,
241
+ "regnal": _detect_regnal,
242
+ }
243
+
244
+
245
+ # ──────────────────────────────────────────────────────────────────────────
246
+ # Calcul principal
247
+ # ──────────────────────────────────────────────────────────────────────────
248
+
249
+
250
+ def _classify_per_category(
251
+ gt_items: list,
252
+ hyp_items: list,
253
+ *,
254
+ form_extractor,
255
+ value_extractor,
256
+ ) -> dict:
257
+ """Pour chaque item GT, le classe en strict_preserved /
258
+ value_preserved / lost.
259
+
260
+ Multiplicité respectée : un item hypothèse ne peut servir
261
+ qu'à un seul match (forme prioritaire sur valeur).
262
+ """
263
+ hyp_used = [False] * len(hyp_items)
264
+ n_strict = 0
265
+ n_value = 0
266
+ lost: list[str] = []
267
+ # Première passe : matchs stricts (forme exacte)
268
+ matched: list[bool] = [False] * len(gt_items)
269
+ for gi, gt_item in enumerate(gt_items):
270
+ gt_form = form_extractor(gt_item)
271
+ for hi, hyp_item in enumerate(hyp_items):
272
+ if hyp_used[hi]:
273
+ continue
274
+ if form_extractor(hyp_item) == gt_form:
275
+ hyp_used[hi] = True
276
+ matched[gi] = True
277
+ n_strict += 1
278
+ break
279
+ # Deuxième passe : matchs sur valeur (forme différente)
280
+ for gi, gt_item in enumerate(gt_items):
281
+ if matched[gi]:
282
+ n_value += 1 # strict implique value
283
+ continue
284
+ gt_val = value_extractor(gt_item)
285
+ for hi, hyp_item in enumerate(hyp_items):
286
+ if hyp_used[hi]:
287
+ continue
288
+ if value_extractor(hyp_item) == gt_val:
289
+ hyp_used[hi] = True
290
+ matched[gi] = True
291
+ n_value += 1
292
+ break
293
+ if not matched[gi]:
294
+ lost.append(form_extractor(gt_item))
295
+ n_total = len(gt_items)
296
+ return {
297
+ "n_total": n_total,
298
+ "strict": n_strict,
299
+ "value": n_value,
300
+ "strict_score": n_strict / n_total if n_total else 0.0,
301
+ "value_score": n_value / n_total if n_total else 0.0,
302
+ "lost_items": lost,
303
+ }
304
+
305
+
306
+ def compute_numerical_sequence_metrics(
307
+ reference: Optional[str],
308
+ hypothesis: Optional[str],
309
+ ) -> dict:
310
+ """Calcule la précision sur séquences numériques.
311
+
312
+ Returns
313
+ -------
314
+ dict
315
+ Voir docstring du module. Si ``reference`` est vide
316
+ ou ne contient aucune séquence détectée, retourne
317
+ ``{n_total: 0, ...}`` avec scores à 0 (pas None).
318
+ """
319
+ ref = reference or ""
320
+ hyp = hypothesis or ""
321
+
322
+ # Spécifications par catégorie : (gt_items, hyp_items,
323
+ # extractor de forme, extractor de valeur).
324
+ specs: dict[str, dict] = {}
325
+ # year : (form="1789", value=1789)
326
+ specs["year"] = {
327
+ "gt": _detect_years(ref),
328
+ "hyp": _detect_years(hyp),
329
+ "form": lambda it: it[0],
330
+ "value": lambda it: it[1],
331
+ }
332
+ # roman : (form="MDCLXVIII", value=1668)
333
+ specs["roman"] = {
334
+ "gt": _detect_romans_with_values(ref),
335
+ "hyp": _detect_romans_with_values(hyp),
336
+ "form": lambda it: it[0],
337
+ "value": lambda it: it[1],
338
+ }
339
+ # foliation : (form="f. 12r", value="12r")
340
+ specs["foliation"] = {
341
+ "gt": _detect_foliations(ref),
342
+ "hyp": _detect_foliations(hyp),
343
+ "form": lambda it: it[0],
344
+ "value": lambda it: it[1],
345
+ }
346
+ # currency : (form="12 livres", value=("12", "livre"))
347
+ specs["currency"] = {
348
+ "gt": _detect_currencies(ref),
349
+ "hyp": _detect_currencies(hyp),
350
+ "form": lambda it: it[0],
351
+ "value": lambda it: it[1],
352
+ }
353
+ # regnal : (form="an III", value=3)
354
+ specs["regnal"] = {
355
+ "gt": _detect_regnal(ref),
356
+ "hyp": _detect_regnal(hyp),
357
+ "form": lambda it: it[0],
358
+ "value": lambda it: it[1],
359
+ }
360
+
361
+ per_category: dict[str, dict] = {}
362
+ total = 0
363
+ total_strict = 0
364
+ total_value = 0
365
+ for cat, spec in specs.items():
366
+ breakdown = _classify_per_category(
367
+ spec["gt"], spec["hyp"],
368
+ form_extractor=spec["form"],
369
+ value_extractor=spec["value"],
370
+ )
371
+ per_category[cat] = breakdown
372
+ total += breakdown["n_total"]
373
+ total_strict += breakdown["strict"]
374
+ total_value += breakdown["value"]
375
+
376
+ return {
377
+ "n_total": total,
378
+ "global_strict_score": (
379
+ total_strict / total if total else 0.0
380
+ ),
381
+ "global_value_score": (
382
+ total_value / total if total else 0.0
383
+ ),
384
+ "per_category": per_category,
385
+ }
386
+
387
+
388
+ # ──────────────────────────────────────────────────────────────────────────
389
+ # Enregistrement registre typé
390
+ # ──────────────────────────────────────────────────────────────────────────
391
+
392
+
393
+ @register_metric(
394
+ name="numerical_sequence_strict_score",
395
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
396
+ description=(
397
+ "Précision sur séquences numériques en mode strict (forme "
398
+ "préservée). Couvre années arabes, numéraux romains, "
399
+ "foliotation, montants Ancien Régime, années régnales."
400
+ ),
401
+ )
402
+ def numerical_sequence_strict_score(reference: str, hypothesis: str) -> float:
403
+ return compute_numerical_sequence_metrics(
404
+ reference, hypothesis,
405
+ )["global_strict_score"]
406
+
407
+
408
+ @register_metric(
409
+ name="numerical_sequence_value_score",
410
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
411
+ description=(
412
+ "Précision sur séquences numériques en mode valeur "
413
+ "(la valeur est préservée même si la forme diffère, "
414
+ "ex. XIV → 14)."
415
+ ),
416
+ )
417
+ def numerical_sequence_value_score(reference: str, hypothesis: str) -> float:
418
+ return compute_numerical_sequence_metrics(
419
+ reference, hypothesis,
420
+ )["global_value_score"]
421
+
422
+
423
+ __all__ = [
424
+ "CATEGORIES",
425
+ "compute_numerical_sequence_metrics",
426
+ "numerical_sequence_strict_score",
427
+ "numerical_sequence_value_score",
428
+ ]
picarones/evaluation/metrics/roman_numerals.py ADDED
@@ -0,0 +1,484 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Numéraux romains — Sprint 60.
2
+
3
+ Phase 5.C.batch7 — module relocalisé depuis
4
+ ``picarones.measurements.roman_numerals`` vers
5
+ ``picarones.evaluation.metrics.roman_numerals``. Le chemin legacy
6
+ reste disponible via un shim avec ``DeprecationWarning`` ;
7
+ suppression prévue en 2.0.
8
+
9
+ Sprint 60 — Étape 3 / extension philologique transversale du plan
10
+ d'évolution 2026.
11
+
12
+ Pourquoi ce module
13
+ ------------------
14
+ Les numéraux romains traversent **toutes les périodes patrimoniales**
15
+ servies par Picarones :
16
+
17
+ - **Médiéval** : minuscules avec ``j`` final pour le dernier ``i``
18
+ (``ij`` = 2, ``iij`` = 3, ``viij`` = 8, ``mcclxxxij`` = 1282).
19
+ Convention scribale standard dans les chartes et registres.
20
+ - **Imprimé ancien** : majuscules (``Tome IV``, ``Chap. VII``).
21
+ - **Moderne** : majuscules pour les souverains (``Louis XIV``) et
22
+ les siècles (``XIXᵉ siècle`` — la partie exposant ᵉ est gérée
23
+ par le Sprint 59 ``ordinals``, ce module ne traite que la partie
24
+ numérale ``XIX``).
25
+
26
+ Quatre traitements possibles d'un numéral par l'OCR
27
+ ----------------------------------------------------
28
+ Pour chaque numéral romain présent dans la GT, l'OCR peut :
29
+
30
+ 1. **Préserver strictement** : forme exacte gardée
31
+ (``mcclxxxij`` → ``mcclxxxij``). Édition diplomatique idéale.
32
+ 2. **Préserver en changeant la casse** : la valeur est intacte mais
33
+ la convention typographique est modifiée
34
+ (``xiv`` → ``XIV``). Modernisation typographique courante.
35
+ 3. **Préserver en supprimant le ``j`` final** :
36
+ (``mcclxxxij`` → ``mcclxxxii``). Modernisation orthographique
37
+ médiévale → standard académique moderne.
38
+ 4. **Convertir en chiffres arabes** : la valeur est préservée mais
39
+ le système de numération est modernisé
40
+ (``XIV`` → ``14``). Modernisation profonde, perte de
41
+ l'information typographique.
42
+ 5. **Perdre** : aucune trace de la valeur dans l'hypothèse.
43
+
44
+ Ce module retourne un breakdown par statut pour que le chercheur
45
+ juge lui-même la convention adoptée par chaque moteur, **sans
46
+ classification automatique imposée**.
47
+
48
+ Stratégie de découpage
49
+ ----------------------
50
+ Cohérente avec NER (38), Flesch (52), Reading order F1 (53),
51
+ Layout F1 (54), Bloc Unicode (55), Abréviations (56), MUFI (57),
52
+ Imprimé ancien (58), Archives modernes (59) : couche de calcul
53
+ pure d'abord ; câblage runner et HTML dans des sprints dédiés.
54
+
55
+ Limites documentées
56
+ -------------------
57
+ - Détection greedy par regex ``\\b[IVXLCDMivxlcdmj]+\\b`` puis
58
+ validation par parsing. Les faux positifs restent possibles sur
59
+ des mots courts (``I`` pronom anglais, ``MM`` initiales, ``LL``).
60
+ Le paramètre ``min_length`` permet de filtrer les single-letter.
61
+ - Pas de gestion des notations rares avec barre suscript pour
62
+ multiplier par 1000 (V̄ = 5000, X̄ = 10000) — usage très rare en
63
+ corpus patrimonial européen courant.
64
+ """
65
+
66
+ from __future__ import annotations
67
+
68
+ import logging
69
+ import re
70
+ from typing import Optional
71
+
72
+ from picarones.evaluation.metric_registry import register_metric
73
+ from picarones.domain.artifacts import ArtifactType
74
+
75
+ logger = logging.getLogger(__name__)
76
+
77
+
78
+ # ──────────────────────────────────────────────────────────────────────────
79
+ # Table de conversion + parsing
80
+ # ──────────────────────────────────────────────────────────────────────────
81
+
82
+ ROMAN_VALUES: dict[str, int] = {
83
+ "I": 1, "V": 5, "X": 10,
84
+ "L": 50, "C": 100, "D": 500, "M": 1000,
85
+ }
86
+
87
+ # Caractères acceptés en entrée (incluant minuscules + j médiéval).
88
+ _ROMAN_CHARS = "IVXLCDMivxlcdmj"
89
+ _ROMAN_RE = re.compile(rf"\b[{_ROMAN_CHARS}]+\b")
90
+
91
+
92
+ def _normalize_roman(s: str) -> str:
93
+ """Normalise un numéral romain : majuscule + ``j`` final → ``i``.
94
+
95
+ Les manuscrits médiévaux notent traditionnellement le dernier
96
+ ``i`` d'une suite par ``j`` (« ij », « iij », « viij »…). On
97
+ convertit pour pouvoir parser comme un numéral standard.
98
+ """
99
+ if not s:
100
+ return ""
101
+ upper = s.upper()
102
+ if upper.endswith("J"):
103
+ upper = upper[:-1] + "I"
104
+ return upper
105
+
106
+
107
+ def _parse_normalized_roman(s: str) -> Optional[int]:
108
+ """Parse un numéral romain **après normalisation** (majuscule,
109
+ sans ``j`` médiéval). Retourne ``None`` si la chaîne n'est pas
110
+ un numéral romain valide.
111
+
112
+ Validation : on parse en additionnant/soustrayant selon la règle
113
+ classique, puis on **regénère la forme standard** et on compare
114
+ pour rejeter les formes non canoniques (« IIII » au lieu de
115
+ « IV », « VV » au lieu de « X »). Cette stricte validation
116
+ garantit qu'on ne compte pas des séquences absurdes comme
117
+ « XXXX » comme un numéral.
118
+
119
+ Note : les manuscrits médiévaux utilisent fr��quemment « IIII »
120
+ pour 4 (notation soustractive plus tardive). On accepte donc
121
+ aussi cette forme via une règle relâchée : tant que les valeurs
122
+ sont décroissantes ou suivent la règle soustractive standard,
123
+ on accepte.
124
+ """
125
+ if not s or not all(c in "IVXLCDM" for c in s):
126
+ return None
127
+ # Calcul par soustraction.
128
+ total = 0
129
+ prev_value = 0
130
+ for ch in reversed(s):
131
+ v = ROMAN_VALUES[ch]
132
+ if v < prev_value:
133
+ total -= v
134
+ else:
135
+ total += v
136
+ prev_value = v
137
+ if total <= 0:
138
+ return None
139
+ # Validation relâchée : on accepte les formes médiévales (IIII,
140
+ # VIIII) mais on rejette les vraiment absurdes (IIIII, VVVV).
141
+ if not _is_plausible_roman(s):
142
+ return None
143
+ return total
144
+
145
+
146
+ def _is_plausible_roman(s: str) -> bool:
147
+ """Validation relâchée d'un numéral romain (majuscule).
148
+
149
+ On rejette :
150
+
151
+ - 5 caractères identiques d'affilée ou plus (« IIIII », « XXXXX »).
152
+ - Les répétitions de V, L, D (jamais répétés en notation
153
+ classique : « VV », « LL », « DD »).
154
+ - Les paires soustractives non standard. En romain canonique,
155
+ seules sont valides : IV, IX, XL, XC, CD, CM. Toute autre
156
+ combinaison « petit avant grand » est rejetée. Cela élimine
157
+ les faux positifs sur des mots français comme « ici » (qui
158
+ formerait sinon « I + C » = 99) ou « IL » qui formerait 49.
159
+ """
160
+ if not s:
161
+ return False
162
+ # Pas de répétitions invalides
163
+ for forbidden in ("VV", "LL", "DD", "IIIII", "XXXXX", "CCCCC", "MMMMMM"):
164
+ if forbidden in s:
165
+ return False
166
+ # Paires soustractives autorisées (toutes les autres sont rejetées)
167
+ legal_subtractive = {"IV", "IX", "XL", "XC", "CD", "CM"}
168
+ for i in range(len(s) - 1):
169
+ a, b = s[i], s[i + 1]
170
+ if ROMAN_VALUES[a] < ROMAN_VALUES[b]:
171
+ if (a + b) not in legal_subtractive:
172
+ return False
173
+ return True
174
+
175
+
176
+ def roman_to_int(s: Optional[str]) -> Optional[int]:
177
+ """Convertit une chaîne en numéral romain entier. Tolère casse
178
+ et ``j`` médiéval final. Retourne ``None`` si invalide.
179
+ """
180
+ if not s:
181
+ return None
182
+ return _parse_normalized_roman(_normalize_roman(s))
183
+
184
+
185
+ def int_to_roman(n: int) -> str:
186
+ """Convertit un entier en numéral romain majuscule standard.
187
+
188
+ Utilise la notation classique (IV, IX, XL, XC, CD, CM) — pas la
189
+ forme médiévale relâchée.
190
+ """
191
+ if n <= 0:
192
+ raise ValueError("n must be positive")
193
+ pairs = [
194
+ (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
195
+ (100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
196
+ (10, "X"), (9, "IX"), (5, "V"), (4, "IV"),
197
+ (1, "I"),
198
+ ]
199
+ out: list[str] = []
200
+ for value, symbol in pairs:
201
+ while n >= value:
202
+ out.append(symbol)
203
+ n -= value
204
+ return "".join(out)
205
+
206
+
207
+ # ──────────────────────────────────────────────────────────────────────────
208
+ # Détection dans le texte
209
+ # ──────────────────────────────────────────────────────────────────────────
210
+
211
+
212
+ def detect_roman_numerals(
213
+ text: Optional[str],
214
+ *,
215
+ min_length: int = 1,
216
+ ) -> list[tuple[int, str, int]]:
217
+ """Retourne les numéraux romains valides dans ``text``.
218
+
219
+ Forme : ``[(start_index, numeral_string, integer_value), ...]``
220
+ triée par index croissant.
221
+
222
+ Parameters
223
+ ----------
224
+ text:
225
+ Texte à analyser.
226
+ min_length:
227
+ Longueur minimale d'un numéral retenu. Par défaut ``1``.
228
+ Mettre à ``2`` pour filtrer les single-letter ambigus (``I``
229
+ pronom, ``M`` initiale).
230
+
231
+ Faux positifs connus
232
+ --------------------
233
+ - ``I`` (pronom anglais), ``M`` ou ``D`` en initiale d'une
234
+ personne ne peuvent pas être distingués sans NER. Le chercheur
235
+ qui s'inquiète de ces faux positifs peut passer
236
+ ``min_length=2``.
237
+ """
238
+ if not text:
239
+ return []
240
+ found: list[tuple[int, str, int]] = []
241
+ for match in _ROMAN_RE.finditer(text):
242
+ s = match.group(0)
243
+ if len(s) < min_length:
244
+ continue
245
+ value = roman_to_int(s)
246
+ if value is None:
247
+ continue
248
+ found.append((match.start(), s, value))
249
+ return found
250
+
251
+
252
+ # ──────────────────────────────────────────────────────────────────────────
253
+ # Classification de la restitution dans l'hypothèse
254
+ # ──────────────────────────────────────────��───────────────────────────────
255
+
256
+ # Statuts possibles, dans l'ordre de priorité (un numéral est
257
+ # classé selon le premier statut qui s'applique).
258
+
259
+ STATUS_STRICT_PRESERVED = "strict_preserved"
260
+ STATUS_CASE_CHANGED = "case_changed"
261
+ STATUS_J_DROPPED = "j_dropped"
262
+ STATUS_CONVERTED_TO_ARABIC = "converted_to_arabic"
263
+ STATUS_LOST = "lost"
264
+
265
+ ALL_STATUSES = (
266
+ STATUS_STRICT_PRESERVED,
267
+ STATUS_CASE_CHANGED,
268
+ STATUS_J_DROPPED,
269
+ STATUS_CONVERTED_TO_ARABIC,
270
+ STATUS_LOST,
271
+ )
272
+
273
+ # Statuts qui indiquent une préservation de la valeur (par opposition
274
+ # à la perte).
275
+ VALUE_PRESERVING_STATUSES = frozenset({
276
+ STATUS_STRICT_PRESERVED,
277
+ STATUS_CASE_CHANGED,
278
+ STATUS_J_DROPPED,
279
+ STATUS_CONVERTED_TO_ARABIC,
280
+ })
281
+
282
+
283
+ def _classify_restitution(numeral: str, value: int, hyp: str) -> str:
284
+ """Classifie comment ``numeral`` (de valeur ``value``) est
285
+ restitué dans ``hyp`` selon les 5 statuts définis."""
286
+ # 1. Forme stricte présente
287
+ if re.search(r"(?<![A-Za-z])" + re.escape(numeral) + r"(?![A-Za-z])", hyp):
288
+ return STATUS_STRICT_PRESERVED
289
+ # 2. Variante de casse seule
290
+ swapped = numeral.swapcase()
291
+ if swapped != numeral and re.search(
292
+ r"(?<![A-Za-z])" + re.escape(swapped) + r"(?![A-Za-z])", hyp,
293
+ ):
294
+ return STATUS_CASE_CHANGED
295
+ # 3. ``j`` final remplacé par ``i`` (ou inverse)
296
+ if numeral.lower().endswith("j"):
297
+ no_j = numeral[:-1] + ("I" if numeral[-1] == "J" else "i")
298
+ elif numeral.lower().endswith("i"):
299
+ no_j = numeral[:-1] + ("J" if numeral[-1] == "I" else "j")
300
+ else:
301
+ no_j = numeral
302
+ if no_j != numeral and re.search(
303
+ r"(?<![A-Za-z])" + re.escape(no_j) + r"(?![A-Za-z])", hyp,
304
+ ):
305
+ return STATUS_J_DROPPED
306
+ # Variante de casse + j-flip combinés
307
+ no_j_swapped = no_j.swapcase()
308
+ if no_j_swapped != numeral and re.search(
309
+ r"(?<![A-Za-z])" + re.escape(no_j_swapped) + r"(?![A-Za-z])", hyp,
310
+ ):
311
+ return STATUS_J_DROPPED
312
+ # 4. Conversion en chiffres arabes
313
+ if re.search(r"(?<!\d)" + str(value) + r"(?!\d)", hyp):
314
+ return STATUS_CONVERTED_TO_ARABIC
315
+ # 5. Perdu
316
+ return STATUS_LOST
317
+
318
+
319
+ # ──────────────────────────────────────────────────────────────────────────
320
+ # Calcul de la métrique
321
+ # ──────────────────────────────────────────────────────────────────────────
322
+
323
+
324
+ def compute_roman_numeral_metrics(
325
+ reference: Optional[str],
326
+ hypothesis: Optional[str],
327
+ *,
328
+ min_length: int = 1,
329
+ ) -> dict:
330
+ """Calcule la préservation des numéraux romains.
331
+
332
+ Pour chaque numéral romain dans la GT, on classifie sa
333
+ restitution dans l'hypothèse selon l'un des 5 statuts (forme
334
+ stricte / casse modifiée / j supprimé / conversion arabe / perdu).
335
+
336
+ Returns
337
+ -------
338
+ dict
339
+ ``{
340
+ "n_numerals_reference": int,
341
+ "n_strict_preserved": int,
342
+ "n_value_preserved": int, # tous statuts sauf LOST
343
+ "global_strict_score": float,
344
+ "global_value_score": float,
345
+ "per_status": {status: count for status in ALL_STATUSES},
346
+ "per_numeral": [
347
+ {"index", "numeral", "value", "status"}
348
+ ],
349
+ "lost_numerals": [
350
+ {"index", "numeral", "value"}
351
+ ],
352
+ }``
353
+
354
+ Cas dégénérés
355
+ -------------
356
+ - GT vide ou sans numéral → tous compteurs à 0, scores à 0.0,
357
+ ``per_status`` initialisé à 0 sur tous les statuts.
358
+ - GT avec numéraux + hyp vide → tous classés ``lost``,
359
+ strict_score = value_score = 0.0.
360
+ """
361
+ ref = reference or ""
362
+ hyp = hypothesis or ""
363
+
364
+ detected = detect_roman_numerals(ref, min_length=min_length)
365
+ n_total = len(detected)
366
+ per_status_init = {status: 0 for status in ALL_STATUSES}
367
+
368
+ if n_total == 0:
369
+ return {
370
+ "n_numerals_reference": 0,
371
+ "n_strict_preserved": 0,
372
+ "n_value_preserved": 0,
373
+ "global_strict_score": 0.0,
374
+ "global_value_score": 0.0,
375
+ "per_status": per_status_init,
376
+ "per_numeral": [],
377
+ "lost_numerals": [],
378
+ }
379
+
380
+ per_status: dict[str, int] = dict(per_status_init)
381
+ per_numeral: list[dict] = []
382
+ lost: list[dict] = []
383
+ for index, numeral, value in detected:
384
+ status = _classify_restitution(numeral, value, hyp)
385
+ per_status[status] = per_status.get(status, 0) + 1
386
+ per_numeral.append({
387
+ "index": index,
388
+ "numeral": numeral,
389
+ "value": value,
390
+ "status": status,
391
+ })
392
+ if status == STATUS_LOST:
393
+ lost.append({"index": index, "numeral": numeral, "value": value})
394
+
395
+ n_strict = per_status[STATUS_STRICT_PRESERVED]
396
+ n_value = sum(per_status[s] for s in VALUE_PRESERVING_STATUSES)
397
+
398
+ return {
399
+ "n_numerals_reference": n_total,
400
+ "n_strict_preserved": n_strict,
401
+ "n_value_preserved": n_value,
402
+ "global_strict_score": n_strict / n_total,
403
+ "global_value_score": n_value / n_total,
404
+ "per_status": per_status,
405
+ "per_numeral": per_numeral,
406
+ "lost_numerals": lost,
407
+ }
408
+
409
+
410
+ def roman_numeral_strict_score(
411
+ reference: Optional[str], hypothesis: Optional[str],
412
+ ) -> float:
413
+ """Raccourci : taux global de préservation **stricte** des
414
+ numéraux romains ∈ [0, 1]."""
415
+ return compute_roman_numeral_metrics(
416
+ reference, hypothesis,
417
+ )["global_strict_score"]
418
+
419
+
420
+ def roman_numeral_value_score(
421
+ reference: Optional[str], hypothesis: Optional[str],
422
+ ) -> float:
423
+ """Raccourci : taux global de préservation de la **valeur** des
424
+ numéraux romains (toute forme confondue : strict, case_changed,
425
+ j_dropped, arabe) ∈ [0, 1]."""
426
+ return compute_roman_numeral_metrics(
427
+ reference, hypothesis,
428
+ )["global_value_score"]
429
+
430
+
431
+ # ──────────────────────────────────────────────────────────────────────────
432
+ # Enregistrement dans le registre typé (Sprint 34)
433
+ # ──────────────────────────────────────────────────────────────────────────
434
+
435
+
436
+ @register_metric(
437
+ name="roman_numeral_strict_score",
438
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
439
+ description=(
440
+ "Taux de préservation stricte des numéraux romains "
441
+ "(forme exacte gardée : casse, j médiéval final). "
442
+ "Métrique transversale aux périodes médiévale, imprimé "
443
+ "ancien et moderne."
444
+ ),
445
+ higher_is_better=True,
446
+ tags={"text", "roman_numerals", "philology"},
447
+ )
448
+ def _registered_strict(reference: str, hypothesis: str) -> float:
449
+ return roman_numeral_strict_score(reference, hypothesis)
450
+
451
+
452
+ @register_metric(
453
+ name="roman_numeral_value_score",
454
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
455
+ description=(
456
+ "Taux de préservation de la valeur numérique des numéraux "
457
+ "romains, indépendamment de la forme (strict, casse "
458
+ "changée, j supprimé, conversion en chiffres arabes). "
459
+ "Le breakdown per_status permet au chercheur de juger la "
460
+ "convention adoptée."
461
+ ),
462
+ higher_is_better=True,
463
+ tags={"text", "roman_numerals", "philology"},
464
+ )
465
+ def _registered_value(reference: str, hypothesis: str) -> float:
466
+ return roman_numeral_value_score(reference, hypothesis)
467
+
468
+
469
+ __all__ = [
470
+ "ROMAN_VALUES",
471
+ "ALL_STATUSES",
472
+ "STATUS_STRICT_PRESERVED",
473
+ "STATUS_CASE_CHANGED",
474
+ "STATUS_J_DROPPED",
475
+ "STATUS_CONVERTED_TO_ARABIC",
476
+ "STATUS_LOST",
477
+ "VALUE_PRESERVING_STATUSES",
478
+ "compute_roman_numeral_metrics",
479
+ "detect_roman_numerals",
480
+ "int_to_roman",
481
+ "roman_numeral_strict_score",
482
+ "roman_numeral_value_score",
483
+ "roman_to_int",
484
+ ]
picarones/evaluation/pipeline.py ADDED
@@ -0,0 +1,622 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Banc d'essai de pipelines composées — Sprint 63 (axe B).
2
+
3
+ Phase 5.C.batch7 — module relocalisé depuis
4
+ ``picarones.core.pipeline`` vers ``picarones.evaluation.pipeline``.
5
+ Le chemin legacy reste disponible via un shim avec
6
+ ``DeprecationWarning`` ; suppression prévue en 2.0.
7
+
8
+ Coexistence avec ``picarones.pipeline.executor``
9
+ ------------------------------------------------
10
+ Le présent module porte le ``PipelineRunner`` historique
11
+ (Sprint 63), riche en behavior, qui orchestre l'exécution
12
+ mono-document. Le module canonique
13
+ ``picarones.pipeline.executor`` (Sprint S6) propose un design
14
+ différent (instance-based, immutable specs). Les deux
15
+ cohabitent volontairement ; un convertisseur explicite viendra
16
+ quand un caller institutionnel l'exigera.
17
+
18
+ Sprint 63 — Étape 4 / axe B du plan d'évolution 2026 : démarrage du
19
+ banc d'essai de pipelines.
20
+
21
+ Philosophie
22
+ -----------
23
+ Picarones est un **banc d'essai**, pas un atelier de production.
24
+ Cette infrastructure permet d'**évaluer des pipelines composées de
25
+ modules tiers** que l'utilisateur amène — par exemple :
26
+
27
+ - ``[OCR(image→texte)] → [reconstructeur ALTO tiers(texte→ALTO)]``
28
+ - ``[VLM(image→ALTO)] → [post-processing tiers(ALTO→ALTO)]``
29
+ - ``[OCR(image→texte)] → [LLM correcteur(texte→texte)]``
30
+
31
+ Picarones **ne fournit aucun module métier** (pas de
32
+ reconstructeur ALTO, pas de correcteur, pas de re-segmenteur).
33
+ L'utilisateur branche ses propres ``BaseModule`` (Sprint 33), le
34
+ runner orchestre l'exécution séquentielle, valide les types aux
35
+ jonctions et **évalue automatiquement** chaque artefact produit
36
+ contre la GT du même niveau (Sprint 32) en sélectionnant les
37
+ métriques pertinentes du registre typé (Sprint 34).
38
+
39
+ Périmètre Sprint 63
40
+ -------------------
41
+ Inclus :
42
+
43
+ - Spécification déclarative d'une pipeline séquentielle.
44
+ - Exécution sur un seul document avec passage typé d'artefacts.
45
+ - Validation des types aux jonctions inter-modules.
46
+ - Évaluation automatique aux jonctions GT-vs-sortie pour chaque
47
+ niveau de GT disponible sur le document.
48
+ - Mesure du temps par étape.
49
+ - Capture gracieuse des erreurs (un module qui lève n'arrête pas
50
+ les étapes suivantes — leur entrée manquante est rapportée
51
+ comme erreur explicite).
52
+
53
+ Reporté à des sprints dédiés :
54
+
55
+ - DAG branchant non séquentiel (1 → {2, 3} → 4) — Sprint 64+.
56
+ - Orchestration corpus-wide + agrégation par pipeline — Sprint 65+.
57
+ - Vue HTML dédiée aux pipelines composées — Sprint 66+.
58
+ - Cache d'artefacts intermédiaires — non prévu.
59
+ - Parallélisation inter-étapes — non prévue (les modules
60
+ ``execution_mode`` sont déjà respectés par le runner historique
61
+ pour le bench OCR mono-étage).
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ import logging
67
+ import time
68
+ from dataclasses import dataclass, field
69
+ from typing import Any, Optional
70
+
71
+ from picarones.evaluation.corpus import Document, GTLevel
72
+ from picarones.evaluation.metric_registry import compute_at_junction
73
+ from picarones.domain.artifacts import ArtifactType
74
+ from picarones.domain.module_protocol import BaseModule
75
+
76
+ # Sprint A3 (renforce la règle Cercle 1 → Cercle 1 uniquement) — la
77
+ # cérémonie d'eager-load des métriques typées (Sprint 34) qui vivait
78
+ # ici a été déplacée dans ``picarones/measurements/__init__.py``. Tout
79
+ # consommateur de ``compute_at_junction`` (typiquement la classe
80
+ # ``PipelineRunner`` ci-dessous) doit avoir importé
81
+ # ``picarones.measurements`` au moins une fois — c'est le cas dans
82
+ # l'API publique via ``picarones.__init__`` qui déclenche le trigger.
83
+
84
+ logger = logging.getLogger(__name__)
85
+
86
+
87
+ # ──────────────────────────────────────────────────────────────────────────
88
+ # Conversion ArtifactType <-> GTLevel
89
+ # ──────────────────────────────────────────────────────────────────────────
90
+
91
+
92
+ #: Map ``ArtifactType`` canonique → ``GTLevel`` legacy. Phase 4-bis :
93
+ #: ``ArtifactType`` a été migré vers ``domain/artifacts.py`` qui
94
+ #: distingue ``RAW_TEXT``/``CORRECTED_TEXT`` (vs ``TEXT`` legacy) et
95
+ #: ``ALTO_XML``/``PAGE_XML`` (vs ``ALTO``/``PAGE`` legacy). Les
96
+ #: valeurs canoniques ne matchent donc plus celles de ``GTLevel``.
97
+ #: Ce mapping explicite fait le pont — sera retiré en 2.0 quand
98
+ #: ``GTLevel`` aura aussi été retiré au profit de la projection
99
+ #: ``ArtifactType → niveau d'évaluation`` du rewrite.
100
+ _ARTIFACT_TO_GT_LEVEL: dict[ArtifactType, GTLevel] = {
101
+ ArtifactType.RAW_TEXT: GTLevel.TEXT,
102
+ ArtifactType.CORRECTED_TEXT: GTLevel.TEXT,
103
+ ArtifactType.ALTO_XML: GTLevel.ALTO,
104
+ ArtifactType.PAGE_XML: GTLevel.PAGE,
105
+ ArtifactType.ENTITIES: GTLevel.ENTITIES,
106
+ ArtifactType.READING_ORDER: GTLevel.READING_ORDER,
107
+ }
108
+
109
+
110
+ def _artifact_type_to_gt_level(at: ArtifactType) -> Optional[GTLevel]:
111
+ """Retourne le ``GTLevel`` correspondant à un ``ArtifactType``.
112
+
113
+ ``IMAGE`` n'a pas de correspondance GT (on n'évalue pas une
114
+ image en sortie d'un module — c'est typiquement une entrée).
115
+ Les types ``CONFIDENCES``, ``ALIGNMENT``, ``CANONICAL_DOCUMENT``
116
+ n'ont pas non plus de niveau de GT direct dans le legacy.
117
+ """
118
+ return _ARTIFACT_TO_GT_LEVEL.get(at)
119
+
120
+
121
+ # ──────────────────────────────────────────────────────────────────────────
122
+ # PipelineStep + PipelineSpec
123
+ # ──────────────────────────────────────────────────────────────────────────
124
+
125
+
126
+ @dataclass
127
+ class PipelineStep:
128
+ """Une étape dans une pipeline composée.
129
+
130
+ L'étape porte un nom lisible (utile pour le rapport et le
131
+ diagnostic) et une instance de ``BaseModule`` fournie par
132
+ l'utilisateur. Les types d'entrée et de sortie ne sont pas
133
+ redéclarés ici : ils sont lus depuis le module lui-même
134
+ (``module.input_types`` / ``module.output_types``).
135
+
136
+ Sprint 66 — DAG branchant
137
+ -------------------------
138
+ ``inputs_from`` permet de désigner explicitement, pour chaque
139
+ type d'entrée, l'étape source dont on veut consommer l'artefact.
140
+ Utile quand plusieurs étapes antérieures produisent le même
141
+ type et qu'on veut éviter l'écrasement implicite (par exemple
142
+ deux correcteurs LLM en parallèle qui partent du même OCR).
143
+
144
+ - ``inputs_from = {}`` (défaut) : pour chaque type d'entrée,
145
+ le runner prend la version **la plus récente** disponible
146
+ dans le bag (comportement Sprint 63, rétrocompat stricte).
147
+ - ``inputs_from = {ArtifactType.TEXT: "ocr"}`` : exige la
148
+ version du ``TEXT`` produite par l'étape nommée ``"ocr"``.
149
+ Si cette étape n'existe pas ou n'a pas produit ce type,
150
+ ``PipelineSpec.validate`` remonte un problème explicite et
151
+ le runner remonte une erreur d'entrée manquante.
152
+
153
+ La chaîne spéciale ``"__initial__"`` désigne les artefacts
154
+ fournis dans ``initial_inputs`` (par exemple ``IMAGE``).
155
+ """
156
+
157
+ name: str
158
+ module: BaseModule
159
+ inputs_from: dict[ArtifactType, str] = field(default_factory=dict)
160
+
161
+ @property
162
+ def input_types(self) -> tuple[ArtifactType, ...]:
163
+ return tuple(self.module.input_types)
164
+
165
+ @property
166
+ def output_types(self) -> tuple[ArtifactType, ...]:
167
+ return tuple(self.module.output_types)
168
+
169
+ def __repr__(self) -> str:
170
+ ins = ",".join(t.value for t in self.input_types) or "·"
171
+ outs = ",".join(t.value for t in self.output_types) or "·"
172
+ if self.inputs_from:
173
+ refs = ",".join(
174
+ f"{t.value}@{src}" for t, src in self.inputs_from.items()
175
+ )
176
+ return f"PipelineStep({self.name}: [{refs}] → {outs})"
177
+ return f"PipelineStep({self.name}: {ins} → {outs})"
178
+
179
+
180
+ @dataclass
181
+ class PipelineSpec:
182
+ """DAG séquentiel de ``PipelineStep``.
183
+
184
+ Sprint 63 — séquentiel uniquement : l'étape ``i+1`` consomme
185
+ les artefacts produits par l'étape ``i`` (et tous les artefacts
186
+ initiaux fournis au runner, par exemple l'image source).
187
+
188
+ Le DAG branchant arrive dans un sprint dédié.
189
+ """
190
+
191
+ name: str
192
+ steps: list[PipelineStep] = field(default_factory=list)
193
+
194
+ def validate(self, initial_inputs: tuple[ArtifactType, ...]) -> list[str]:
195
+ """Vérifie que les types s'enchaînent et retourne la liste
196
+ des problèmes détectés (vide si la pipeline est valide).
197
+
198
+ Une pipeline est valide si, pour chaque étape, tous les
199
+ ``input_types`` sont disponibles : soit dans les
200
+ ``initial_inputs`` (typiquement ``IMAGE``), soit produits
201
+ par une étape antérieure.
202
+
203
+ Sprint 66 — validation des références ``inputs_from`` :
204
+ si une étape déclare ``inputs_from[type] = "foo"``,
205
+ l'étape ``foo`` doit exister parmi les étapes antérieures
206
+ et avoir ce type dans ses ``output_types``. La chaîne
207
+ spéciale ``"__initial__"`` désigne les entrées initiales.
208
+ """
209
+ problems: list[str] = []
210
+ if not self.steps:
211
+ problems.append("pipeline vide : au moins une étape est requise")
212
+ return problems
213
+ # Map type → set des steps qui ont produit ce type
214
+ # ("__initial__" pour les entrées initiales) — utilisé pour
215
+ # valider les références ``inputs_from``.
216
+ producers: dict[ArtifactType, set[str]] = {
217
+ t: {"__initial__"} for t in initial_inputs
218
+ }
219
+ # Map step_name → set des types produits, pour la validation
220
+ # des références.
221
+ step_outputs: dict[str, set[ArtifactType]] = {
222
+ "__initial__": set(initial_inputs),
223
+ }
224
+ # Set des types disponibles à un instant t (latest seulement).
225
+ available: set[ArtifactType] = set(initial_inputs)
226
+
227
+ for i, step in enumerate(self.steps):
228
+ # 1. Toutes les entrées doivent être disponibles
229
+ missing = [t for t in step.input_types if t not in available]
230
+ if missing:
231
+ miss_str = ",".join(t.value for t in missing)
232
+ problems.append(
233
+ f"étape {i} ({step.name}) demande {miss_str} "
234
+ f"qui n'est ni dans les entrées initiales "
235
+ f"ni produit par une étape antérieure"
236
+ )
237
+ # 2. Vérification des références ``inputs_from``
238
+ for ref_type, ref_step in step.inputs_from.items():
239
+ if ref_type not in step.input_types:
240
+ problems.append(
241
+ f"étape {i} ({step.name}) déclare "
242
+ f"inputs_from[{ref_type.value}]={ref_step!r} "
243
+ f"mais le module ne consomme pas ce type"
244
+ )
245
+ continue
246
+ if ref_step not in step_outputs:
247
+ problems.append(
248
+ f"étape {i} ({step.name}) référence "
249
+ f"inputs_from[{ref_type.value}]={ref_step!r} "
250
+ f"qui n'est pas une étape antérieure connue"
251
+ )
252
+ continue
253
+ if ref_type not in step_outputs[ref_step]:
254
+ problems.append(
255
+ f"étape {i} ({step.name}) référence "
256
+ f"inputs_from[{ref_type.value}]={ref_step!r} "
257
+ f"mais cette étape ne produit pas ce type"
258
+ )
259
+ # 3. Mise à jour pour les étapes suivantes
260
+ available.update(step.output_types)
261
+ step_outputs[step.name] = set(step.output_types)
262
+ for out_type in step.output_types:
263
+ producers.setdefault(out_type, set()).add(step.name)
264
+ return problems
265
+
266
+ def is_valid(self, initial_inputs: tuple[ArtifactType, ...]) -> bool:
267
+ return not self.validate(initial_inputs)
268
+
269
+ def __repr__(self) -> str:
270
+ chain = " → ".join(str(s) for s in self.steps)
271
+ return f"PipelineSpec({self.name}: {chain})"
272
+
273
+
274
+ # ──────────────────────────────────────────────────────────────────────────
275
+ # StepResult + PipelineResult
276
+ # ──────────────────────────────────────────────────────────────────────────
277
+
278
+
279
+ @dataclass
280
+ class StepResult:
281
+ """Résultat de l'exécution d'une étape sur un document.
282
+
283
+ Champs
284
+ ------
285
+ step_name:
286
+ Nom de l'étape (cf. ``PipelineStep.name``).
287
+ duration_seconds:
288
+ Temps d'exécution de ``module.process`` mesuré en wall-clock.
289
+ output_types:
290
+ Types effectivement présents dans la sortie (peut être un
291
+ sous-ensemble de ``module.output_types`` si le module a
292
+ omis un type — cas reporté ici comme info pour diagnostic).
293
+ junction_metrics:
294
+ Pour chaque type produit qui correspond à un ``GTLevel``
295
+ dont le document porte une GT : dictionnaire ``{type: dict
296
+ métriques}`` retourné par ``compute_at_junction``.
297
+ error:
298
+ ``None`` si l'étape s'est bien déroulée ; sinon message
299
+ d'erreur (le module a levé, l'entrée est manquante, ou la
300
+ validation des types a échoué).
301
+ """
302
+
303
+ step_name: str
304
+ duration_seconds: float
305
+ output_types: tuple[ArtifactType, ...]
306
+ junction_metrics: dict[str, dict[str, Any]] = field(default_factory=dict)
307
+ """Map ``{artifact_type_value: {metric_name: value}}``.
308
+
309
+ La clé est la valeur string du ``ArtifactType`` (ex. ``"text"``,
310
+ ``"alto"``) et non l'enum lui-même, pour faciliter la
311
+ sérialisation JSON.
312
+ """
313
+ error: Optional[str] = None
314
+
315
+
316
+ @dataclass
317
+ class PipelineResult:
318
+ """Résultat complet d'une exécution de pipeline sur un document.
319
+
320
+ On capture la durée totale, la durée par étape et les
321
+ métriques aux jonctions pour chaque artefact produit qui a une
322
+ GT correspondante.
323
+ """
324
+
325
+ pipeline_name: str
326
+ doc_id: str
327
+ steps: list[StepResult] = field(default_factory=list)
328
+ total_duration_seconds: float = 0.0
329
+ error: Optional[str] = None
330
+ """Erreur fatale au niveau pipeline (ex. validation des types
331
+ en amont avant la première étape). ``None`` n'implique pas
332
+ qu'aucune étape n'a échoué — voir ``StepResult.error`` pour le
333
+ détail par étape."""
334
+
335
+ @property
336
+ def succeeded(self) -> bool:
337
+ """Vrai si la pipeline s'est exécutée jusqu'au bout sans
338
+ qu'aucune étape ne lève d'erreur."""
339
+ if self.error is not None:
340
+ return False
341
+ return all(s.error is None for s in self.steps)
342
+
343
+ @property
344
+ def failing_steps(self) -> list[str]:
345
+ """Noms des étapes ayant levé une erreur."""
346
+ return [s.step_name for s in self.steps if s.error is not None]
347
+
348
+ def junction_metrics_for(
349
+ self, artifact_type: ArtifactType,
350
+ ) -> Optional[dict[str, Any]]:
351
+ """Retourne les métriques de la **dernière** étape qui a
352
+ produit ``artifact_type``, ou ``None`` si aucune étape ne
353
+ l'a produit avec succès.
354
+
355
+ Utile pour comparer plusieurs pipelines qui produisent in
356
+ fine le même type (ex. deux DAG aboutissant à du texte
357
+ corrigé).
358
+ """
359
+ from picarones.domain.artifacts import LEGACY_VALUE_ALIASES
360
+ legacy_alias = LEGACY_VALUE_ALIASES.get(artifact_type.value)
361
+ for step in reversed(self.steps):
362
+ if step.error is not None:
363
+ continue
364
+ metrics = step.junction_metrics.get(artifact_type.value)
365
+ if metrics is None and legacy_alias is not None:
366
+ # Phase 4-bis : un caller legacy peut avoir construit
367
+ # le dict avec la clé pré-rewrite ("text" au lieu de
368
+ # "raw_text"). expand_legacy_keys synchronise les deux
369
+ # côtés sur les sites d'écriture du runner, mais des
370
+ # StepResult construits à la main par les tests ou par
371
+ # un caller externe peuvent encore avoir une seule
372
+ # clé — on tolère.
373
+ metrics = step.junction_metrics.get(legacy_alias)
374
+ if metrics is not None:
375
+ return metrics
376
+ return None
377
+
378
+
379
+ # ──────────────────────────────────────────────────────────────────────────
380
+ # Exécuteur
381
+ # ──────────────────────────────────────────────────────────────────────────
382
+
383
+
384
+ class PipelineRunner:
385
+ """Exécute une ``PipelineSpec`` sur un document.
386
+
387
+ Sprint 63 — un seul document à la fois. L'orchestration
388
+ corpus-wide et l'agrégation par pipeline sont reportées à un
389
+ sprint dédié.
390
+
391
+ Usage typique
392
+ -------------
393
+
394
+ >>> spec = PipelineSpec(
395
+ ... name="ocr_then_rewrite",
396
+ ... steps=[
397
+ ... PipelineStep("ocr", my_ocr_module),
398
+ ... PipelineStep("rewrite", my_llm_rewriter),
399
+ ... ],
400
+ ... )
401
+ >>> runner = PipelineRunner()
402
+ >>> result = runner.run(spec, document, {ArtifactType.IMAGE: "/path/img.png"})
403
+ >>> result.succeeded
404
+ True
405
+ >>> result.junction_metrics_for(ArtifactType.TEXT)
406
+ {'cer': 0.05, 'wer': 0.12, ...}
407
+ """
408
+
409
+ @staticmethod
410
+ def run(
411
+ spec: PipelineSpec,
412
+ document: Document,
413
+ initial_inputs: dict[ArtifactType, Any],
414
+ ) -> PipelineResult:
415
+ """Exécute ``spec`` sur ``document`` à partir de
416
+ ``initial_inputs``.
417
+
418
+ Parameters
419
+ ----------
420
+ spec:
421
+ Spécification de la pipeline.
422
+ document:
423
+ Document du corpus, porteur de zéro ou plusieurs niveaux
424
+ de GT (Sprint 32).
425
+ initial_inputs:
426
+ Artefacts initiaux par type — typiquement
427
+ ``{ArtifactType.IMAGE: "/path/img.png"}`` pour une
428
+ pipeline qui démarre par un OCR.
429
+
430
+ Returns
431
+ -------
432
+ PipelineResult
433
+ Résultat complet : durée totale, résultat par étape,
434
+ métriques aux jonctions évaluées contre la GT.
435
+ """
436
+ result = PipelineResult(
437
+ pipeline_name=spec.name, doc_id=document.doc_id,
438
+ )
439
+
440
+ # Validation amont : si la pipeline est statiquement
441
+ # invalide, on n'exécute aucune étape.
442
+ problems = spec.validate(tuple(initial_inputs.keys()))
443
+ if problems:
444
+ result.error = " ; ".join(problems)
445
+ return result
446
+
447
+ # Sprint 66 — bag versionné : ``versioned[(type, src_step)]``
448
+ # contient l'artefact produit par ``src_step`` pour ``type``.
449
+ # ``src_step`` vaut ``"__initial__"`` pour les entrées
450
+ # initiales fournies par l'utilisateur. ``latest[type]``
451
+ # désigne le nom de l'étape qui a produit la version la plus
452
+ # récente du type — utilisé en l'absence d'``inputs_from``
453
+ # explicite (rétrocompat Sprint 63).
454
+ versioned: dict[tuple[ArtifactType, str], Any] = {
455
+ (t, "__initial__"): v for t, v in initial_inputs.items()
456
+ }
457
+ latest: dict[ArtifactType, str] = {
458
+ t: "__initial__" for t in initial_inputs
459
+ }
460
+
461
+ pipeline_t0 = time.monotonic()
462
+ for step in spec.steps:
463
+ step_result = PipelineRunner._run_step(
464
+ step, versioned, latest, document,
465
+ )
466
+ result.steps.append(step_result)
467
+ result.total_duration_seconds = time.monotonic() - pipeline_t0
468
+ return result
469
+
470
+ @staticmethod
471
+ def _run_step(
472
+ step: PipelineStep,
473
+ versioned: dict[tuple[ArtifactType, str], Any],
474
+ latest: dict[ArtifactType, str],
475
+ document: Document,
476
+ ) -> StepResult:
477
+ # Sprint 66 — résolution des entrées : pour chaque type
478
+ # demandé, on consulte ``inputs_from`` ; sinon on prend la
479
+ # dernière version disponible (rétrocompat Sprint 63).
480
+ resolved: dict[ArtifactType, Any] = {}
481
+ missing: list[str] = []
482
+ for t in step.input_types:
483
+ src = step.inputs_from.get(t, latest.get(t))
484
+ if src is None:
485
+ missing.append(t.value)
486
+ continue
487
+ key = (t, src)
488
+ if key not in versioned:
489
+ # Référence explicite vers une étape qui n'a pas
490
+ # produit cet artefact (ex. l'étape source a échoué).
491
+ missing.append(f"{t.value}@{src}")
492
+ continue
493
+ resolved[t] = versioned[key]
494
+ if missing:
495
+ miss_str = ",".join(missing)
496
+ return StepResult(
497
+ step_name=step.name,
498
+ duration_seconds=0.0,
499
+ output_types=(),
500
+ error=f"entrée manquante : {miss_str}",
501
+ )
502
+ inputs_for_module = resolved
503
+ # Exécution chronométrée
504
+ t0 = time.monotonic()
505
+ try:
506
+ outputs = step.module.process(inputs_for_module)
507
+ except Exception as exc: # noqa: BLE001
508
+ duration = time.monotonic() - t0
509
+ logger.warning(
510
+ "[pipeline_runner] étape '%s' a levé : %s",
511
+ step.name, exc,
512
+ )
513
+ return StepResult(
514
+ step_name=step.name,
515
+ duration_seconds=duration,
516
+ output_types=(),
517
+ error=f"{type(exc).__name__}: {exc}",
518
+ )
519
+ duration = time.monotonic() - t0
520
+
521
+ # Validation des sorties : le module est censé déclarer ses
522
+ # output_types, on vérifie qu'il les a tous produits. Si
523
+ # ce n'est pas le cas, on remonte une erreur explicite mais
524
+ # on conserve les sorties effectivement présentes (utile
525
+ # pour le diagnostic).
526
+ if not isinstance(outputs, dict):
527
+ return StepResult(
528
+ step_name=step.name,
529
+ duration_seconds=duration,
530
+ output_types=(),
531
+ error=(
532
+ f"le module a retourné {type(outputs).__name__}, "
533
+ f"un dict[ArtifactType, Any] est attendu"
534
+ ),
535
+ )
536
+ produced = tuple(t for t in step.output_types if t in outputs)
537
+ missing_outputs = [t for t in step.output_types if t not in outputs]
538
+ error: Optional[str] = None
539
+ if missing_outputs:
540
+ miss_str = ",".join(t.value for t in missing_outputs)
541
+ error = f"sortie manquante : {miss_str}"
542
+
543
+ # Mise à jour du bag versionné : on stocke la sortie sous
544
+ # une clé (type, step.name) ET on met à jour ``latest`` pour
545
+ # que les étapes suivantes la récupèrent par défaut.
546
+ for t in produced:
547
+ versioned[(t, step.name)] = outputs[t]
548
+ latest[t] = step.name
549
+
550
+ # Évaluation aux jonctions : pour chaque type produit, si
551
+ # la GT du même niveau existe, on calcule les métriques.
552
+ junction_metrics: dict[str, dict[str, Any]] = {}
553
+ for at in produced:
554
+ gt_level = _artifact_type_to_gt_level(at)
555
+ if gt_level is None:
556
+ continue
557
+ gt_payload = document.get_gt(gt_level)
558
+ if gt_payload is None:
559
+ continue
560
+ try:
561
+ metrics = compute_at_junction(
562
+ _gt_payload_to_value(gt_payload),
563
+ outputs[at],
564
+ (at, at),
565
+ )
566
+ except Exception as exc: # noqa: BLE001
567
+ logger.warning(
568
+ "[pipeline_runner] évaluation à la jonction %s "
569
+ "a levé : %s",
570
+ at.value, exc,
571
+ )
572
+ continue
573
+ if metrics:
574
+ junction_metrics[at.value] = metrics
575
+
576
+ # Phase 4-bis : double-clé pour rétrocompat. Les tests
577
+ # legacy cherchent junction_metrics["text"] mais le runner
578
+ # peut produire junction_metrics["raw_text"] si l'enum est
579
+ # migré (ArtifactType.TEXT alias de RAW_TEXT, valeur
580
+ # "raw_text"). expand_legacy_keys ajoute la clé legacy
581
+ # ("text") à côté de la canonique ("raw_text") sans écraser.
582
+ from picarones.domain.artifacts import expand_legacy_keys
583
+ expand_legacy_keys(junction_metrics)
584
+
585
+ return StepResult(
586
+ step_name=step.name,
587
+ duration_seconds=duration,
588
+ output_types=produced,
589
+ junction_metrics=junction_metrics,
590
+ error=error,
591
+ )
592
+
593
+
594
+ def _gt_payload_to_value(payload: Any) -> Any:
595
+ """Extrait la valeur exploitable d'un ``GTPayload`` typé.
596
+
597
+ Pour ``TextGT`` on veut juste la chaîne ; pour les autres
598
+ payloads on retourne le payload entier (la métrique sait quoi
599
+ en faire selon sa signature de types).
600
+ """
601
+ # Import paresseux pour éviter une dépendance cyclique
602
+ from picarones.evaluation.corpus import (
603
+ AltoGT, EntitiesGT, PageGT, ReadingOrderGT, TextGT,
604
+ )
605
+ if isinstance(payload, TextGT):
606
+ return payload.text
607
+ if isinstance(payload, EntitiesGT):
608
+ return payload.entities
609
+ if isinstance(payload, ReadingOrderGT):
610
+ return payload.region_order
611
+ if isinstance(payload, (AltoGT, PageGT)):
612
+ return payload
613
+ return payload
614
+
615
+
616
+ __all__ = [
617
+ "PipelineRunner",
618
+ "PipelineResult",
619
+ "PipelineSpec",
620
+ "PipelineStep",
621
+ "StepResult",
622
+ ]
picarones/evaluation/pipeline_benchmark.py ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Orchestration corpus-wide d'une pipeline composée — Sprint 64
2
+ (axe B).
3
+
4
+ Phase 5.C.batch7 — module relocalisé depuis
5
+ ``picarones.measurements.pipeline_benchmark`` vers
6
+ ``picarones.evaluation.pipeline_benchmark``. Le chemin legacy
7
+ reste disponible via un shim avec ``DeprecationWarning`` ;
8
+ suppression prévue en 2.0.
9
+
10
+ Sprint 64 — Étape 4 / axe B du plan d'évolution 2026 : suite directe
11
+ du Sprint 63. Le ``PipelineRunner`` exécute une pipeline sur **un**
12
+ document ; ce module fournit l'orchestration sur un **corpus
13
+ complet** et l'agrégation des résultats par étape.
14
+
15
+ Philosophie inchangée
16
+ ---------------------
17
+ Picarones reste un **banc d'essai**. Aucun module métier n'est
18
+ fourni — l'utilisateur amène ses propres ``BaseModule`` (Sprint 33).
19
+ Cette infrastructure se contente d'orchestrer leur exécution sur un
20
+ corpus, de mesurer le temps, de capturer les erreurs gracieusement,
21
+ et d'agréger les métriques calculées aux jonctions GT-vs-sortie.
22
+
23
+ Périmètre Sprint 64
24
+ -------------------
25
+ Inclus :
26
+
27
+ - ``run_pipeline_benchmark(spec, corpus, initial_inputs_factory)``
28
+ qui itère séquentiellement sur les documents.
29
+ - Agrégation par étape : ``StepAggregate`` avec n_succeeded /
30
+ n_failed, durées (total / mean / median), failing_doc_ids,
31
+ métriques agrégées par type d'artefact (mean / median sur les
32
+ métriques numériques uniquement), breakdown des types d'erreur.
33
+ - ``PipelineBenchmarkResult`` : conteneur global avec liste des
34
+ ``PipelineResult`` par doc + liste des ``StepAggregate``.
35
+ - Helper ``default_initial_inputs`` qui couvre le cas standard
36
+ ``IMAGE`` depuis ``Document.image_path``.
37
+
38
+ Reporté à des sprints suivants :
39
+
40
+ - Comparaison de N pipelines sur le même corpus (Sprint 65).
41
+ - DAG branchant non séquentiel (Sprint 66).
42
+ - Vue HTML dédiée aux pipelines composées (Sprint 67).
43
+ - Parallélisation inter-documents (à arbitrer selon les besoins).
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import logging
49
+ import statistics
50
+ import time
51
+ from dataclasses import dataclass, field
52
+ from typing import Any, Callable, Optional
53
+
54
+ from picarones.evaluation.corpus import Corpus, Document
55
+ from picarones.domain.artifacts import ArtifactType
56
+ from picarones.evaluation.pipeline import (
57
+ PipelineResult,
58
+ PipelineRunner,
59
+ PipelineSpec,
60
+ )
61
+
62
+ logger = logging.getLogger(__name__)
63
+
64
+
65
+ # ──────────────────────────────────────────────────────────────────────────
66
+ # Helpers : factory d'entrées initiales
67
+ # ──────────────────────────────────────────────────────────────────────────
68
+
69
+ InitialInputsFactory = Callable[[Document], dict[ArtifactType, Any]]
70
+
71
+
72
+ def default_initial_inputs(document: Document) -> dict[ArtifactType, Any]:
73
+ """Factory d'entrées initiales par défaut : couvre le cas
74
+ « la pipeline démarre par un module qui consomme l'image ».
75
+
76
+ Retourne ``{ArtifactType.IMAGE: document.image_path}`` si
77
+ ``image_path`` est présent, sinon dict vide (la première étape
78
+ devra alors signaler « entrée manquante »).
79
+ """
80
+ if document.image_path:
81
+ return {ArtifactType.IMAGE: document.image_path}
82
+ return {}
83
+
84
+
85
+ # ──────────────────────────────────────────────────────────────────────────
86
+ # Agrégats
87
+ # ──────────────────────────────────────────────────────────────────────────
88
+
89
+
90
+ @dataclass
91
+ class StepAggregate:
92
+ """Agrégat des résultats d'une étape sur tout le corpus.
93
+
94
+ Champs
95
+ ------
96
+ step_name:
97
+ Nom de l'étape (cf. ``PipelineStep.name``).
98
+ n_docs:
99
+ Nombre de documents pour lesquels l'étape a été tentée.
100
+ n_succeeded:
101
+ Nombre de documents pour lesquels l'étape s'est terminée
102
+ sans erreur (``StepResult.error is None``).
103
+ n_failed:
104
+ Nombre de documents pour lesquels l'étape a renvoyé une
105
+ erreur.
106
+ duration_seconds_total / mean / median:
107
+ Statistiques de durée sur les **étapes ayant réussi**
108
+ uniquement (les étapes en erreur peuvent avoir une durée
109
+ artificielle).
110
+ failing_doc_ids:
111
+ Liste des ``doc_id`` pour lesquels cette étape a échoué.
112
+ junction_metrics:
113
+ ``{artifact_type_value: {metric_name: {"mean": float,
114
+ "median": float, "n": int}}}`` — agrégé sur les documents
115
+ où la métrique a été calculée (n peut différer de
116
+ ``n_succeeded`` si la GT du type n'est pas portée par tous
117
+ les docs).
118
+ error_breakdown:
119
+ ``{type_d_erreur: count}`` où ``type_d_erreur`` est extrait
120
+ en heuristique depuis le message (``"missing_input"``,
121
+ ``"raised_exception"``, ``"missing_output"``,
122
+ ``"other"``).
123
+ """
124
+
125
+ step_name: str
126
+ n_docs: int = 0
127
+ n_succeeded: int = 0
128
+ n_failed: int = 0
129
+ duration_seconds_total: float = 0.0
130
+ duration_seconds_mean: float = 0.0
131
+ duration_seconds_median: float = 0.0
132
+ failing_doc_ids: list[str] = field(default_factory=list)
133
+ junction_metrics: dict[str, dict[str, dict[str, float]]] = field(
134
+ default_factory=dict,
135
+ )
136
+ error_breakdown: dict[str, int] = field(default_factory=dict)
137
+
138
+ @property
139
+ def success_rate(self) -> float:
140
+ if self.n_docs == 0:
141
+ return 0.0
142
+ return self.n_succeeded / self.n_docs
143
+
144
+
145
+ @dataclass
146
+ class PipelineBenchmarkResult:
147
+ """Résultat d'un benchmark de pipeline sur un corpus complet.
148
+
149
+ On capture la durée totale, les résultats par document
150
+ (utiles pour le rapport HTML par-doc des sprints suivants), et
151
+ l'agrégation par étape.
152
+ """
153
+
154
+ pipeline_name: str
155
+ corpus_name: str
156
+ n_docs: int = 0
157
+ per_doc_results: list[PipelineResult] = field(default_factory=list)
158
+ per_step_aggregates: list[StepAggregate] = field(default_factory=list)
159
+ total_duration_seconds: float = 0.0
160
+
161
+ @property
162
+ def n_pipelines_succeeded(self) -> int:
163
+ return sum(1 for r in self.per_doc_results if r.succeeded)
164
+
165
+ @property
166
+ def n_pipelines_failed(self) -> int:
167
+ return sum(1 for r in self.per_doc_results if not r.succeeded)
168
+
169
+ def aggregate_for_step(self, step_name: str) -> Optional[StepAggregate]:
170
+ for agg in self.per_step_aggregates:
171
+ if agg.step_name == step_name:
172
+ return agg
173
+ return None
174
+
175
+
176
+ # ──────────────────────────────────────────────────────────────────────────
177
+ # Classification des erreurs
178
+ # ──────────────────────────────────────────────────────────────────────────
179
+
180
+
181
+ _ERROR_PATTERNS: tuple[tuple[str, str], ...] = (
182
+ ("entrée manquante", "missing_input"),
183
+ ("sortie manquante", "missing_output"),
184
+ ("Error", "raised_exception"), # RuntimeError, ValueError…
185
+ )
186
+
187
+
188
+ def _classify_error(message: str) -> str:
189
+ """Heuristique simple pour catégoriser une erreur d'étape.
190
+
191
+ On regarde des marqueurs lexicaux dans le message (les messages
192
+ sont produits par ``pipeline_runner._run_step`` qui les contrôle
193
+ entièrement, donc cette heuristique est stable).
194
+ """
195
+ if not message:
196
+ return "other"
197
+ for pattern, label in _ERROR_PATTERNS:
198
+ if pattern in message:
199
+ return label
200
+ return "other"
201
+
202
+
203
+ # ──────────────────────────────────────────────────────────────────────────
204
+ # Agrégation
205
+ # ──────────────────────────────────────────────────────────────────────────
206
+
207
+
208
+ def _aggregate_step(
209
+ step_name: str, per_doc: list[tuple[str, Any]],
210
+ ) -> StepAggregate:
211
+ """Construit le ``StepAggregate`` pour une étape donnée.
212
+
213
+ ``per_doc`` est une liste de tuples ``(doc_id, step_result)`` où
214
+ ``step_result`` peut être ``None`` (cas où la pipeline a été
215
+ arrêtée en amont avant cette étape) ou un ``StepResult``.
216
+ """
217
+ agg = StepAggregate(step_name=step_name)
218
+ durations_succeeded: list[float] = []
219
+ metrics_by_type: dict[str, dict[str, list[float]]] = {}
220
+
221
+ for doc_id, sr in per_doc:
222
+ if sr is None:
223
+ # L'étape n'a même pas été exécutée (validation amont
224
+ # invalide, ou exécutée n'a pas atteint l'index — ne se
225
+ # produit pas en séquentiel mais peut arriver avec un
226
+ # DAG plus tard). On compte ce cas comme échec
227
+ # explicite avec un type dédié.
228
+ agg.n_docs += 1
229
+ agg.n_failed += 1
230
+ agg.failing_doc_ids.append(doc_id)
231
+ agg.error_breakdown["pipeline_aborted"] = (
232
+ agg.error_breakdown.get("pipeline_aborted", 0) + 1
233
+ )
234
+ continue
235
+ agg.n_docs += 1
236
+ if sr.error is None:
237
+ agg.n_succeeded += 1
238
+ durations_succeeded.append(sr.duration_seconds)
239
+ # Collecte des métriques pour agrégation moyenne/médiane
240
+ for at_value, metrics in sr.junction_metrics.items():
241
+ slot = metrics_by_type.setdefault(at_value, {})
242
+ for mname, mvalue in metrics.items():
243
+ if isinstance(mvalue, (int, float)) and not isinstance(
244
+ mvalue, bool,
245
+ ):
246
+ slot.setdefault(mname, []).append(float(mvalue))
247
+ else:
248
+ agg.n_failed += 1
249
+ agg.failing_doc_ids.append(doc_id)
250
+ label = _classify_error(sr.error)
251
+ agg.error_breakdown[label] = (
252
+ agg.error_breakdown.get(label, 0) + 1
253
+ )
254
+
255
+ if durations_succeeded:
256
+ agg.duration_seconds_total = sum(durations_succeeded)
257
+ agg.duration_seconds_mean = statistics.fmean(durations_succeeded)
258
+ agg.duration_seconds_median = statistics.median(durations_succeeded)
259
+
260
+ for at_value, metrics in metrics_by_type.items():
261
+ agg.junction_metrics[at_value] = {
262
+ mname: {
263
+ "mean": statistics.fmean(values),
264
+ "median": statistics.median(values),
265
+ "n": len(values),
266
+ }
267
+ for mname, values in metrics.items()
268
+ }
269
+ # Phase 4-bis : double-clé legacy/canonique pour rétrocompat.
270
+ from picarones.domain.artifacts import expand_legacy_keys
271
+ expand_legacy_keys(agg.junction_metrics)
272
+ return agg
273
+
274
+
275
+ # ──────────────────────────────────────────────────────────────────────────
276
+ # Orchestrateur principal
277
+ # ──────────────────────────────────────────────────────────────────────────
278
+
279
+
280
+ def run_pipeline_benchmark(
281
+ spec: PipelineSpec,
282
+ corpus: Corpus,
283
+ initial_inputs_factory: InitialInputsFactory = default_initial_inputs,
284
+ ) -> PipelineBenchmarkResult:
285
+ """Exécute ``spec`` sur tous les documents de ``corpus``.
286
+
287
+ Parameters
288
+ ----------
289
+ spec:
290
+ Spécification de la pipeline composée. Toutes les étapes
291
+ sont des ``BaseModule`` fournis par l'utilisateur.
292
+ corpus:
293
+ Corpus chargé via ``Corpus.from_directory`` ou équivalent.
294
+ initial_inputs_factory:
295
+ Fonction qui produit, pour chaque document, les artefacts
296
+ d'entrée de la pipeline. Par défaut : ``IMAGE`` depuis
297
+ ``document.image_path``. L'utilisateur peut fournir une
298
+ factory personnalisée pour brancher d'autres sources
299
+ (par exemple ``ALTO`` pré-existant pour évaluer un
300
+ pipeline qui démarre par un re-segmenteur).
301
+
302
+ Returns
303
+ -------
304
+ PipelineBenchmarkResult
305
+ Résultat global avec ``per_doc_results``,
306
+ ``per_step_aggregates``, durée totale.
307
+
308
+ Comportement
309
+ ------------
310
+ L'orchestration est **séquentielle** par document. Pour chaque
311
+ document, ``PipelineRunner.run`` est appelé ; quel que soit le
312
+ résultat (réussi, partiellement échoué, totalement invalide),
313
+ le résultat est ajouté à ``per_doc_results`` et le benchmark
314
+ continue avec le document suivant.
315
+
316
+ Si la spec est statiquement invalide (cf.
317
+ ``PipelineSpec.validate``), tous les documents auront un
318
+ ``PipelineResult.error`` non vide et aucune étape ne sera
319
+ exécutée — le résultat reste cohérent.
320
+ """
321
+ result = PipelineBenchmarkResult(
322
+ pipeline_name=spec.name, corpus_name=corpus.name,
323
+ )
324
+ documents = list(corpus.documents)
325
+ result.n_docs = len(documents)
326
+
327
+ benchmark_t0 = time.monotonic()
328
+ for doc in documents:
329
+ try:
330
+ initial = initial_inputs_factory(doc)
331
+ except Exception as exc: # noqa: BLE001
332
+ logger.warning(
333
+ "[pipeline_benchmark] factory a levé sur %s : %s",
334
+ doc.doc_id, exc,
335
+ )
336
+ # On crée un PipelineResult portant l'erreur factory
337
+ failed = PipelineResult(
338
+ pipeline_name=spec.name, doc_id=doc.doc_id,
339
+ error=f"initial_inputs_factory: {type(exc).__name__}: {exc}",
340
+ )
341
+ result.per_doc_results.append(failed)
342
+ continue
343
+ per_doc = PipelineRunner.run(spec, doc, initial)
344
+ result.per_doc_results.append(per_doc)
345
+ result.total_duration_seconds = time.monotonic() - benchmark_t0
346
+
347
+ # Agrégation par étape
348
+ step_names = [step.name for step in spec.steps]
349
+ for idx, step_name in enumerate(step_names):
350
+ per_doc_step: list[tuple[str, Any]] = []
351
+ for pr in result.per_doc_results:
352
+ if idx < len(pr.steps):
353
+ per_doc_step.append((pr.doc_id, pr.steps[idx]))
354
+ else:
355
+ # Pipeline a été arrêtée en amont : aucune étape de
356
+ # cet index n'existe. On compte ça comme une
357
+ # absence d'étape (cf. ``_aggregate_step`` qui gère
358
+ # le ``None``).
359
+ per_doc_step.append((pr.doc_id, None))
360
+ result.per_step_aggregates.append(
361
+ _aggregate_step(step_name, per_doc_step),
362
+ )
363
+
364
+ return result
365
+
366
+
367
+ __all__ = [
368
+ "InitialInputsFactory",
369
+ "PipelineBenchmarkResult",
370
+ "StepAggregate",
371
+ "default_initial_inputs",
372
+ "run_pipeline_benchmark",
373
+ ]
picarones/evaluation/pipeline_comparison.py ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Comparaison de N pipelines sur le même corpus — Sprint 65 (axe B).
2
+
3
+ Phase 5.C.batch7 — module relocalisé depuis
4
+ ``picarones.measurements.pipeline_comparison`` vers
5
+ ``picarones.evaluation.pipeline_comparison``. Le chemin legacy
6
+ reste disponible via un shim avec ``DeprecationWarning`` ;
7
+ suppression prévue en 2.0.
8
+
9
+ Sprint 65 — Étape 4 / axe B du plan d'évolution 2026 : suite directe
10
+ des Sprints 63-64. Le runner mono-document (Sprint 63) et
11
+ l'orchestration corpus-wide (Sprint 64) permettent d'évaluer **une**
12
+ pipeline composée ; ce sprint répond à la question typique BnF :
13
+
14
+ « OCR seul vs OCR+correcteur A vs OCR+correcteur B :
15
+ laquelle est la meilleure sur mon corpus, et de combien ? »
16
+
17
+ Philosophie inchangée
18
+ ---------------------
19
+ Picarones reste un **banc d'essai** — on juge des pipelines tierces
20
+ sur le **même corpus** avec la **même GT**, en exposant des chiffres
21
+ bruts comparatifs. Aucun verdict imposé : le chercheur lit le
22
+ ranking et la table de gain et conclut selon ses critères.
23
+
24
+ Périmètre Sprint 65
25
+ -------------------
26
+ Inclus :
27
+
28
+ - ``compare_pipelines(specs, corpus, factories=None)`` qui exécute
29
+ séquentiellement N pipelines sur le même corpus.
30
+ - ``PipelineComparisonResult`` : conteneur avec
31
+ ``per_pipeline: dict[name → PipelineBenchmarkResult]``,
32
+ ``ranking_by_final_metric(artifact_type, metric_name,
33
+ higher_is_better)`` qui retourne ``[(pipeline_name, score), ...]``
34
+ trié, et ``gain_table(artifact_type, metric_name,
35
+ baseline_pipeline)`` qui retourne pour chaque pipeline le
36
+ ``{absolute, relative}`` vs baseline.
37
+ - ``factories``: dict ``{pipeline_name: InitialInputsFactory}`` pour
38
+ personnaliser les entrées initiales par pipeline (utile pour
39
+ comparer une pipeline qui démarre par IMAGE et une qui démarre
40
+ par TEXT).
41
+ - Garde-fou : noms de pipelines uniques exigés.
42
+
43
+ Reporté à des sprints suivants :
44
+
45
+ - DAG branchant non séquentiel (Sprint 66).
46
+ - Vue HTML dédiée à la comparaison de pipelines (Sprint 67+).
47
+ - Tests statistiques (Wilcoxon, Friedman, Nemenyi) sur les
48
+ pipelines composées — déjà disponibles côté OCR (Sprint 18) ;
49
+ l'application au cadre pipeline arrive plus tard.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import logging
55
+ import time
56
+ from dataclasses import dataclass, field
57
+ from typing import Optional
58
+
59
+ from picarones.evaluation.corpus import Corpus
60
+ from picarones.domain.artifacts import ArtifactType
61
+ from picarones.evaluation.pipeline_benchmark import (
62
+ InitialInputsFactory,
63
+ PipelineBenchmarkResult,
64
+ default_initial_inputs,
65
+ run_pipeline_benchmark,
66
+ )
67
+ from picarones.evaluation.pipeline import PipelineSpec
68
+
69
+ logger = logging.getLogger(__name__)
70
+
71
+
72
+ # ──────────────────────────────────────────────────────────────────────────
73
+ # Conteneur de résultats
74
+ # ──────────────────────────────────────────────────────────────────────────
75
+
76
+
77
+ @dataclass
78
+ class PipelineComparisonResult:
79
+ """Résultat de la comparaison de N pipelines sur un corpus.
80
+
81
+ Champs
82
+ ------
83
+ corpus_name:
84
+ Nom du corpus (commun à toutes les pipelines comparées).
85
+ n_docs:
86
+ Nombre de documents du corpus.
87
+ per_pipeline:
88
+ Map ``{pipeline_name: PipelineBenchmarkResult}``. L'ordre
89
+ d'insertion suit l'ordre des ``specs`` passées à
90
+ ``compare_pipelines`` ; on s'appuie sur le ``dict`` ordonné
91
+ de Python 3.7+.
92
+ total_duration_seconds:
93
+ Durée totale de la comparaison (sommes des durées par
94
+ pipeline + petit overhead).
95
+ """
96
+
97
+ corpus_name: str
98
+ n_docs: int = 0
99
+ per_pipeline: dict[str, PipelineBenchmarkResult] = field(
100
+ default_factory=dict,
101
+ )
102
+ total_duration_seconds: float = 0.0
103
+
104
+ def pipeline_names(self) -> list[str]:
105
+ """Retourne la liste des noms de pipelines dans leur ordre
106
+ d'insertion (= ordre de la comparaison initiale)."""
107
+ return list(self.per_pipeline.keys())
108
+
109
+ def _final_metric_value(
110
+ self,
111
+ pipeline_name: str,
112
+ artifact_type: ArtifactType,
113
+ metric_name: str,
114
+ ) -> Optional[float]:
115
+ """Retourne le ``mean`` de la métrique demandée à la
116
+ **dernière étape** de la pipeline qui a produit
117
+ ``artifact_type`` (avec succès sur ≥ 1 doc), ou ``None``
118
+ si la métrique n'est pas disponible.
119
+
120
+ Cohérent avec ``PipelineResult.junction_metrics_for`` du
121
+ Sprint 63 mais au niveau corpus-wide.
122
+ """
123
+ bench = self.per_pipeline.get(pipeline_name)
124
+ if bench is None:
125
+ return None
126
+ from picarones.domain.artifacts import LEGACY_VALUE_ALIASES
127
+ legacy_alias = LEGACY_VALUE_ALIASES.get(artifact_type.value)
128
+ for agg in reversed(bench.per_step_aggregates):
129
+ type_metrics = agg.junction_metrics.get(artifact_type.value)
130
+ if not type_metrics and legacy_alias is not None:
131
+ # Phase 4-bis : un caller (typiquement les tests
132
+ # ou un agrégateur tiers) peut avoir construit le
133
+ # dict avec la clé legacy ``"text"`` au lieu de la
134
+ # canonique ``"raw_text"``. expand_legacy_keys
135
+ # synchronise les deux côtés sur les sites
136
+ # d'écriture du runner — ce fallback couvre le
137
+ # reste.
138
+ type_metrics = agg.junction_metrics.get(legacy_alias)
139
+ if not type_metrics:
140
+ continue
141
+ stats = type_metrics.get(metric_name)
142
+ if stats is None:
143
+ continue
144
+ return stats["mean"]
145
+ return None
146
+
147
+ def ranking_by_final_metric(
148
+ self,
149
+ artifact_type: ArtifactType,
150
+ metric_name: str,
151
+ higher_is_better: bool = False,
152
+ ) -> list[tuple[str, Optional[float]]]:
153
+ """Classe les pipelines par la valeur **finale** de
154
+ ``metric_name`` à la jonction ``artifact_type``.
155
+
156
+ Returns
157
+ -------
158
+ list[tuple[str, Optional[float]]]
159
+ Liste ``[(pipeline_name, mean_value)]`` triée :
160
+
161
+ - Les pipelines avec une valeur définie viennent en
162
+ premier, triées selon ``higher_is_better``.
163
+ - Les pipelines sans valeur (métrique absente) viennent
164
+ en queue, dans leur ordre d'insertion.
165
+ """
166
+ with_value: list[tuple[str, float]] = []
167
+ without_value: list[tuple[str, Optional[float]]] = []
168
+ for name in self.pipeline_names():
169
+ value = self._final_metric_value(name, artifact_type, metric_name)
170
+ if value is None:
171
+ without_value.append((name, None))
172
+ else:
173
+ with_value.append((name, value))
174
+ with_value.sort(
175
+ key=lambda pair: pair[1],
176
+ reverse=higher_is_better,
177
+ )
178
+ return [*with_value, *without_value]
179
+
180
+ def gain_table(
181
+ self,
182
+ artifact_type: ArtifactType,
183
+ metric_name: str,
184
+ baseline_pipeline: str,
185
+ ) -> dict[str, dict[str, Optional[float]]]:
186
+ """Calcule l'écart de chaque pipeline vs la baseline.
187
+
188
+ Returns
189
+ -------
190
+ dict
191
+ Map ``{pipeline_name: {"value", "absolute", "relative"}}``
192
+ où :
193
+
194
+ - ``value`` : valeur finale de la métrique pour cette
195
+ pipeline (``None`` si absente).
196
+ - ``absolute`` : ``value - baseline_value``
197
+ (``None`` si l'une des deux est absente).
198
+ - ``relative`` : ``(value - baseline_value) /
199
+ baseline_value`` (``None`` si baseline absente ou
200
+ égale à 0).
201
+
202
+ La baseline elle-même apparaît avec ``absolute == 0`` et
203
+ ``relative == 0``.
204
+ """
205
+ if baseline_pipeline not in self.per_pipeline:
206
+ raise KeyError(
207
+ f"baseline {baseline_pipeline!r} absente de la comparaison",
208
+ )
209
+ baseline_value = self._final_metric_value(
210
+ baseline_pipeline, artifact_type, metric_name,
211
+ )
212
+ out: dict[str, dict[str, Optional[float]]] = {}
213
+ for name in self.pipeline_names():
214
+ value = self._final_metric_value(
215
+ name, artifact_type, metric_name,
216
+ )
217
+ absolute: Optional[float]
218
+ relative: Optional[float]
219
+ if value is None or baseline_value is None:
220
+ absolute = None
221
+ relative = None
222
+ else:
223
+ absolute = value - baseline_value
224
+ relative = (
225
+ (value - baseline_value) / baseline_value
226
+ if baseline_value != 0 else None
227
+ )
228
+ out[name] = {
229
+ "value": value,
230
+ "absolute": absolute,
231
+ "relative": relative,
232
+ }
233
+ return out
234
+
235
+
236
+ # ──────────────────────────────────────────────────────────────────────────
237
+ # Orchestrateur
238
+ # ──────────────────────────────────────────────────────────────────────────
239
+
240
+
241
+ def compare_pipelines(
242
+ specs: list[PipelineSpec],
243
+ corpus: Corpus,
244
+ factories: Optional[dict[str, InitialInputsFactory]] = None,
245
+ ) -> PipelineComparisonResult:
246
+ """Exécute N ``PipelineSpec`` sur le **même** ``corpus``.
247
+
248
+ Parameters
249
+ ----------
250
+ specs:
251
+ Liste de ``PipelineSpec``. Les noms de pipelines doivent
252
+ être uniques (sinon ``ValueError``).
253
+ corpus:
254
+ Corpus partagé entre toutes les pipelines comparées —
255
+ c'est le point fort du sprint : même corpus, même GT, on
256
+ peut comparer apple-to-apple.
257
+ factories:
258
+ Optionnel. Si fourni, dict ``{pipeline_name:
259
+ InitialInputsFactory}`` pour personnaliser les entrées
260
+ initiales par pipeline. Les pipelines absentes du dict
261
+ utilisent ``default_initial_inputs`` (cas standard
262
+ ``IMAGE`` depuis ``Document.image_path``).
263
+
264
+ Returns
265
+ -------
266
+ PipelineComparisonResult
267
+ Conteneur avec ``per_pipeline`` indexé par nom et
268
+ utilitaires comparatifs (``ranking_by_final_metric``,
269
+ ``gain_table``).
270
+
271
+ Raises
272
+ ------
273
+ ValueError
274
+ Si deux ``PipelineSpec`` ont le même nom (impossible alors
275
+ de les distinguer dans le résultat).
276
+ """
277
+ names = [s.name for s in specs]
278
+ if len(set(names)) != len(names):
279
+ seen: set[str] = set()
280
+ duplicates: list[str] = []
281
+ for n in names:
282
+ if n in seen:
283
+ duplicates.append(n)
284
+ seen.add(n)
285
+ raise ValueError(
286
+ f"noms de pipelines non uniques : {sorted(set(duplicates))}",
287
+ )
288
+
289
+ factories = factories or {}
290
+ result = PipelineComparisonResult(
291
+ corpus_name=corpus.name,
292
+ n_docs=len(list(corpus.documents)),
293
+ )
294
+
295
+ t0 = time.monotonic()
296
+ for spec in specs:
297
+ factory = factories.get(spec.name, default_initial_inputs)
298
+ bench = run_pipeline_benchmark(spec, corpus, factory)
299
+ result.per_pipeline[spec.name] = bench
300
+ result.total_duration_seconds = time.monotonic() - t0
301
+ return result
302
+
303
+
304
+ __all__ = [
305
+ "PipelineComparisonResult",
306
+ "compare_pipelines",
307
+ ]
picarones/measurements/builtin_hooks.py CHANGED
@@ -267,7 +267,7 @@ def _searchability_hook(*, ground_truth, hypothesis, **_):
267
  profiles=_STANDARD_PROFILES,
268
  )
269
  def _numerical_sequences_hook(*, ground_truth, hypothesis, **_):
270
- from picarones.measurements.numerical_sequences_hooks import (
271
  compute_numerical_sequence_metrics_adaptive,
272
  )
273
  return compute_numerical_sequence_metrics_adaptive(ground_truth, hypothesis)
@@ -567,7 +567,7 @@ def _aggregate_searchability(doc_results: list) -> Optional[dict]:
567
  profiles=_STANDARD_PROFILES,
568
  )
569
  def _aggregate_numerical_sequences(doc_results: list) -> Optional[dict]:
570
- from picarones.measurements.numerical_sequences_hooks import (
571
  aggregate_numerical_sequence_metrics,
572
  )
573
  return aggregate_numerical_sequence_metrics(
 
267
  profiles=_STANDARD_PROFILES,
268
  )
269
  def _numerical_sequences_hook(*, ground_truth, hypothesis, **_):
270
+ from picarones.evaluation.metrics.numerical_sequences_hooks import (
271
  compute_numerical_sequence_metrics_adaptive,
272
  )
273
  return compute_numerical_sequence_metrics_adaptive(ground_truth, hypothesis)
 
567
  profiles=_STANDARD_PROFILES,
568
  )
569
  def _aggregate_numerical_sequences(doc_results: list) -> Optional[dict]:
570
+ from picarones.evaluation.metrics.numerical_sequences_hooks import (
571
  aggregate_numerical_sequence_metrics,
572
  )
573
  return aggregate_numerical_sequence_metrics(
picarones/measurements/numerical_sequences.py CHANGED
@@ -1,422 +1,18 @@
1
- """Précision sur séquences numériques Sprint 85 (A.II.5b).
2
 
3
- Sprint 85 — A.II.5b du plan d'évolution 2026.
4
-
5
- Pourquoi ce module
6
- ------------------
7
- Pour un économiste-historien, un éditeur de chartes ou un
8
- archiviste, la **fidélité aux séquences numériques** est un
9
- proxy direct de la qualité éditoriale. Un OCR qui rate
10
- *« 1789 »* dans une charte révolutionnaire ou *« f. 12v »*
11
- dans une cote d'archives produit un corpus inutilisable pour la
12
- recherche fine, même si le CER global est respectable.
13
-
14
- Catégories couvertes
15
- --------------------
16
- 1. **Dates arabes** : ``1789``, ``1450``, ``1ᵉʳ janvier 1789``
17
- (le module détecte les **années** sur 4 chiffres dans la
18
- plage [1000-2099]).
19
- 2. **Numéraux romains** : ``MDCLXVIII``, ``XIV``, ``Tome IV``.
20
- Réutilise ``picarones.measurements.roman_numerals`` (Sprint 60).
21
- 3. **Foliotation** : ``f. 12``, ``f. 12r``, ``fol. 24v``,
22
- ``p. 5``, ``pp. 12-15``, ``n° 42``.
23
- 4. **Montants** : ``12 livres``, ``5 sols``, ``8 deniers``,
24
- ``100 £``, ``50 ₣``, ``20 €``, formes Ancien Régime
25
- (``l.``, ``s.``, ``d.``).
26
- 5. **Années régnales** : ``an III``, ``l'an V``, ``an de
27
- grâce 1450``, ``an de la République``.
28
-
29
- Méthode
30
- -------
31
- Pour chaque catégorie, on extrait les occurrences (regex
32
- spécialisée) en GT et en hypothèse. On classe ensuite chaque
33
- GT en **3 statuts** :
34
-
35
- - ``strict_preserved`` : forme exacte présente dans
36
- l'hypothèse (sensible à la casse seulement pour la
37
- foliotation, sinon la convention est documentée par
38
- catégorie) ;
39
- - ``value_preserved`` : la **valeur** apparaît même si la
40
- forme diffère (ex. ``XIV`` GT et ``14`` hypothèse —
41
- considéré comme valeur préservée mais forme non) ;
42
- - ``lost`` : aucune trace exploitable.
43
-
44
- Sortie
45
- ------
46
- ``compute_numerical_sequence_metrics(reference, hypothesis)``
47
- retourne :
48
-
49
- ```
50
- {
51
- "global_strict_score": float, # ∈ [0, 1]
52
- "global_value_score": float, # ∈ [0, 1]
53
- "n_total": int,
54
- "per_category": {
55
- "year": {"n_total": int, "strict": int, "value": int,
56
- "strict_score": float, "value_score": float,
57
- "lost_items": list[str]},
58
- "roman": {...},
59
- "foliation": {...},
60
- "currency": {...},
61
- "regnal": {...},
62
- },
63
- }
64
- ```
65
-
66
- Limites
67
- -------
68
- - Les regex sont **conservatrices** : on rate quelques
69
- formes rares plutôt que de produire des faux positifs (par
70
- exemple, ``mil cinq cens`` en français médiéval n'est pas
71
- détecté comme année — la couche calcul s'en tient aux
72
- formes les plus reconnaissables). Pour un corpus
73
- spécifique, l'utilisateur peut composer ses propres
74
- détecteurs et les passer via ``custom_detectors``.
75
- - ``value_preserved`` exige une équivalence de **valeur
76
- numérique** : ``XIV`` ↔ ``14`` est OK pour les romains ;
77
- ``f. 12v`` ↔ ``f. 12r`` n'est **pas** OK pour la
78
- foliotation (recto/verso est une information distincte).
79
  """
80
 
81
  from __future__ import annotations
82
 
83
- import logging
84
- import re
85
- from typing import Optional
86
-
87
- from picarones.evaluation.metric_registry import register_metric
88
- from picarones.domain.artifacts import ArtifactType
89
- from picarones.measurements.roman_numerals import (
90
- detect_roman_numerals,
91
- roman_to_int,
92
- )
93
-
94
- logger = logging.getLogger(__name__)
95
-
96
-
97
- # ──────────────────────────────────────────────────────────────────────────
98
- # Constantes / catégories
99
- # ──────────────────────────────────────────────────────────────────────────
100
-
101
-
102
- CATEGORIES = ("year", "roman", "foliation", "currency", "regnal")
103
-
104
-
105
- # Dates arabes — 4 chiffres dans la plage [1000-2099].
106
- # On exige une frontière de mot pour ne pas attraper
107
- # « 12345 » (volume) ou « 0001 » (numéro de page).
108
- _RE_YEAR = re.compile(r"\b(1[0-9]{3}|20[0-9]{2})\b")
109
-
110
-
111
- # Foliotation : f. 12, f. 12r, fol. 24v, p. 5, pp. 12-15, n° 42
112
- # La capture conserve la forme intégrale (avec ponctuation et
113
- # r/v) parce que recto/verso est une information distincte.
114
- _RE_FOLIATION = re.compile(
115
- r"\b(?:fol\.?|f\.|pp\.|p\.|n\.°|n°)\s*" # préfixe : fol., f., pp., p., n°
116
- r"(\d+(?:\s*-\s*\d+)?)" # nombre ou plage (12 / 12-15)
117
- r"\s*([rvRV])?", # suffixe optionnel r/v
118
- re.UNICODE,
119
- )
120
-
121
-
122
- # Montants : nombre suivi d'une unité monétaire.
123
- # On accepte espaces multiples mais pas de saut de ligne.
124
- _RE_CURRENCY = re.compile(
125
- r"\b(\d+(?:[.,]\d+)?)\s*" # montant (entier ou décimal)
126
- r"(livres?|sols?|deniers?|��cus?|florins?|francs?|"
127
- r"l\.|s\.|d\.|£|€|₣)" # unité
128
- r"(?=\b|[\s,;.!?:]|$)", # frontière souple post-symbole
129
- re.UNICODE | re.IGNORECASE,
130
- )
131
-
132
-
133
- # Années régnales : « an III », « an de grâce 1450 »,
134
- # « l'an V de la République ».
135
- # Capture le numéral (romain ou arabe).
136
- _RE_REGNAL = re.compile(
137
- r"\b(?:l['’]\s*)?an\s+(?:de\s+(?:grâce|la\s+R[eé]publique)\s+)?"
138
- r"([IVXLCDMivxlcdm]+|\d{1,4})\b",
139
- re.UNICODE,
140
- )
141
-
142
-
143
- # ──────────────────────────────────────────────────────────────────────────
144
- # Détection par catégorie
145
- # ──────────────────────────────────────────────────────────────────────────
146
-
147
-
148
- def _detect_years(text: str) -> list[tuple[str, int]]:
149
- """Retourne [(forme, valeur)] pour chaque année 4 chiffres."""
150
- if not text:
151
- return []
152
- return [(m.group(0), int(m.group(0))) for m in _RE_YEAR.finditer(text)]
153
 
 
154
 
155
- def _detect_romans_with_values(text: str) -> list[tuple[str, int]]:
156
- """Numéraux romains accompagnés de leur valeur entière.
157
- Délègue à ``roman_numerals.detect_roman_numerals`` (Sprint 60),
158
- qui retourne ``(start, form, value)``.
159
- """
160
- if not text:
161
- return []
162
- out: list[tuple[str, int]] = []
163
- for _start, form, value in detect_roman_numerals(text, min_length=2):
164
- if value is not None:
165
- out.append((form, value))
166
- return out
167
-
168
-
169
- def _detect_foliations(text: str) -> list[tuple[str, str]]:
170
- """Foliotation. Retourne [(forme_complète, clé_normalisée)] où la
171
- clé inclut le suffixe r/v normalisé (recto/verso).
172
- """
173
- if not text:
174
- return []
175
- out: list[tuple[str, str]] = []
176
- for m in _RE_FOLIATION.finditer(text):
177
- full = m.group(0).strip()
178
- nums = re.sub(r"\s+", "", m.group(1)) # ex : "12-15"
179
- suffix = (m.group(2) or "").lower()
180
- key = f"{nums}{suffix}"
181
- out.append((full, key))
182
- return out
183
-
184
-
185
- def _detect_currencies(text: str) -> list[tuple[str, tuple[str, str]]]:
186
- """Montants. Clé = (montant_normalisé, unité_canonique).
187
-
188
- L'unité canonique compresse les variantes (« livres » et
189
- « livre » → « livre » ; « £ » reste « £ »).
190
- """
191
- if not text:
192
- return []
193
- canon = {
194
- "livre": "livre", "livres": "livre", "l.": "livre",
195
- "sol": "sol", "sols": "sol", "s.": "sol",
196
- "denier": "denier", "deniers": "denier", "d.": "denier",
197
- "écu": "écu", "écus": "écu",
198
- "florin": "florin", "florins": "florin",
199
- "franc": "franc", "francs": "franc",
200
- "£": "£", "€": "€", "₣": "₣",
201
- }
202
- out: list[tuple[str, tuple[str, str]]] = []
203
- for m in _RE_CURRENCY.finditer(text):
204
- amount = m.group(1).replace(",", ".")
205
- unit_raw = m.group(2).lower()
206
- unit = canon.get(unit_raw, unit_raw)
207
- out.append((m.group(0), (amount, unit)))
208
- return out
209
-
210
-
211
- def _detect_regnal(text: str) -> list[tuple[str, int]]:
212
- """Années régnales. Retourne [(forme, valeur_int)] avec la
213
- valeur extraite (romain → int ou arabe → int).
214
- """
215
- if not text:
216
- return []
217
- out: list[tuple[str, int]] = []
218
- for m in _RE_REGNAL.finditer(text):
219
- numeral = m.group(1)
220
- value: Optional[int]
221
- if numeral.isdigit():
222
- value = int(numeral)
223
- else:
224
- value = roman_to_int(numeral)
225
- if value is not None:
226
- out.append((m.group(0), value))
227
- return out
228
-
229
-
230
- _DETECTORS = {
231
- "year": _detect_years,
232
- "roman": _detect_romans_with_values,
233
- "foliation": _detect_foliations,
234
- "currency": _detect_currencies,
235
- "regnal": _detect_regnal,
236
- }
237
-
238
-
239
- # ──────────────────────────────────────────────────────────────────────────
240
- # Calcul principal
241
- # ──────────────────────────────────────────────────────────────────────────
242
-
243
-
244
- def _classify_per_category(
245
- gt_items: list,
246
- hyp_items: list,
247
- *,
248
- form_extractor,
249
- value_extractor,
250
- ) -> dict:
251
- """Pour chaque item GT, le classe en strict_preserved /
252
- value_preserved / lost.
253
-
254
- Multiplicité respectée : un item hypothèse ne peut servir
255
- qu'à un seul match (forme prioritaire sur valeur).
256
- """
257
- hyp_used = [False] * len(hyp_items)
258
- n_strict = 0
259
- n_value = 0
260
- lost: list[str] = []
261
- # Première passe : matchs stricts (forme exacte)
262
- matched: list[bool] = [False] * len(gt_items)
263
- for gi, gt_item in enumerate(gt_items):
264
- gt_form = form_extractor(gt_item)
265
- for hi, hyp_item in enumerate(hyp_items):
266
- if hyp_used[hi]:
267
- continue
268
- if form_extractor(hyp_item) == gt_form:
269
- hyp_used[hi] = True
270
- matched[gi] = True
271
- n_strict += 1
272
- break
273
- # Deuxième passe : matchs sur valeur (forme différente)
274
- for gi, gt_item in enumerate(gt_items):
275
- if matched[gi]:
276
- n_value += 1 # strict implique value
277
- continue
278
- gt_val = value_extractor(gt_item)
279
- for hi, hyp_item in enumerate(hyp_items):
280
- if hyp_used[hi]:
281
- continue
282
- if value_extractor(hyp_item) == gt_val:
283
- hyp_used[hi] = True
284
- matched[gi] = True
285
- n_value += 1
286
- break
287
- if not matched[gi]:
288
- lost.append(form_extractor(gt_item))
289
- n_total = len(gt_items)
290
- return {
291
- "n_total": n_total,
292
- "strict": n_strict,
293
- "value": n_value,
294
- "strict_score": n_strict / n_total if n_total else 0.0,
295
- "value_score": n_value / n_total if n_total else 0.0,
296
- "lost_items": lost,
297
- }
298
-
299
-
300
- def compute_numerical_sequence_metrics(
301
- reference: Optional[str],
302
- hypothesis: Optional[str],
303
- ) -> dict:
304
- """Calcule la précision sur séquences numériques.
305
-
306
- Returns
307
- -------
308
- dict
309
- Voir docstring du module. Si ``reference`` est vide
310
- ou ne contient aucune séquence détectée, retourne
311
- ``{n_total: 0, ...}`` avec scores à 0 (pas None).
312
- """
313
- ref = reference or ""
314
- hyp = hypothesis or ""
315
-
316
- # Spécifications par catégorie : (gt_items, hyp_items,
317
- # extractor de forme, extractor de valeur).
318
- specs: dict[str, dict] = {}
319
- # year : (form="1789", value=1789)
320
- specs["year"] = {
321
- "gt": _detect_years(ref),
322
- "hyp": _detect_years(hyp),
323
- "form": lambda it: it[0],
324
- "value": lambda it: it[1],
325
- }
326
- # roman : (form="MDCLXVIII", value=1668)
327
- specs["roman"] = {
328
- "gt": _detect_romans_with_values(ref),
329
- "hyp": _detect_romans_with_values(hyp),
330
- "form": lambda it: it[0],
331
- "value": lambda it: it[1],
332
- }
333
- # foliation : (form="f. 12r", value="12r")
334
- specs["foliation"] = {
335
- "gt": _detect_foliations(ref),
336
- "hyp": _detect_foliations(hyp),
337
- "form": lambda it: it[0],
338
- "value": lambda it: it[1],
339
- }
340
- # currency : (form="12 livres", value=("12", "livre"))
341
- specs["currency"] = {
342
- "gt": _detect_currencies(ref),
343
- "hyp": _detect_currencies(hyp),
344
- "form": lambda it: it[0],
345
- "value": lambda it: it[1],
346
- }
347
- # regnal : (form="an III", value=3)
348
- specs["regnal"] = {
349
- "gt": _detect_regnal(ref),
350
- "hyp": _detect_regnal(hyp),
351
- "form": lambda it: it[0],
352
- "value": lambda it: it[1],
353
- }
354
-
355
- per_category: dict[str, dict] = {}
356
- total = 0
357
- total_strict = 0
358
- total_value = 0
359
- for cat, spec in specs.items():
360
- breakdown = _classify_per_category(
361
- spec["gt"], spec["hyp"],
362
- form_extractor=spec["form"],
363
- value_extractor=spec["value"],
364
- )
365
- per_category[cat] = breakdown
366
- total += breakdown["n_total"]
367
- total_strict += breakdown["strict"]
368
- total_value += breakdown["value"]
369
-
370
- return {
371
- "n_total": total,
372
- "global_strict_score": (
373
- total_strict / total if total else 0.0
374
- ),
375
- "global_value_score": (
376
- total_value / total if total else 0.0
377
- ),
378
- "per_category": per_category,
379
- }
380
-
381
-
382
- # ──────────────────────────────────────────────────────────────────────────
383
- # Enregistrement registre typé
384
- # ──────────────────────────────────────────────────────────────────────────
385
-
386
-
387
- @register_metric(
388
- name="numerical_sequence_strict_score",
389
- input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
390
- description=(
391
- "Précision sur séquences numériques en mode strict (forme "
392
- "préservée). Couvre années arabes, numéraux romains, "
393
- "foliotation, montants Ancien Régime, années régnales."
394
- ),
395
  )
396
- def numerical_sequence_strict_score(reference: str, hypothesis: str) -> float:
397
- return compute_numerical_sequence_metrics(
398
- reference, hypothesis,
399
- )["global_strict_score"]
400
-
401
-
402
- @register_metric(
403
- name="numerical_sequence_value_score",
404
- input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
405
- description=(
406
- "Précision sur séquences numériques en mode valeur "
407
- "(la valeur est préservée même si la forme diffère, "
408
- "ex. XIV → 14)."
409
- ),
410
- )
411
- def numerical_sequence_value_score(reference: str, hypothesis: str) -> float:
412
- return compute_numerical_sequence_metrics(
413
- reference, hypothesis,
414
- )["global_value_score"]
415
-
416
-
417
- __all__ = [
418
- "CATEGORIES",
419
- "compute_numerical_sequence_metrics",
420
- "numerical_sequence_strict_score",
421
- "numerical_sequence_value_score",
422
- ]
 
1
+ """``picarones.measurements.numerical_sequences``shim re-export (déprécié, suppression 2.0).
2
 
3
+ Canonique : :mod:`picarones.evaluation.metrics.numerical_sequences`.
4
+ Phase 5.C.batch7 du retrait du legacy.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
9
+ import warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ from picarones.evaluation.metrics.numerical_sequences import * # noqa: F401, F403
12
 
13
+ warnings.warn(
14
+ "picarones.measurements.numerical_sequences is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.evaluation.metrics.numerical_sequences instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/measurements/numerical_sequences_hooks.py CHANGED
@@ -18,7 +18,7 @@ from __future__ import annotations
18
  import logging
19
  from typing import Iterable, Optional
20
 
21
- from picarones.measurements.numerical_sequences import (
22
  CATEGORIES,
23
  compute_numerical_sequence_metrics,
24
  )
 
18
  import logging
19
  from typing import Iterable, Optional
20
 
21
+ from picarones.evaluation.metrics.numerical_sequences import (
22
  CATEGORIES,
23
  compute_numerical_sequence_metrics,
24
  )
picarones/measurements/philological_hooks.py CHANGED
@@ -34,7 +34,7 @@ from picarones.measurements.abbreviations import compute_abbreviation_metrics
34
  from picarones.measurements.early_modern_typography import compute_early_modern_metrics
35
  from picarones.measurements.modern_archives import compute_modern_archives_metrics
36
  from picarones.measurements.mufi import compute_mufi_coverage
37
- from picarones.measurements.roman_numerals import compute_roman_numeral_metrics
38
  from picarones.measurements.unicode_blocks import compute_unicode_block_accuracy
39
 
40
  logger = logging.getLogger(__name__)
@@ -296,7 +296,7 @@ def _aggregate_modern_archives(per_doc: list[dict]) -> dict:
296
 
297
 
298
  def _aggregate_roman_numerals(per_doc: list[dict]) -> dict:
299
- from picarones.measurements.roman_numerals import ALL_STATUSES, VALUE_PRESERVING_STATUSES
300
 
301
  n_total = 0
302
  per_status: dict[str, int] = {s: 0 for s in ALL_STATUSES}
 
34
  from picarones.measurements.early_modern_typography import compute_early_modern_metrics
35
  from picarones.measurements.modern_archives import compute_modern_archives_metrics
36
  from picarones.measurements.mufi import compute_mufi_coverage
37
+ from picarones.evaluation.metrics.roman_numerals import compute_roman_numeral_metrics
38
  from picarones.measurements.unicode_blocks import compute_unicode_block_accuracy
39
 
40
  logger = logging.getLogger(__name__)
 
296
 
297
 
298
  def _aggregate_roman_numerals(per_doc: list[dict]) -> dict:
299
+ from picarones.evaluation.metrics.roman_numerals import ALL_STATUSES, VALUE_PRESERVING_STATUSES
300
 
301
  n_total = 0
302
  per_status: dict[str, int] = {s: 0 for s in ALL_STATUSES}
picarones/measurements/pipeline_benchmark.py CHANGED
@@ -1,367 +1,18 @@
1
- """Orchestration corpus-wide d'une pipeline composée Sprint 64
2
- (axe B).
3
 
4
- Sprint 64 — Étape 4 / axe B du plan d'évolution 2026 : suite directe
5
- du Sprint 63. Le ``PipelineRunner`` exécute une pipeline sur **un**
6
- document ; ce module fournit l'orchestration sur un **corpus
7
- complet** et l'agrégation des résultats par étape.
8
-
9
- Philosophie inchangée
10
- ---------------------
11
- Picarones reste un **banc d'essai**. Aucun module métier n'est
12
- fourni — l'utilisateur amène ses propres ``BaseModule`` (Sprint 33).
13
- Cette infrastructure se contente d'orchestrer leur exécution sur un
14
- corpus, de mesurer le temps, de capturer les erreurs gracieusement,
15
- et d'agréger les métriques calculées aux jonctions GT-vs-sortie.
16
-
17
- Périmètre Sprint 64
18
- -------------------
19
- Inclus :
20
-
21
- - ``run_pipeline_benchmark(spec, corpus, initial_inputs_factory)``
22
- qui itère séquentiellement sur les documents.
23
- - Agrégation par étape : ``StepAggregate`` avec n_succeeded /
24
- n_failed, durées (total / mean / median), failing_doc_ids,
25
- métriques agrégées par type d'artefact (mean / median sur les
26
- métriques numériques uniquement), breakdown des types d'erreur.
27
- - ``PipelineBenchmarkResult`` : conteneur global avec liste des
28
- ``PipelineResult`` par doc + liste des ``StepAggregate``.
29
- - Helper ``default_initial_inputs`` qui couvre le cas standard
30
- ``IMAGE`` depuis ``Document.image_path``.
31
-
32
- Reporté à des sprints suivants :
33
-
34
- - Comparaison de N pipelines sur le même corpus (Sprint 65).
35
- - DAG branchant non séquentiel (Sprint 66).
36
- - Vue HTML dédiée aux pipelines composées (Sprint 67).
37
- - Parallélisation inter-documents (à arbitrer selon les besoins).
38
  """
39
 
40
  from __future__ import annotations
41
 
42
- import logging
43
- import statistics
44
- import time
45
- from dataclasses import dataclass, field
46
- from typing import Any, Callable, Optional
47
-
48
- from picarones.evaluation.corpus import Corpus, Document
49
- from picarones.domain.artifacts import ArtifactType
50
- from picarones.core.pipeline import (
51
- PipelineResult,
52
- PipelineRunner,
53
- PipelineSpec,
54
- )
55
-
56
- logger = logging.getLogger(__name__)
57
-
58
-
59
- # ──────────────────────────────────────────────────────────────────────────
60
- # Helpers : factory d'entrées initiales
61
- # ──────────────────────────────────────────────────────────────────────────
62
-
63
- InitialInputsFactory = Callable[[Document], dict[ArtifactType, Any]]
64
-
65
-
66
- def default_initial_inputs(document: Document) -> dict[ArtifactType, Any]:
67
- """Factory d'entrées initiales par défaut : couvre le cas
68
- « la pipeline démarre par un module qui consomme l'image ».
69
-
70
- Retourne ``{ArtifactType.IMAGE: document.image_path}`` si
71
- ``image_path`` est présent, sinon dict vide (la première étape
72
- devra alors signaler « entrée manquante »).
73
- """
74
- if document.image_path:
75
- return {ArtifactType.IMAGE: document.image_path}
76
- return {}
77
-
78
-
79
- # ──────────────────────────────────────────────────────────────────────────
80
- # Agrégats
81
- # ──────────────────────────────────────────────────────────────────────────
82
-
83
-
84
- @dataclass
85
- class StepAggregate:
86
- """Agrégat des résultats d'une étape sur tout le corpus.
87
-
88
- Champs
89
- ------
90
- step_name:
91
- Nom de l'étape (cf. ``PipelineStep.name``).
92
- n_docs:
93
- Nombre de documents pour lesquels l'étape a été tentée.
94
- n_succeeded:
95
- Nombre de documents pour lesquels l'étape s'est terminée
96
- sans erreur (``StepResult.error is None``).
97
- n_failed:
98
- Nombre de documents pour lesquels l'étape a renvoyé une
99
- erreur.
100
- duration_seconds_total / mean / median:
101
- Statistiques de durée sur les **étapes ayant réussi**
102
- uniquement (les étapes en erreur peuvent avoir une durée
103
- artificielle).
104
- failing_doc_ids:
105
- Liste des ``doc_id`` pour lesquels cette étape a échoué.
106
- junction_metrics:
107
- ``{artifact_type_value: {metric_name: {"mean": float,
108
- "median": float, "n": int}}}`` — agrégé sur les documents
109
- où la métrique a été calculée (n peut différer de
110
- ``n_succeeded`` si la GT du type n'est pas portée par tous
111
- les docs).
112
- error_breakdown:
113
- ``{type_d_erreur: count}`` où ``type_d_erreur`` est extrait
114
- en heuristique depuis le message (``"missing_input"``,
115
- ``"raised_exception"``, ``"missing_output"``,
116
- ``"other"``).
117
- """
118
-
119
- step_name: str
120
- n_docs: int = 0
121
- n_succeeded: int = 0
122
- n_failed: int = 0
123
- duration_seconds_total: float = 0.0
124
- duration_seconds_mean: float = 0.0
125
- duration_seconds_median: float = 0.0
126
- failing_doc_ids: list[str] = field(default_factory=list)
127
- junction_metrics: dict[str, dict[str, dict[str, float]]] = field(
128
- default_factory=dict,
129
- )
130
- error_breakdown: dict[str, int] = field(default_factory=dict)
131
-
132
- @property
133
- def success_rate(self) -> float:
134
- if self.n_docs == 0:
135
- return 0.0
136
- return self.n_succeeded / self.n_docs
137
-
138
-
139
- @dataclass
140
- class PipelineBenchmarkResult:
141
- """Résultat d'un benchmark de pipeline sur un corpus complet.
142
-
143
- On capture la durée totale, les résultats par document
144
- (utiles pour le rapport HTML par-doc des sprints suivants), et
145
- l'agrégation par étape.
146
- """
147
-
148
- pipeline_name: str
149
- corpus_name: str
150
- n_docs: int = 0
151
- per_doc_results: list[PipelineResult] = field(default_factory=list)
152
- per_step_aggregates: list[StepAggregate] = field(default_factory=list)
153
- total_duration_seconds: float = 0.0
154
-
155
- @property
156
- def n_pipelines_succeeded(self) -> int:
157
- return sum(1 for r in self.per_doc_results if r.succeeded)
158
-
159
- @property
160
- def n_pipelines_failed(self) -> int:
161
- return sum(1 for r in self.per_doc_results if not r.succeeded)
162
 
163
- def aggregate_for_step(self, step_name: str) -> Optional[StepAggregate]:
164
- for agg in self.per_step_aggregates:
165
- if agg.step_name == step_name:
166
- return agg
167
- return None
168
 
169
-
170
- # ──────────────────────────────────────────────────────────────────────────
171
- # Classification des erreurs
172
- # ──────────────────────────────────────────────────────────────────────────
173
-
174
-
175
- _ERROR_PATTERNS: tuple[tuple[str, str], ...] = (
176
- ("entrée manquante", "missing_input"),
177
- ("sortie manquante", "missing_output"),
178
- ("Error", "raised_exception"), # RuntimeError, ValueError…
179
  )
180
-
181
-
182
- def _classify_error(message: str) -> str:
183
- """Heuristique simple pour catégoriser une erreur d'étape.
184
-
185
- On regarde des marqueurs lexicaux dans le message (les messages
186
- sont produits par ``pipeline_runner._run_step`` qui les contrôle
187
- entièrement, donc cette heuristique est stable).
188
- """
189
- if not message:
190
- return "other"
191
- for pattern, label in _ERROR_PATTERNS:
192
- if pattern in message:
193
- return label
194
- return "other"
195
-
196
-
197
- # ──────────────────────────────────────────────────────────────────────────
198
- # Agrégation
199
- # ──────────────────────────────────────────────────────────────────────────
200
-
201
-
202
- def _aggregate_step(
203
- step_name: str, per_doc: list[tuple[str, Any]],
204
- ) -> StepAggregate:
205
- """Construit le ``StepAggregate`` pour une étape donnée.
206
-
207
- ``per_doc`` est une liste de tuples ``(doc_id, step_result)`` où
208
- ``step_result`` peut être ``None`` (cas où la pipeline a été
209
- arrêtée en amont avant cette étape) ou un ``StepResult``.
210
- """
211
- agg = StepAggregate(step_name=step_name)
212
- durations_succeeded: list[float] = []
213
- metrics_by_type: dict[str, dict[str, list[float]]] = {}
214
-
215
- for doc_id, sr in per_doc:
216
- if sr is None:
217
- # L'étape n'a même pas été exécutée (validation amont
218
- # invalide, ou exécutée n'a pas atteint l'index — ne se
219
- # produit pas en séquentiel mais peut arriver avec un
220
- # DAG plus tard). On compte ce cas comme échec
221
- # explicite avec un type dédié.
222
- agg.n_docs += 1
223
- agg.n_failed += 1
224
- agg.failing_doc_ids.append(doc_id)
225
- agg.error_breakdown["pipeline_aborted"] = (
226
- agg.error_breakdown.get("pipeline_aborted", 0) + 1
227
- )
228
- continue
229
- agg.n_docs += 1
230
- if sr.error is None:
231
- agg.n_succeeded += 1
232
- durations_succeeded.append(sr.duration_seconds)
233
- # Collecte des métriques pour agrégation moyenne/médiane
234
- for at_value, metrics in sr.junction_metrics.items():
235
- slot = metrics_by_type.setdefault(at_value, {})
236
- for mname, mvalue in metrics.items():
237
- if isinstance(mvalue, (int, float)) and not isinstance(
238
- mvalue, bool,
239
- ):
240
- slot.setdefault(mname, []).append(float(mvalue))
241
- else:
242
- agg.n_failed += 1
243
- agg.failing_doc_ids.append(doc_id)
244
- label = _classify_error(sr.error)
245
- agg.error_breakdown[label] = (
246
- agg.error_breakdown.get(label, 0) + 1
247
- )
248
-
249
- if durations_succeeded:
250
- agg.duration_seconds_total = sum(durations_succeeded)
251
- agg.duration_seconds_mean = statistics.fmean(durations_succeeded)
252
- agg.duration_seconds_median = statistics.median(durations_succeeded)
253
-
254
- for at_value, metrics in metrics_by_type.items():
255
- agg.junction_metrics[at_value] = {
256
- mname: {
257
- "mean": statistics.fmean(values),
258
- "median": statistics.median(values),
259
- "n": len(values),
260
- }
261
- for mname, values in metrics.items()
262
- }
263
- # Phase 4-bis : double-clé legacy/canonique pour rétrocompat.
264
- from picarones.domain.artifacts import expand_legacy_keys
265
- expand_legacy_keys(agg.junction_metrics)
266
- return agg
267
-
268
-
269
- # ──────────────────────────────────────────────────────────────────────────
270
- # Orchestrateur principal
271
- # ──────────────────────────────────────────────────────────────────────────
272
-
273
-
274
- def run_pipeline_benchmark(
275
- spec: PipelineSpec,
276
- corpus: Corpus,
277
- initial_inputs_factory: InitialInputsFactory = default_initial_inputs,
278
- ) -> PipelineBenchmarkResult:
279
- """Exécute ``spec`` sur tous les documents de ``corpus``.
280
-
281
- Parameters
282
- ----------
283
- spec:
284
- Spécification de la pipeline composée. Toutes les étapes
285
- sont des ``BaseModule`` fournis par l'utilisateur.
286
- corpus:
287
- Corpus chargé via ``Corpus.from_directory`` ou équivalent.
288
- initial_inputs_factory:
289
- Fonction qui produit, pour chaque document, les artefacts
290
- d'entrée de la pipeline. Par défaut : ``IMAGE`` depuis
291
- ``document.image_path``. L'utilisateur peut fournir une
292
- factory personnalisée pour brancher d'autres sources
293
- (par exemple ``ALTO`` pré-existant pour évaluer un
294
- pipeline qui démarre par un re-segmenteur).
295
-
296
- Returns
297
- -------
298
- PipelineBenchmarkResult
299
- Résultat global avec ``per_doc_results``,
300
- ``per_step_aggregates``, durée totale.
301
-
302
- Comportement
303
- ------------
304
- L'orchestration est **séquentielle** par document. Pour chaque
305
- document, ``PipelineRunner.run`` est appelé ; quel que soit le
306
- résultat (réussi, partiellement échoué, totalement invalide),
307
- le résultat est ajouté à ``per_doc_results`` et le benchmark
308
- continue avec le document suivant.
309
-
310
- Si la spec est statiquement invalide (cf.
311
- ``PipelineSpec.validate``), tous les documents auront un
312
- ``PipelineResult.error`` non vide et aucune étape ne sera
313
- exécutée — le résultat reste cohérent.
314
- """
315
- result = PipelineBenchmarkResult(
316
- pipeline_name=spec.name, corpus_name=corpus.name,
317
- )
318
- documents = list(corpus.documents)
319
- result.n_docs = len(documents)
320
-
321
- benchmark_t0 = time.monotonic()
322
- for doc in documents:
323
- try:
324
- initial = initial_inputs_factory(doc)
325
- except Exception as exc: # noqa: BLE001
326
- logger.warning(
327
- "[pipeline_benchmark] factory a levé sur %s : %s",
328
- doc.doc_id, exc,
329
- )
330
- # On crée un PipelineResult portant l'erreur factory
331
- failed = PipelineResult(
332
- pipeline_name=spec.name, doc_id=doc.doc_id,
333
- error=f"initial_inputs_factory: {type(exc).__name__}: {exc}",
334
- )
335
- result.per_doc_results.append(failed)
336
- continue
337
- per_doc = PipelineRunner.run(spec, doc, initial)
338
- result.per_doc_results.append(per_doc)
339
- result.total_duration_seconds = time.monotonic() - benchmark_t0
340
-
341
- # Agrégation par étape
342
- step_names = [step.name for step in spec.steps]
343
- for idx, step_name in enumerate(step_names):
344
- per_doc_step: list[tuple[str, Any]] = []
345
- for pr in result.per_doc_results:
346
- if idx < len(pr.steps):
347
- per_doc_step.append((pr.doc_id, pr.steps[idx]))
348
- else:
349
- # Pipeline a été arrêtée en amont : aucune étape de
350
- # cet index n'existe. On compte ça comme une
351
- # absence d'étape (cf. ``_aggregate_step`` qui gère
352
- # le ``None``).
353
- per_doc_step.append((pr.doc_id, None))
354
- result.per_step_aggregates.append(
355
- _aggregate_step(step_name, per_doc_step),
356
- )
357
-
358
- return result
359
-
360
-
361
- __all__ = [
362
- "InitialInputsFactory",
363
- "PipelineBenchmarkResult",
364
- "StepAggregate",
365
- "default_initial_inputs",
366
- "run_pipeline_benchmark",
367
- ]
 
1
+ """``picarones.measurements.pipeline_benchmark`` shim re-export (déprécié, suppression 2.0).
 
2
 
3
+ Canonique : :mod:`picarones.evaluation.pipeline_benchmark`.
4
+ Phase 5.C.batch7 du retrait du legacy.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
9
+ import warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ from picarones.evaluation.pipeline_benchmark import * # noqa: F401, F403
 
 
 
 
12
 
13
+ warnings.warn(
14
+ "picarones.measurements.pipeline_benchmark is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.evaluation.pipeline_benchmark instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
 
 
 
 
 
18
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/measurements/pipeline_comparison.py CHANGED
@@ -1,301 +1,18 @@
1
- """Comparaison de N pipelines sur le même corpus Sprint 65 (axe B).
2
 
3
- Sprint 65 — Étape 4 / axe B du plan d'évolution 2026 : suite directe
4
- des Sprints 63-64. Le runner mono-document (Sprint 63) et
5
- l'orchestration corpus-wide (Sprint 64) permettent d'évaluer **une**
6
- pipeline composée ; ce sprint répond à la question typique BnF :
7
-
8
- « OCR seul vs OCR+correcteur A vs OCR+correcteur B :
9
- laquelle est la meilleure sur mon corpus, et de combien ? »
10
-
11
- Philosophie inchangée
12
- ---------------------
13
- Picarones reste un **banc d'essai** — on juge des pipelines tierces
14
- sur le **même corpus** avec la **même GT**, en exposant des chiffres
15
- bruts comparatifs. Aucun verdict imposé : le chercheur lit le
16
- ranking et la table de gain et conclut selon ses critères.
17
-
18
- Périmètre Sprint 65
19
- -------------------
20
- Inclus :
21
-
22
- - ``compare_pipelines(specs, corpus, factories=None)`` qui exécute
23
- séquentiellement N pipelines sur le même corpus.
24
- - ``PipelineComparisonResult`` : conteneur avec
25
- ``per_pipeline: dict[name → PipelineBenchmarkResult]``,
26
- ``ranking_by_final_metric(artifact_type, metric_name,
27
- higher_is_better)`` qui retourne ``[(pipeline_name, score), ...]``
28
- trié, et ``gain_table(artifact_type, metric_name,
29
- baseline_pipeline)`` qui retourne pour chaque pipeline le
30
- ``{absolute, relative}`` vs baseline.
31
- - ``factories``: dict ``{pipeline_name: InitialInputsFactory}`` pour
32
- personnaliser les entrées initiales par pipeline (utile pour
33
- comparer une pipeline qui démarre par IMAGE et une qui démarre
34
- par TEXT).
35
- - Garde-fou : noms de pipelines uniques exigés.
36
-
37
- Reporté à des sprints suivants :
38
-
39
- - DAG branchant non séquentiel (Sprint 66).
40
- - Vue HTML dédiée à la comparaison de pipelines (Sprint 67+).
41
- - Tests statistiques (Wilcoxon, Friedman, Nemenyi) sur les
42
- pipelines composées — déjà disponibles côté OCR (Sprint 18) ;
43
- l'application au cadre pipeline arrive plus tard.
44
  """
45
 
46
  from __future__ import annotations
47
 
48
- import logging
49
- import time
50
- from dataclasses import dataclass, field
51
- from typing import Optional
52
-
53
- from picarones.evaluation.corpus import Corpus
54
- from picarones.domain.artifacts import ArtifactType
55
- from picarones.measurements.pipeline_benchmark import (
56
- InitialInputsFactory,
57
- PipelineBenchmarkResult,
58
- default_initial_inputs,
59
- run_pipeline_benchmark,
60
- )
61
- from picarones.core.pipeline import PipelineSpec
62
-
63
- logger = logging.getLogger(__name__)
64
-
65
-
66
- # ──────────────────────────────────────────────────────────────────────────
67
- # Conteneur de résultats
68
- # ──────────────────────────────────────────────────────────────────────────
69
-
70
-
71
- @dataclass
72
- class PipelineComparisonResult:
73
- """Résultat de la comparaison de N pipelines sur un corpus.
74
-
75
- Champs
76
- ------
77
- corpus_name:
78
- Nom du corpus (commun à toutes les pipelines comparées).
79
- n_docs:
80
- Nombre de documents du corpus.
81
- per_pipeline:
82
- Map ``{pipeline_name: PipelineBenchmarkResult}``. L'ordre
83
- d'insertion suit l'ordre des ``specs`` passées à
84
- ``compare_pipelines`` ; on s'appuie sur le ``dict`` ordonné
85
- de Python 3.7+.
86
- total_duration_seconds:
87
- Durée totale de la comparaison (sommes des durées par
88
- pipeline + petit overhead).
89
- """
90
-
91
- corpus_name: str
92
- n_docs: int = 0
93
- per_pipeline: dict[str, PipelineBenchmarkResult] = field(
94
- default_factory=dict,
95
- )
96
- total_duration_seconds: float = 0.0
97
-
98
- def pipeline_names(self) -> list[str]:
99
- """Retourne la liste des noms de pipelines dans leur ordre
100
- d'insertion (= ordre de la comparaison initiale)."""
101
- return list(self.per_pipeline.keys())
102
-
103
- def _final_metric_value(
104
- self,
105
- pipeline_name: str,
106
- artifact_type: ArtifactType,
107
- metric_name: str,
108
- ) -> Optional[float]:
109
- """Retourne le ``mean`` de la métrique demandée à la
110
- **dernière étape** de la pipeline qui a produit
111
- ``artifact_type`` (avec succès sur ≥ 1 doc), ou ``None``
112
- si la métrique n'est pas disponible.
113
-
114
- Cohérent avec ``PipelineResult.junction_metrics_for`` du
115
- Sprint 63 mais au niveau corpus-wide.
116
- """
117
- bench = self.per_pipeline.get(pipeline_name)
118
- if bench is None:
119
- return None
120
- from picarones.domain.artifacts import LEGACY_VALUE_ALIASES
121
- legacy_alias = LEGACY_VALUE_ALIASES.get(artifact_type.value)
122
- for agg in reversed(bench.per_step_aggregates):
123
- type_metrics = agg.junction_metrics.get(artifact_type.value)
124
- if not type_metrics and legacy_alias is not None:
125
- # Phase 4-bis : un caller (typiquement les tests
126
- # ou un agrégateur tiers) peut avoir construit le
127
- # dict avec la clé legacy ``"text"`` au lieu de la
128
- # canonique ``"raw_text"``. expand_legacy_keys
129
- # synchronise les deux côtés sur les sites
130
- # d'écriture du runner — ce fallback couvre le
131
- # reste.
132
- type_metrics = agg.junction_metrics.get(legacy_alias)
133
- if not type_metrics:
134
- continue
135
- stats = type_metrics.get(metric_name)
136
- if stats is None:
137
- continue
138
- return stats["mean"]
139
- return None
140
 
141
- def ranking_by_final_metric(
142
- self,
143
- artifact_type: ArtifactType,
144
- metric_name: str,
145
- higher_is_better: bool = False,
146
- ) -> list[tuple[str, Optional[float]]]:
147
- """Classe les pipelines par la valeur **finale** de
148
- ``metric_name`` à la jonction ``artifact_type``.
149
 
150
- Returns
151
- -------
152
- list[tuple[str, Optional[float]]]
153
- Liste ``[(pipeline_name, mean_value)]`` triée :
154
-
155
- - Les pipelines avec une valeur définie viennent en
156
- premier, triées selon ``higher_is_better``.
157
- - Les pipelines sans valeur (métrique absente) viennent
158
- en queue, dans leur ordre d'insertion.
159
- """
160
- with_value: list[tuple[str, float]] = []
161
- without_value: list[tuple[str, Optional[float]]] = []
162
- for name in self.pipeline_names():
163
- value = self._final_metric_value(name, artifact_type, metric_name)
164
- if value is None:
165
- without_value.append((name, None))
166
- else:
167
- with_value.append((name, value))
168
- with_value.sort(
169
- key=lambda pair: pair[1],
170
- reverse=higher_is_better,
171
- )
172
- return [*with_value, *without_value]
173
-
174
- def gain_table(
175
- self,
176
- artifact_type: ArtifactType,
177
- metric_name: str,
178
- baseline_pipeline: str,
179
- ) -> dict[str, dict[str, Optional[float]]]:
180
- """Calcule l'écart de chaque pipeline vs la baseline.
181
-
182
- Returns
183
- -------
184
- dict
185
- Map ``{pipeline_name: {"value", "absolute", "relative"}}``
186
- où :
187
-
188
- - ``value`` : valeur finale de la métrique pour cette
189
- pipeline (``None`` si absente).
190
- - ``absolute`` : ``value - baseline_value``
191
- (``None`` si l'une des deux est absente).
192
- - ``relative`` : ``(value - baseline_value) /
193
- baseline_value`` (``None`` si baseline absente ou
194
- égale à 0).
195
-
196
- La baseline elle-même apparaît avec ``absolute == 0`` et
197
- ``relative == 0``.
198
- """
199
- if baseline_pipeline not in self.per_pipeline:
200
- raise KeyError(
201
- f"baseline {baseline_pipeline!r} absente de la comparaison",
202
- )
203
- baseline_value = self._final_metric_value(
204
- baseline_pipeline, artifact_type, metric_name,
205
- )
206
- out: dict[str, dict[str, Optional[float]]] = {}
207
- for name in self.pipeline_names():
208
- value = self._final_metric_value(
209
- name, artifact_type, metric_name,
210
- )
211
- absolute: Optional[float]
212
- relative: Optional[float]
213
- if value is None or baseline_value is None:
214
- absolute = None
215
- relative = None
216
- else:
217
- absolute = value - baseline_value
218
- relative = (
219
- (value - baseline_value) / baseline_value
220
- if baseline_value != 0 else None
221
- )
222
- out[name] = {
223
- "value": value,
224
- "absolute": absolute,
225
- "relative": relative,
226
- }
227
- return out
228
-
229
-
230
- # ──────────────────────────────────────────────────────────────────────────
231
- # Orchestrateur
232
- # ──────────────────────────────────────────────────────────────────────────
233
-
234
-
235
- def compare_pipelines(
236
- specs: list[PipelineSpec],
237
- corpus: Corpus,
238
- factories: Optional[dict[str, InitialInputsFactory]] = None,
239
- ) -> PipelineComparisonResult:
240
- """Exécute N ``PipelineSpec`` sur le **même** ``corpus``.
241
-
242
- Parameters
243
- ----------
244
- specs:
245
- Liste de ``PipelineSpec``. Les noms de pipelines doivent
246
- être uniques (sinon ``ValueError``).
247
- corpus:
248
- Corpus partagé entre toutes les pipelines comparées —
249
- c'est le point fort du sprint : même corpus, même GT, on
250
- peut comparer apple-to-apple.
251
- factories:
252
- Optionnel. Si fourni, dict ``{pipeline_name:
253
- InitialInputsFactory}`` pour personnaliser les entrées
254
- initiales par pipeline. Les pipelines absentes du dict
255
- utilisent ``default_initial_inputs`` (cas standard
256
- ``IMAGE`` depuis ``Document.image_path``).
257
-
258
- Returns
259
- -------
260
- PipelineComparisonResult
261
- Conteneur avec ``per_pipeline`` indexé par nom et
262
- utilitaires comparatifs (``ranking_by_final_metric``,
263
- ``gain_table``).
264
-
265
- Raises
266
- ------
267
- ValueError
268
- Si deux ``PipelineSpec`` ont le même nom (impossible alors
269
- de les distinguer dans le résultat).
270
- """
271
- names = [s.name for s in specs]
272
- if len(set(names)) != len(names):
273
- seen: set[str] = set()
274
- duplicates: list[str] = []
275
- for n in names:
276
- if n in seen:
277
- duplicates.append(n)
278
- seen.add(n)
279
- raise ValueError(
280
- f"noms de pipelines non uniques : {sorted(set(duplicates))}",
281
- )
282
-
283
- factories = factories or {}
284
- result = PipelineComparisonResult(
285
- corpus_name=corpus.name,
286
- n_docs=len(list(corpus.documents)),
287
- )
288
-
289
- t0 = time.monotonic()
290
- for spec in specs:
291
- factory = factories.get(spec.name, default_initial_inputs)
292
- bench = run_pipeline_benchmark(spec, corpus, factory)
293
- result.per_pipeline[spec.name] = bench
294
- result.total_duration_seconds = time.monotonic() - t0
295
- return result
296
-
297
-
298
- __all__ = [
299
- "PipelineComparisonResult",
300
- "compare_pipelines",
301
- ]
 
1
+ """``picarones.measurements.pipeline_comparison``shim re-export (déprécié, suppression 2.0).
2
 
3
+ Canonique : :mod:`picarones.evaluation.pipeline_comparison`.
4
+ Phase 5.C.batch7 du retrait du legacy.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
9
+ import warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ from picarones.evaluation.pipeline_comparison import * # noqa: F401, F403
 
 
 
 
 
 
 
12
 
13
+ warnings.warn(
14
+ "picarones.measurements.pipeline_comparison is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.evaluation.pipeline_comparison instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
18
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/measurements/pipeline_spec_loader.py CHANGED
@@ -69,7 +69,7 @@ from typing import Any
69
 
70
  from picarones.domain.artifacts import ArtifactType
71
  from picarones.domain.module_protocol import BaseModule
72
- from picarones.core.pipeline import PipelineSpec, PipelineStep
73
 
74
  logger = logging.getLogger(__name__)
75
 
 
69
 
70
  from picarones.domain.artifacts import ArtifactType
71
  from picarones.domain.module_protocol import BaseModule
72
+ from picarones.evaluation.pipeline import PipelineSpec, PipelineStep
73
 
74
  logger = logging.getLogger(__name__)
75
 
picarones/measurements/roman_numerals.py CHANGED
@@ -1,478 +1,18 @@
1
- """Numéraux romains Sprint 60.
2
 
3
- Sprint 60 — Étape 3 / extension philologique transversale du plan
4
- d'évolution 2026.
5
-
6
- Pourquoi ce module
7
- ------------------
8
- Les numéraux romains traversent **toutes les périodes patrimoniales**
9
- servies par Picarones :
10
-
11
- - **Médiéval** : minuscules avec ``j`` final pour le dernier ``i``
12
- (``ij`` = 2, ``iij`` = 3, ``viij`` = 8, ``mcclxxxij`` = 1282).
13
- Convention scribale standard dans les chartes et registres.
14
- - **Imprimé ancien** : majuscules (``Tome IV``, ``Chap. VII``).
15
- - **Moderne** : majuscules pour les souverains (``Louis XIV``) et
16
- les siècles (``XIXᵉ siècle`` — la partie exposant ᵉ est gérée
17
- par le Sprint 59 ``ordinals``, ce module ne traite que la partie
18
- numérale ``XIX``).
19
-
20
- Quatre traitements possibles d'un numéral par l'OCR
21
- ----------------------------------------------------
22
- Pour chaque numéral romain présent dans la GT, l'OCR peut :
23
-
24
- 1. **Préserver strictement** : forme exacte gardée
25
- (``mcclxxxij`` → ``mcclxxxij``). Édition diplomatique idéale.
26
- 2. **Préserver en changeant la casse** : la valeur est intacte mais
27
- la convention typographique est modifiée
28
- (``xiv`` → ``XIV``). Modernisation typographique courante.
29
- 3. **Préserver en supprimant le ``j`` final** :
30
- (``mcclxxxij`` → ``mcclxxxii``). Modernisation orthographique
31
- médiévale → standard académique moderne.
32
- 4. **Convertir en chiffres arabes** : la valeur est préservée mais
33
- le système de numération est modernisé
34
- (``XIV`` → ``14``). Modernisation profonde, perte de
35
- l'information typographique.
36
- 5. **Perdre** : aucune trace de la valeur dans l'hypothèse.
37
-
38
- Ce module retourne un breakdown par statut pour que le chercheur
39
- juge lui-même la convention adoptée par chaque moteur, **sans
40
- classification automatique imposée**.
41
-
42
- Stratégie de découpage
43
- ----------------------
44
- Cohérente avec NER (38), Flesch (52), Reading order F1 (53),
45
- Layout F1 (54), Bloc Unicode (55), Abréviations (56), MUFI (57),
46
- Imprimé ancien (58), Archives modernes (59) : couche de calcul
47
- pure d'abord ; câblage runner et HTML dans des sprints dédiés.
48
-
49
- Limites documentées
50
- -------------------
51
- - Détection greedy par regex ``\\b[IVXLCDMivxlcdmj]+\\b`` puis
52
- validation par parsing. Les faux positifs restent possibles sur
53
- des mots courts (``I`` pronom anglais, ``MM`` initiales, ``LL``).
54
- Le paramètre ``min_length`` permet de filtrer les single-letter.
55
- - Pas de gestion des notations rares avec barre suscript pour
56
- multiplier par 1000 (V̄ = 5000, X̄ = 10000) — usage très rare en
57
- corpus patrimonial européen courant.
58
  """
59
 
60
  from __future__ import annotations
61
 
62
- import logging
63
- import re
64
- from typing import Optional
65
-
66
- from picarones.evaluation.metric_registry import register_metric
67
- from picarones.domain.artifacts import ArtifactType
68
-
69
- logger = logging.getLogger(__name__)
70
-
71
-
72
- # ──────────────────────────────────────────────────────────────────────────
73
- # Table de conversion + parsing
74
- # ──────────────────────────────────────────────────────────────────────────
75
-
76
- ROMAN_VALUES: dict[str, int] = {
77
- "I": 1, "V": 5, "X": 10,
78
- "L": 50, "C": 100, "D": 500, "M": 1000,
79
- }
80
-
81
- # Caractères acceptés en entrée (incluant minuscules + j médiéval).
82
- _ROMAN_CHARS = "IVXLCDMivxlcdmj"
83
- _ROMAN_RE = re.compile(rf"\b[{_ROMAN_CHARS}]+\b")
84
-
85
-
86
- def _normalize_roman(s: str) -> str:
87
- """Normalise un numéral romain : majuscule + ``j`` final → ``i``.
88
-
89
- Les manuscrits médiévaux notent traditionnellement le dernier
90
- ``i`` d'une suite par ``j`` (« ij », « iij », « viij »…). On
91
- convertit pour pouvoir parser comme un numéral standard.
92
- """
93
- if not s:
94
- return ""
95
- upper = s.upper()
96
- if upper.endswith("J"):
97
- upper = upper[:-1] + "I"
98
- return upper
99
-
100
-
101
- def _parse_normalized_roman(s: str) -> Optional[int]:
102
- """Parse un numéral romain **après normalisation** (majuscule,
103
- sans ``j`` médiéval). Retourne ``None`` si la chaîne n'est pas
104
- un numéral romain valide.
105
-
106
- Validation : on parse en additionnant/soustrayant selon la règle
107
- classique, puis on **regénère la forme standard** et on compare
108
- pour rejeter les formes non canoniques (« IIII » au lieu de
109
- « IV », « VV » au lieu de « X »). Cette stricte validation
110
- garantit qu'on ne compte pas des séquences absurdes comme
111
- « XXXX » comme un numéral.
112
-
113
- Note : les manuscrits médiévaux utilisent fréquemment « IIII »
114
- pour 4 (notation soustractive plus tardive). On accepte donc
115
- aussi cette forme via une règle relâchée : tant que les valeurs
116
- sont décroissantes ou suivent la règle soustractive standard,
117
- on accepte.
118
- """
119
- if not s or not all(c in "IVXLCDM" for c in s):
120
- return None
121
- # Calcul par soustraction.
122
- total = 0
123
- prev_value = 0
124
- for ch in reversed(s):
125
- v = ROMAN_VALUES[ch]
126
- if v < prev_value:
127
- total -= v
128
- else:
129
- total += v
130
- prev_value = v
131
- if total <= 0:
132
- return None
133
- # Validation relâchée : on accepte les formes médiévales (IIII,
134
- # VIIII) mais on rejette les vraiment absurdes (IIIII, VVVV).
135
- if not _is_plausible_roman(s):
136
- return None
137
- return total
138
-
139
-
140
- def _is_plausible_roman(s: str) -> bool:
141
- """Validation relâchée d'un numéral romain (majuscule).
142
-
143
- On rejette :
144
-
145
- - 5 caractères identiques d'affilée ou plus (« IIIII », « XXXXX »).
146
- - Les répétitions de V, L, D (jamais répétés en notation
147
- classique : « VV », « LL », « DD »).
148
- - Les paires soustractives non standard. En romain canonique,
149
- seules sont valides : IV, IX, XL, XC, CD, CM. Toute autre
150
- combinaison « petit avant grand » est rejetée. Cela élimine
151
- les faux positifs sur des mots français comme « ici » (qui
152
- formerait sinon « I + C » = 99) ou « IL » qui formerait 49.
153
- """
154
- if not s:
155
- return False
156
- # Pas de répétitions invalides
157
- for forbidden in ("VV", "LL", "DD", "IIIII", "XXXXX", "CCCCC", "MMMMMM"):
158
- if forbidden in s:
159
- return False
160
- # Paires soustractives autorisées (toutes les autres sont rejetées)
161
- legal_subtractive = {"IV", "IX", "XL", "XC", "CD", "CM"}
162
- for i in range(len(s) - 1):
163
- a, b = s[i], s[i + 1]
164
- if ROMAN_VALUES[a] < ROMAN_VALUES[b]:
165
- if (a + b) not in legal_subtractive:
166
- return False
167
- return True
168
-
169
-
170
- def roman_to_int(s: Optional[str]) -> Optional[int]:
171
- """Convertit une chaîne en numéral romain entier. Tolère casse
172
- et ``j`` médiéval final. Retourne ``None`` si invalide.
173
- """
174
- if not s:
175
- return None
176
- return _parse_normalized_roman(_normalize_roman(s))
177
-
178
-
179
- def int_to_roman(n: int) -> str:
180
- """Convertit un entier en numéral romain majuscule standard.
181
-
182
- Utilise la notation classique (IV, IX, XL, XC, CD, CM) — pas la
183
- forme médiévale relâchée.
184
- """
185
- if n <= 0:
186
- raise ValueError("n must be positive")
187
- pairs = [
188
- (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
189
- (100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
190
- (10, "X"), (9, "IX"), (5, "V"), (4, "IV"),
191
- (1, "I"),
192
- ]
193
- out: list[str] = []
194
- for value, symbol in pairs:
195
- while n >= value:
196
- out.append(symbol)
197
- n -= value
198
- return "".join(out)
199
-
200
-
201
- # ──────────────────────────────────────────────────────────────────────────
202
- # Détection dans le texte
203
- # ──────────────────────────────────────────────────────────────────────────
204
-
205
-
206
- def detect_roman_numerals(
207
- text: Optional[str],
208
- *,
209
- min_length: int = 1,
210
- ) -> list[tuple[int, str, int]]:
211
- """Retourne les numéraux romains valides dans ``text``.
212
-
213
- Forme : ``[(start_index, numeral_string, integer_value), ...]``
214
- triée par index croissant.
215
 
216
- Parameters
217
- ----------
218
- text:
219
- Texte à analyser.
220
- min_length:
221
- Longueur minimale d'un numéral retenu. Par défaut ``1``.
222
- Mettre à ``2`` pour filtrer les single-letter ambigus (``I``
223
- pronom, ``M`` initiale).
224
 
225
- Faux positifs connus
226
- --------------------
227
- - ``I`` (pronom anglais), ``M`` ou ``D`` en initiale d'une
228
- personne ne peuvent pas être distingués sans NER. Le chercheur
229
- qui s'inquiète de ces faux positifs peut passer
230
- ``min_length=2``.
231
- """
232
- if not text:
233
- return []
234
- found: list[tuple[int, str, int]] = []
235
- for match in _ROMAN_RE.finditer(text):
236
- s = match.group(0)
237
- if len(s) < min_length:
238
- continue
239
- value = roman_to_int(s)
240
- if value is None:
241
- continue
242
- found.append((match.start(), s, value))
243
- return found
244
-
245
-
246
- # ──────────────────────────────────────────────────────────────────────────
247
- # Classification de la restitution dans l'hypothèse
248
- # ──────────────────────────────────────────────────────────────────────────
249
-
250
- # Statuts possibles, dans l'ordre de priorité (un numéral est
251
- # classé selon le premier statut qui s'applique).
252
-
253
- STATUS_STRICT_PRESERVED = "strict_preserved"
254
- STATUS_CASE_CHANGED = "case_changed"
255
- STATUS_J_DROPPED = "j_dropped"
256
- STATUS_CONVERTED_TO_ARABIC = "converted_to_arabic"
257
- STATUS_LOST = "lost"
258
-
259
- ALL_STATUSES = (
260
- STATUS_STRICT_PRESERVED,
261
- STATUS_CASE_CHANGED,
262
- STATUS_J_DROPPED,
263
- STATUS_CONVERTED_TO_ARABIC,
264
- STATUS_LOST,
265
- )
266
-
267
- # Statuts qui indiquent une préservation de la valeur (par opposition
268
- # à la perte).
269
- VALUE_PRESERVING_STATUSES = frozenset({
270
- STATUS_STRICT_PRESERVED,
271
- STATUS_CASE_CHANGED,
272
- STATUS_J_DROPPED,
273
- STATUS_CONVERTED_TO_ARABIC,
274
- })
275
-
276
-
277
- def _classify_restitution(numeral: str, value: int, hyp: str) -> str:
278
- """Classifie comment ``numeral`` (de valeur ``value``) est
279
- restitué dans ``hyp`` selon les 5 statuts définis."""
280
- # 1. Forme stricte présente
281
- if re.search(r"(?<![A-Za-z])" + re.escape(numeral) + r"(?![A-Za-z])", hyp):
282
- return STATUS_STRICT_PRESERVED
283
- # 2. Variante de casse seule
284
- swapped = numeral.swapcase()
285
- if swapped != numeral and re.search(
286
- r"(?<![A-Za-z])" + re.escape(swapped) + r"(?![A-Za-z])", hyp,
287
- ):
288
- return STATUS_CASE_CHANGED
289
- # 3. ``j`` final remplacé par ``i`` (ou inverse)
290
- if numeral.lower().endswith("j"):
291
- no_j = numeral[:-1] + ("I" if numeral[-1] == "J" else "i")
292
- elif numeral.lower().endswith("i"):
293
- no_j = numeral[:-1] + ("J" if numeral[-1] == "I" else "j")
294
- else:
295
- no_j = numeral
296
- if no_j != numeral and re.search(
297
- r"(?<![A-Za-z])" + re.escape(no_j) + r"(?![A-Za-z])", hyp,
298
- ):
299
- return STATUS_J_DROPPED
300
- # Variante de casse + j-flip combinés
301
- no_j_swapped = no_j.swapcase()
302
- if no_j_swapped != numeral and re.search(
303
- r"(?<![A-Za-z])" + re.escape(no_j_swapped) + r"(?![A-Za-z])", hyp,
304
- ):
305
- return STATUS_J_DROPPED
306
- # 4. Conversion en chiffres arabes
307
- if re.search(r"(?<!\d)" + str(value) + r"(?!\d)", hyp):
308
- return STATUS_CONVERTED_TO_ARABIC
309
- # 5. Perdu
310
- return STATUS_LOST
311
-
312
-
313
- # ──────────────────────────────────────────────────────────────────────────
314
- # Calcul de la métrique
315
- # ──────────────────────────────────────────────────────────────────────────
316
-
317
-
318
- def compute_roman_numeral_metrics(
319
- reference: Optional[str],
320
- hypothesis: Optional[str],
321
- *,
322
- min_length: int = 1,
323
- ) -> dict:
324
- """Calcule la préservation des numéraux romains.
325
-
326
- Pour chaque numéral romain dans la GT, on classifie sa
327
- restitution dans l'hypothèse selon l'un des 5 statuts (forme
328
- stricte / casse modifiée / j supprimé / conversion arabe / perdu).
329
-
330
- Returns
331
- -------
332
- dict
333
- ``{
334
- "n_numerals_reference": int,
335
- "n_strict_preserved": int,
336
- "n_value_preserved": int, # tous statuts sauf LOST
337
- "global_strict_score": float,
338
- "global_value_score": float,
339
- "per_status": {status: count for status in ALL_STATUSES},
340
- "per_numeral": [
341
- {"index", "numeral", "value", "status"}
342
- ],
343
- "lost_numerals": [
344
- {"index", "numeral", "value"}
345
- ],
346
- }``
347
-
348
- Cas dégénérés
349
- -------------
350
- - GT vide ou sans numéral → tous compteurs à 0, scores à 0.0,
351
- ``per_status`` initialisé à 0 sur tous les statuts.
352
- - GT avec numéraux + hyp vide → tous classés ``lost``,
353
- strict_score = value_score = 0.0.
354
- """
355
- ref = reference or ""
356
- hyp = hypothesis or ""
357
-
358
- detected = detect_roman_numerals(ref, min_length=min_length)
359
- n_total = len(detected)
360
- per_status_init = {status: 0 for status in ALL_STATUSES}
361
-
362
- if n_total == 0:
363
- return {
364
- "n_numerals_reference": 0,
365
- "n_strict_preserved": 0,
366
- "n_value_preserved": 0,
367
- "global_strict_score": 0.0,
368
- "global_value_score": 0.0,
369
- "per_status": per_status_init,
370
- "per_numeral": [],
371
- "lost_numerals": [],
372
- }
373
-
374
- per_status: dict[str, int] = dict(per_status_init)
375
- per_numeral: list[dict] = []
376
- lost: list[dict] = []
377
- for index, numeral, value in detected:
378
- status = _classify_restitution(numeral, value, hyp)
379
- per_status[status] = per_status.get(status, 0) + 1
380
- per_numeral.append({
381
- "index": index,
382
- "numeral": numeral,
383
- "value": value,
384
- "status": status,
385
- })
386
- if status == STATUS_LOST:
387
- lost.append({"index": index, "numeral": numeral, "value": value})
388
-
389
- n_strict = per_status[STATUS_STRICT_PRESERVED]
390
- n_value = sum(per_status[s] for s in VALUE_PRESERVING_STATUSES)
391
-
392
- return {
393
- "n_numerals_reference": n_total,
394
- "n_strict_preserved": n_strict,
395
- "n_value_preserved": n_value,
396
- "global_strict_score": n_strict / n_total,
397
- "global_value_score": n_value / n_total,
398
- "per_status": per_status,
399
- "per_numeral": per_numeral,
400
- "lost_numerals": lost,
401
- }
402
-
403
-
404
- def roman_numeral_strict_score(
405
- reference: Optional[str], hypothesis: Optional[str],
406
- ) -> float:
407
- """Raccourci : taux global de préservation **stricte** des
408
- numéraux romains ∈ [0, 1]."""
409
- return compute_roman_numeral_metrics(
410
- reference, hypothesis,
411
- )["global_strict_score"]
412
-
413
-
414
- def roman_numeral_value_score(
415
- reference: Optional[str], hypothesis: Optional[str],
416
- ) -> float:
417
- """Raccourci : taux global de préservation de la **valeur** des
418
- numéraux romains (toute forme confondue : strict, case_changed,
419
- j_dropped, arabe) ∈ [0, 1]."""
420
- return compute_roman_numeral_metrics(
421
- reference, hypothesis,
422
- )["global_value_score"]
423
-
424
-
425
- # ──────────────────────────────────────────────────────────────────────────
426
- # Enregistrement dans le registre typé (Sprint 34)
427
- # ──────────────────────────────────────────────────────────────────────────
428
-
429
-
430
- @register_metric(
431
- name="roman_numeral_strict_score",
432
- input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
433
- description=(
434
- "Taux de préservation stricte des numéraux romains "
435
- "(forme exacte gardée : casse, j médiéval final). "
436
- "Métrique transversale aux périodes médiévale, imprimé "
437
- "ancien et moderne."
438
- ),
439
- higher_is_better=True,
440
- tags={"text", "roman_numerals", "philology"},
441
- )
442
- def _registered_strict(reference: str, hypothesis: str) -> float:
443
- return roman_numeral_strict_score(reference, hypothesis)
444
-
445
-
446
- @register_metric(
447
- name="roman_numeral_value_score",
448
- input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
449
- description=(
450
- "Taux de préservation de la valeur numérique des numéraux "
451
- "romains, indépendamment de la forme (strict, casse "
452
- "changée, j supprimé, conversion en chiffres arabes). "
453
- "Le breakdown per_status permet au chercheur de juger la "
454
- "convention adoptée."
455
- ),
456
- higher_is_better=True,
457
- tags={"text", "roman_numerals", "philology"},
458
  )
459
- def _registered_value(reference: str, hypothesis: str) -> float:
460
- return roman_numeral_value_score(reference, hypothesis)
461
-
462
-
463
- __all__ = [
464
- "ROMAN_VALUES",
465
- "ALL_STATUSES",
466
- "STATUS_STRICT_PRESERVED",
467
- "STATUS_CASE_CHANGED",
468
- "STATUS_J_DROPPED",
469
- "STATUS_CONVERTED_TO_ARABIC",
470
- "STATUS_LOST",
471
- "VALUE_PRESERVING_STATUSES",
472
- "compute_roman_numeral_metrics",
473
- "detect_roman_numerals",
474
- "int_to_roman",
475
- "roman_numeral_strict_score",
476
- "roman_numeral_value_score",
477
- "roman_to_int",
478
- ]
 
1
+ """``picarones.measurements.roman_numerals``shim re-export (déprécié, suppression 2.0).
2
 
3
+ Canonique : :mod:`picarones.evaluation.metrics.roman_numerals`.
4
+ Phase 5.C.batch7 du retrait du legacy.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
9
+ import warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ from picarones.evaluation.metrics.roman_numerals import * # noqa: F401, F403
 
 
 
 
 
 
 
12
 
13
+ warnings.warn(
14
+ "picarones.measurements.roman_numerals is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.evaluation.metrics.roman_numerals instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/report/generator.py CHANGED
@@ -290,7 +290,7 @@ class ReportGenerator:
290
  from picarones.reports_v2.html.renderers.searchability import (
291
  build_searchability_summary_html,
292
  )
293
- from picarones.report.numerical_sequences_render import (
294
  build_numerical_sequences_html,
295
  )
296
  # Sprint 87 — A.II.2 : lisibilité (delta Flesch).
 
290
  from picarones.reports_v2.html.renderers.searchability import (
291
  build_searchability_summary_html,
292
  )
293
+ from picarones.reports_v2.html.renderers.numerical_sequences import (
294
  build_numerical_sequences_html,
295
  )
296
  # Sprint 87 — A.II.2 : lisibilité (delta Flesch).
picarones/report/numerical_sequences_render.py CHANGED
@@ -1,149 +1,18 @@
1
- """Rendu HTML « Précision sur séquences numériques » — Sprint 86.
2
 
3
- Suite directe ``picarones/core/numerical_sequences.py``
4
- (Sprint 85) + câblage runner Sprint 86.
5
-
6
- Pattern identique aux autres rendus : server-side, pas de JS,
7
- anti-injection systématique.
8
-
9
- Vue
10
- ---
11
- Tableau moteur × catégorie (year / roman / foliation / currency
12
- / regnal) × score strict ; une ligne par moteur, une cellule
13
- colorée par cellule. Une seconde ligne donne le score ``value``
14
- (en plus petit). Catégorie omise si **aucun** moteur n'a de
15
- GT exploitable pour elle.
16
-
17
- Adaptative : ``""`` si aucun moteur n'a de
18
- ``aggregated_numerical_sequences``.
19
  """
20
 
21
  from __future__ import annotations
22
 
23
- from html import escape as _e
24
- from typing import Optional
25
-
26
- from picarones.measurements.numerical_sequences import CATEGORIES
27
- from picarones.reports_v2._helpers.render_helpers import color_traffic_light
28
-
29
-
30
- def _category_columns_with_signal(rows: list[dict]) -> list[str]:
31
- """Ne garde que les catégories où ≥ 1 moteur a un n_total > 0."""
32
- visible: list[str] = []
33
- for cat in CATEGORIES:
34
- for r in rows:
35
- agg = r.get("aggregated_numerical_sequences") or {}
36
- cat_data = (agg.get("per_category") or {}).get(cat) or {}
37
- if (cat_data.get("n_total") or 0) > 0:
38
- visible.append(cat)
39
- break
40
- return visible
41
-
42
-
43
- def build_numerical_sequences_html(
44
- engines: list[dict],
45
- labels: Optional[dict[str, str]] = None,
46
- ) -> str:
47
- """Construit la section HTML séquences numériques.
48
-
49
- Returns
50
- -------
51
- str
52
- ``""`` si aucun moteur n'a de signal.
53
- """
54
- rows = [
55
- e for e in engines
56
- if isinstance(e.get("aggregated_numerical_sequences"), dict)
57
- ]
58
- if not rows:
59
- return ""
60
- visible_cats = _category_columns_with_signal(rows)
61
- if not visible_cats:
62
- return ""
63
- labels = labels or {}
64
- title = labels.get(
65
- "numseq_title", "Précision sur séquences numériques",
66
- )
67
- note = labels.get(
68
- "numseq_note",
69
- "Score strict (forme préservée) — la valeur entre "
70
- "parenthèses est le score sur la valeur (XIV ↔ 14 "
71
- "accepté). Foliotation : recto/verso non interchangeables.",
72
- )
73
- col_engine = labels.get("numseq_engine", "Moteur")
74
- col_global = labels.get("numseq_global", "Global")
75
- cat_label = {
76
- "year": labels.get("numseq_cat_year", "Année"),
77
- "roman": labels.get("numseq_cat_roman", "Romain"),
78
- "foliation": labels.get("numseq_cat_foliation", "Foliation"),
79
- "currency": labels.get("numseq_cat_currency", "Montant"),
80
- "regnal": labels.get("numseq_cat_regnal", "Régnal"),
81
- }
82
-
83
- parts = [
84
- '<div class="numseq-section" style="margin:1rem 0">',
85
- f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
86
- f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
87
- f'{_e(note)}</div>',
88
- '<table style="border-collapse:collapse;width:100%;'
89
- 'font-size:.9rem">',
90
- '<thead><tr>',
91
- f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
92
- f'border-bottom:1px solid #ccc;font-weight:600">'
93
- f'{_e(col_engine)}</th>',
94
- f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
95
- f'border-bottom:1px solid #ccc;font-weight:600">'
96
- f'{_e(col_global)}</th>',
97
- ]
98
- for cat in visible_cats:
99
- parts.append(
100
- f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
101
- f'border-bottom:1px solid #ccc;font-weight:600">'
102
- f'{_e(cat_label.get(cat, cat))}</th>'
103
- )
104
- parts.append("</tr></thead><tbody>")
105
-
106
- for engine in rows:
107
- agg = engine["aggregated_numerical_sequences"]
108
- name = engine.get("name") or "?"
109
- per_cat = agg.get("per_category") or {}
110
- global_strict = float(agg.get("global_strict_score") or 0.0)
111
- global_value = float(agg.get("global_value_score") or 0.0)
112
- n_total = int(agg.get("n_total") or 0)
113
- global_color = color_traffic_light(global_strict)
114
- parts.append(
115
- f'<tr>'
116
- f'<td style="padding:.4rem .6rem">{_e(str(name))}</td>'
117
- f'<td style="padding:.4rem .6rem;text-align:right;'
118
- f'background:{global_color};font-family:monospace;'
119
- f'font-weight:600">'
120
- f'{global_strict * 100:.1f}%'
121
- f'<span style="font-size:.75rem;font-weight:400;'
122
- f'opacity:.75"> ({global_value * 100:.0f}%, '
123
- f'n={n_total})</span></td>'
124
- )
125
- for cat in visible_cats:
126
- cat_data = per_cat.get(cat) or {}
127
- n = int(cat_data.get("n_total") or 0)
128
- if n == 0:
129
- parts.append(
130
- '<td style="padding:.4rem .6rem;text-align:right;'
131
- 'opacity:.4">—</td>'
132
- )
133
- continue
134
- strict = float(cat_data.get("strict_score") or 0.0)
135
- value = float(cat_data.get("value_score") or 0.0)
136
- color = color_traffic_light(strict)
137
- parts.append(
138
- f'<td style="padding:.4rem .6rem;text-align:right;'
139
- f'background:{color};font-family:monospace">'
140
- f'{strict * 100:.0f}%'
141
- f'<span style="font-size:.75rem;opacity:.75"> '
142
- f'({value * 100:.0f}%, n={n})</span></td>'
143
- )
144
- parts.append("</tr>")
145
- parts.append("</tbody></table></div>")
146
- return "".join(parts)
147
 
 
148
 
149
- __all__ = ["build_numerical_sequences_html"]
 
 
 
 
 
 
1
+ """``picarones.report.numerical_sequences_render`` shim re-export (déprécié, suppression 2.0).
2
 
3
+ Canonique : :mod:`picarones.reports_v2.html.renderers.numerical_sequences`.
4
+ Phase 5.C.batch7 du retrait du legacy.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
9
+ import warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ from picarones.reports_v2.html.renderers.numerical_sequences import * # noqa: F401, F403
12
 
13
+ warnings.warn(
14
+ "picarones.report.numerical_sequences_render is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.reports_v2.html.renderers.numerical_sequences instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
18
+ )
picarones/report/pipeline_render.py CHANGED
@@ -1,707 +1,18 @@
1
- """Rendu HTML server-side d'un benchmark de pipeline composée
2
- (Sprint 67).
3
 
4
- Suite directe Sprints 63-66 (axe B) — produit les blocs HTML qui
5
- exposent le résultat d'une pipeline composée.
6
-
7
- Pattern identique aux Sprints 41 (NER), 43 (calibration) et 62
8
- (philologie) : rendu **server-side**, pas de JavaScript,
9
- déterministe, anti-injection systématique via ``html.escape``.
10
-
11
- Vue distincte du rapport OCR historique
12
- ---------------------------------------
13
- Le rapport HTML OCR (``picarones/report/generator.py``) attend un
14
- ``BenchmarkResult`` (axe A). Pour les pipelines composées, on
15
- travaille avec ``PipelineBenchmarkResult`` (axe B, Sprint 64).
16
-
17
- Ce module fournit donc un rapport **autonome** : la fonction
18
- ``build_pipeline_report_html`` produit un document HTML complet
19
- (``<!doctype html>...``) que l'utilisateur peut écrire directement
20
- sur disque, sans dépendre du générateur OCR.
21
-
22
- Sprint 67 — périmètre
23
- ---------------------
24
- Inclus :
25
-
26
- - ``build_pipeline_summary_html(bench)`` — encart résumé global
27
- (corpus, n_docs, taux de succès, durée totale).
28
- - ``build_pipeline_steps_table_html(bench)`` — tableau par étape
29
- (durée mean/median, n_succeeded/failed, error_breakdown,
30
- métriques aux jonctions).
31
- - ``build_pipeline_report_html(bench, lang)`` — document HTML
32
- complet à sauver sur disque.
33
-
34
- Reporté à Sprint 68 :
35
-
36
- - Rendu d'un ``PipelineComparisonResult`` (ranking entre N
37
- pipelines + gain table).
38
-
39
- Toujours pas de classification automatique
40
- ------------------------------------------
41
- On affiche les chiffres bruts ; le chercheur lit et conclut.
42
  """
43
 
44
  from __future__ import annotations
45
 
46
- from dataclasses import dataclass
47
- from html import escape as _e
48
- from typing import Optional
49
-
50
- from picarones.domain.artifacts import ArtifactType
51
- from picarones.measurements.pipeline_benchmark import PipelineBenchmarkResult
52
- from picarones.measurements.pipeline_comparison import PipelineComparisonResult
53
- from picarones.reports_v2._helpers.render_helpers import color_traffic_light
54
-
55
-
56
- # ──────────────────────────────────────────────────────────────────────────
57
- # Helpers communs
58
- # ──────────────────────────────────────────────────────────────────────────
59
-
60
-
61
- def _format_duration(seconds: float) -> str:
62
- """Formate une durée en ms si < 1s, en s sinon."""
63
- if seconds < 1.0:
64
- return f"{seconds * 1000:.1f} ms"
65
- if seconds < 60.0:
66
- return f"{seconds:.2f} s"
67
- minutes = int(seconds // 60)
68
- rest = seconds - minutes * 60
69
- return f"{minutes}min {rest:.1f}s"
70
-
71
-
72
- # ──────────────────────────────────────────────────────────────────────────
73
- # Encart résumé corpus-wide
74
- # ──────────────────────────────────────────────────────────────────────────
75
-
76
-
77
- def build_pipeline_summary_html(
78
- bench: PipelineBenchmarkResult,
79
- labels: Optional[dict[str, str]] = None,
80
- ) -> str:
81
- """Construit l'encart résumé global du benchmark."""
82
- labels = labels or {}
83
- title = labels.get("pipeline_summary_title", "Résumé du benchmark")
84
- pipeline_label = labels.get("pipeline_name_label", "Pipeline")
85
- corpus_label = labels.get("pipeline_corpus_label", "Corpus")
86
- n_docs_label = labels.get("pipeline_n_docs_label", "Documents")
87
- succeeded_label = labels.get(
88
- "pipeline_succeeded_label", "Pipelines réussies",
89
- )
90
- failed_label = labels.get("pipeline_failed_label", "Pipelines échouées")
91
- duration_label = labels.get("pipeline_duration_label", "Durée totale")
92
-
93
- success = bench.n_pipelines_succeeded
94
- failed = bench.n_pipelines_failed
95
- total = bench.n_docs
96
- rate = success / total if total > 0 else 0.0
97
- color = color_traffic_light(rate)
98
-
99
- parts = [
100
- '<div class="pipeline-summary" '
101
- 'style="margin:1rem 0;padding:.75rem;'
102
- 'background:var(--bg-secondary,#f7f7f7);border-radius:6px">',
103
- f'<div style="font-weight:600;margin-bottom:.5rem">{_e(title)}</div>',
104
- '<table style="border-collapse:collapse;font-size:.9rem">',
105
- ]
106
- rows = [
107
- (pipeline_label, _e(bench.pipeline_name)),
108
- (corpus_label, _e(bench.corpus_name)),
109
- (n_docs_label, str(total)),
110
- (
111
- succeeded_label,
112
- f'<span style="background:{color};padding:.1rem .4rem;'
113
- f'border-radius:3px">{success} / {total}</span>',
114
- ),
115
- (failed_label, str(failed)),
116
- (duration_label, _e(_format_duration(bench.total_duration_seconds))),
117
- ]
118
- for label, value in rows:
119
- parts.append(
120
- f'<tr>'
121
- f'<td style="padding:.2rem .5rem;font-weight:500;'
122
- f'color:#555">{_e(label)}</td>'
123
- f'<td style="padding:.2rem .5rem">{value}</td>'
124
- f'</tr>'
125
- )
126
- parts.append("</table></div>")
127
- return "".join(parts)
128
-
129
-
130
- # ──────────────────────────────────────────────────────────────────────────
131
- # Tableau par étape
132
- # ──────────────────────────────────────────────────────────────────────────
133
-
134
-
135
- def build_pipeline_steps_table_html(
136
- bench: PipelineBenchmarkResult,
137
- labels: Optional[dict[str, str]] = None,
138
- ) -> str:
139
- """Construit le tableau par étape de la pipeline.
140
-
141
- Colonnes : nom de l'étape, n_succeeded, n_failed, taux de
142
- succès (cellule colorée), durée mean/median, métriques aux
143
- jonctions (mean) regroupées par type, error_breakdown
144
- catégorisé.
145
- """
146
- if not bench.per_step_aggregates:
147
- return ""
148
- labels = labels or {}
149
- title = labels.get("pipeline_steps_title", "Détail par étape")
150
- name_label = labels.get("pipeline_step_name_label", "Étape")
151
- succ_label = labels.get("pipeline_succeeded_label", "Réussies")
152
- fail_label = labels.get("pipeline_failed_label", "Échouées")
153
- rate_label = labels.get("pipeline_success_rate_label", "Taux succès")
154
- dmean_label = labels.get("pipeline_duration_mean_label", "Durée moyenne")
155
- dmedian_label = labels.get(
156
- "pipeline_duration_median_label", "Durée médiane",
157
- )
158
- metrics_label = labels.get(
159
- "pipeline_junction_metrics_label", "Métriques aux jonctions",
160
- )
161
- errors_label = labels.get("pipeline_error_breakdown_label", "Erreurs")
162
-
163
- parts = [
164
- '<div class="pipeline-steps" style="margin:1rem 0">',
165
- f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
166
- '<table style="border-collapse:collapse;font-size:.85rem;'
167
- 'width:100%">',
168
- '<thead><tr>',
169
- ]
170
- for col in (
171
- name_label, succ_label, fail_label, rate_label,
172
- dmean_label, dmedian_label, metrics_label, errors_label,
173
- ):
174
- parts.append(
175
- f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
176
- f'border-bottom:1px solid #ccc;font-weight:600">'
177
- f'{_e(col)}</th>'
178
- )
179
- parts.append("</tr></thead><tbody>")
180
-
181
- for agg in bench.per_step_aggregates:
182
- rate = agg.success_rate
183
- rate_color = color_traffic_light(rate)
184
- # Métriques aux jonctions : pour chaque type d'artefact,
185
- # liste des métriques mean
186
- metrics_cells: list[str] = []
187
- for at_value, type_metrics in sorted(agg.junction_metrics.items()):
188
- type_str = _e(at_value)
189
- for mname, stats in sorted(type_metrics.items()):
190
- mean = stats["mean"]
191
- n = stats["n"]
192
- metrics_cells.append(
193
- f'<div style="font-size:.8rem;line-height:1.3">'
194
- f'<code>{type_str}.{_e(mname)}</code>: '
195
- f'{mean:.3f} '
196
- f'<span style="opacity:.6">(n={n})</span></div>'
197
- )
198
- metrics_html = "".join(metrics_cells) or (
199
- '<span style="opacity:.5">—</span>'
200
- )
201
- # Error breakdown
202
- err_cells: list[str] = []
203
- for label, count in sorted(agg.error_breakdown.items()):
204
- err_cells.append(
205
- f'<div style="font-size:.8rem;line-height:1.3">'
206
- f'<code>{_e(label)}</code>: {count}</div>'
207
- )
208
- err_html = "".join(err_cells) or (
209
- '<span style="opacity:.5">—</span>'
210
- )
211
-
212
- parts.append(
213
- f'<tr>'
214
- f'<td style="padding:.3rem .5rem;font-weight:500">'
215
- f'{_e(agg.step_name)}</td>'
216
- f'<td style="padding:.3rem .5rem;text-align:right">'
217
- f'{agg.n_succeeded}</td>'
218
- f'<td style="padding:.3rem .5rem;text-align:right">'
219
- f'{agg.n_failed}</td>'
220
- f'<td style="padding:.3rem .5rem;text-align:center;'
221
- f'background:{rate_color}">{rate * 100:.0f}%</td>'
222
- f'<td style="padding:.3rem .5rem;text-align:right">'
223
- f'{_e(_format_duration(agg.duration_seconds_mean))}</td>'
224
- f'<td style="padding:.3rem .5rem;text-align:right">'
225
- f'{_e(_format_duration(agg.duration_seconds_median))}</td>'
226
- f'<td style="padding:.3rem .5rem">{metrics_html}</td>'
227
- f'<td style="padding:.3rem .5rem">{err_html}</td>'
228
- f'</tr>'
229
- )
230
- parts.append("</tbody></table></div>")
231
- return "".join(parts)
232
-
233
-
234
- # ──────────────────────────────────────────────────────────────────────────
235
- # Document HTML autonome
236
- # ──────────────────────────────────────────────────────────────────────────
237
-
238
-
239
- _DOC_STYLES = """
240
- :root {
241
- --bg-primary: #ffffff;
242
- --bg-secondary: #f7f7f7;
243
- --text-primary: #222;
244
- --text-muted: #666;
245
- --border: #ddd;
246
- }
247
- * { box-sizing: border-box; }
248
- body {
249
- margin: 0;
250
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
251
- background: var(--bg-primary);
252
- color: var(--text-primary);
253
- line-height: 1.5;
254
- }
255
- header {
256
- padding: 1.5rem 2rem;
257
- border-bottom: 1px solid var(--border);
258
- }
259
- header h1 { margin: 0 0 .3rem 0; font-size: 1.4rem; }
260
- header .subtitle { color: var(--text-muted); font-size: .9rem; }
261
- main { padding: 1rem 2rem 3rem 2rem; max-width: 1400px; margin: 0 auto; }
262
- table { border: 1px solid var(--border); }
263
- code { background: #f0f0f0; padding: 0 .2rem; border-radius: 2px; font-size: .85em; }
264
- .note {
265
- font-size: .85rem;
266
- color: var(--text-muted);
267
- font-style: italic;
268
- margin: .5rem 0 1.5rem 0;
269
- }
270
- """
271
-
272
-
273
- def build_pipeline_report_html(
274
- bench: PipelineBenchmarkResult,
275
- labels: Optional[dict[str, str]] = None,
276
- lang: str = "fr",
277
- ) -> str:
278
- """Construit un document HTML autonome pour un benchmark de
279
- pipeline composée.
280
-
281
- Le document est complet (``<!doctype html>...``) et peut être
282
- sauvé directement sur disque par l'utilisateur :
283
-
284
- >>> html = build_pipeline_report_html(bench)
285
- >>> Path("rapport_pipeline.html").write_text(html)
286
- """
287
- labels = labels or {}
288
- main_title = labels.get(
289
- "pipeline_report_title", "Rapport de pipeline composée",
290
- )
291
- note = labels.get(
292
- "pipeline_report_note",
293
- "Données brutes par étape. L'outil mesure et agrège — il "
294
- "ne classe pas la pipeline « bonne » ou « mauvaise ». "
295
- "C'est au chercheur de juger les chiffres selon ses critères.",
296
- )
297
- summary = build_pipeline_summary_html(bench, labels)
298
- steps = build_pipeline_steps_table_html(bench, labels)
299
-
300
- title_text = f"{main_title} — {bench.pipeline_name}"
301
- parts = [
302
- "<!doctype html>",
303
- f'<html lang="{_e(lang)}">',
304
- "<head>",
305
- '<meta charset="utf-8">',
306
- '<meta name="viewport" content="width=device-width,initial-scale=1">',
307
- f"<title>{_e(title_text)}</title>",
308
- "<style>", _DOC_STYLES, "</style>",
309
- "</head>",
310
- "<body>",
311
- "<header>",
312
- f"<h1>{_e(title_text)}</h1>",
313
- f'<div class="subtitle">{_e(bench.corpus_name)} — '
314
- f'{bench.n_docs} {_e(labels.get("pipeline_docs_short", "docs"))}'
315
- f'</div>',
316
- "</header>",
317
- "<main>",
318
- f'<p class="note">{_e(note)}</p>',
319
- summary,
320
- steps,
321
- "</main>",
322
- "</body>",
323
- "</html>",
324
- ]
325
- return "".join(parts)
326
-
327
-
328
- # ──────────────────────────────────────────────────────────────────────────
329
- # Sprint 68 — comparaison de N pipelines : ranking + gain table
330
- # ──────────────────────────────────────────────────────────────────────────
331
-
332
-
333
- @dataclass
334
- class RankingSpec:
335
- """Spec d'un classement à afficher.
336
-
337
- Décrit la jonction (``artifact_type``) et la métrique
338
- (``metric_name``) à utiliser pour classer les pipelines.
339
-
340
- Attributs
341
- ---------
342
- artifact_type:
343
- Type d'artefact où la métrique est calculée (typiquement
344
- ``ArtifactType.TEXT`` pour des métriques OCR).
345
- metric_name:
346
- Nom de la métrique dans le registre typé Sprint 34
347
- (``"cer"``, ``"wer"``, ``"flesch_delta_fr"``, etc.).
348
- higher_is_better:
349
- ``False`` (défaut) pour les métriques d'erreur (CER, WER) ;
350
- ``True`` pour les métriques de qualité (accuracy, F1,
351
- coverage…).
352
- label:
353
- Libellé optionnel à afficher dans le tableau ; sinon
354
- construit comme ``"<artifact_type>.<metric_name>"``.
355
- """
356
-
357
- artifact_type: ArtifactType
358
- metric_name: str
359
- higher_is_better: bool = False
360
- label: Optional[str] = None
361
-
362
- @property
363
- def display_label(self) -> str:
364
- if self.label:
365
- return self.label
366
- return f"{self.artifact_type.value}.{self.metric_name}"
367
-
368
-
369
- def _bg_for_rank(rank: int, total: int) -> str:
370
- """Gradient vert (rang 1) → rouge (dernier rang).
371
-
372
- Mapping : ``rank ∈ [1, total]`` → ``color_traffic_light`` avec
373
- ``low_is_good=True`` (rang bas = bon).
374
- """
375
- if total <= 1:
376
- return color_traffic_light(1.0)
377
- return color_traffic_light(
378
- float(rank), low_is_good=True, scale_min=1.0, scale_max=float(total),
379
- )
380
-
381
-
382
- def build_pipeline_ranking_table_html(
383
- comparison: PipelineComparisonResult,
384
- ranking_spec: RankingSpec,
385
- labels: Optional[dict[str, str]] = None,
386
- ) -> str:
387
- """Tableau de classement des pipelines selon une métrique finale.
388
-
389
- Colonnes : rang, nom du pipeline, valeur de la métrique (mean
390
- sur le corpus à la dernière jonction qui produit
391
- ``artifact_type``). Les pipelines sans valeur sont listés en
392
- queue avec un tiret.
393
- """
394
- labels = labels or {}
395
- title_template = labels.get(
396
- "pipeline_ranking_title", "Classement par {label}",
397
- )
398
- title = title_template.format(label=ranking_spec.display_label)
399
- rank_label = labels.get("pipeline_rank_label", "Rang")
400
- name_label = labels.get("pipeline_name_label", "Pipeline")
401
- value_label = labels.get("pipeline_value_label", "Valeur")
402
-
403
- ranked = comparison.ranking_by_final_metric(
404
- ranking_spec.artifact_type,
405
- ranking_spec.metric_name,
406
- higher_is_better=ranking_spec.higher_is_better,
407
- )
408
- if not ranked:
409
- return ""
410
-
411
- n_with_value = sum(1 for _name, v in ranked if v is not None)
412
-
413
- parts = [
414
- '<div class="pipeline-ranking" style="margin:1rem 0">',
415
- f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
416
- '<table style="border-collapse:collapse;font-size:.85rem">',
417
- '<thead><tr>',
418
- ]
419
- for col in (rank_label, name_label, value_label):
420
- parts.append(
421
- f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
422
- f'border-bottom:1px solid #ccc;font-weight:600">'
423
- f'{_e(col)}</th>'
424
- )
425
- parts.append("</tr></thead><tbody>")
426
-
427
- rank = 0
428
- for name, value in ranked:
429
- if value is None:
430
- rank_str = "—"
431
- value_str = "—"
432
- rank_color = "#f0f0f0"
433
- else:
434
- rank += 1
435
- rank_str = str(rank)
436
- value_str = f"{value:.4f}"
437
- rank_color = _bg_for_rank(rank, n_with_value)
438
- parts.append(
439
- f'<tr>'
440
- f'<td style="padding:.3rem .5rem;text-align:center;'
441
- f'background:{rank_color};font-weight:600">{rank_str}</td>'
442
- f'<td style="padding:.3rem .5rem">{_e(name)}</td>'
443
- f'<td style="padding:.3rem .5rem;text-align:right;'
444
- f'font-family:monospace">{value_str}</td>'
445
- f'</tr>'
446
- )
447
- parts.append("</tbody></table></div>")
448
- return "".join(parts)
449
-
450
-
451
- def build_pipeline_gain_table_html(
452
- comparison: PipelineComparisonResult,
453
- ranking_spec: RankingSpec,
454
- baseline_pipeline: str,
455
- labels: Optional[dict[str, str]] = None,
456
- ) -> str:
457
- """Tableau gain vs baseline pour une métrique donnée.
458
-
459
- Colonnes : pipeline, valeur, gain absolu, gain relatif. La
460
- baseline est marquée explicitement (cellule grisée).
461
- Convention de couleur : vert si gain favorable selon
462
- ``higher_is_better``, rouge sinon.
463
- """
464
- labels = labels or {}
465
- title_template = labels.get(
466
- "pipeline_gain_title", "Gain vs {baseline} sur {label}",
467
- )
468
- title = title_template.format(
469
- baseline=baseline_pipeline,
470
- label=ranking_spec.display_label,
471
- )
472
- name_label = labels.get("pipeline_name_label", "Pipeline")
473
- value_label = labels.get("pipeline_value_label", "Valeur")
474
- abs_label = labels.get("pipeline_gain_absolute_label", "Gain absolu")
475
- rel_label = labels.get("pipeline_gain_relative_label", "Gain relatif")
476
- baseline_label = labels.get(
477
- "pipeline_baseline_marker", "(référence)",
478
- )
479
-
480
- try:
481
- gains = comparison.gain_table(
482
- ranking_spec.artifact_type,
483
- ranking_spec.metric_name,
484
- baseline_pipeline,
485
- )
486
- except KeyError:
487
- return ""
488
-
489
- parts = [
490
- '<div class="pipeline-gain" style="margin:1rem 0">',
491
- f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
492
- '<table style="border-collapse:collapse;font-size:.85rem">',
493
- '<thead><tr>',
494
- ]
495
- for col in (name_label, value_label, abs_label, rel_label):
496
- parts.append(
497
- f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
498
- f'border-bottom:1px solid #ccc;font-weight:600">'
499
- f'{_e(col)}</th>'
500
- )
501
- parts.append("</tr></thead><tbody>")
502
-
503
- for name, g in gains.items():
504
- is_baseline = name == baseline_pipeline
505
- value = g["value"]
506
- absolute = g["absolute"]
507
- relative = g["relative"]
508
- # Formatage des cellules
509
- value_str = "—" if value is None else f"{value:.4f}"
510
- abs_str = "—" if absolute is None else f"{absolute:+.4f}"
511
- rel_str = "—" if relative is None else f"{relative * 100:+.1f}%"
512
- # Couleur du gain : vert si favorable, rouge sinon, gris pour
513
- # la baseline.
514
- if is_baseline:
515
- gain_color = "#f0f0f0"
516
- elif absolute is None or absolute == 0:
517
- gain_color = "#f0f0f0"
518
- else:
519
- favorable = (
520
- absolute > 0 if ranking_spec.higher_is_better else absolute < 0
521
- )
522
- gain_color = "#cfe8cf" if favorable else "#f4cfcf"
523
- # Marqueur baseline
524
- name_cell = _e(name)
525
- if is_baseline:
526
- name_cell += (
527
- f' <span style="opacity:.6;font-size:.85em">'
528
- f'{_e(baseline_label)}</span>'
529
- )
530
- parts.append(
531
- f'<tr>'
532
- f'<td style="padding:.3rem .5rem;font-weight:500">{name_cell}</td>'
533
- f'<td style="padding:.3rem .5rem;text-align:right;'
534
- f'font-family:monospace">{value_str}</td>'
535
- f'<td style="padding:.3rem .5rem;text-align:right;'
536
- f'font-family:monospace;background:{gain_color}">{abs_str}</td>'
537
- f'<td style="padding:.3rem .5rem;text-align:right;'
538
- f'font-family:monospace;background:{gain_color}">{rel_str}</td>'
539
- f'</tr>'
540
- )
541
- parts.append("</tbody></table></div>")
542
- return "".join(parts)
543
-
544
-
545
- def build_pipeline_comparison_summary_html(
546
- comparison: PipelineComparisonResult,
547
- labels: Optional[dict[str, str]] = None,
548
- ) -> str:
549
- """Encart de résumé global d'une comparaison de pipelines.
550
-
551
- Affiche corpus, n_docs, durée totale, nombre de pipelines, et
552
- pour chacune un mini-résumé n_succeeded / n_docs.
553
- """
554
- labels = labels or {}
555
- title = labels.get(
556
- "pipeline_comparison_summary_title", "Résumé de la comparaison",
557
- )
558
- corpus_label = labels.get("pipeline_corpus_label", "Corpus")
559
- n_docs_label = labels.get("pipeline_n_docs_label", "Documents")
560
- n_pipelines_label = labels.get(
561
- "pipeline_n_pipelines_label", "Pipelines comparées",
562
- )
563
- duration_label = labels.get("pipeline_duration_label", "Durée totale")
564
-
565
- parts = [
566
- '<div class="pipeline-comparison-summary" '
567
- 'style="margin:1rem 0;padding:.75rem;'
568
- 'background:var(--bg-secondary,#f7f7f7);border-radius:6px">',
569
- f'<div style="font-weight:600;margin-bottom:.5rem">{_e(title)}</div>',
570
- '<table style="border-collapse:collapse;font-size:.9rem">',
571
- ]
572
- rows = [
573
- (corpus_label, _e(comparison.corpus_name)),
574
- (n_docs_label, str(comparison.n_docs)),
575
- (n_pipelines_label, str(len(comparison.per_pipeline))),
576
- (duration_label, _e(_format_duration(comparison.total_duration_seconds))),
577
- ]
578
- for label, value in rows:
579
- parts.append(
580
- f'<tr>'
581
- f'<td style="padding:.2rem .5rem;font-weight:500;color:#555">'
582
- f'{_e(label)}</td>'
583
- f'<td style="padding:.2rem .5rem">{value}</td>'
584
- f'</tr>'
585
- )
586
- parts.append("</table>")
587
- # Mini-résumé par pipeline
588
- if comparison.per_pipeline:
589
- per_pipeline_label = labels.get(
590
- "pipeline_per_pipeline_label", "Par pipeline",
591
- )
592
- parts.append(
593
- f'<div style="margin-top:.6rem;font-size:.85rem">'
594
- f'<span style="font-weight:500;color:#555">'
595
- f'{_e(per_pipeline_label)} :</span>'
596
- )
597
- items: list[str] = []
598
- for name, bench in comparison.per_pipeline.items():
599
- items.append(
600
- f'<code>{_e(name)}</code> '
601
- f'({bench.n_pipelines_succeeded}/{bench.n_docs})'
602
- )
603
- parts.append(" — ".join(items))
604
- parts.append("</div>")
605
- parts.append("</div>")
606
- return "".join(parts)
607
-
608
-
609
- def build_pipeline_comparison_report_html(
610
- comparison: PipelineComparisonResult,
611
- ranking_specs: Optional[list[RankingSpec]] = None,
612
- baseline_pipeline: Optional[str] = None,
613
- labels: Optional[dict[str, str]] = None,
614
- lang: str = "fr",
615
- ) -> str:
616
- """Document HTML autonome pour une comparaison de N pipelines.
617
-
618
- Parameters
619
- ----------
620
- comparison:
621
- Résultat de ``compare_pipelines`` (Sprint 65).
622
- ranking_specs:
623
- Liste explicite des classements à afficher. Pour chaque
624
- spec, on rend un tableau de classement et, si
625
- ``baseline_pipeline`` est fourni, un tableau de gain.
626
- Si ``None`` ou vide, on affiche uniquement le résumé
627
- global et les résumés par pipeline (sans verdict).
628
- baseline_pipeline:
629
- Pipeline de référence pour les tableaux de gain. Si
630
- ``None``, les tableaux de gain ne sont pas affichés.
631
- labels:
632
- Map i18n.
633
- lang:
634
- Code langue pour ``<html lang="…">``.
635
-
636
- Returns
637
- -------
638
- str
639
- Document HTML complet (``<!doctype html>`` + ``<html>``).
640
- """
641
- labels = labels or {}
642
- main_title = labels.get(
643
- "pipeline_comparison_report_title",
644
- "Rapport de comparaison de pipelines",
645
- )
646
- note = labels.get(
647
- "pipeline_comparison_report_note",
648
- "Données comparatives brutes. L'outil mesure et classe — il "
649
- "ne tranche pas le débat éditorial. C'est au chercheur de "
650
- "lire les chiffres et de conclure selon ses critères.",
651
- )
652
- title_text = f"{main_title} — {comparison.corpus_name}"
653
- summary = build_pipeline_comparison_summary_html(comparison, labels)
654
-
655
- rankings_html: list[str] = []
656
- for spec in (ranking_specs or []):
657
- rankings_html.append(
658
- build_pipeline_ranking_table_html(comparison, spec, labels),
659
- )
660
- if baseline_pipeline is not None:
661
- rankings_html.append(
662
- build_pipeline_gain_table_html(
663
- comparison, spec, baseline_pipeline, labels,
664
- ),
665
- )
666
-
667
- parts = [
668
- "<!doctype html>",
669
- f'<html lang="{_e(lang)}">',
670
- "<head>",
671
- '<meta charset="utf-8">',
672
- '<meta name="viewport" content="width=device-width,initial-scale=1">',
673
- f"<title>{_e(title_text)}</title>",
674
- "<style>", _DOC_STYLES, "</style>",
675
- "</head>",
676
- "<body>",
677
- "<header>",
678
- f"<h1>{_e(title_text)}</h1>",
679
- f'<div class="subtitle">{len(comparison.per_pipeline)} '
680
- f'{_e(labels.get("pipeline_n_pipelines_short", "pipelines"))} '
681
- f'— {comparison.n_docs} '
682
- f'{_e(labels.get("pipeline_docs_short", "docs"))}'
683
- f'</div>',
684
- "</header>",
685
- "<main>",
686
- f'<p class="note">{_e(note)}</p>',
687
- summary,
688
- ]
689
- parts.extend(rankings_html)
690
- parts.extend([
691
- "</main>",
692
- "</body>",
693
- "</html>",
694
- ])
695
- return "".join(parts)
696
 
 
697
 
698
- __all__ = [
699
- "build_pipeline_summary_html",
700
- "build_pipeline_steps_table_html",
701
- "build_pipeline_report_html",
702
- "RankingSpec",
703
- "build_pipeline_ranking_table_html",
704
- "build_pipeline_gain_table_html",
705
- "build_pipeline_comparison_summary_html",
706
- "build_pipeline_comparison_report_html",
707
- ]
 
1
+ """``picarones.report.pipeline_render`` shim re-export (déprécié, suppression 2.0).
 
2
 
3
+ Canonique : :mod:`picarones.reports_v2.html.renderers.pipeline`.
4
+ Phase 5.C.batch7 du retrait du legacy.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
9
+ import warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ from picarones.reports_v2.html.renderers.pipeline import * # noqa: F401, F403
12
 
13
+ warnings.warn(
14
+ "picarones.report.pipeline_render is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.reports_v2.html.renderers.pipeline instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
18
+ )
 
 
 
 
picarones/report/views/pipeline.py CHANGED
@@ -101,7 +101,7 @@ def build_pipeline_view_html(
101
  # Sous-section 1 : résumé + steps table
102
  if pipeline_benchmark is not None:
103
  try:
104
- from picarones.report.pipeline_render import (
105
  build_pipeline_steps_table_html,
106
  build_pipeline_summary_html,
107
  )
 
101
  # Sous-section 1 : résumé + steps table
102
  if pipeline_benchmark is not None:
103
  try:
104
+ from picarones.reports_v2.html.renderers.pipeline import (
105
  build_pipeline_steps_table_html,
106
  build_pipeline_summary_html,
107
  )
picarones/reports_v2/html/renderers/numerical_sequences.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML « Précision sur séquences numériques » — Sprint 86.
2
+
3
+ Phase 5.C.batch7 — module relocalisé depuis
4
+ ``picarones.report.numerical_sequences_render`` vers
5
+ ``picarones.reports_v2.html.renderers.numerical_sequences``.
6
+ Le chemin legacy reste disponible via un shim avec
7
+ ``DeprecationWarning`` ; suppression prévue en 2.0.
8
+
9
+ Suite directe ``picarones/core/numerical_sequences.py``
10
+ (Sprint 85) + câblage runner Sprint 86.
11
+
12
+ Pattern identique aux autres rendus : server-side, pas de JS,
13
+ anti-injection systématique.
14
+
15
+ Vue
16
+ ---
17
+ Tableau moteur × catégorie (year / roman / foliation / currency
18
+ / regnal) × score strict ; une ligne par moteur, une cellule
19
+ colorée par cellule. Une seconde ligne donne le score ``value``
20
+ (en plus petit). Catégorie omise si **aucun** moteur n'a de
21
+ GT exploitable pour elle.
22
+
23
+ Adaptative : ``""`` si aucun moteur n'a de
24
+ ``aggregated_numerical_sequences``.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from html import escape as _e
30
+ from typing import Optional
31
+
32
+ from picarones.evaluation.metrics.numerical_sequences import CATEGORIES
33
+ from picarones.reports_v2._helpers.render_helpers import color_traffic_light
34
+
35
+
36
+ def _category_columns_with_signal(rows: list[dict]) -> list[str]:
37
+ """Ne garde que les catégories où ≥ 1 moteur a un n_total > 0."""
38
+ visible: list[str] = []
39
+ for cat in CATEGORIES:
40
+ for r in rows:
41
+ agg = r.get("aggregated_numerical_sequences") or {}
42
+ cat_data = (agg.get("per_category") or {}).get(cat) or {}
43
+ if (cat_data.get("n_total") or 0) > 0:
44
+ visible.append(cat)
45
+ break
46
+ return visible
47
+
48
+
49
+ def build_numerical_sequences_html(
50
+ engines: list[dict],
51
+ labels: Optional[dict[str, str]] = None,
52
+ ) -> str:
53
+ """Construit la section HTML séquences numériques.
54
+
55
+ Returns
56
+ -------
57
+ str
58
+ ``""`` si aucun moteur n'a de signal.
59
+ """
60
+ rows = [
61
+ e for e in engines
62
+ if isinstance(e.get("aggregated_numerical_sequences"), dict)
63
+ ]
64
+ if not rows:
65
+ return ""
66
+ visible_cats = _category_columns_with_signal(rows)
67
+ if not visible_cats:
68
+ return ""
69
+ labels = labels or {}
70
+ title = labels.get(
71
+ "numseq_title", "Précision sur séquences numériques",
72
+ )
73
+ note = labels.get(
74
+ "numseq_note",
75
+ "Score strict (forme préservée) — la valeur entre "
76
+ "parenthèses est le score sur la valeur (XIV ↔ 14 "
77
+ "accepté). Foliotation : recto/verso non interchangeables.",
78
+ )
79
+ col_engine = labels.get("numseq_engine", "Moteur")
80
+ col_global = labels.get("numseq_global", "Global")
81
+ cat_label = {
82
+ "year": labels.get("numseq_cat_year", "Année"),
83
+ "roman": labels.get("numseq_cat_roman", "Romain"),
84
+ "foliation": labels.get("numseq_cat_foliation", "Foliation"),
85
+ "currency": labels.get("numseq_cat_currency", "Montant"),
86
+ "regnal": labels.get("numseq_cat_regnal", "Régnal"),
87
+ }
88
+
89
+ parts = [
90
+ '<div class="numseq-section" style="margin:1rem 0">',
91
+ f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
92
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
93
+ f'{_e(note)}</div>',
94
+ '<table style="border-collapse:collapse;width:100%;'
95
+ 'font-size:.9rem">',
96
+ '<thead><tr>',
97
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
98
+ f'border-bottom:1px solid #ccc;font-weight:600">'
99
+ f'{_e(col_engine)}</th>',
100
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
101
+ f'border-bottom:1px solid #ccc;font-weight:600">'
102
+ f'{_e(col_global)}</th>',
103
+ ]
104
+ for cat in visible_cats:
105
+ parts.append(
106
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
107
+ f'border-bottom:1px solid #ccc;font-weight:600">'
108
+ f'{_e(cat_label.get(cat, cat))}</th>'
109
+ )
110
+ parts.append("</tr></thead><tbody>")
111
+
112
+ for engine in rows:
113
+ agg = engine["aggregated_numerical_sequences"]
114
+ name = engine.get("name") or "?"
115
+ per_cat = agg.get("per_category") or {}
116
+ global_strict = float(agg.get("global_strict_score") or 0.0)
117
+ global_value = float(agg.get("global_value_score") or 0.0)
118
+ n_total = int(agg.get("n_total") or 0)
119
+ global_color = color_traffic_light(global_strict)
120
+ parts.append(
121
+ f'<tr>'
122
+ f'<td style="padding:.4rem .6rem">{_e(str(name))}</td>'
123
+ f'<td style="padding:.4rem .6rem;text-align:right;'
124
+ f'background:{global_color};font-family:monospace;'
125
+ f'font-weight:600">'
126
+ f'{global_strict * 100:.1f}%'
127
+ f'<span style="font-size:.75rem;font-weight:400;'
128
+ f'opacity:.75"> ({global_value * 100:.0f}%, '
129
+ f'n={n_total})</span></td>'
130
+ )
131
+ for cat in visible_cats:
132
+ cat_data = per_cat.get(cat) or {}
133
+ n = int(cat_data.get("n_total") or 0)
134
+ if n == 0:
135
+ parts.append(
136
+ '<td style="padding:.4rem .6rem;text-align:right;'
137
+ 'opacity:.4">—</td>'
138
+ )
139
+ continue
140
+ strict = float(cat_data.get("strict_score") or 0.0)
141
+ value = float(cat_data.get("value_score") or 0.0)
142
+ color = color_traffic_light(strict)
143
+ parts.append(
144
+ f'<td style="padding:.4rem .6rem;text-align:right;'
145
+ f'background:{color};font-family:monospace">'
146
+ f'{strict * 100:.0f}%'
147
+ f'<span style="font-size:.75rem;opacity:.75"> '
148
+ f'({value * 100:.0f}%, n={n})</span></td>'
149
+ )
150
+ parts.append("</tr>")
151
+ parts.append("</tbody></table></div>")
152
+ return "".join(parts)
153
+
154
+
155
+ __all__ = ["build_numerical_sequences_html"]
picarones/reports_v2/html/renderers/pipeline.py ADDED
@@ -0,0 +1,713 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML server-side d'un benchmark de pipeline composée
2
+ (Sprint 67).
3
+
4
+ Phase 5.C.batch7 — module relocalisé depuis
5
+ ``picarones.report.pipeline_render`` vers
6
+ ``picarones.reports_v2.html.renderers.pipeline``. Le chemin legacy
7
+ reste disponible via un shim avec ``DeprecationWarning`` ;
8
+ suppression prévue en 2.0.
9
+
10
+ Suite directe Sprints 63-66 (axe B) — produit les blocs HTML qui
11
+ exposent le résultat d'une pipeline composée.
12
+
13
+ Pattern identique aux Sprints 41 (NER), 43 (calibration) et 62
14
+ (philologie) : rendu **server-side**, pas de JavaScript,
15
+ déterministe, anti-injection systématique via ``html.escape``.
16
+
17
+ Vue distincte du rapport OCR historique
18
+ ---------------------------------------
19
+ Le rapport HTML OCR (``picarones/report/generator.py``) attend un
20
+ ``BenchmarkResult`` (axe A). Pour les pipelines composées, on
21
+ travaille avec ``PipelineBenchmarkResult`` (axe B, Sprint 64).
22
+
23
+ Ce module fournit donc un rapport **autonome** : la fonction
24
+ ``build_pipeline_report_html`` produit un document HTML complet
25
+ (``<!doctype html>...``) que l'utilisateur peut écrire directement
26
+ sur disque, sans dépendre du générateur OCR.
27
+
28
+ Sprint 67 — périmètre
29
+ ---------------------
30
+ Inclus :
31
+
32
+ - ``build_pipeline_summary_html(bench)`` — encart résumé global
33
+ (corpus, n_docs, taux de succès, durée totale).
34
+ - ``build_pipeline_steps_table_html(bench)`` — tableau par étape
35
+ (durée mean/median, n_succeeded/failed, error_breakdown,
36
+ métriques aux jonctions).
37
+ - ``build_pipeline_report_html(bench, lang)`` — document HTML
38
+ complet à sauver sur disque.
39
+
40
+ Reporté à Sprint 68 :
41
+
42
+ - Rendu d'un ``PipelineComparisonResult`` (ranking entre N
43
+ pipelines + gain table).
44
+
45
+ Toujours pas de classification automatique
46
+ ------------------------------------------
47
+ On affiche les chiffres bruts ; le chercheur lit et conclut.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ from dataclasses import dataclass
53
+ from html import escape as _e
54
+ from typing import Optional
55
+
56
+ from picarones.domain.artifacts import ArtifactType
57
+ from picarones.evaluation.pipeline_benchmark import PipelineBenchmarkResult
58
+ from picarones.evaluation.pipeline_comparison import PipelineComparisonResult
59
+ from picarones.reports_v2._helpers.render_helpers import color_traffic_light
60
+
61
+
62
+ # ──────────────────────────────────────────────────────────────────────────
63
+ # Helpers communs
64
+ # ──────────────────────────────────────────────────────────────────────────
65
+
66
+
67
+ def _format_duration(seconds: float) -> str:
68
+ """Formate une durée en ms si < 1s, en s sinon."""
69
+ if seconds < 1.0:
70
+ return f"{seconds * 1000:.1f} ms"
71
+ if seconds < 60.0:
72
+ return f"{seconds:.2f} s"
73
+ minutes = int(seconds // 60)
74
+ rest = seconds - minutes * 60
75
+ return f"{minutes}min {rest:.1f}s"
76
+
77
+
78
+ # ──────────────────────────────────────────────────────────────────────────
79
+ # Encart résumé corpus-wide
80
+ # ──────────────────────────────────────────────────────────────────────────
81
+
82
+
83
+ def build_pipeline_summary_html(
84
+ bench: PipelineBenchmarkResult,
85
+ labels: Optional[dict[str, str]] = None,
86
+ ) -> str:
87
+ """Construit l'encart résumé global du benchmark."""
88
+ labels = labels or {}
89
+ title = labels.get("pipeline_summary_title", "Résumé du benchmark")
90
+ pipeline_label = labels.get("pipeline_name_label", "Pipeline")
91
+ corpus_label = labels.get("pipeline_corpus_label", "Corpus")
92
+ n_docs_label = labels.get("pipeline_n_docs_label", "Documents")
93
+ succeeded_label = labels.get(
94
+ "pipeline_succeeded_label", "Pipelines réussies",
95
+ )
96
+ failed_label = labels.get("pipeline_failed_label", "Pipelines échouées")
97
+ duration_label = labels.get("pipeline_duration_label", "Durée totale")
98
+
99
+ success = bench.n_pipelines_succeeded
100
+ failed = bench.n_pipelines_failed
101
+ total = bench.n_docs
102
+ rate = success / total if total > 0 else 0.0
103
+ color = color_traffic_light(rate)
104
+
105
+ parts = [
106
+ '<div class="pipeline-summary" '
107
+ 'style="margin:1rem 0;padding:.75rem;'
108
+ 'background:var(--bg-secondary,#f7f7f7);border-radius:6px">',
109
+ f'<div style="font-weight:600;margin-bottom:.5rem">{_e(title)}</div>',
110
+ '<table style="border-collapse:collapse;font-size:.9rem">',
111
+ ]
112
+ rows = [
113
+ (pipeline_label, _e(bench.pipeline_name)),
114
+ (corpus_label, _e(bench.corpus_name)),
115
+ (n_docs_label, str(total)),
116
+ (
117
+ succeeded_label,
118
+ f'<span style="background:{color};padding:.1rem .4rem;'
119
+ f'border-radius:3px">{success} / {total}</span>',
120
+ ),
121
+ (failed_label, str(failed)),
122
+ (duration_label, _e(_format_duration(bench.total_duration_seconds))),
123
+ ]
124
+ for label, value in rows:
125
+ parts.append(
126
+ f'<tr>'
127
+ f'<td style="padding:.2rem .5rem;font-weight:500;'
128
+ f'color:#555">{_e(label)}</td>'
129
+ f'<td style="padding:.2rem .5rem">{value}</td>'
130
+ f'</tr>'
131
+ )
132
+ parts.append("</table></div>")
133
+ return "".join(parts)
134
+
135
+
136
+ # ──────────────────────────────────────────────────────────────────────────
137
+ # Tableau par étape
138
+ # ──────────────────────────────────────────────────────────────────────────
139
+
140
+
141
+ def build_pipeline_steps_table_html(
142
+ bench: PipelineBenchmarkResult,
143
+ labels: Optional[dict[str, str]] = None,
144
+ ) -> str:
145
+ """Construit le tableau par étape de la pipeline.
146
+
147
+ Colonnes : nom de l'étape, n_succeeded, n_failed, taux de
148
+ succès (cellule colorée), durée mean/median, métriques aux
149
+ jonctions (mean) regroupées par type, error_breakdown
150
+ catégorisé.
151
+ """
152
+ if not bench.per_step_aggregates:
153
+ return ""
154
+ labels = labels or {}
155
+ title = labels.get("pipeline_steps_title", "Détail par étape")
156
+ name_label = labels.get("pipeline_step_name_label", "Étape")
157
+ succ_label = labels.get("pipeline_succeeded_label", "Réussies")
158
+ fail_label = labels.get("pipeline_failed_label", "Échouées")
159
+ rate_label = labels.get("pipeline_success_rate_label", "Taux succès")
160
+ dmean_label = labels.get("pipeline_duration_mean_label", "Durée moyenne")
161
+ dmedian_label = labels.get(
162
+ "pipeline_duration_median_label", "Durée médiane",
163
+ )
164
+ metrics_label = labels.get(
165
+ "pipeline_junction_metrics_label", "Métriques aux jonctions",
166
+ )
167
+ errors_label = labels.get("pipeline_error_breakdown_label", "Erreurs")
168
+
169
+ parts = [
170
+ '<div class="pipeline-steps" style="margin:1rem 0">',
171
+ f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
172
+ '<table style="border-collapse:collapse;font-size:.85rem;'
173
+ 'width:100%">',
174
+ '<thead><tr>',
175
+ ]
176
+ for col in (
177
+ name_label, succ_label, fail_label, rate_label,
178
+ dmean_label, dmedian_label, metrics_label, errors_label,
179
+ ):
180
+ parts.append(
181
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
182
+ f'border-bottom:1px solid #ccc;font-weight:600">'
183
+ f'{_e(col)}</th>'
184
+ )
185
+ parts.append("</tr></thead><tbody>")
186
+
187
+ for agg in bench.per_step_aggregates:
188
+ rate = agg.success_rate
189
+ rate_color = color_traffic_light(rate)
190
+ # Métriques aux jonctions : pour chaque type d'artefact,
191
+ # liste des métriques mean
192
+ metrics_cells: list[str] = []
193
+ for at_value, type_metrics in sorted(agg.junction_metrics.items()):
194
+ type_str = _e(at_value)
195
+ for mname, stats in sorted(type_metrics.items()):
196
+ mean = stats["mean"]
197
+ n = stats["n"]
198
+ metrics_cells.append(
199
+ f'<div style="font-size:.8rem;line-height:1.3">'
200
+ f'<code>{type_str}.{_e(mname)}</code>: '
201
+ f'{mean:.3f} '
202
+ f'<span style="opacity:.6">(n={n})</span></div>'
203
+ )
204
+ metrics_html = "".join(metrics_cells) or (
205
+ '<span style="opacity:.5">—</span>'
206
+ )
207
+ # Error breakdown
208
+ err_cells: list[str] = []
209
+ for label, count in sorted(agg.error_breakdown.items()):
210
+ err_cells.append(
211
+ f'<div style="font-size:.8rem;line-height:1.3">'
212
+ f'<code>{_e(label)}</code>: {count}</div>'
213
+ )
214
+ err_html = "".join(err_cells) or (
215
+ '<span style="opacity:.5">—</span>'
216
+ )
217
+
218
+ parts.append(
219
+ f'<tr>'
220
+ f'<td style="padding:.3rem .5rem;font-weight:500">'
221
+ f'{_e(agg.step_name)}</td>'
222
+ f'<td style="padding:.3rem .5rem;text-align:right">'
223
+ f'{agg.n_succeeded}</td>'
224
+ f'<td style="padding:.3rem .5rem;text-align:right">'
225
+ f'{agg.n_failed}</td>'
226
+ f'<td style="padding:.3rem .5rem;text-align:center;'
227
+ f'background:{rate_color}">{rate * 100:.0f}%</td>'
228
+ f'<td style="padding:.3rem .5rem;text-align:right">'
229
+ f'{_e(_format_duration(agg.duration_seconds_mean))}</td>'
230
+ f'<td style="padding:.3rem .5rem;text-align:right">'
231
+ f'{_e(_format_duration(agg.duration_seconds_median))}</td>'
232
+ f'<td style="padding:.3rem .5rem">{metrics_html}</td>'
233
+ f'<td style="padding:.3rem .5rem">{err_html}</td>'
234
+ f'</tr>'
235
+ )
236
+ parts.append("</tbody></table></div>")
237
+ return "".join(parts)
238
+
239
+
240
+ # ──────────────────────────────────────────────────────────────────────────
241
+ # Document HTML autonome
242
+ # ──────────────────────────────────────────────────────────────────────────
243
+
244
+
245
+ _DOC_STYLES = """
246
+ :root {
247
+ --bg-primary: #ffffff;
248
+ --bg-secondary: #f7f7f7;
249
+ --text-primary: #222;
250
+ --text-muted: #666;
251
+ --border: #ddd;
252
+ }
253
+ * { box-sizing: border-box; }
254
+ body {
255
+ margin: 0;
256
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
257
+ background: var(--bg-primary);
258
+ color: var(--text-primary);
259
+ line-height: 1.5;
260
+ }
261
+ header {
262
+ padding: 1.5rem 2rem;
263
+ border-bottom: 1px solid var(--border);
264
+ }
265
+ header h1 { margin: 0 0 .3rem 0; font-size: 1.4rem; }
266
+ header .subtitle { color: var(--text-muted); font-size: .9rem; }
267
+ main { padding: 1rem 2rem 3rem 2rem; max-width: 1400px; margin: 0 auto; }
268
+ table { border: 1px solid var(--border); }
269
+ code { background: #f0f0f0; padding: 0 .2rem; border-radius: 2px; font-size: .85em; }
270
+ .note {
271
+ font-size: .85rem;
272
+ color: var(--text-muted);
273
+ font-style: italic;
274
+ margin: .5rem 0 1.5rem 0;
275
+ }
276
+ """
277
+
278
+
279
+ def build_pipeline_report_html(
280
+ bench: PipelineBenchmarkResult,
281
+ labels: Optional[dict[str, str]] = None,
282
+ lang: str = "fr",
283
+ ) -> str:
284
+ """Construit un document HTML autonome pour un benchmark de
285
+ pipeline composée.
286
+
287
+ Le document est complet (``<!doctype html>...``) et peut être
288
+ sauvé directement sur disque par l'utilisateur :
289
+
290
+ >>> html = build_pipeline_report_html(bench)
291
+ >>> Path("rapport_pipeline.html").write_text(html)
292
+ """
293
+ labels = labels or {}
294
+ main_title = labels.get(
295
+ "pipeline_report_title", "Rapport de pipeline composée",
296
+ )
297
+ note = labels.get(
298
+ "pipeline_report_note",
299
+ "Données brutes par étape. L'outil mesure et agrège — il "
300
+ "ne classe pas la pipeline « bonne » ou « mauvaise ». "
301
+ "C'est au chercheur de juger les chiffres selon ses critères.",
302
+ )
303
+ summary = build_pipeline_summary_html(bench, labels)
304
+ steps = build_pipeline_steps_table_html(bench, labels)
305
+
306
+ title_text = f"{main_title} — {bench.pipeline_name}"
307
+ parts = [
308
+ "<!doctype html>",
309
+ f'<html lang="{_e(lang)}">',
310
+ "<head>",
311
+ '<meta charset="utf-8">',
312
+ '<meta name="viewport" content="width=device-width,initial-scale=1">',
313
+ f"<title>{_e(title_text)}</title>",
314
+ "<style>", _DOC_STYLES, "</style>",
315
+ "</head>",
316
+ "<body>",
317
+ "<header>",
318
+ f"<h1>{_e(title_text)}</h1>",
319
+ f'<div class="subtitle">{_e(bench.corpus_name)} — '
320
+ f'{bench.n_docs} {_e(labels.get("pipeline_docs_short", "docs"))}'
321
+ f'</div>',
322
+ "</header>",
323
+ "<main>",
324
+ f'<p class="note">{_e(note)}</p>',
325
+ summary,
326
+ steps,
327
+ "</main>",
328
+ "</body>",
329
+ "</html>",
330
+ ]
331
+ return "".join(parts)
332
+
333
+
334
+ # ──────────────────────────────────────────────────────────────────────────
335
+ # Sprint 68 — comparaison de N pipelines : ranking + gain table
336
+ # ──────────────────────────────────────────────────────────────────────────
337
+
338
+
339
+ @dataclass
340
+ class RankingSpec:
341
+ """Spec d'un classement à afficher.
342
+
343
+ Décrit la jonction (``artifact_type``) et la métrique
344
+ (``metric_name``) à utiliser pour classer les pipelines.
345
+
346
+ Attributs
347
+ ---------
348
+ artifact_type:
349
+ Type d'artefact où la métrique est calculée (typiquement
350
+ ``ArtifactType.TEXT`` pour des métriques OCR).
351
+ metric_name:
352
+ Nom de la métrique dans le registre typé Sprint 34
353
+ (``"cer"``, ``"wer"``, ``"flesch_delta_fr"``, etc.).
354
+ higher_is_better:
355
+ ``False`` (défaut) pour les métriques d'erreur (CER, WER) ;
356
+ ``True`` pour les métriques de qualité (accuracy, F1,
357
+ coverage…).
358
+ label:
359
+ Libellé optionnel à afficher dans le tableau ; sinon
360
+ construit comme ``"<artifact_type>.<metric_name>"``.
361
+ """
362
+
363
+ artifact_type: ArtifactType
364
+ metric_name: str
365
+ higher_is_better: bool = False
366
+ label: Optional[str] = None
367
+
368
+ @property
369
+ def display_label(self) -> str:
370
+ if self.label:
371
+ return self.label
372
+ return f"{self.artifact_type.value}.{self.metric_name}"
373
+
374
+
375
+ def _bg_for_rank(rank: int, total: int) -> str:
376
+ """Gradient vert (rang 1) → rouge (dernier rang).
377
+
378
+ Mapping : ``rank ∈ [1, total]`` → ``color_traffic_light`` avec
379
+ ``low_is_good=True`` (rang bas = bon).
380
+ """
381
+ if total <= 1:
382
+ return color_traffic_light(1.0)
383
+ return color_traffic_light(
384
+ float(rank), low_is_good=True, scale_min=1.0, scale_max=float(total),
385
+ )
386
+
387
+
388
+ def build_pipeline_ranking_table_html(
389
+ comparison: PipelineComparisonResult,
390
+ ranking_spec: RankingSpec,
391
+ labels: Optional[dict[str, str]] = None,
392
+ ) -> str:
393
+ """Tableau de classement des pipelines selon une métrique finale.
394
+
395
+ Colonnes : rang, nom du pipeline, valeur de la métrique (mean
396
+ sur le corpus à la dernière jonction qui produit
397
+ ``artifact_type``). Les pipelines sans valeur sont listés en
398
+ queue avec un tiret.
399
+ """
400
+ labels = labels or {}
401
+ title_template = labels.get(
402
+ "pipeline_ranking_title", "Classement par {label}",
403
+ )
404
+ title = title_template.format(label=ranking_spec.display_label)
405
+ rank_label = labels.get("pipeline_rank_label", "Rang")
406
+ name_label = labels.get("pipeline_name_label", "Pipeline")
407
+ value_label = labels.get("pipeline_value_label", "Valeur")
408
+
409
+ ranked = comparison.ranking_by_final_metric(
410
+ ranking_spec.artifact_type,
411
+ ranking_spec.metric_name,
412
+ higher_is_better=ranking_spec.higher_is_better,
413
+ )
414
+ if not ranked:
415
+ return ""
416
+
417
+ n_with_value = sum(1 for _name, v in ranked if v is not None)
418
+
419
+ parts = [
420
+ '<div class="pipeline-ranking" style="margin:1rem 0">',
421
+ f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
422
+ '<table style="border-collapse:collapse;font-size:.85rem">',
423
+ '<thead><tr>',
424
+ ]
425
+ for col in (rank_label, name_label, value_label):
426
+ parts.append(
427
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
428
+ f'border-bottom:1px solid #ccc;font-weight:600">'
429
+ f'{_e(col)}</th>'
430
+ )
431
+ parts.append("</tr></thead><tbody>")
432
+
433
+ rank = 0
434
+ for name, value in ranked:
435
+ if value is None:
436
+ rank_str = "—"
437
+ value_str = "—"
438
+ rank_color = "#f0f0f0"
439
+ else:
440
+ rank += 1
441
+ rank_str = str(rank)
442
+ value_str = f"{value:.4f}"
443
+ rank_color = _bg_for_rank(rank, n_with_value)
444
+ parts.append(
445
+ f'<tr>'
446
+ f'<td style="padding:.3rem .5rem;text-align:center;'
447
+ f'background:{rank_color};font-weight:600">{rank_str}</td>'
448
+ f'<td style="padding:.3rem .5rem">{_e(name)}</td>'
449
+ f'<td style="padding:.3rem .5rem;text-align:right;'
450
+ f'font-family:monospace">{value_str}</td>'
451
+ f'</tr>'
452
+ )
453
+ parts.append("</tbody></table></div>")
454
+ return "".join(parts)
455
+
456
+
457
+ def build_pipeline_gain_table_html(
458
+ comparison: PipelineComparisonResult,
459
+ ranking_spec: RankingSpec,
460
+ baseline_pipeline: str,
461
+ labels: Optional[dict[str, str]] = None,
462
+ ) -> str:
463
+ """Tableau gain vs baseline pour une métrique donnée.
464
+
465
+ Colonnes : pipeline, valeur, gain absolu, gain relatif. La
466
+ baseline est marquée explicitement (cellule grisée).
467
+ Convention de couleur : vert si gain favorable selon
468
+ ``higher_is_better``, rouge sinon.
469
+ """
470
+ labels = labels or {}
471
+ title_template = labels.get(
472
+ "pipeline_gain_title", "Gain vs {baseline} sur {label}",
473
+ )
474
+ title = title_template.format(
475
+ baseline=baseline_pipeline,
476
+ label=ranking_spec.display_label,
477
+ )
478
+ name_label = labels.get("pipeline_name_label", "Pipeline")
479
+ value_label = labels.get("pipeline_value_label", "Valeur")
480
+ abs_label = labels.get("pipeline_gain_absolute_label", "Gain absolu")
481
+ rel_label = labels.get("pipeline_gain_relative_label", "Gain relatif")
482
+ baseline_label = labels.get(
483
+ "pipeline_baseline_marker", "(référence)",
484
+ )
485
+
486
+ try:
487
+ gains = comparison.gain_table(
488
+ ranking_spec.artifact_type,
489
+ ranking_spec.metric_name,
490
+ baseline_pipeline,
491
+ )
492
+ except KeyError:
493
+ return ""
494
+
495
+ parts = [
496
+ '<div class="pipeline-gain" style="margin:1rem 0">',
497
+ f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
498
+ '<table style="border-collapse:collapse;font-size:.85rem">',
499
+ '<thead><tr>',
500
+ ]
501
+ for col in (name_label, value_label, abs_label, rel_label):
502
+ parts.append(
503
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
504
+ f'border-bottom:1px solid #ccc;font-weight:600">'
505
+ f'{_e(col)}</th>'
506
+ )
507
+ parts.append("</tr></thead><tbody>")
508
+
509
+ for name, g in gains.items():
510
+ is_baseline = name == baseline_pipeline
511
+ value = g["value"]
512
+ absolute = g["absolute"]
513
+ relative = g["relative"]
514
+ # Formatage des cellules
515
+ value_str = "—" if value is None else f"{value:.4f}"
516
+ abs_str = "—" if absolute is None else f"{absolute:+.4f}"
517
+ rel_str = "—" if relative is None else f"{relative * 100:+.1f}%"
518
+ # Couleur du gain : vert si favorable, rouge sinon, gris pour
519
+ # la baseline.
520
+ if is_baseline:
521
+ gain_color = "#f0f0f0"
522
+ elif absolute is None or absolute == 0:
523
+ gain_color = "#f0f0f0"
524
+ else:
525
+ favorable = (
526
+ absolute > 0 if ranking_spec.higher_is_better else absolute < 0
527
+ )
528
+ gain_color = "#cfe8cf" if favorable else "#f4cfcf"
529
+ # Marqueur baseline
530
+ name_cell = _e(name)
531
+ if is_baseline:
532
+ name_cell += (
533
+ f' <span style="opacity:.6;font-size:.85em">'
534
+ f'{_e(baseline_label)}</span>'
535
+ )
536
+ parts.append(
537
+ f'<tr>'
538
+ f'<td style="padding:.3rem .5rem;font-weight:500">{name_cell}</td>'
539
+ f'<td style="padding:.3rem .5rem;text-align:right;'
540
+ f'font-family:monospace">{value_str}</td>'
541
+ f'<td style="padding:.3rem .5rem;text-align:right;'
542
+ f'font-family:monospace;background:{gain_color}">{abs_str}</td>'
543
+ f'<td style="padding:.3rem .5rem;text-align:right;'
544
+ f'font-family:monospace;background:{gain_color}">{rel_str}</td>'
545
+ f'</tr>'
546
+ )
547
+ parts.append("</tbody></table></div>")
548
+ return "".join(parts)
549
+
550
+
551
+ def build_pipeline_comparison_summary_html(
552
+ comparison: PipelineComparisonResult,
553
+ labels: Optional[dict[str, str]] = None,
554
+ ) -> str:
555
+ """Encart de résumé global d'une comparaison de pipelines.
556
+
557
+ Affiche corpus, n_docs, durée totale, nombre de pipelines, et
558
+ pour chacune un mini-résumé n_succeeded / n_docs.
559
+ """
560
+ labels = labels or {}
561
+ title = labels.get(
562
+ "pipeline_comparison_summary_title", "Résumé de la comparaison",
563
+ )
564
+ corpus_label = labels.get("pipeline_corpus_label", "Corpus")
565
+ n_docs_label = labels.get("pipeline_n_docs_label", "Documents")
566
+ n_pipelines_label = labels.get(
567
+ "pipeline_n_pipelines_label", "Pipelines comparées",
568
+ )
569
+ duration_label = labels.get("pipeline_duration_label", "Durée totale")
570
+
571
+ parts = [
572
+ '<div class="pipeline-comparison-summary" '
573
+ 'style="margin:1rem 0;padding:.75rem;'
574
+ 'background:var(--bg-secondary,#f7f7f7);border-radius:6px">',
575
+ f'<div style="font-weight:600;margin-bottom:.5rem">{_e(title)}</div>',
576
+ '<table style="border-collapse:collapse;font-size:.9rem">',
577
+ ]
578
+ rows = [
579
+ (corpus_label, _e(comparison.corpus_name)),
580
+ (n_docs_label, str(comparison.n_docs)),
581
+ (n_pipelines_label, str(len(comparison.per_pipeline))),
582
+ (duration_label, _e(_format_duration(comparison.total_duration_seconds))),
583
+ ]
584
+ for label, value in rows:
585
+ parts.append(
586
+ f'<tr>'
587
+ f'<td style="padding:.2rem .5rem;font-weight:500;color:#555">'
588
+ f'{_e(label)}</td>'
589
+ f'<td style="padding:.2rem .5rem">{value}</td>'
590
+ f'</tr>'
591
+ )
592
+ parts.append("</table>")
593
+ # Mini-résumé par pipeline
594
+ if comparison.per_pipeline:
595
+ per_pipeline_label = labels.get(
596
+ "pipeline_per_pipeline_label", "Par pipeline",
597
+ )
598
+ parts.append(
599
+ f'<div style="margin-top:.6rem;font-size:.85rem">'
600
+ f'<span style="font-weight:500;color:#555">'
601
+ f'{_e(per_pipeline_label)} :</span>'
602
+ )
603
+ items: list[str] = []
604
+ for name, bench in comparison.per_pipeline.items():
605
+ items.append(
606
+ f'<code>{_e(name)}</code> '
607
+ f'({bench.n_pipelines_succeeded}/{bench.n_docs})'
608
+ )
609
+ parts.append(" — ".join(items))
610
+ parts.append("</div>")
611
+ parts.append("</div>")
612
+ return "".join(parts)
613
+
614
+
615
+ def build_pipeline_comparison_report_html(
616
+ comparison: PipelineComparisonResult,
617
+ ranking_specs: Optional[list[RankingSpec]] = None,
618
+ baseline_pipeline: Optional[str] = None,
619
+ labels: Optional[dict[str, str]] = None,
620
+ lang: str = "fr",
621
+ ) -> str:
622
+ """Document HTML autonome pour une comparaison de N pipelines.
623
+
624
+ Parameters
625
+ ----------
626
+ comparison:
627
+ Résultat de ``compare_pipelines`` (Sprint 65).
628
+ ranking_specs:
629
+ Liste explicite des classements à afficher. Pour chaque
630
+ spec, on rend un tableau de classement et, si
631
+ ``baseline_pipeline`` est fourni, un tableau de gain.
632
+ Si ``None`` ou vide, on affiche uniquement le résumé
633
+ global et les résumés par pipeline (sans verdict).
634
+ baseline_pipeline:
635
+ Pipeline de référence pour les tableaux de gain. Si
636
+ ``None``, les tableaux de gain ne sont pas affichés.
637
+ labels:
638
+ Map i18n.
639
+ lang:
640
+ Code langue pour ``<html lang="…">``.
641
+
642
+ Returns
643
+ -------
644
+ str
645
+ Document HTML complet (``<!doctype html>`` + ``<html>``).
646
+ """
647
+ labels = labels or {}
648
+ main_title = labels.get(
649
+ "pipeline_comparison_report_title",
650
+ "Rapport de comparaison de pipelines",
651
+ )
652
+ note = labels.get(
653
+ "pipeline_comparison_report_note",
654
+ "Données comparatives brutes. L'outil mesure et classe — il "
655
+ "ne tranche pas le débat éditorial. C'est au chercheur de "
656
+ "lire les chiffres et de conclure selon ses critères.",
657
+ )
658
+ title_text = f"{main_title} — {comparison.corpus_name}"
659
+ summary = build_pipeline_comparison_summary_html(comparison, labels)
660
+
661
+ rankings_html: list[str] = []
662
+ for spec in (ranking_specs or []):
663
+ rankings_html.append(
664
+ build_pipeline_ranking_table_html(comparison, spec, labels),
665
+ )
666
+ if baseline_pipeline is not None:
667
+ rankings_html.append(
668
+ build_pipeline_gain_table_html(
669
+ comparison, spec, baseline_pipeline, labels,
670
+ ),
671
+ )
672
+
673
+ parts = [
674
+ "<!doctype html>",
675
+ f'<html lang="{_e(lang)}">',
676
+ "<head>",
677
+ '<meta charset="utf-8">',
678
+ '<meta name="viewport" content="width=device-width,initial-scale=1">',
679
+ f"<title>{_e(title_text)}</title>",
680
+ "<style>", _DOC_STYLES, "</style>",
681
+ "</head>",
682
+ "<body>",
683
+ "<header>",
684
+ f"<h1>{_e(title_text)}</h1>",
685
+ f'<div class="subtitle">{len(comparison.per_pipeline)} '
686
+ f'{_e(labels.get("pipeline_n_pipelines_short", "pipelines"))} '
687
+ f'— {comparison.n_docs} '
688
+ f'{_e(labels.get("pipeline_docs_short", "docs"))}'
689
+ f'</div>',
690
+ "</header>",
691
+ "<main>",
692
+ f'<p class="note">{_e(note)}</p>',
693
+ summary,
694
+ ]
695
+ parts.extend(rankings_html)
696
+ parts.extend([
697
+ "</main>",
698
+ "</body>",
699
+ "</html>",
700
+ ])
701
+ return "".join(parts)
702
+
703
+
704
+ __all__ = [
705
+ "build_pipeline_summary_html",
706
+ "build_pipeline_steps_table_html",
707
+ "build_pipeline_report_html",
708
+ "RankingSpec",
709
+ "build_pipeline_ranking_table_html",
710
+ "build_pipeline_gain_table_html",
711
+ "build_pipeline_comparison_summary_html",
712
+ "build_pipeline_comparison_report_html",
713
+ ]
tests/architecture/test_file_budgets.py CHANGED
@@ -52,7 +52,9 @@ FILE_BUDGETS: dict[str, int] = {
52
  "picarones/report/generator.py": 500, # actuel 431
53
  # --- Fichiers métier larges.
54
  "picarones/measurements/robustness.py": 850, # actuel 731
55
- "picarones/report/pipeline_render.py": 815, # actuel 707 (rétréci)
 
 
56
  # Phase 4-ter : ``core/results.py`` est désormais un shim
57
  # (≤ 25 l). Le contenu canonique vit dans ``evaluation/`` ;
58
  # même budget pour la même raison historique (modèles
@@ -65,7 +67,9 @@ FILE_BUDGETS: dict[str, int] = {
65
  "picarones/measurements/history.py": 725, # actuel 615
66
  "picarones/measurements/modern_archives.py": 700, # actuel 599
67
  "picarones/measurements/builtin_hooks.py": 700, # actuel 590
68
- "picarones/core/pipeline.py": 675, # actuel 571
 
 
69
  "picarones/extras/importers/iiif.py": 675, # actuel 567
70
  "picarones/extras/importers/gallica.py": 675, # actuel 563
71
  "picarones/measurements/levers.py": 675, # actuel 561 (re-export S10)
@@ -115,7 +119,10 @@ FILE_BUDGETS: dict[str, int] = {
115
  "picarones/evaluation/corpus.py": 600, # actuel 533
116
  "picarones/fixtures.py": 600, # actuel 510
117
  "picarones/measurements/inter_engine.py": 575, # actuel 484
118
- "picarones/measurements/roman_numerals.py": 575, # actuel 478
 
 
 
119
  "picarones/extras/importers/htr_united.py": 575, # actuel 473 (re-export S11)
120
  # Sprint A14-S11 — d\xc3\xa9plac\xc3\xa9s depuis extras/importers/, l'ancien
121
  # emplacement est d\xc3\xa9sormais un re-export.
@@ -128,7 +135,10 @@ FILE_BUDGETS: dict[str, int] = {
128
  # même budget pour la même raison historique (centralise les
129
  # hooks document/corpus, croissance maîtrisée).
130
  "picarones/evaluation/metric_hooks.py": 500, # actuel 427
131
- "picarones/measurements/numerical_sequences.py": 500, # actuel 422
 
 
 
132
  "picarones/measurements/normalization.py": 500, # actuel 420 (re-export S9)
133
  # Sprint A14-S9 — déplacé depuis measurements/normalization.py.
134
  # L'ancien emplacement est désormais un re-export ; le contenu
 
52
  "picarones/report/generator.py": 500, # actuel 431
53
  # --- Fichiers métier larges.
54
  "picarones/measurements/robustness.py": 850, # actuel 731
55
+ # Phase 5.C.batch7 : ``report/pipeline_render.py`` est désormais
56
+ # un shim ; canonique dans ``reports_v2/html/renderers/pipeline.py``.
57
+ "picarones/reports_v2/html/renderers/pipeline.py": 815, # actuel 713
58
  # Phase 4-ter : ``core/results.py`` est désormais un shim
59
  # (≤ 25 l). Le contenu canonique vit dans ``evaluation/`` ;
60
  # même budget pour la même raison historique (modèles
 
67
  "picarones/measurements/history.py": 725, # actuel 615
68
  "picarones/measurements/modern_archives.py": 700, # actuel 599
69
  "picarones/measurements/builtin_hooks.py": 700, # actuel 590
70
+ # Phase 5.C.batch7 : ``core/pipeline.py`` est désormais un shim ;
71
+ # canonique dans ``evaluation/pipeline.py``.
72
+ "picarones/evaluation/pipeline.py": 700, # actuel 622
73
  "picarones/extras/importers/iiif.py": 675, # actuel 567
74
  "picarones/extras/importers/gallica.py": 675, # actuel 563
75
  "picarones/measurements/levers.py": 675, # actuel 561 (re-export S10)
 
119
  "picarones/evaluation/corpus.py": 600, # actuel 533
120
  "picarones/fixtures.py": 600, # actuel 510
121
  "picarones/measurements/inter_engine.py": 575, # actuel 484
122
+ # Phase 5.C.batch7 : ``measurements/roman_numerals.py`` est
123
+ # désormais un shim ; canonique dans
124
+ # ``evaluation/metrics/roman_numerals.py``.
125
+ "picarones/evaluation/metrics/roman_numerals.py": 575, # actuel 484
126
  "picarones/extras/importers/htr_united.py": 575, # actuel 473 (re-export S11)
127
  # Sprint A14-S11 — d\xc3\xa9plac\xc3\xa9s depuis extras/importers/, l'ancien
128
  # emplacement est d\xc3\xa9sormais un re-export.
 
135
  # même budget pour la même raison historique (centralise les
136
  # hooks document/corpus, croissance maîtrisée).
137
  "picarones/evaluation/metric_hooks.py": 500, # actuel 427
138
+ # Phase 5.C.batch7 : ``measurements/numerical_sequences.py`` est
139
+ # désormais un shim ; canonique dans
140
+ # ``evaluation/metrics/numerical_sequences.py``.
141
+ "picarones/evaluation/metrics/numerical_sequences.py": 500, # actuel 428
142
  "picarones/measurements/normalization.py": 500, # actuel 420 (re-export S9)
143
  # Sprint A14-S9 — déplacé depuis measurements/normalization.py.
144
  # L'ancien emplacement est désormais un re-export ; le contenu
tests/architecture/test_module_coverage.py CHANGED
@@ -73,6 +73,17 @@ TEST_ONLY_BASELINE: frozenset[str] = frozenset({
73
  "specialization",
74
  "lexical_modernization",
75
  "robustness_projection",
 
 
 
 
 
 
 
 
 
 
 
76
  })
77
 
78
 
 
73
  "specialization",
74
  "lexical_modernization",
75
  "robustness_projection",
76
+ # Phase 5.C.batch7 : 4 modules supplémentaires migrés vers
77
+ # ``evaluation/`` (``numerical_sequences``,
78
+ # ``pipeline_benchmark``, ``pipeline_comparison``) ou
79
+ # ``evaluation/metrics/`` (``numerical_sequences``).
80
+ # ``numerical_sequences_hooks`` n'est plus consommé en prod
81
+ # car son seul consommateur (le renderer) consomme désormais
82
+ # le canonique.
83
+ "numerical_sequences",
84
+ "numerical_sequences_hooks",
85
+ "pipeline_benchmark",
86
+ "pipeline_comparison",
87
  })
88
 
89
 
tests/core/test_public_api.py CHANGED
@@ -420,25 +420,26 @@ class TestCercle1IsLean:
420
  # Tout module avec de la logique métier (calcul, orchestration)
421
  # appartient au Cercle 2 (``measurements/``) ou au Cercle 3
422
  # (``extras/``, ``report/``).
423
- EXPECTED_CERCLE1 = {
424
- "pipeline.py",
425
- # Phase 1 du retrait du legacy a déplacé `facts.py`,
426
- # `diff_utils.py` et `xml_utils.py` vers leurs canoniques
427
- # (`domain/facts.py`, `evaluation/_diff_utils.py`,
428
- # `formats/_xml_utils.py`). Les fichiers `core/X.py`
429
- # restent comme shims re-export avec DeprecationWarning
430
- # (< 30 lignes), donc ne comptent plus comme "real_modules"
431
- # au sens de ce test.
432
- # Phase 4-bis a fait pareil pour `modules.py` (canonique :
433
- # `domain/module_protocol.py` + `domain/artifacts.py`).
434
- # Phase 4-ter a fait pareil pour `metric_registry.py`,
435
- # `metric_hooks.py` (canonique : `evaluation/metric_*.py`),
436
- # `metrics.py` (canonique : `evaluation/metric_result.py`)
437
- # et `results.py` (canonique :
438
- # `evaluation/benchmark_result.py`).
439
- # Phase 4-quater a fait pareil pour `corpus.py`
440
- # (canonique : `evaluation/corpus.py`).
441
- }
 
442
 
443
  def test_cercle1_files_lean(self):
444
  from pathlib import Path
 
420
  # Tout module avec de la logique métier (calcul, orchestration)
421
  # appartient au Cercle 2 (``measurements/``) ou au Cercle 3
422
  # (``extras/``, ``report/``).
423
+ EXPECTED_CERCLE1: set[str] = set()
424
+ # Phase 1 du retrait du legacy a déplacé `facts.py`,
425
+ # `diff_utils.py` et `xml_utils.py` vers leurs canoniques
426
+ # (`domain/facts.py`, `evaluation/_diff_utils.py`,
427
+ # `formats/_xml_utils.py`). Les fichiers `core/X.py`
428
+ # restent comme shims re-export avec DeprecationWarning
429
+ # (< 30 lignes), donc ne comptent plus comme "real_modules"
430
+ # au sens de ce test.
431
+ # Phase 4-bis a fait pareil pour `modules.py` (canonique :
432
+ # `domain/module_protocol.py` + `domain/artifacts.py`).
433
+ # Phase 4-ter a fait pareil pour `metric_registry.py`,
434
+ # `metric_hooks.py` (canonique : `evaluation/metric_*.py`),
435
+ # `metrics.py` (canonique : `evaluation/metric_result.py`)
436
+ # et `results.py` (canonique :
437
+ # `evaluation/benchmark_result.py`).
438
+ # Phase 4-quater a fait pareil pour `corpus.py`
439
+ # (canonique : `evaluation/corpus.py`).
440
+ # Phase 5.C.batch7 a fait pareil pour `pipeline.py`
441
+ # (canonique : `evaluation/pipeline.py`). Désormais
442
+ # ``core/`` ne contient plus que des shims < 30 lignes.
443
 
444
  def test_cercle1_files_lean(self):
445
  from pathlib import Path
tests/core/test_sprint63_pipeline_runner.py CHANGED
@@ -28,7 +28,7 @@ from typing import Any
28
 
29
  from picarones.core.corpus import Document, GTLevel, TextGT
30
  from picarones.core.modules import ArtifactType, BaseModule
31
- from picarones.core.pipeline import (
32
  PipelineResult,
33
  PipelineRunner,
34
  PipelineSpec,
 
28
 
29
  from picarones.core.corpus import Document, GTLevel, TextGT
30
  from picarones.core.modules import ArtifactType, BaseModule
31
+ from picarones.evaluation.pipeline import (
32
  PipelineResult,
33
  PipelineRunner,
34
  PipelineSpec,
tests/core/test_sprint66_dag_branching.py CHANGED
@@ -32,7 +32,7 @@ from typing import Any
32
 
33
  from picarones.core.corpus import Document, GTLevel, TextGT
34
  from picarones.core.modules import ArtifactType, BaseModule
35
- from picarones.core.pipeline import (
36
  PipelineRunner,
37
  PipelineSpec,
38
  PipelineStep,
 
32
 
33
  from picarones.core.corpus import Document, GTLevel, TextGT
34
  from picarones.core.modules import ArtifactType, BaseModule
35
+ from picarones.evaluation.pipeline import (
36
  PipelineRunner,
37
  PipelineSpec,
38
  PipelineStep,
tests/integration/test_alto_baseline.py CHANGED
@@ -29,7 +29,7 @@ from picarones.measurements.alto_metrics import (
29
  from picarones.core.corpus import AltoGT, Document, GTLevel, TextGT
30
  from picarones.core.metric_registry import compute_at_junction, select_metrics
31
  from picarones.core.modules import ArtifactType, BaseModule
32
- from picarones.core.pipeline import (
33
  PipelineRunner,
34
  PipelineSpec,
35
  PipelineStep,
 
29
  from picarones.core.corpus import AltoGT, Document, GTLevel, TextGT
30
  from picarones.core.metric_registry import compute_at_junction, select_metrics
31
  from picarones.core.modules import ArtifactType, BaseModule
32
+ from picarones.evaluation.pipeline import (
33
  PipelineRunner,
34
  PipelineSpec,
35
  PipelineStep,
tests/integration/test_pipeline_ocr_to_alto.py CHANGED
@@ -34,7 +34,7 @@ import pytest
34
  from picarones.core.corpus import AltoGT, Document, GTLevel, TextGT
35
  from picarones.core.metric_registry import select_metrics
36
  from picarones.core.modules import ArtifactType, BaseModule
37
- from picarones.core.pipeline import (
38
  PipelineRunner,
39
  PipelineSpec,
40
  PipelineStep,
 
34
  from picarones.core.corpus import AltoGT, Document, GTLevel, TextGT
35
  from picarones.core.metric_registry import select_metrics
36
  from picarones.core.modules import ArtifactType, BaseModule
37
+ from picarones.evaluation.pipeline import (
38
  PipelineRunner,
39
  PipelineSpec,
40
  PipelineStep,
tests/integration/test_sprint69_user_doc.py CHANGED
@@ -153,7 +153,7 @@ class TestCodeSnippets:
153
  # Les imports doivent pointer vers les vrais modules
154
  # picarones.core.* et picarones.report.*
155
  assert "from picarones.core.modules import" in doc
156
- assert "from picarones.core.pipeline import" in doc
157
- assert "from picarones.measurements.pipeline_benchmark import" in doc
158
- assert "from picarones.measurements.pipeline_comparison import" in doc
159
- assert "from picarones.report.pipeline_render import" in doc
 
153
  # Les imports doivent pointer vers les vrais modules
154
  # picarones.core.* et picarones.report.*
155
  assert "from picarones.core.modules import" in doc
156
+ assert "from picarones.evaluation.pipeline import" in doc
157
+ assert "from picarones.evaluation.pipeline_benchmark import" in doc
158
+ assert "from picarones.evaluation.pipeline_comparison import" in doc
159
+ assert "from picarones.reports_v2.html.renderers.pipeline import" in doc
tests/measurements/test_sprint60_roman_numerals.py CHANGED
@@ -23,7 +23,7 @@ import pytest
23
 
24
  from picarones.core.metric_registry import compute_at_junction, select_metrics
25
  from picarones.core.modules import ArtifactType
26
- from picarones.measurements.roman_numerals import (
27
  ALL_STATUSES,
28
  STATUS_CASE_CHANGED,
29
  STATUS_CONVERTED_TO_ARABIC,
 
23
 
24
  from picarones.core.metric_registry import compute_at_junction, select_metrics
25
  from picarones.core.modules import ArtifactType
26
+ from picarones.evaluation.metrics.roman_numerals import (
27
  ALL_STATUSES,
28
  STATUS_CASE_CHANGED,
29
  STATUS_CONVERTED_TO_ARABIC,
tests/measurements/test_sprint64_pipeline_benchmark.py CHANGED
@@ -31,13 +31,13 @@ from typing import Any
31
 
32
  from picarones.core.corpus import Corpus, Document, GTLevel, TextGT
33
  from picarones.core.modules import ArtifactType, BaseModule
34
- from picarones.measurements.pipeline_benchmark import (
35
  PipelineBenchmarkResult,
36
  StepAggregate,
37
  default_initial_inputs,
38
  run_pipeline_benchmark,
39
  )
40
- from picarones.core.pipeline import PipelineSpec, PipelineStep
41
 
42
 
43
  # ──────────────────────────────────────────────────────────────────────────
 
31
 
32
  from picarones.core.corpus import Corpus, Document, GTLevel, TextGT
33
  from picarones.core.modules import ArtifactType, BaseModule
34
+ from picarones.evaluation.pipeline_benchmark import (
35
  PipelineBenchmarkResult,
36
  StepAggregate,
37
  default_initial_inputs,
38
  run_pipeline_benchmark,
39
  )
40
+ from picarones.evaluation.pipeline import PipelineSpec, PipelineStep
41
 
42
 
43
  # ──────────────────────────────────────────────────────────────────────────
tests/measurements/test_sprint65_pipeline_comparison.py CHANGED
@@ -33,11 +33,11 @@ import pytest
33
 
34
  from picarones.core.corpus import Corpus, Document, GTLevel, TextGT
35
  from picarones.core.modules import ArtifactType, BaseModule
36
- from picarones.measurements.pipeline_comparison import (
37
  PipelineComparisonResult,
38
  compare_pipelines,
39
  )
40
- from picarones.core.pipeline import PipelineSpec, PipelineStep
41
 
42
 
43
  # ──────────────────────────────────────────────────────────────────────────
 
33
 
34
  from picarones.core.corpus import Corpus, Document, GTLevel, TextGT
35
  from picarones.core.modules import ArtifactType, BaseModule
36
+ from picarones.evaluation.pipeline_comparison import (
37
  PipelineComparisonResult,
38
  compare_pipelines,
39
  )
40
+ from picarones.evaluation.pipeline import PipelineSpec, PipelineStep
41
 
42
 
43
  # ──────────────────────────────────────────────────────────────────────────
tests/measurements/test_sprint85_numerical_sequences.py CHANGED
@@ -16,7 +16,7 @@ Couvre :
16
 
17
  from __future__ import annotations
18
 
19
- from picarones.measurements.numerical_sequences import (
20
  CATEGORIES,
21
  _detect_currencies,
22
  _detect_foliations,
 
16
 
17
  from __future__ import annotations
18
 
19
+ from picarones.evaluation.metrics.numerical_sequences import (
20
  CATEGORIES,
21
  _detect_currencies,
22
  _detect_foliations,
tests/report/test_sprint67_pipeline_html.py CHANGED
@@ -21,11 +21,11 @@ from __future__ import annotations
21
  import json
22
  from pathlib import Path
23
 
24
- from picarones.measurements.pipeline_benchmark import (
25
  PipelineBenchmarkResult,
26
  StepAggregate,
27
  )
28
- from picarones.report.pipeline_render import (
29
  build_pipeline_report_html,
30
  build_pipeline_steps_table_html,
31
  build_pipeline_summary_html,
 
21
  import json
22
  from pathlib import Path
23
 
24
+ from picarones.evaluation.pipeline_benchmark import (
25
  PipelineBenchmarkResult,
26
  StepAggregate,
27
  )
28
+ from picarones.reports_v2.html.renderers.pipeline import (
29
  build_pipeline_report_html,
30
  build_pipeline_steps_table_html,
31
  build_pipeline_summary_html,
tests/report/test_sprint68_pipeline_comparison_html.py CHANGED
@@ -32,12 +32,12 @@ import json
32
  from pathlib import Path
33
 
34
  from picarones.core.modules import ArtifactType
35
- from picarones.measurements.pipeline_benchmark import (
36
  PipelineBenchmarkResult,
37
  StepAggregate,
38
  )
39
- from picarones.measurements.pipeline_comparison import PipelineComparisonResult
40
- from picarones.report.pipeline_render import (
41
  RankingSpec,
42
  build_pipeline_comparison_report_html,
43
  build_pipeline_comparison_summary_html,
 
32
  from pathlib import Path
33
 
34
  from picarones.core.modules import ArtifactType
35
+ from picarones.evaluation.pipeline_benchmark import (
36
  PipelineBenchmarkResult,
37
  StepAggregate,
38
  )
39
+ from picarones.evaluation.pipeline_comparison import PipelineComparisonResult
40
+ from picarones.reports_v2.html.renderers.pipeline import (
41
  RankingSpec,
42
  build_pipeline_comparison_report_html,
43
  build_pipeline_comparison_summary_html,
tests/report/test_sprint86_aii5_html.py CHANGED
@@ -36,7 +36,7 @@ from picarones.measurements.searchability_hooks import (
36
  aggregate_searchability_metrics,
37
  compute_searchability_metrics,
38
  )
39
- from picarones.report.numerical_sequences_render import (
40
  build_numerical_sequences_html,
41
  )
42
  from picarones.reports_v2.html.renderers.searchability import (
 
36
  aggregate_searchability_metrics,
37
  compute_searchability_metrics,
38
  )
39
+ from picarones.reports_v2.html.renderers.numerical_sequences import (
40
  build_numerical_sequences_html,
41
  )
42
  from picarones.reports_v2.html.renderers.searchability import (