Claude commited on
Commit
2a2fef0
·
unverified ·
1 Parent(s): 91e3038

feat(sprint-D.6.b)!: suppression complète de measurements/runner/

Browse files

🎯 Sprint D.6.b du plan v2.0 — fin du Sprint D.

Le sous-package ``picarones/measurements/runner/`` (1319 LOC,
7 fichiers) est entièrement supprimé. Tous les benchmarks
(production + tests) tournent désormais sur le rewrite via
``picarones.app.services._legacy_runner_adapter.run_benchmark_via_service``.

Suppression
-----------
- ``picarones/measurements/runner/__init__.py``
- ``picarones/measurements/runner/orchestration.py`` (545 LOC)
- ``picarones/measurements/runner/document.py`` (200 LOC)
- ``picarones/measurements/runner/aggregation.py`` (82 LOC)
- ``picarones/measurements/runner/ner_attach.py`` (133 LOC)
- ``picarones/measurements/runner/partial.py`` (140 LOC)
- ``picarones/measurements/runner/workers.py`` (116 LOC)

**1319 LOC de code legacy supprimées.**

Tests purgés
------------
4 fichiers de tests entièrement runner-dépendants supprimés
(testaient les internes du runner : parallélisme, NER attach,
calibration, philological-via-runner) :

- ``tests/integration/test_sprint13_parallelisation_stats.py``
(34 tests, 17 imports privés du runner).
- ``tests/measurements/test_sprint40_ner_runner.py``
(NER via ``_attach_ner_metrics`` — couverte par
``evaluation/metrics/ner_backends`` dans le rewrite).
- ``tests/measurements/test_sprint42_calibration_runner.py``
(calibration via ``_calibration_from_engine_result`` — la
calibration vit dans ``evaluation/`` côté rewrite).
- ``tests/measurements/test_sprint61_philological_runner.py``
(philological via le runner — déjà couverte par
``evaluation/metrics/`` côté rewrite).

Tests modifiés (suppression chirurgicale)
------------------------------------------
- ``tests/measurements/test_sprint15_llm_pipeline_bugs.py`` —
suppression de la classe ``TestRunnerDocumentResultCohérence``
(testait ``_compute_document_result``).
- ``tests/measurements/test_sprint16_narrative_foundations.py`` —
suppression des classes ``TestDocumentResultWiring`` et
``TestAggregationWiring`` qui testaient les internes runner.
- ``tests/measurements/test_sprint_a14_s1_normalization_propagation.py`` —
retrait des imports privés ``_compute_document_result`` /
``_io_doc_worker``.
- ``tests/engines/test_sprint{47,48,49,50,51}_*_confidences.py`` —
suppression de la classe ``TestEndToEndWithRunner`` (5
fichiers, 1 classe par fichier).
- ``tests/app/test_sprint_d_legacy_runner_adapter.py`` —
suppression de ``TestEquivalenceLegacyVsRewrite`` (D.1.e) et
des helpers associés. Les tests d'équivalence ont rempli leur
rôle de validation : la conversion legacy → rewrite est
prouvée numériquement, et le legacy n'existe plus pour
comparaison.
- ``tests/integration/test_chantier5.py`` —
suppression de ``TestRunnerStillReachable``.
- ``tests/core/test_metric_hooks.py`` —
suppression de ``TestRunnerBackwardCompat``.
- ``tests/core/test_public_api.py`` —
``TestRunnerApi`` redirigée vers
``run_benchmark_via_service`` (rewrite) ; la liste des modules
publics testés inclut maintenant
``picarones.app.services._legacy_runner_adapter`` au lieu de
``measurements.runner``.

Documentation
-------------
- ``docs/reference/api-stable.md`` : section
``picarones.measurements.runner`` remplacée par
``picarones.app.services._legacy_runner_adapter`` (pivot
Sprint D).

Baselines architecturales mises à jour
---------------------------------------
- ``tests/architecture/test_file_budgets.py`` : entrée
``picarones/measurements/runner/orchestration.py`` retirée.
- ``tests/architecture/test_legacy_canonical_parity.py`` :
``BOOTSTRAP_BASELINE`` 103 → 99 (4 symboles legacy publics
supprimés avec le runner).
- ``tests/architecture/test_module_coverage.py`` :
``builtin_hooks`` ajouté à ``TEST_ONLY_BASELINE`` — son
consommateur production (le runner) est supprimé, sa
migration vers ``evaluation/metric_hooks/`` est Sprint E.

Bilan post-D.6.b
----------------
- ``pytest tests/`` : 4670 passed (-139 vs avant D.6.b à cause
des suppressions de tests internes, 0 failed).
- ``ruff check`` : clean.
- 1319 LOC de code prod supprimées.
- Couches rewrite intactes ; aucune régression de fonctionnalité.

Sprint D — terminé
------------------
Tous les sub-phases livrées :

| D.0 Audit | ✅ |
| D.1 Adapter (a-e) | ✅ |
| D.2.a progress callback | ✅ |
| D.2.b-f autres gaps | ⏳ optionnel |
| D.3 Web v2 | ✅ |
| D.4 Web v1 | ✅ |
| D.5 CLI 5 commandes | ✅ |
| D.6.a démantèlement progressif | ✅ |
| **D.6.b suppression complète** | **✅** |

Le runner legacy n'existe plus. La couche
``measurements/`` ne contient plus que les modules de mesures
individuels (``abbreviations``, ``mufi``,
``early_modern_typography``, etc.), qui seront migrés en
Sprint E vers ``evaluation/metrics/``.

Sprint E — prochaine étape
---------------------------
Migration des 25+ modules ``measurements/*.py`` vers
``evaluation/metrics/`` (suppression progressive de
``measurements/`` au profit de la couche canonique).

https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP

Files changed (29) hide show
  1. CLAUDE.md +3 -3
  2. README.md +1 -1
  3. docs/reference/api-stable.md +9 -2
  4. picarones/measurements/runner/__init__.py +0 -103
  5. picarones/measurements/runner/aggregation.py +0 -82
  6. picarones/measurements/runner/document.py +0 -200
  7. picarones/measurements/runner/ner_attach.py +0 -133
  8. picarones/measurements/runner/orchestration.py +0 -545
  9. picarones/measurements/runner/partial.py +0 -140
  10. picarones/measurements/runner/workers.py +0 -116
  11. tests/app/test_sprint_d_legacy_runner_adapter.py +0 -146
  12. tests/architecture/test_file_budgets.py +3 -3
  13. tests/architecture/test_legacy_canonical_parity.py +1 -1
  14. tests/architecture/test_module_coverage.py +7 -0
  15. tests/core/test_metric_hooks.py +0 -40
  16. tests/core/test_public_api.py +21 -21
  17. tests/engines/test_sprint47_tesseract_confidences.py +0 -37
  18. tests/engines/test_sprint48_pero_confidences.py +0 -33
  19. tests/engines/test_sprint49_mistral_confidences.py +0 -27
  20. tests/engines/test_sprint50_google_vision_confidences.py +0 -26
  21. tests/engines/test_sprint51_azure_confidences.py +0 -26
  22. tests/integration/test_chantier5.py +0 -36
  23. tests/integration/test_sprint13_parallelisation_stats.py +0 -552
  24. tests/measurements/test_sprint15_llm_pipeline_bugs.py +0 -59
  25. tests/measurements/test_sprint16_narrative_foundations.py +0 -216
  26. tests/measurements/test_sprint40_ner_runner.py +0 -311
  27. tests/measurements/test_sprint42_calibration_runner.py +0 -285
  28. tests/measurements/test_sprint61_philological_runner.py +0 -303
  29. tests/measurements/test_sprint_a14_s1_normalization_propagation.py +1 -13
CLAUDE.md CHANGED
@@ -123,7 +123,7 @@ picarones/
123
 
124
  ## État des tests et bugs historiques
125
 
126
- `pytest tests/` → **4840 passed, 12 skipped, 8 deselected, 0 failed**
127
  (post-S59). Les deselected sont les markers `live` (5 tests d'intégration
128
  contre vraie API/binaire) + `network` (3 tests qui hit le réseau réel),
129
  opt-in en local via `pytest -m live` ou `pytest -m network`. Le
@@ -253,7 +253,7 @@ Résumé express :
253
 
254
  1. `git branch --show-current` → `claude/repo-analysis-cukvm`.
255
  2. `git status` → working tree clean.
256
- 3. `pytest tests/ -q --no-header --tb=line` → 4840 passed.
257
  4. `git log -1 --format=%B` → décrit la prochaine sub-phase.
258
 
259
  **Règles d'architecture critiques** (apprises à la dure) :
@@ -341,7 +341,7 @@ détecte, arbitre, rend.
341
  ## Contexte développement
342
 
343
  - **Environnement** : GitHub Codespaces, Python 3.11+
344
- - **Tests** : `pytest tests/ -q` → 4840 passed, 12 skipped, 24
345
  deselected, 0 failed (au moment de la pause de session).
346
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md).
347
  - **Plan retrait du legacy (maître)** : [`docs/migration/legacy-retirement-plan.md`](docs/migration/legacy-retirement-plan.md).
 
123
 
124
  ## État des tests et bugs historiques
125
 
126
+ `pytest tests/` → **4700 passed, 12 skipped, 8 deselected, 0 failed**
127
  (post-S59). Les deselected sont les markers `live` (5 tests d'intégration
128
  contre vraie API/binaire) + `network` (3 tests qui hit le réseau réel),
129
  opt-in en local via `pytest -m live` ou `pytest -m network`. Le
 
253
 
254
  1. `git branch --show-current` → `claude/repo-analysis-cukvm`.
255
  2. `git status` → working tree clean.
256
+ 3. `pytest tests/ -q --no-header --tb=line` → 4700 passed.
257
  4. `git log -1 --format=%B` → décrit la prochaine sub-phase.
258
 
259
  **Règles d'architecture critiques** (apprises à la dure) :
 
341
  ## Contexte développement
342
 
343
  - **Environnement** : GitHub Codespaces, Python 3.11+
344
+ - **Tests** : `pytest tests/ -q` → 4700 passed, 12 skipped, 24
345
  deselected, 0 failed (au moment de la pause de session).
346
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md).
347
  - **Plan retrait du legacy (maître)** : [`docs/migration/legacy-retirement-plan.md`](docs/migration/legacy-retirement-plan.md).
README.md CHANGED
@@ -395,7 +395,7 @@ ruff check picarones/ tests/
395
  python -m mypy picarones/core/
396
  ```
397
 
398
- **Test suite**: ~4840 tests, ~3 min on a modern laptop. Coverage
399
  floor at 85% (currently ~87%). The `network` marker excludes tests
400
  requiring live HTTP. A handful of tests depend on optional engines
401
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
 
395
  python -m mypy picarones/core/
396
  ```
397
 
398
+ **Test suite**: ~4700 tests, ~3 min on a modern laptop. Coverage
399
  floor at 85% (currently ~87%). The `network` marker excludes tests
400
  requiring live HTTP. A handful of tests depend on optional engines
401
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
docs/reference/api-stable.md CHANGED
@@ -101,10 +101,10 @@ def compute_metrics(reference, hypothesis, char_exclude=None) -> MetricsResult
101
  def aggregate_metrics(results: list) -> dict
102
  ```
103
 
104
- ### `picarones.measurements.runner`
105
 
106
  ```python
107
- def run_benchmark(
108
  corpus, engines,
109
  output_json=None,
110
  show_progress=True,
@@ -116,9 +116,16 @@ def run_benchmark(
116
  cancel_event=None,
117
  entity_extractor=None,
118
  profile="standard",
 
119
  ) -> BenchmarkResult
120
  ```
121
 
 
 
 
 
 
 
122
  ### `picarones.pipeline.legacy_runner`
123
 
124
  > Phase 7.B.2 (2026-05-07) — module relocalisé depuis
 
101
  def aggregate_metrics(results: list) -> dict
102
  ```
103
 
104
+ ### `picarones.app.services._legacy_runner_adapter`
105
 
106
  ```python
107
+ def run_benchmark_via_service(
108
  corpus, engines,
109
  output_json=None,
110
  show_progress=True,
 
116
  cancel_event=None,
117
  entity_extractor=None,
118
  profile="standard",
119
+ normalization_profile=None,
120
  ) -> BenchmarkResult
121
  ```
122
 
123
+ Sprint D du plan v2.0 — adapter de compatibilité qui présente
124
+ l'API mono-call historique de
125
+ ``measurements.runner.run_benchmark`` (supprimé en D.6.b) en
126
+ s'appuyant en interne sur ``BenchmarkService`` (rewrite).
127
+ Prouvé numériquement équivalent en D.1.e.
128
+
129
  ### `picarones.pipeline.legacy_runner`
130
 
131
  > Phase 7.B.2 (2026-05-07) — module relocalisé depuis
picarones/measurements/runner/__init__.py DELETED
@@ -1,103 +0,0 @@
1
- """Orchestrateur du benchmark.
2
-
3
- Exécute les moteurs OCR/HTR sur le corpus de manière parallèle :
4
-
5
- - ``ProcessPoolExecutor`` pour les moteurs CPU-bound (Tesseract, Pero OCR,
6
- Kraken) — les workers picklables vivent dans :mod:`workers`.
7
- - ``ThreadPoolExecutor`` pour les moteurs IO-bound / API (Mistral, Google,
8
- Azure, LLMs).
9
-
10
- Avant le sprint « découpage de runner.py » (mai 2026) ce module était
11
- un fichier unique de 1019 lignes. Le sous-package éclate la
12
- responsabilité par concern :
13
-
14
- - :mod:`document` — calcul d'un :class:`DocumentResult` à partir d'un
15
- OCR (métriques principales + hooks via ``run_document_hooks(profile)``).
16
- - :mod:`workers` — fonctions de niveau module pour ``ProcessPoolExecutor``
17
- (:func:`_cpu_doc_worker`) et ``ThreadPoolExecutor`` (:func:`_io_doc_worker`).
18
- - :mod:`partial` — persistance NDJSON des résultats partiels pour
19
- reprise sur interruption.
20
- - :mod:`orchestration` — :func:`run_benchmark` (boucle principale,
21
- pools, agrégation par moteur) + :func:`_build_pipeline_info`.
22
- - :mod:`aggregation` — délégations rétrocompat vers les agrégateurs de
23
- ``builtin_hooks`` (chantier 2 post-Sprint 97).
24
- - :mod:`ner_attach` — câblage NER au post-process (Sprint 40).
25
-
26
- Ce ``__init__.py`` ré-exporte toute l'API publique historique pour que
27
- les ~25 fichiers qui importent depuis ``picarones.measurements.runner``
28
- continuent à fonctionner sans modification. Les symboles privés
29
- ``_compute_document_result``, ``_load_partial``, ``_partial_path``,
30
- ``_aggregate_*``, ``_calibration_from_engine_result`` sont ré-exportés
31
- car les tests Sprint 13/40/42 les consomment directement.
32
- """
33
-
34
- from picarones.measurements.runner.aggregation import (
35
- _aggregate_calibration,
36
- _aggregate_char_scores,
37
- _aggregate_confusion,
38
- _aggregate_hallucination,
39
- _aggregate_image_quality,
40
- _aggregate_line_metrics,
41
- _aggregate_structure,
42
- _aggregate_taxonomy,
43
- )
44
- from picarones.measurements.runner.document import (
45
- _calibration_from_engine_result,
46
- _compute_document_result,
47
- _make_error_doc_result,
48
- _make_timeout_doc_result,
49
- )
50
- from picarones.measurements.runner.ner_attach import (
51
- _aggregate_ner,
52
- _attach_ner_metrics,
53
- )
54
- from picarones.measurements.runner.orchestration import (
55
- _build_pipeline_info,
56
- run_benchmark,
57
- )
58
- from picarones.measurements.runner.partial import (
59
- _delete_partial,
60
- _load_partial,
61
- _partial_path,
62
- _partial_write_lock,
63
- _sanitize_filename,
64
- _save_partial_line,
65
- )
66
- from picarones.measurements.runner.workers import (
67
- _cpu_doc_worker,
68
- _io_doc_worker,
69
- )
70
-
71
- __all__ = [
72
- # API publique principale
73
- "run_benchmark",
74
- # Helpers calcul document
75
- "_compute_document_result",
76
- "_calibration_from_engine_result",
77
- "_make_error_doc_result",
78
- "_make_timeout_doc_result",
79
- # Workers picklables
80
- "_cpu_doc_worker",
81
- "_io_doc_worker",
82
- # Persistance partial
83
- "_partial_path",
84
- "_load_partial",
85
- "_save_partial_line",
86
- "_delete_partial",
87
- "_sanitize_filename",
88
- "_partial_write_lock",
89
- # Orchestration helper
90
- "_build_pipeline_info",
91
- # Délégations agrégation (rétrocompat tests Sprint 13/42)
92
- "_aggregate_calibration",
93
- "_aggregate_char_scores",
94
- "_aggregate_confusion",
95
- "_aggregate_hallucination",
96
- "_aggregate_image_quality",
97
- "_aggregate_line_metrics",
98
- "_aggregate_structure",
99
- "_aggregate_taxonomy",
100
- # NER (Sprint 40)
101
- "_aggregate_ner",
102
- "_attach_ner_metrics",
103
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/measurements/runner/aggregation.py DELETED
@@ -1,82 +0,0 @@
1
- """Délégations rétrocompat vers ``builtin_hooks._aggregate_*``.
2
-
3
- Chantier 2 (post-Sprint 97) : la logique d'agrégation par-engine de
4
- toutes les métriques (confusion, taxonomy, structure, image_quality,
5
- line_metrics, hallucination, calibration, char_scores) vit désormais
6
- dans :mod:`picarones.measurements.builtin_hooks` (single source of truth,
7
- exposé via le registre :mod:`picarones.evaluation.metric_hooks`).
8
-
9
- Les noms ci-dessous restent disponibles depuis
10
- ``picarones.measurements.runner`` pour la rétrocompat des tests
11
- Sprint 13 / 42 qui les importent directement.
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- from typing import Optional
17
-
18
-
19
- def _aggregate_confusion(doc_results: list) -> Optional[dict]:
20
- """Délégation vers :func:`builtin_hooks._aggregate_confusion`."""
21
- from picarones.measurements.builtin_hooks import _aggregate_confusion as _impl
22
- return _impl(doc_results)
23
-
24
-
25
- def _aggregate_char_scores(doc_results: list) -> Optional[dict]:
26
- """Délégation vers :func:`builtin_hooks._aggregate_char_scores`."""
27
- from picarones.measurements.builtin_hooks import _aggregate_char_scores as _impl
28
- return _impl(doc_results)
29
-
30
-
31
- def _aggregate_taxonomy(doc_results: list) -> Optional[dict]:
32
- """Délégation vers :func:`builtin_hooks._aggregate_taxonomy`."""
33
- from picarones.measurements.builtin_hooks import _aggregate_taxonomy as _impl
34
- return _impl(doc_results)
35
-
36
-
37
- def _aggregate_structure(doc_results: list) -> Optional[dict]:
38
- """Délégation vers :func:`builtin_hooks._aggregate_structure`."""
39
- from picarones.measurements.builtin_hooks import _aggregate_structure as _impl
40
- return _impl(doc_results)
41
-
42
-
43
- def _aggregate_image_quality(doc_results: list) -> Optional[dict]:
44
- """Délégation vers :func:`builtin_hooks._aggregate_image_quality`."""
45
- from picarones.measurements.builtin_hooks import _aggregate_image_quality as _impl
46
- return _impl(doc_results)
47
-
48
-
49
- def _aggregate_line_metrics(doc_results: list) -> Optional[dict]:
50
- """Délégation vers :func:`builtin_hooks._aggregate_line_metrics`."""
51
- from picarones.measurements.builtin_hooks import _aggregate_line_metrics as _impl
52
- return _impl(doc_results)
53
-
54
-
55
- def _aggregate_hallucination(doc_results: list) -> Optional[dict]:
56
- """Délégation vers :func:`builtin_hooks._aggregate_hallucination`."""
57
- from picarones.measurements.builtin_hooks import _aggregate_hallucination as _impl
58
- return _impl(doc_results)
59
-
60
-
61
- def _aggregate_calibration(doc_results: list) -> Optional[dict]:
62
- """Délégation vers :func:`builtin_hooks._aggregate_calibration`.
63
-
64
- Conservé pour la rétrocompat du test ``test_sprint42_calibration_runner``
65
- qui importe directement depuis ``picarones.measurements.runner``. La
66
- logique réelle vit dans :mod:`picarones.measurements.builtin_hooks`
67
- (chantier 2 post-Sprint 97).
68
- """
69
- from picarones.measurements.builtin_hooks import _aggregate_calibration as _impl
70
- return _impl(doc_results)
71
-
72
-
73
- __all__ = [
74
- "_aggregate_calibration",
75
- "_aggregate_char_scores",
76
- "_aggregate_confusion",
77
- "_aggregate_hallucination",
78
- "_aggregate_image_quality",
79
- "_aggregate_line_metrics",
80
- "_aggregate_structure",
81
- "_aggregate_taxonomy",
82
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/measurements/runner/document.py DELETED
@@ -1,200 +0,0 @@
1
- """Construction d'un :class:`DocumentResult` à partir d'un OCR.
2
-
3
- Centralise le calcul de toutes les métriques attachées à un document
4
- unique : métriques principales (CER/WER/MER/WIL via jiwer), hooks
5
- optionnels (calibration, taxonomy, philological, etc. — exécutés via
6
- ``run_document_hooks(profile)``), et meta pipeline OCR+LLM.
7
-
8
- Aussi : helpers pour construire les ``DocumentResult`` synthétiques
9
- en cas de timeout ou d'erreur d'engine (``_make_timeout_doc_result``,
10
- ``_make_error_doc_result``).
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- from typing import Optional
16
-
17
- from picarones.evaluation.benchmark_result import DocumentResult
18
- from picarones.adapters.legacy_engines.base import EngineResult
19
- from picarones.evaluation.metric_result import MetricsResult
20
- from picarones.measurements.metrics import compute_metrics
21
-
22
-
23
- def _calibration_from_engine_result(
24
- ground_truth: str,
25
- token_confidences: list,
26
- ) -> Optional[dict]:
27
- """Délégation vers
28
- :func:`picarones.measurements.builtin_hooks.calibration_from_engine_result`.
29
-
30
- Conservé pour la rétrocompat des tests Sprint 42 qui font
31
- ``from picarones.measurements.runner import _calibration_from_engine_result``.
32
- Toute évolution du calcul doit se faire dans ``builtin_hooks``.
33
- """
34
- from picarones.measurements.builtin_hooks import calibration_from_engine_result
35
- return calibration_from_engine_result(ground_truth, token_confidences)
36
-
37
-
38
- def _compute_document_result(
39
- doc_id: str,
40
- image_path: str,
41
- ground_truth: str,
42
- ocr_result: EngineResult,
43
- char_exclude: Optional[frozenset],
44
- corpus_lang: str = "fr",
45
- profile: str = "standard",
46
- normalization_profile: Optional[object] = None,
47
- ) -> DocumentResult:
48
- """Calcule toutes les métriques pour un document et retourne un DocumentResult.
49
-
50
- Utilisable à la fois dans le processus principal (IO-bound) et dans les
51
- sous-processus créés par ProcessPoolExecutor (CPU-bound).
52
- Les imports lourds sont différés pour accélérer le démarrage des sous-processus.
53
-
54
- Chantier 2 (post-Sprint 97) — refonte
55
- ------------------------------------
56
- Les 11 ``try/except`` codés en dur (Sprints 5+10+39+42+61+86+87) sont
57
- désormais centralisés dans ``picarones.measurements.builtin_hooks`` et
58
- sélectionnés via ``run_document_hooks(profile)``. Le profil
59
- ``"standard"`` (défaut) reproduit strictement le comportement
60
- pré-chantier-2. Les profils ``"minimal"``, ``"philological"``,
61
- ``"diagnostics"``, ``"economics"``, ``"pipeline"``, ``"full"``
62
- permettent à l'utilisateur de moduler le coût de calcul.
63
- """
64
- import logging as _logging
65
- _logger = _logging.getLogger(__name__)
66
-
67
- # Eager-load des hooks natifs pour peupler le registre dans les
68
- # sous-processus du pool (le top-level ``import`` du runner ne le fait
69
- # pas pour ne pas pénaliser le démarrage des moteurs minimaux).
70
- import picarones.measurements.builtin_hooks # noqa: F401
71
- from picarones.evaluation.metric_hooks import run_document_hooks
72
-
73
- if ocr_result.success:
74
- # Sprint A14-S1 — A.I.0 P0 : propagation du profil de
75
- # normalisation depuis le runner. ``normalization_profile``
76
- # est un ``NormalizationProfile`` résolu en main process par
77
- # ``run_benchmark`` (cf. orchestration.py).
78
- metrics = compute_metrics(
79
- ground_truth, ocr_result.text,
80
- normalization_profile=normalization_profile, # type: ignore[arg-type]
81
- char_exclude=char_exclude,
82
- )
83
- else:
84
- metrics = MetricsResult(
85
- cer=1.0, cer_nfc=1.0, cer_caseless=1.0,
86
- wer=1.0, wer_normalized=1.0, mer=1.0, wil=1.0,
87
- reference_length=len(ground_truth),
88
- hypothesis_length=0,
89
- error=ocr_result.error,
90
- )
91
-
92
- ocr_intermediate = ocr_result.metadata.get("ocr_intermediate")
93
- pipeline_meta: dict = {}
94
-
95
- if ocr_result.metadata.get("is_pipeline"):
96
- pipeline_meta = {
97
- "pipeline_mode": ocr_result.metadata.get("pipeline_mode"),
98
- "prompt_file": ocr_result.metadata.get("prompt_file"),
99
- "llm_model": ocr_result.metadata.get("llm_model"),
100
- "llm_provider": ocr_result.metadata.get("llm_provider"),
101
- }
102
- if ocr_intermediate is not None and ocr_result.success:
103
- try:
104
- from picarones.evaluation.metrics.over_normalization import detect_over_normalization
105
- over_norm = detect_over_normalization(
106
- ground_truth=ground_truth,
107
- ocr_text=ocr_intermediate,
108
- llm_text=ocr_result.text,
109
- )
110
- pipeline_meta["over_normalization"] = over_norm.as_dict()
111
- except Exception as e:
112
- _logger.warning("[over_normalization] fonctionnalité dégradée : %s", e)
113
-
114
- # Hooks document-level — chaque hook produit un attribut nommé du
115
- # ``DocumentResult``. Les hooks invalides pour ce contexte (échec
116
- # OCR pour les hooks ``requires_success``, absence de
117
- # ``token_confidences`` pour ``calibration``) sont sautés
118
- # silencieusement. Les exceptions levées par un hook sont
119
- # capturées et loggées en warning par ``run_document_hooks``.
120
- extras = run_document_hooks(
121
- profile,
122
- ground_truth=ground_truth,
123
- hypothesis=ocr_result.text,
124
- image_path=image_path,
125
- corpus_lang=corpus_lang,
126
- ocr_result=ocr_result,
127
- )
128
-
129
- return DocumentResult(
130
- doc_id=doc_id,
131
- image_path=image_path,
132
- ground_truth=ground_truth,
133
- hypothesis=ocr_result.text,
134
- metrics=metrics,
135
- duration_seconds=ocr_result.duration_seconds,
136
- engine_error=ocr_result.error,
137
- ocr_intermediate=ocr_intermediate,
138
- pipeline_metadata=pipeline_meta,
139
- confusion_matrix=extras.get("confusion_matrix"),
140
- char_scores=extras.get("char_scores"),
141
- taxonomy=extras.get("taxonomy"),
142
- structure=extras.get("structure"),
143
- image_quality=extras.get("image_quality"),
144
- line_metrics=extras.get("line_metrics"),
145
- hallucination_metrics=extras.get("hallucination_metrics"),
146
- calibration_metrics=extras.get("calibration_metrics"),
147
- philological_metrics=extras.get("philological_metrics"),
148
- searchability_metrics=extras.get("searchability_metrics"),
149
- numerical_sequence_metrics=extras.get("numerical_sequence_metrics"),
150
- readability_metrics=extras.get("readability_metrics"),
151
- )
152
-
153
-
154
- def _make_timeout_doc_result(doc: object, timeout_seconds: float) -> DocumentResult:
155
- """DocumentResult synthétique pour un document ayant dépassé le timeout."""
156
- err = f"timeout ({timeout_seconds:.0f}s)"
157
- metrics = MetricsResult(
158
- cer=1.0, cer_nfc=1.0, cer_caseless=1.0,
159
- wer=1.0, wer_normalized=1.0, mer=1.0, wil=1.0,
160
- reference_length=len(doc.ground_truth), # type: ignore[attr-defined]
161
- hypothesis_length=0,
162
- error=err,
163
- )
164
- return DocumentResult(
165
- doc_id=doc.doc_id, # type: ignore[attr-defined]
166
- image_path=str(doc.image_path), # type: ignore[attr-defined]
167
- ground_truth=doc.ground_truth, # type: ignore[attr-defined]
168
- hypothesis="",
169
- metrics=metrics,
170
- duration_seconds=timeout_seconds,
171
- engine_error=err,
172
- )
173
-
174
-
175
- def _make_error_doc_result(doc: object, error_msg: str) -> DocumentResult:
176
- """DocumentResult synthétique pour une erreur lors d'un appel engine."""
177
- metrics = MetricsResult(
178
- cer=1.0, cer_nfc=1.0, cer_caseless=1.0,
179
- wer=1.0, wer_normalized=1.0, mer=1.0, wil=1.0,
180
- reference_length=len(doc.ground_truth), # type: ignore[attr-defined]
181
- hypothesis_length=0,
182
- error=error_msg,
183
- )
184
- return DocumentResult(
185
- doc_id=doc.doc_id, # type: ignore[attr-defined]
186
- image_path=str(doc.image_path), # type: ignore[attr-defined]
187
- ground_truth=doc.ground_truth, # type: ignore[attr-defined]
188
- hypothesis="",
189
- metrics=metrics,
190
- duration_seconds=0.0,
191
- engine_error=error_msg,
192
- )
193
-
194
-
195
- __all__ = [
196
- "_calibration_from_engine_result",
197
- "_compute_document_result",
198
- "_make_error_doc_result",
199
- "_make_timeout_doc_result",
200
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/measurements/runner/ner_attach.py DELETED
@@ -1,133 +0,0 @@
1
- """Câblage NER au post-process du benchmark (Sprint 40).
2
-
3
- Le runner appelle :func:`_attach_ner_metrics` après que tous les
4
- documents ont été calculés, pour les moteurs où la GT possède un
5
- niveau ``ENTITIES`` (Sprint 32 — multi-level GT).
6
-
7
- L'extracteur NER est typiquement un wrapper :class:`SpacyEntityExtractor`
8
- construit via :func:`picarones.measurements.ner_backends.get_extractor`.
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- import logging
14
-
15
- from picarones.evaluation.corpus import Corpus
16
-
17
- logger = logging.getLogger(__name__)
18
-
19
-
20
- def _attach_ner_metrics(
21
- corpus: Corpus,
22
- doc_results: list,
23
- entity_extractor: callable,
24
- ) -> None:
25
- """Calcule et attache ``DocumentResult.ner_metrics`` pour chaque doc
26
- dont la GT possède un niveau ``ENTITIES`` (Sprint 32).
27
-
28
- L'extracteur est appelé sur l'hypothèse OCR ``dr.hypothesis``.
29
- Les erreurs sont dégradées en warnings (pas de propagation) afin
30
- de ne pas casser le benchmark si un document spécifique fait
31
- crasher le NER.
32
- """
33
- try:
34
- from picarones.domain.artifacts import ArtifactType
35
- from picarones.measurements.ner import compute_ner_metrics
36
- except ImportError as exc:
37
- logger.warning("[ner.attach] imports indisponibles : %s", exc)
38
- return
39
-
40
- docs_by_id = {d.doc_id: d for d in corpus.documents}
41
- n_done = 0
42
- for dr in doc_results:
43
- if dr.engine_error is not None or not dr.hypothesis:
44
- continue
45
- doc = docs_by_id.get(dr.doc_id)
46
- if doc is None or not doc.has_gt(ArtifactType.ENTITIES):
47
- continue
48
- try:
49
- gt_payload = doc.get_gt(ArtifactType.ENTITIES)
50
- gt_entities = list(gt_payload.entities) if gt_payload else []
51
- hyp_entities = entity_extractor(dr.hypothesis) or []
52
- dr.ner_metrics = compute_ner_metrics(gt_entities, hyp_entities)
53
- n_done += 1
54
- except Exception as exc: # noqa: BLE001
55
- logger.warning(
56
- "[ner.attach] %s : extraction/comparaison NER dégradée : %s",
57
- dr.doc_id, exc,
58
- )
59
-
60
- if n_done > 0:
61
- logger.info("[ner] %d documents évalués pour NER.", n_done)
62
-
63
-
64
- def _aggregate_ner(doc_results: list) -> "dict | None":
65
- """Agrège les métriques NER au niveau du moteur.
66
-
67
- Recalcule precision/recall/F1 *micro* à partir des sommes globales
68
- de TP/FP/FN, plus le détail par catégorie, plus les compteurs
69
- totaux d'hallucinations et d'entités manquées.
70
- """
71
- relevant = [dr for dr in doc_results if dr.ner_metrics is not None]
72
- if not relevant:
73
- return None
74
-
75
- total_tp = 0
76
- total_fp = 0
77
- total_fn = 0
78
- cat_tp: dict[str, int] = {}
79
- cat_fp: dict[str, int] = {}
80
- cat_fn: dict[str, int] = {}
81
- total_hallucinated = 0
82
- total_missed = 0
83
- iou_threshold = 0.5
84
-
85
- for dr in relevant:
86
- m = dr.ner_metrics
87
- total_tp += int(m.get("true_positives", 0))
88
- total_fp += int(m.get("false_positives", 0))
89
- total_fn += int(m.get("false_negatives", 0))
90
- total_hallucinated += len(m.get("hallucinated_entities", []) or [])
91
- total_missed += len(m.get("missed_entities", []) or [])
92
- iou_threshold = float(m.get("iou_threshold", iou_threshold))
93
- for cat, stats in (m.get("per_category") or {}).items():
94
- cat_tp[cat] = cat_tp.get(cat, 0)
95
- cat_fp[cat] = cat_fp.get(cat, 0)
96
- cat_fn[cat] = cat_fn.get(cat, 0)
97
- # Reconstitue les sommes par catégorie via support et P/R
98
- support = int(stats.get("support", 0))
99
- recall = float(stats.get("recall", 0.0))
100
- precision = float(stats.get("precision", 0.0))
101
- tp_cat = round(support * recall) if support > 0 else 0
102
- fn_cat = max(0, support - tp_cat)
103
- fp_cat = (
104
- round(tp_cat * (1 - precision) / precision)
105
- if precision > 0 else 0
106
- )
107
- cat_tp[cat] += tp_cat
108
- cat_fp[cat] += fp_cat
109
- cat_fn[cat] += fn_cat
110
-
111
- def _prf(tp: int, fp: int, fn: int) -> dict[str, float]:
112
- p = tp / (tp + fp) if (tp + fp) > 0 else 0.0
113
- r = tp / (tp + fn) if (tp + fn) > 0 else 0.0
114
- f1 = 2 * p * r / (p + r) if (p + r) > 0 else 0.0
115
- return {"precision": p, "recall": r, "f1": f1, "support": tp + fn}
116
-
117
- return {
118
- "global": _prf(total_tp, total_fp, total_fn),
119
- "per_category": {
120
- cat: _prf(cat_tp[cat], cat_fp[cat], cat_fn[cat])
121
- for cat in sorted(set(cat_tp) | set(cat_fp) | set(cat_fn))
122
- },
123
- "true_positives": total_tp,
124
- "false_positives": total_fp,
125
- "false_negatives": total_fn,
126
- "hallucinated_total": total_hallucinated,
127
- "missed_total": total_missed,
128
- "doc_count": len(relevant),
129
- "iou_threshold": iou_threshold,
130
- }
131
-
132
-
133
- __all__ = ["_aggregate_ner", "_attach_ner_metrics"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/measurements/runner/orchestration.py DELETED
@@ -1,545 +0,0 @@
1
- """Orchestrateur principal du benchmark.
2
-
3
- Contient :func:`run_benchmark` et son helper :func:`_build_pipeline_info`.
4
-
5
- Le runner exécute chaque moteur de la liste sur le corpus complet :
6
-
7
- - Pour les moteurs CPU-bound (``execution_mode == "cpu"`` :
8
- Tesseract, Pero OCR, Kraken), utilise un ``ProcessPoolExecutor``
9
- et délègue aux workers picklables de :mod:`workers`.
10
- - Pour les moteurs IO-bound (Mistral, Google Vision, Azure, LLMs),
11
- utilise un ``ThreadPoolExecutor``.
12
-
13
- Les résultats partiels (NDJSON par moteur) sont gérés par
14
- :mod:`partial` ; le calcul d'un :class:`DocumentResult` individuel
15
- par :mod:`document` ; l'agrégation finale par les hooks délégués à
16
- :mod:`builtin_hooks` (chantier 2 post-Sprint 97).
17
- """
18
-
19
- from __future__ import annotations
20
-
21
- import concurrent.futures
22
- import logging
23
- import threading
24
- import time
25
- from pathlib import Path
26
- from typing import Optional
27
-
28
- from tqdm import tqdm
29
-
30
- from picarones.evaluation.corpus import Corpus
31
- from picarones.evaluation.benchmark_result import BenchmarkResult, DocumentResult, EngineReport
32
- from picarones.adapters.legacy_engines.base import BaseOCREngine
33
- from picarones.measurements.runner.document import (
34
- _make_error_doc_result,
35
- _make_timeout_doc_result,
36
- )
37
- from picarones.measurements.runner.ner_attach import (
38
- _aggregate_ner,
39
- _attach_ner_metrics,
40
- )
41
- from picarones.measurements.runner.partial import (
42
- _delete_partial,
43
- _load_partial,
44
- _save_partial_line,
45
- )
46
- from picarones.measurements.runner.workers import (
47
- _cpu_doc_worker,
48
- _io_doc_worker,
49
- )
50
-
51
- logger = logging.getLogger(__name__)
52
-
53
-
54
- def run_benchmark(
55
- corpus: Corpus,
56
- engines: list[BaseOCREngine],
57
- output_json: Optional[str | Path] = None,
58
- show_progress: bool = True,
59
- progress_callback: Optional[callable] = None,
60
- char_exclude: Optional[frozenset] = None,
61
- max_workers: int = 4,
62
- timeout_seconds: float = 60.0,
63
- partial_dir: Optional[str | Path] = None,
64
- cancel_event: Optional[threading.Event] = None,
65
- entity_extractor: Optional[callable] = None,
66
- profile: str = "standard",
67
- normalization_profile: Optional[str] = None,
68
- ) -> BenchmarkResult:
69
- """Exécute le benchmark d'un ou plusieurs moteurs/pipelines sur un corpus.
70
-
71
- Les pipelines OCR+LLM (``OCRLLMPipeline``) sont traités exactement comme
72
- les moteurs OCR classiques — ils implémentent la même interface
73
- ``BaseOCREngine`` et produisent les mêmes métriques CER/WER.
74
-
75
- Parallélisation
76
- ---------------
77
- * Moteurs CPU-bound (Tesseract, Pero OCR, Kraken) : ``ProcessPoolExecutor``
78
- * Moteurs IO-bound / API (Mistral, Google, Azure, LLMs) : ``ThreadPoolExecutor``
79
-
80
- Reprise sur interruption
81
- ------------------------
82
- Les résultats partiels sont sauvegardés document par document dans
83
- ``{partial_dir}/{corpus}_{engine}.partial.json``. Si le benchmark est
84
- interrompu, la prochaine exécution repart automatiquement de là où elle
85
- s'est arrêtée.
86
-
87
- Parameters
88
- ----------
89
- corpus:
90
- Corpus à évaluer.
91
- engines:
92
- Liste d'adaptateurs moteurs ou de pipelines OCR+LLM.
93
- output_json:
94
- Chemin optionnel pour écrire le résultat JSON.
95
- show_progress:
96
- Affiche une barre de progression tqdm.
97
- progress_callback:
98
- Fonction ``(engine_name, doc_idx, doc_id) → None`` appelée après chaque
99
- document traité. Une exception dans le callback est loguée en WARNING
100
- et n'interrompt pas le benchmark.
101
- char_exclude:
102
- Ensemble de caractères à exclure du calcul CER/WER.
103
- max_workers:
104
- Taille maximale des pools de threads/processus (défaut : 4).
105
- Peut être défini via le champ ``max_workers`` du YAML de configuration.
106
- timeout_seconds:
107
- Timeout par document en secondes (défaut : 60). Un document dépassant
108
- ce délai est marqué comme erreur ``timeout`` et le benchmark continue.
109
- partial_dir:
110
- Répertoire pour les fichiers de reprise (défaut : répertoire temporaire
111
- système).
112
- cancel_event:
113
- ``threading.Event`` optionnel. Si défini et signalé (``set()``),
114
- le benchmark s'interrompt proprement dès que possible et retourne
115
- les résultats partiels collectés jusque-là.
116
- profile:
117
- Profil de calcul des métriques (chantier 2 post-Sprint 97).
118
- Valeurs : ``"minimal"`` (CER/WER seuls), ``"standard"`` (défaut,
119
- comportement historique avec les 12 hooks), ``"philological"``,
120
- ``"diagnostics"``, ``"economics"``, ``"pipeline"``, ``"full"``.
121
- Le profil ``"standard"`` est strictement rétrocompatible avec
122
- le runner pré-chantier-2.
123
- normalization_profile:
124
- Identifiant d'un profil de normalisation diplomatique
125
- (cf. ``measurements.normalization.NORMALIZATION_PROFILES``).
126
- Sprint A14-S1 — A.I.0 P0 : auparavant l'API web exposait ce
127
- paramètre mais il était silencieusement perdu avant
128
- d'atteindre ``compute_metrics``, ce qui rendait
129
- scientifiquement faux tout benchmark lancé via la web app.
130
- Désormais propagé end-to-end : web → run_benchmark → workers
131
- → compute_metrics. ``None`` = profil par défaut (medieval_french).
132
-
133
- Returns
134
- -------
135
- BenchmarkResult
136
- """
137
- # Validation du profil dès l'entrée pour échouer rapidement sur
138
- # une faute de frappe utilisateur, avant de soumettre des futures
139
- # aux pools. Eager-load des hooks natifs pour peupler le registre
140
- # dans le main process (les sous-processus du pool feront leur
141
- # propre import dans ``_compute_document_result``).
142
- import picarones.measurements.builtin_hooks # noqa: F401
143
- from picarones.evaluation.metric_hooks import (
144
- run_corpus_aggregators, validate_profile,
145
- )
146
- validate_profile(profile)
147
-
148
- # Sprint A14-S1 — résolution one-shot du profil de normalisation.
149
- # On le fait ici (main process) pour échouer rapidement sur un ID
150
- # invalide avant de soumettre des futures aux pools, et pour
151
- # éviter de re-résoudre N fois côté workers.
152
- norm_profile_obj = None
153
- if normalization_profile is not None:
154
- from picarones.evaluation.metrics.normalization import get_builtin_profile
155
- norm_profile_obj = get_builtin_profile(normalization_profile)
156
-
157
- def _is_cancelled() -> bool:
158
- return cancel_event is not None and cancel_event.is_set()
159
- engine_reports: list[EngineReport] = []
160
- # Sprint 36 — collecte des hypothèses brutes par moteur avant
161
- # ``compact()`` pour pouvoir calculer la divergence taxonomique et
162
- # la complémentarité (oracle) en fin de benchmark.
163
- per_engine_outputs: dict[str, dict[str, str]] = {}
164
- ground_truths_by_doc: dict[str, str] = {}
165
- # Sprint 45 — A.III stratification : capture du ``script_type`` par
166
- # document avant ``compact()`` (qui efface ``image_quality``).
167
- doc_strata: dict[str, str] = {}
168
-
169
- # Sprint 87 — langue du corpus pour le delta Flesch (A.II.2).
170
- # Lecture depuis corpus.metadata, fallback "fr".
171
- corpus_lang: str = (corpus.metadata or {}).get("language", "fr")
172
- if corpus_lang not in ("fr", "en"):
173
- # Sprint 52 ne supporte que fr/en — fallback "fr" en warning.
174
- logger.warning(
175
- "[readability] langue '%s' non supportée, fallback 'fr'.",
176
- corpus_lang,
177
- )
178
- corpus_lang = "fr"
179
-
180
- for engine in engines:
181
- if _is_cancelled():
182
- logger.info("Benchmark annulé avant le moteur '%s'.", engine.name)
183
- break
184
- logger.info("Démarrage : %s", engine.name)
185
-
186
- # Reprise depuis résultats partiels d'une éventuelle exécution précédente
187
- partial_path, loaded_results = _load_partial(corpus.name, engine.name, partial_dir)
188
- loaded_doc_ids = {dr.doc_id for dr in loaded_results}
189
- if loaded_results:
190
- logger.info(
191
- "Reprise depuis résultats partiels : %d/%d documents déjà traités.",
192
- len(loaded_results), len(corpus),
193
- )
194
-
195
- docs_to_process = [doc for doc in corpus.documents if doc.doc_id not in loaded_doc_ids]
196
- if loaded_doc_ids:
197
- logger.info(
198
- "[%s] %d doc(s) ignorés (résultats partiels existants) — "
199
- "supprimer le fichier partiel '%s' pour forcer le recalcul.",
200
- engine.name, len(loaded_doc_ids), partial_path,
201
- )
202
- document_results: list[DocumentResult] = list(loaded_results)
203
-
204
- # Sélection du type d'exécution selon execution_mode du moteur
205
- is_cpu_bound = getattr(engine, "execution_mode", "io") == "cpu"
206
- ExecutorClass = (
207
- concurrent.futures.ProcessPoolExecutor
208
- if is_cpu_bound
209
- else concurrent.futures.ThreadPoolExecutor
210
- )
211
- logger.info(
212
- "[%s] classe=%s, exécuteur=%s, docs à traiter=%d (reprise=%d).",
213
- engine.name,
214
- engine.__class__.__name__,
215
- "ProcessPoolExecutor" if is_cpu_bound else "ThreadPoolExecutor",
216
- len(docs_to_process),
217
- len(loaded_results),
218
- )
219
-
220
- pbar = tqdm(
221
- total=len(corpus.documents),
222
- initial=len(loaded_results),
223
- desc=f"[{engine.name}]",
224
- unit="doc",
225
- disable=not show_progress,
226
- )
227
- processed_count = len(loaded_results)
228
-
229
- executor = ExecutorClass(max_workers=max_workers)
230
- try:
231
- # Soumission de tous les documents au pool
232
- future_to_doc: dict = {}
233
- submitted_at: dict = {}
234
-
235
- for doc in docs_to_process:
236
- if _is_cancelled():
237
- logger.info("[%s] annulation — arrêt de la soumission.", engine.name)
238
- break
239
- if is_cpu_bound:
240
- engine_module = engine.__class__.__module__
241
- engine_class_name = engine.__class__.__name__
242
- char_exclude_tuple = tuple(char_exclude) if char_exclude else ()
243
- future = executor.submit(
244
- _cpu_doc_worker,
245
- (engine_module, engine_class_name, engine.config,
246
- doc.doc_id, str(doc.image_path), doc.ground_truth,
247
- char_exclude_tuple, corpus_lang, profile,
248
- norm_profile_obj),
249
- )
250
- else:
251
- future = executor.submit(
252
- _io_doc_worker, engine, doc, char_exclude,
253
- corpus_lang, profile, norm_profile_obj,
254
- )
255
- future_to_doc[future] = doc
256
- submitted_at[future] = time.monotonic()
257
-
258
- remaining = set(future_to_doc)
259
-
260
- while remaining:
261
- if _is_cancelled():
262
- logger.info("[%s] annulation — annulation des futures restantes.", engine.name)
263
- for f in remaining:
264
- f.cancel()
265
- break
266
-
267
- done, remaining = concurrent.futures.wait(
268
- remaining,
269
- timeout=0.5,
270
- return_when=concurrent.futures.FIRST_COMPLETED,
271
- )
272
-
273
- for future in done:
274
- doc = future_to_doc[future]
275
- try:
276
- doc_result = future.result()
277
- except Exception as e:
278
- logger.warning(
279
- "[%s] doc %s : erreur inattendue : %s",
280
- engine.name, doc.doc_id, e,
281
- )
282
- doc_result = _make_error_doc_result(doc, str(e))
283
-
284
- document_results.append(doc_result)
285
- _save_partial_line(partial_path, doc_result)
286
- pbar.update(1)
287
-
288
- if progress_callback is not None:
289
- try:
290
- progress_callback(engine.name, processed_count, doc.doc_id)
291
- except Exception as e:
292
- logger.warning("[progress_callback] fonctionnalité dégradée : %s", e)
293
- processed_count += 1
294
-
295
- # Vérification des timeouts par document
296
- now = time.monotonic()
297
- timed_out = [
298
- f for f in remaining
299
- if now - submitted_at[f] > timeout_seconds
300
- ]
301
- for future in timed_out:
302
- remaining.discard(future)
303
- doc = future_to_doc[future]
304
- future.cancel()
305
- logger.warning(
306
- "[%s] doc %s : timeout (%.0fs), document marqué en erreur.",
307
- engine.name, doc.doc_id, timeout_seconds,
308
- )
309
- doc_result = _make_timeout_doc_result(doc, timeout_seconds)
310
- document_results.append(doc_result)
311
- _save_partial_line(partial_path, doc_result)
312
- pbar.update(1)
313
-
314
- if progress_callback is not None:
315
- try:
316
- progress_callback(engine.name, processed_count, doc.doc_id)
317
- except Exception as e:
318
- logger.warning(
319
- "[progress_callback] fonctionnalité dégradée : %s", e
320
- )
321
- processed_count += 1
322
-
323
- finally:
324
- pbar.close()
325
- # Sur Python 3.12+, ``ProcessPoolExecutor.shutdown(wait=False)``
326
- # laisse les workers (sous-processus) vivants ; l'atexit
327
- # ``_python_exit`` de ``concurrent.futures.process`` essaie
328
- # ensuite de les joindre indéfiniment au shutdown global de
329
- # l'interpréteur, ce qui hang la CI Ubuntu (exit code 124
330
- # après timeout GNU 9 min). Le ``ThreadPoolExecutor`` n'a
331
- # pas ce problème (les threads daemon meurent avec le
332
- # processus).
333
- #
334
- # ``cancel_futures=True`` continue d'annuler les futures en
335
- # queue dans les deux cas ; ``wait=is_cpu_bound`` garantit
336
- # que les workers ProcessPool en cours finissent leur batch
337
- # et libèrent leurs sous-processus avant le retour. Pas de
338
- # changement de comportement pour les engines IO-bound (qui
339
- # gardent leur shutdown rapide non-bloquant).
340
- #
341
- # Ce flow est exercé en CI via les tests web qui chargent le
342
- # vrai ``TesseractEngine`` (``execution_mode="cpu"``) via
343
- # ``engine_from_name("tesseract")`` — d'où la nécessité du
344
- # fix dans le code de prod et pas seulement dans les tests.
345
- executor.shutdown(
346
- wait=is_cpu_bound,
347
- cancel_futures=True,
348
- )
349
-
350
- if _is_cancelled():
351
- logger.info(
352
- "[%s] annulé — %d documents traités sur %d.",
353
- engine.name, len(document_results) - len(loaded_results),
354
- len(docs_to_process),
355
- )
356
- # Conserver le fichier partiel pour reprise ultérieure
357
- break
358
-
359
- # Réordonner selon l'ordre du corpus pour reproductibilité
360
- doc_order = {doc.doc_id: i for i, doc in enumerate(corpus.documents)}
361
- document_results.sort(key=lambda dr: doc_order.get(dr.doc_id, len(doc_order)))
362
-
363
- logger.info(
364
- "[%s] collecte terminée — %d/%d documents (dont %d chargés depuis reprise).",
365
- engine.name,
366
- len(document_results),
367
- len(corpus.documents),
368
- len(loaded_results),
369
- )
370
- if not document_results:
371
- logger.warning(
372
- "[%s] aucun DocumentResult collecté — le rapport affichera 0/0 documents. "
373
- "Vérifier que le moteur/pipeline a bien produit des résultats.",
374
- engine.name,
375
- )
376
-
377
- # Supprimer le fichier partiel — moteur terminé avec succès
378
- _delete_partial(partial_path)
379
-
380
- engine_version = engine._safe_version()
381
- pipeline_info = _build_pipeline_info(engine, document_results)
382
-
383
- # Chantier 2 (post-Sprint 97) — agrégation déléguée au registre.
384
- # Les 12 appels manuels aux fonctions ``_aggregate_*`` sont
385
- # remplacés par un seul appel qui itère sur les agrégateurs
386
- # actifs du profil. Le profil ``"standard"`` (défaut) reproduit
387
- # exactement le comportement pré-chantier-2.
388
- aggregated = run_corpus_aggregators(profile, document_results)
389
-
390
- report = EngineReport(
391
- engine_name=engine.name,
392
- engine_version=engine_version,
393
- engine_config=engine.config,
394
- document_results=document_results,
395
- pipeline_info=pipeline_info,
396
- aggregated_confusion=aggregated.get("aggregated_confusion"),
397
- aggregated_char_scores=aggregated.get("aggregated_char_scores"),
398
- aggregated_taxonomy=aggregated.get("aggregated_taxonomy"),
399
- aggregated_structure=aggregated.get("aggregated_structure"),
400
- aggregated_image_quality=aggregated.get("aggregated_image_quality"),
401
- aggregated_line_metrics=aggregated.get("aggregated_line_metrics"),
402
- aggregated_hallucination=aggregated.get("aggregated_hallucination"),
403
- aggregated_calibration=aggregated.get("aggregated_calibration"),
404
- aggregated_philological=aggregated.get("aggregated_philological"),
405
- aggregated_searchability=aggregated.get("aggregated_searchability"),
406
- aggregated_numerical_sequences=aggregated.get("aggregated_numerical_sequences"),
407
- aggregated_readability=aggregated.get("aggregated_readability"),
408
- )
409
- engine_reports.append(report)
410
- logger.info(
411
- "%s terminé — CER moyen : %.2f%%",
412
- engine.name,
413
- (report.mean_cer or 0) * 100,
414
- )
415
-
416
- # Sprint 36 — capture des hypothèses brutes pour le calcul
417
- # inter-moteurs (effectué après la boucle, avant la sérialisation).
418
- # On clone les chaînes pour ne pas dépendre de la durée de vie des
419
- # DocumentResult après ``compact()``.
420
- per_engine_outputs[engine.name] = {
421
- dr.doc_id: dr.hypothesis for dr in document_results
422
- if dr.engine_error is None
423
- }
424
- for dr in document_results:
425
- if dr.doc_id not in ground_truths_by_doc and dr.ground_truth:
426
- ground_truths_by_doc[dr.doc_id] = dr.ground_truth
427
- # Sprint 45 — capture script_type avant compact()
428
- if dr.doc_id not in doc_strata and dr.image_quality:
429
- st = dr.image_quality.get("script_type")
430
- if st:
431
- doc_strata[dr.doc_id] = str(st)
432
-
433
- # Sprint 40 — calcul des métriques NER si :
434
- # 1. l'utilisateur a fourni un EntityExtractor au runner ;
435
- # 2. ET le document a un niveau de GT ENTITIES (Sprint 32).
436
- # Fait dans le main process (pas dans les sous-processus du pool)
437
- # pour éviter de pickler l'extracteur (spaCy + modèle).
438
- if entity_extractor is not None:
439
- _attach_ner_metrics(corpus, document_results, entity_extractor)
440
- agg_ner = _aggregate_ner(document_results)
441
- report.aggregated_ner = agg_ner
442
-
443
- # Sprint A14-S1 — A.I.0 P0 : la compaction inconditionnelle qui
444
- # vivait ici amputait silencieusement le JSON exporté (et donc
445
- # le rapport HTML qui le consomme) en supprimant 13 dicts
446
- # d'analyse per-document et en tronquant les textes à 200 chars.
447
- # ``DocumentResult.compact()`` est désormais opt-in (paramètres
448
- # ``text_limit`` et ``drop_analyses``) ; le runner ne compacte
449
- # plus par défaut afin que ``output_json`` contienne réellement
450
- # toutes les analyses détaillées promises par le README.
451
- # Un caller qui veut un JSON léger peut appeler
452
- # ``dr.compact(text_limit=200, drop_analyses=True)`` lui-même
453
- # après ``run_benchmark`` et avant la sérialisation finale.
454
-
455
- # Sprint 36 — analyse inter-moteurs (divergence taxonomique +
456
- # complémentarité / oracle). N'est calculée qu'à partir de 2
457
- # moteurs ; en deçà l'analyse n'a pas de sens.
458
- inter_engine_payload: Optional[dict] = None
459
- if len(engine_reports) >= 2:
460
- try:
461
- from picarones.evaluation.metrics.inter_engine import compute_inter_engine_analysis
462
-
463
- taxonomy_distros = {
464
- report.engine_name: (
465
- report.aggregated_taxonomy.get("class_distribution", {})
466
- if report.aggregated_taxonomy
467
- else {}
468
- )
469
- for report in engine_reports
470
- }
471
- # Élimine les moteurs sans distribution taxonomique pour ne pas
472
- # polluer la matrice.
473
- taxonomy_distros = {
474
- name: dist for name, dist in taxonomy_distros.items() if dist
475
- }
476
- inter_engine_payload = compute_inter_engine_analysis(
477
- per_engine_outputs=per_engine_outputs,
478
- ground_truths=ground_truths_by_doc,
479
- taxonomy_distributions=taxonomy_distros or None,
480
- )
481
- except Exception as exc: # noqa: BLE001
482
- logger.warning(
483
- "[runner] analyse inter-moteurs dégradée : %s — section omise du rapport",
484
- exc,
485
- )
486
-
487
- benchmark = BenchmarkResult(
488
- corpus_name=corpus.name,
489
- corpus_source=corpus.source_path,
490
- document_count=len(corpus),
491
- engine_reports=engine_reports,
492
- inter_engine_analysis=inter_engine_payload,
493
- doc_strata=dict(doc_strata) if doc_strata else None,
494
- )
495
-
496
- if output_json:
497
- path = benchmark.to_json(output_json)
498
- logger.info("Résultats écrits dans : %s", path)
499
-
500
- return benchmark
501
-
502
-
503
- def _build_pipeline_info(engine: BaseOCREngine, doc_results: list[DocumentResult]) -> dict:
504
- """Construit le dictionnaire pipeline_info pour un EngineReport."""
505
- first_with_meta = next(
506
- (dr for dr in doc_results if dr.pipeline_metadata), None
507
- )
508
- if first_with_meta is None:
509
- return {}
510
-
511
- meta = first_with_meta.pipeline_metadata
512
- info: dict = {
513
- "pipeline_mode": meta.get("pipeline_mode"),
514
- "prompt_file": meta.get("prompt_file"),
515
- "llm_model": meta.get("llm_model"),
516
- "llm_provider": meta.get("llm_provider"),
517
- }
518
-
519
- # Sprint C du plan v2.0 : duck typing via ``is_pipeline`` au lieu
520
- # de ``isinstance(engine, OCRLLMPipeline)``. Découple le runner
521
- # legacy de la classe ``OCRLLMPipeline`` — préparation à la
522
- # suppression du sous-package ``picarones.pipelines/`` (Sprint D).
523
- if getattr(engine, "is_pipeline", False):
524
- info["pipeline_steps"] = engine.pipeline_steps_info
525
- info["prompt_template"] = engine.prompt_template
526
-
527
- over_norm_results = [
528
- dr.pipeline_metadata.get("over_normalization")
529
- for dr in doc_results
530
- if dr.pipeline_metadata.get("over_normalization") is not None
531
- ]
532
- if over_norm_results:
533
- total_correct = sum(r["total_correct_ocr_words"] for r in over_norm_results)
534
- total_over = sum(r["over_normalized_count"] for r in over_norm_results)
535
- info["over_normalization"] = {
536
- "score": round(total_over / total_correct, 4) if total_correct > 0 else 0.0,
537
- "total_correct_ocr_words": total_correct,
538
- "over_normalized_count": total_over,
539
- "document_count": len(over_norm_results),
540
- }
541
-
542
- return info
543
-
544
-
545
- __all__ = ["_build_pipeline_info", "run_benchmark"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/measurements/runner/partial.py DELETED
@@ -1,140 +0,0 @@
1
- """Persistance des résultats partiels du benchmark (NDJSON).
2
-
3
- Quand le runner traite un corpus, il écrit chaque ``DocumentResult``
4
- dans un fichier ``{partial_dir}/picarones_{corpus}_{engine}.partial.json``
5
- au format NDJSON. Si le benchmark est interrompu (Ctrl+C, crash, kill),
6
- la prochaine exécution reprend depuis ce fichier sans perdre le travail
7
- déjà fait.
8
-
9
- Thread-safe : le module utilise un :class:`threading.Lock` partagé
10
- entre toutes les écritures pour sérialiser les appends.
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- import json
16
- import logging
17
- import re
18
- import tempfile
19
- import threading
20
- from pathlib import Path
21
- from typing import Optional
22
-
23
- from picarones.evaluation.benchmark_result import DocumentResult
24
- from picarones.evaluation.metric_result import MetricsResult
25
-
26
- logger = logging.getLogger(__name__)
27
-
28
- # Lock pour la sérialisation des écritures de résultats partiels.
29
- # Partagé entre tous les call sites (workers IO et CPU se relayent
30
- # sur la même file).
31
- _partial_write_lock = threading.Lock()
32
-
33
-
34
- def _sanitize_filename(s: str) -> str:
35
- return re.sub(r"[^\w\-]", "_", s)[:64]
36
-
37
-
38
- def _partial_path(
39
- corpus_name: str,
40
- engine_name: str,
41
- partial_dir: Optional[str | Path],
42
- ) -> Path:
43
- base = Path(partial_dir) if partial_dir else Path(tempfile.gettempdir())
44
- name = (
45
- f"picarones_{_sanitize_filename(corpus_name)}"
46
- f"_{_sanitize_filename(engine_name)}.partial.json"
47
- )
48
- return base / name
49
-
50
-
51
- def _load_partial(
52
- corpus_name: str,
53
- engine_name: str,
54
- partial_dir: Optional[str | Path],
55
- ) -> tuple[Path, list[DocumentResult]]:
56
- """Charge les résultats partiels d'une exécution précédente interrompue.
57
-
58
- Returns
59
- -------
60
- (path, results) — chemin du fichier partiel et liste des
61
- DocumentResult déjà calculés.
62
- """
63
- path = _partial_path(corpus_name, engine_name, partial_dir)
64
- results: list[DocumentResult] = []
65
- if not path.exists():
66
- return path, results
67
-
68
- try:
69
- with path.open("r", encoding="utf-8") as fh:
70
- for line in fh:
71
- line = line.strip()
72
- if not line:
73
- continue
74
- d = json.loads(line)
75
- m = d.get("metrics", {})
76
- metrics = MetricsResult(
77
- cer=m.get("cer", 1.0),
78
- cer_nfc=m.get("cer_nfc", 1.0),
79
- cer_caseless=m.get("cer_caseless", 1.0),
80
- wer=m.get("wer", 1.0),
81
- wer_normalized=m.get("wer_normalized", 1.0),
82
- mer=m.get("mer", 1.0),
83
- wil=m.get("wil", 1.0),
84
- reference_length=m.get("reference_length", 0),
85
- hypothesis_length=m.get("hypothesis_length", 0),
86
- error=m.get("error"),
87
- )
88
- results.append(DocumentResult(
89
- doc_id=d["doc_id"],
90
- image_path=d.get("image_path", ""),
91
- ground_truth=d.get("ground_truth", ""),
92
- hypothesis=d.get("hypothesis", ""),
93
- metrics=metrics,
94
- duration_seconds=d.get("duration_seconds", 0.0),
95
- engine_error=d.get("engine_error"),
96
- ocr_intermediate=d.get("ocr_intermediate"),
97
- pipeline_metadata=d.get("pipeline_metadata", {}),
98
- confusion_matrix=d.get("confusion_matrix"),
99
- char_scores=d.get("char_scores"),
100
- taxonomy=d.get("taxonomy"),
101
- structure=d.get("structure"),
102
- image_quality=d.get("image_quality"),
103
- line_metrics=d.get("line_metrics"),
104
- hallucination_metrics=d.get("hallucination_metrics"),
105
- ))
106
- except Exception as e:
107
- logger.warning("Impossible de charger les résultats partiels '%s' : %s", path, e)
108
- results = []
109
-
110
- return path, results
111
-
112
-
113
- def _save_partial_line(partial_path: Path, doc_result: DocumentResult) -> None:
114
- """Ajoute une entrée NDJSON au fichier de résultats partiels (thread-safe)."""
115
- try:
116
- line = json.dumps(doc_result.as_dict(), ensure_ascii=False) + "\n"
117
- with _partial_write_lock:
118
- with partial_path.open("a", encoding="utf-8") as fh:
119
- fh.write(line)
120
- except Exception as e:
121
- logger.warning("Impossible d'écrire dans le fichier partiel '%s' : %s", partial_path, e)
122
-
123
-
124
- def _delete_partial(partial_path: Path) -> None:
125
- """Supprime le fichier de résultats partiels à la fin d'un moteur."""
126
- try:
127
- if partial_path.exists():
128
- partial_path.unlink()
129
- except Exception as e:
130
- logger.warning("Impossible de supprimer le fichier partiel '%s' : %s", partial_path, e)
131
-
132
-
133
- __all__ = [
134
- "_delete_partial",
135
- "_load_partial",
136
- "_partial_path",
137
- "_partial_write_lock",
138
- "_sanitize_filename",
139
- "_save_partial_line",
140
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/measurements/runner/workers.py DELETED
@@ -1,116 +0,0 @@
1
- """Workers de niveau module pour les pools d'exécution.
2
-
3
- Deux workers correspondant aux deux modes d'exécution :
4
-
5
- - :func:`_cpu_doc_worker` — pour ``ProcessPoolExecutor`` (moteurs
6
- CPU-bound, instanciés dans le sous-processus). Doit être picklable :
7
- c'est pour ça qu'il est défini au niveau module.
8
- - :func:`_io_doc_worker` — pour ``ThreadPoolExecutor`` (moteurs
9
- IO-bound / API HTTP). L'instance du moteur est partagée entre les
10
- threads.
11
-
12
- Les deux finissent par appeler :func:`_compute_document_result` du
13
- sous-module :mod:`document` pour calculer toutes les métriques.
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- from typing import Optional
19
-
20
- from picarones.evaluation.benchmark_result import DocumentResult
21
- from picarones.adapters.legacy_engines.base import BaseOCREngine
22
- from picarones.measurements.runner.document import _compute_document_result
23
-
24
-
25
- def _cpu_doc_worker(args: tuple) -> "DocumentResult":
26
- """Worker pour ProcessPoolExecutor (moteurs CPU-bound).
27
-
28
- Instancie le moteur dans le sous-processus, exécute l'OCR et calcule
29
- toutes les métriques. Doit être une fonction de niveau module pour être
30
- sérialisable par ``pickle``.
31
-
32
- Le tuple ``args`` peut contenir, par compatibilité ascendante :
33
- - 7 éléments : legacy (Sprint 13)
34
- - 8 éléments : + ``corpus_lang`` (Sprint 87)
35
- - 9 éléments : + ``profile`` (chantier 2 post-Sprint 97)
36
- - 10 éléments : + ``normalization_profile`` (Sprint A14-S1, A.I.0 P0)
37
- """
38
- norm_profile = None
39
- if len(args) == 10:
40
- (engine_module, engine_class_name, engine_config, doc_id,
41
- image_path, ground_truth, char_exclude_chars, corpus_lang,
42
- profile, norm_profile) = args
43
- elif len(args) == 9:
44
- (engine_module, engine_class_name, engine_config, doc_id,
45
- image_path, ground_truth, char_exclude_chars, corpus_lang,
46
- profile) = args
47
- elif len(args) == 8:
48
- (engine_module, engine_class_name, engine_config, doc_id,
49
- image_path, ground_truth, char_exclude_chars, corpus_lang) = args
50
- profile = "standard"
51
- else:
52
- (engine_module, engine_class_name, engine_config, doc_id,
53
- image_path, ground_truth, char_exclude_chars) = args
54
- corpus_lang = "fr"
55
- profile = "standard"
56
- import importlib
57
- mod = importlib.import_module(engine_module)
58
- engine_cls = getattr(mod, engine_class_name)
59
- engine = engine_cls(config=engine_config)
60
- ocr_result = engine.run(image_path)
61
- char_exclude = frozenset(char_exclude_chars) if char_exclude_chars else None
62
- return _compute_document_result(
63
- doc_id=doc_id,
64
- image_path=image_path,
65
- ground_truth=ground_truth,
66
- ocr_result=ocr_result,
67
- char_exclude=char_exclude,
68
- corpus_lang=corpus_lang,
69
- profile=profile,
70
- normalization_profile=norm_profile,
71
- )
72
-
73
-
74
- def _io_doc_worker(
75
- engine: BaseOCREngine,
76
- doc: object,
77
- char_exclude: Optional[frozenset],
78
- corpus_lang: str = "fr",
79
- profile: str = "standard",
80
- normalization_profile: Optional[object] = None,
81
- ) -> "DocumentResult":
82
- """Worker pour ThreadPoolExecutor (moteurs IO-bound / API).
83
-
84
- Exécute l'OCR et calcule les métriques dans un thread. L'instance du
85
- moteur est partagée entre les threads — les adaptateurs HTTP sont
86
- généralement sans état mutable entre les appels.
87
-
88
- Si le document possède un texte OCR pré-calculé (corpus triplet) et que
89
- le moteur est un pipeline OCR+LLM, utilise ``run_with_ocr_text()`` pour
90
- court-circuiter l'étape OCR et tester directement la post-correction LLM.
91
- """
92
- doc_ocr_text = getattr(doc, "ocr_text", None)
93
- if doc_ocr_text is not None:
94
- # Corpus triplet — vérifier si le moteur supporte run_with_ocr_text
95
- run_with = getattr(engine, "run_with_ocr_text", None)
96
- if run_with is not None:
97
- ocr_result = run_with(doc.image_path, doc_ocr_text) # type: ignore[attr-defined]
98
- else:
99
- # Moteur OCR classique — ignorer le texte OCR pré-calculé
100
- ocr_result = engine.run(doc.image_path) # type: ignore[attr-defined]
101
- else:
102
- ocr_result = engine.run(doc.image_path) # type: ignore[attr-defined]
103
-
104
- return _compute_document_result(
105
- doc_id=doc.doc_id, # type: ignore[attr-defined]
106
- image_path=str(doc.image_path), # type: ignore[attr-defined]
107
- ground_truth=doc.ground_truth, # type: ignore[attr-defined]
108
- ocr_result=ocr_result,
109
- char_exclude=char_exclude,
110
- corpus_lang=corpus_lang,
111
- profile=profile,
112
- normalization_profile=normalization_profile,
113
- )
114
-
115
-
116
- __all__ = ["_cpu_doc_worker", "_io_doc_worker"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/app/test_sprint_d_legacy_runner_adapter.py CHANGED
@@ -14,7 +14,6 @@ from __future__ import annotations
14
 
15
  import json
16
  from pathlib import Path
17
- from typing import Any
18
 
19
  import pytest
20
 
@@ -985,151 +984,6 @@ class TestRunBenchmarkViaService:
985
  assert bm.engine_reports
986
 
987
 
988
- # ──────────────────────────────────────────────────────────────────────
989
- # D.1.e — Équivalence numérique legacy vs rewrite
990
- # ──────────────────────────────────────────────────────────────────────
991
-
992
-
993
- class _DeterministicOCR(BaseOCREngine):
994
- """OCR engine mock dont la sortie dépend uniquement du nom de fichier.
995
-
996
- Permet aux tests d'équivalence de produire des hypothèses identiques
997
- via les deux runners (legacy vs rewrite) — sans dépendance à un
998
- binaire externe (Tesseract, Pero) qui ne tournerait pas en CI.
999
- """
1000
-
1001
- def __init__(self, name: str, hypotheses: dict[str, str]) -> None:
1002
- super().__init__(config={})
1003
- self._name = name
1004
- self._hypotheses = hypotheses
1005
-
1006
- @property
1007
- def name(self) -> str: # type: ignore[override]
1008
- return self._name
1009
-
1010
- def version(self) -> str:
1011
- return "1.0.0"
1012
-
1013
- def _run_ocr(self, image_path) -> str:
1014
- return self._hypotheses.get(Path(image_path).stem, "")
1015
-
1016
-
1017
- def _build_test_corpus(tmp_path: Path) -> Corpus:
1018
- """Petit corpus de 3 docs avec des GT variés (parfait, partiel, vide)."""
1019
- docs = []
1020
- for i, gt in enumerate([
1021
- "bonjour le monde", # doc0
1022
- "lorem ipsum dolor sit", # doc1
1023
- "", # doc2 — GT vide
1024
- ]):
1025
- img = tmp_path / f"doc{i}.png"
1026
- img.write_bytes(b"\x89PNG fake")
1027
- docs.append(
1028
- Document(image_path=img, ground_truth=gt, doc_id=f"doc{i}"),
1029
- )
1030
- return Corpus(name="equiv_test", documents=docs)
1031
-
1032
-
1033
- def _hypotheses_for_test(corpus: Corpus) -> dict[str, str]:
1034
- """Variantes des GT — perfait, 1 erreur, 2 erreurs, vide."""
1035
- return {
1036
- "doc0": "bonjour le monde", # CER 0
1037
- "doc1": "lorem ipsum dolar sit", # CER ~ 1/22 (1 erreur)
1038
- "doc2": "spurious", # CER 1.0 (GT vide)
1039
- }
1040
-
1041
-
1042
- class TestEquivalenceLegacyVsRewrite:
1043
- """Vérifie que ``run_benchmark`` legacy et ``run_benchmark_via_service``
1044
- produisent des métriques identiques sur les mêmes inputs."""
1045
-
1046
- def _run_both(
1047
- self, tmp_path: Path,
1048
- ) -> tuple[Any, Any]:
1049
- """Lance les deux runners sur le même corpus + engine,
1050
- retourne ``(legacy_result, rewrite_result)``."""
1051
- from picarones.app.services._legacy_runner_adapter import (
1052
- run_benchmark_via_service,
1053
- )
1054
- from picarones.measurements.runner import run_benchmark
1055
-
1056
- corpus = _build_test_corpus(tmp_path)
1057
- hypotheses = _hypotheses_for_test(corpus)
1058
- # Deux instances distinctes — les engines mocks ne sont pas
1059
- # thread-safe partagés. Chaque runner reçoit la sienne.
1060
- legacy_engine = _DeterministicOCR("equiv_ocr", hypotheses)
1061
- rewrite_engine = _DeterministicOCR("equiv_ocr", hypotheses)
1062
-
1063
- legacy_result = run_benchmark(
1064
- corpus,
1065
- [legacy_engine],
1066
- show_progress=False,
1067
- max_workers=1,
1068
- )
1069
- rewrite_result = run_benchmark_via_service(
1070
- corpus,
1071
- [rewrite_engine],
1072
- )
1073
- return legacy_result, rewrite_result
1074
-
1075
- def test_corpus_name_matches(self, tmp_path: Path) -> None:
1076
- legacy, rewrite = self._run_both(tmp_path)
1077
- assert legacy.corpus_name == rewrite.corpus_name
1078
- assert legacy.document_count == rewrite.document_count
1079
-
1080
- def test_engine_count_matches(self, tmp_path: Path) -> None:
1081
- legacy, rewrite = self._run_both(tmp_path)
1082
- assert len(legacy.engine_reports) == len(rewrite.engine_reports)
1083
-
1084
- def test_engine_name_and_version_match(self, tmp_path: Path) -> None:
1085
- legacy, rewrite = self._run_both(tmp_path)
1086
- for lr, rr in zip(legacy.engine_reports, rewrite.engine_reports):
1087
- assert lr.engine_name == rr.engine_name
1088
- assert lr.engine_version == rr.engine_version
1089
-
1090
- def test_per_document_hypothesis_matches(self, tmp_path: Path) -> None:
1091
- legacy, rewrite = self._run_both(tmp_path)
1092
- for lr, rr in zip(legacy.engine_reports, rewrite.engine_reports):
1093
- for ld, rd in zip(lr.document_results, rr.document_results):
1094
- assert ld.doc_id == rd.doc_id
1095
- assert ld.ground_truth == rd.ground_truth
1096
- assert ld.hypothesis == rd.hypothesis
1097
-
1098
- def test_per_document_cer_matches(self, tmp_path: Path) -> None:
1099
- """Critère central : les CER doc-par-doc sont identiques au
1100
- round près (les deux runners utilisent ``compute_metrics``)."""
1101
- legacy, rewrite = self._run_both(tmp_path)
1102
- for lr, rr in zip(legacy.engine_reports, rewrite.engine_reports):
1103
- for ld, rd in zip(lr.document_results, rr.document_results):
1104
- assert ld.metrics.cer == pytest.approx(rd.metrics.cer)
1105
- assert ld.metrics.wer == pytest.approx(rd.metrics.wer)
1106
- assert ld.metrics.mer == pytest.approx(rd.metrics.mer)
1107
- assert ld.metrics.wil == pytest.approx(rd.metrics.wil)
1108
-
1109
- def test_aggregated_metrics_match(self, tmp_path: Path) -> None:
1110
- """Les agrégats par engine (cer.mean, wer.mean, etc.) doivent
1111
- coïncider — les deux runners utilisent ``aggregate_metrics``."""
1112
- legacy, rewrite = self._run_both(tmp_path)
1113
- for lr, rr in zip(legacy.engine_reports, rewrite.engine_reports):
1114
- for key in ("cer", "wer", "mer", "wil"):
1115
- lstats = lr.aggregated_metrics.get(key)
1116
- rstats = rr.aggregated_metrics.get(key)
1117
- if lstats is None or rstats is None:
1118
- continue
1119
- # Ces dicts sont {mean, median, min, max, stdev}.
1120
- for stat_name in ("mean", "median", "min", "max"):
1121
- assert lstats[stat_name] == pytest.approx(rstats[stat_name]), (
1122
- f"Différence sur aggregated_metrics[{key!r}][{stat_name!r}] : "
1123
- f"legacy={lstats[stat_name]} rewrite={rstats[stat_name]}"
1124
- )
1125
-
1126
- def test_engine_error_field_matches(self, tmp_path: Path) -> None:
1127
- legacy, rewrite = self._run_both(tmp_path)
1128
- for lr, rr in zip(legacy.engine_reports, rewrite.engine_reports):
1129
- for ld, rd in zip(lr.document_results, rr.document_results):
1130
- # None de chaque côté pour un OCR mock qui ne lève pas.
1131
- assert ld.engine_error == rd.engine_error
1132
-
1133
 
1134
  # ──────────────────────────────────────────────────────────────────────
1135
  # D.2.a — progress_callback dans run_benchmark_via_service
 
14
 
15
  import json
16
  from pathlib import Path
 
17
 
18
  import pytest
19
 
 
984
  assert bm.engine_reports
985
 
986
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
987
 
988
  # ──────────────────────────────────────────────────────────────────────
989
  # D.2.a — progress_callback dans run_benchmark_via_service
tests/architecture/test_file_budgets.py CHANGED
@@ -50,9 +50,9 @@ FILE_BUDGETS: dict[str, int] = {
50
  # de la famille ne dépasse 350 lignes, donc aucune entrée requise.
51
  # runner.py (1019 lignes) a été éclaté en sous-package
52
  # ``picarones/measurements/runner/`` lors du sprint
53
- # « découpage de runner.py » (2026-05-03). Le plus gros sous-module
54
- # est ``orchestration.py`` (494 lignes), surveillé ci-dessous.
55
- "picarones/measurements/runner/orchestration.py": 575, # actuel 494
56
  # --- Refactor (sprint « découpage de generator.py ») : passé de
57
  # 1063 à 431 lignes via extraction vers picarones/report/assets.py
58
  # et le sous-package picarones/report/report_data/. Budget serré
 
50
  # de la famille ne dépasse 350 lignes, donc aucune entrée requise.
51
  # runner.py (1019 lignes) a été éclaté en sous-package
52
  # ``picarones/measurements/runner/`` lors du sprint
53
+ # « découpage de runner.py » (2026-05-03). Le sous-package a été
54
+ # supprimé en Sprint D.6.b du plan v2.0 — son entrée dans
55
+ # ``FILE_BUDGETS`` a été retirée.
56
  # --- Refactor (sprint « découpage de generator.py ») : passé de
57
  # 1063 à 431 lignes via extraction vers picarones/report/assets.py
58
  # et le sous-package picarones/report/report_data/. Budget serré
tests/architecture/test_legacy_canonical_parity.py CHANGED
@@ -73,7 +73,7 @@ LEGACY_PACKAGES: tuple[str, ...] = (
73
  #: :data:`LEGACY_PARITY` sans faire échouer le test. À diminuer
74
  #: à chaque session de migration : on cible 0 quand le retrait
75
  #: est complet.
76
- BOOTSTRAP_BASELINE = 103
77
 
78
 
79
  # ──────────────────────────────────────────────────────────────────
 
73
  #: :data:`LEGACY_PARITY` sans faire échouer le test. À diminuer
74
  #: à chaque session de migration : on cible 0 quand le retrait
75
  #: est complet.
76
+ BOOTSTRAP_BASELINE = 99
77
 
78
 
79
  # ──────────────────────────────────────────────────────────────────
tests/architecture/test_module_coverage.py CHANGED
@@ -69,6 +69,13 @@ TEST_ONLY_BASELINE: frozenset[str] = frozenset({
69
  # ``picarones/`` (renderer canonique qui consomme le canonique
70
  # directement, mais module legacy gardé pour les tests).
71
  "numerical_sequences_hooks",
 
 
 
 
 
 
 
72
  })
73
 
74
 
 
69
  # ``picarones/`` (renderer canonique qui consomme le canonique
70
  # directement, mais module legacy gardé pour les tests).
71
  "numerical_sequences_hooks",
72
+ # Sprint D.6.b du plan v2.0 — le sous-package
73
+ # ``measurements.runner`` a été supprimé. ``builtin_hooks``
74
+ # était son consommateur direct (registre des hooks de
75
+ # métriques) ; sans le runner, il n'a plus de consommateur
76
+ # production. Suppression / migration prévue en Sprint E
77
+ # (migration des hooks vers ``evaluation/metric_hooks/``).
78
+ "builtin_hooks",
79
  })
80
 
81
 
tests/core/test_metric_hooks.py CHANGED
@@ -255,46 +255,6 @@ class TestRunDocumentHooks:
255
  ocr_result=_MockEngineResult(token_confidences=None),
256
  )
257
  assert called == []
258
-
259
-
260
- # ──────────────────────────────────────────────────────────────────────────
261
- # 4. Rétrocompat : runner expose toujours les helpers privés
262
- # ──────────────────────────────────────────────────────────────────────────
263
-
264
-
265
- class TestRunnerBackwardCompat:
266
- """Les tests Sprint 13 et Sprint 42 importent directement depuis
267
- ``picarones.measurements.runner``. Ces noms doivent rester disponibles
268
- après le chantier 2."""
269
-
270
- @pytest.mark.parametrize("name", [
271
- "_aggregate_confusion",
272
- "_aggregate_char_scores",
273
- "_aggregate_taxonomy",
274
- "_aggregate_structure",
275
- "_aggregate_image_quality",
276
- "_aggregate_line_metrics",
277
- "_aggregate_hallucination",
278
- "_aggregate_calibration",
279
- "_calibration_from_engine_result",
280
- ])
281
- def test_helper_still_exported_from_runner(self, name):
282
- # Skip si tqdm ou autres deps absents (sandbox minimaliste).
283
- pytest.importorskip("tqdm")
284
- from picarones.measurements import runner
285
-
286
- assert hasattr(runner, name), (
287
- f"runner.{name} a disparu — casse les tests Sprint 13/42 "
288
- "qui font ``from picarones.measurements.runner import {name}``"
289
- )
290
- assert callable(getattr(runner, name))
291
-
292
-
293
- # ──────────────────────────────────────────────────────────────────────────
294
- # 5. Décorateurs : idempotence + erreurs sur conflit
295
- # ──────────────────────────────────────────────────────────────────────────
296
-
297
-
298
  class TestDecoratorIdempotence:
299
  def test_register_same_func_twice_is_silent(self):
300
  """Ré-import d'un module en test ne doit pas lever sur le
 
255
  ocr_result=_MockEngineResult(token_confidences=None),
256
  )
257
  assert called == []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  class TestDecoratorIdempotence:
259
  def test_register_same_func_twice_is_silent(self):
260
  """Ré-import d'un module en test ne doit pas lever sur le
tests/core/test_public_api.py CHANGED
@@ -197,38 +197,38 @@ class TestMetricsApi:
197
 
198
 
199
  # ──────────────────────────────────────────────────────────────────────────
200
- # 5. picarones.measurements.runnerrun_benchmark
201
  # ──────────────────────────────────────────────────────────────────────────
202
 
203
 
204
  class TestRunnerApi:
205
- def test_run_benchmark_exists(self):
206
- try:
207
- _assert_function("picarones.measurements.runner", "run_benchmark")
208
- except ImportError as exc:
209
- if "tqdm" in str(exc):
210
- pytest.skip("tqdm non installé en sandbox")
211
- raise
212
-
213
- def test_run_benchmark_keyword_args(self):
214
  """Les paramètres clés (corpus, engines, profile…) doivent rester
215
- accessibles. Ajout d'un argument requis = breaking change."""
216
- try:
217
- from picarones.measurements.runner import run_benchmark
218
- except ImportError as exc:
219
- if "tqdm" in str(exc):
220
- pytest.skip("tqdm non installé")
221
- raise
222
- sig = inspect.signature(run_benchmark)
223
  params = sig.parameters
224
- # Arguments contractuels — leur présence est garantie
 
225
  for name in [
226
  "corpus", "engines", "output_json", "show_progress",
227
  "char_exclude", "max_workers", "timeout_seconds",
228
  "profile",
229
  ]:
230
  assert name in params, (
231
- f"run_benchmark : argument '{name}' a disparu (signature : {sig})"
 
232
  )
233
 
234
 
@@ -448,7 +448,7 @@ class TestApiStableDoc:
448
  "picarones.domain.module_protocol",
449
  "picarones.evaluation.benchmark_result",
450
  "picarones.measurements.metrics",
451
- "picarones.measurements.runner",
452
  "picarones.evaluation.metric_registry",
453
  "picarones.evaluation.metric_hooks",
454
  "picarones.measurements.builtin_metrics",
 
197
 
198
 
199
  # ──────────────────────────────────────────────────────────────────────────
200
+ # 5. picarones.app.services._legacy_runner_adapterrun_benchmark_via_service
201
  # ──────────────────────────────────────────────────────────────────────────
202
 
203
 
204
  class TestRunnerApi:
205
+ def test_run_benchmark_via_service_exists(self):
206
+ """Sprint D du plan v2.0 — l'adapter rewrite remplace
207
+ ``measurements.runner.run_benchmark`` (legacy supprimé en D.6)."""
208
+ _assert_function(
209
+ "picarones.app.services._legacy_runner_adapter",
210
+ "run_benchmark_via_service",
211
+ )
212
+
213
+ def test_run_benchmark_via_service_keyword_args(self):
214
  """Les paramètres clés (corpus, engines, profile…) doivent rester
215
+ accessibles dans l'adapter rewrite. Ajout d'un argument requis =
216
+ breaking change."""
217
+ from picarones.app.services._legacy_runner_adapter import (
218
+ run_benchmark_via_service,
219
+ )
220
+ sig = inspect.signature(run_benchmark_via_service)
 
 
221
  params = sig.parameters
222
+ # Arguments contractuels — leur présence est garantie pour
223
+ # rester compatible avec les callers historiques.
224
  for name in [
225
  "corpus", "engines", "output_json", "show_progress",
226
  "char_exclude", "max_workers", "timeout_seconds",
227
  "profile",
228
  ]:
229
  assert name in params, (
230
+ f"run_benchmark_via_service : argument '{name}' a disparu "
231
+ f"(signature : {sig})"
232
  )
233
 
234
 
 
448
  "picarones.domain.module_protocol",
449
  "picarones.evaluation.benchmark_result",
450
  "picarones.measurements.metrics",
451
+ "picarones.app.services._legacy_runner_adapter",
452
  "picarones.evaluation.metric_registry",
453
  "picarones.evaluation.metric_hooks",
454
  "picarones.measurements.builtin_metrics",
tests/engines/test_sprint47_tesseract_confidences.py CHANGED
@@ -245,43 +245,6 @@ class TestTokenFiltering:
245
  # ──────────────────────────────────────────────────────────────────────────
246
 
247
 
248
- class TestEndToEndWithRunner:
249
- def test_runner_picks_up_confidences_and_computes_calibration(
250
- self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
251
- ) -> None:
252
- from picarones.measurements.runner import _compute_document_result
253
- from picarones.adapters.legacy_engines.base import EngineResult
254
-
255
- # Simulation : on appelle directement _compute_document_result
256
- # avec un EngineResult mocké qui porte des confidences. On
257
- # vérifie que la calibration_metrics est bien attachée.
258
- ocr = EngineResult(
259
- engine_name="tess",
260
- image_path="/tmp/x.png",
261
- text="alpha beta gamma",
262
- duration_seconds=0.1,
263
- token_confidences=[
264
- {"token": "alpha", "confidence": 95.0},
265
- {"token": "beta", "confidence": 95.0},
266
- {"token": "gamma", "confidence": 95.0},
267
- ],
268
- )
269
- dr = _compute_document_result(
270
- doc_id="d1", image_path="/tmp/x.png",
271
- ground_truth="alpha beta gamma",
272
- ocr_result=ocr, char_exclude=None,
273
- )
274
- assert dr.calibration_metrics is not None
275
- # 3 tokens, tous corrects → accuracy = 1, conf = 0.95
276
- assert dr.calibration_metrics["overall_accuracy"] == 1.0
277
- assert dr.calibration_metrics["overall_confidence"] == pytest.approx(0.95)
278
-
279
-
280
- # ──────────────────────────────────────────────────────────────────────────
281
- # 7. pytesseract absent → fallback gracieux
282
- # ──────────────────────────────────────────────────────────────────────────
283
-
284
-
285
  class TestPytesseractAbsent:
286
  def test_extraction_returns_none_without_pytesseract(
287
  self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
 
245
  # ──────────────────────────────────────────────────────────────────────────
246
 
247
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  class TestPytesseractAbsent:
249
  def test_extraction_returns_none_without_pytesseract(
250
  self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
tests/engines/test_sprint48_pero_confidences.py CHANGED
@@ -240,39 +240,6 @@ class TestRunPipeline:
240
  # ──────────────────────────────────────────────────────────────────────────
241
 
242
 
243
- class TestEndToEndWithRunner:
244
- def test_runner_picks_up_confidences(self) -> None:
245
- from picarones.measurements.runner import _compute_document_result
246
- from picarones.adapters.legacy_engines.base import EngineResult
247
-
248
- ocr = EngineResult(
249
- engine_name="pero",
250
- image_path="/tmp/x.png",
251
- text="alpha beta gamma",
252
- duration_seconds=0.1,
253
- # Confidence ∈ [0, 1] côté Pero (vs [0, 100] Tesseract) —
254
- # le runner Sprint 42 normalise via le helper bag-of-words.
255
- token_confidences=[
256
- {"token": "alpha", "confidence": 0.95},
257
- {"token": "beta", "confidence": 0.95},
258
- {"token": "gamma", "confidence": 0.95},
259
- ],
260
- )
261
- dr = _compute_document_result(
262
- doc_id="d1", image_path="/tmp/x.png",
263
- ground_truth="alpha beta gamma",
264
- ocr_result=ocr, char_exclude=None,
265
- )
266
- assert dr.calibration_metrics is not None
267
- assert dr.calibration_metrics["overall_accuracy"] == 1.0
268
- assert dr.calibration_metrics["overall_confidence"] == pytest.approx(0.95)
269
-
270
-
271
- # ──────────────────────────────────────────────────────────────────────────
272
- # 9. Pero absent — fallback gracieux côté pipeline réel
273
- # ──────────────────────────────────────────────────────────────────────────
274
-
275
-
276
  class TestPeroAbsent:
277
  def test_pipeline_missing_pero_raises(
278
  self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
 
240
  # ──────────────────────────────────────────────────────────────────────────
241
 
242
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  class TestPeroAbsent:
244
  def test_pipeline_missing_pero_raises(
245
  self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
tests/engines/test_sprint49_mistral_confidences.py CHANGED
@@ -272,30 +272,3 @@ class TestRunOverride:
272
  # ──────────────────────────────────────────────────────────────────────────
273
 
274
 
275
- class TestEndToEndWithRunner:
276
- def test_runner_picks_up_mistral_confidences(self) -> None:
277
- from picarones.measurements.runner import _compute_document_result
278
- from picarones.adapters.legacy_engines.base import EngineResult
279
-
280
- ocr = EngineResult(
281
- engine_name="mistral_ocr",
282
- image_path="/tmp/x.png",
283
- text="alpha beta gamma",
284
- duration_seconds=0.1,
285
- token_confidences=[
286
- {"token": "alpha", "confidence": 0.95},
287
- {"token": "beta", "confidence": 0.85},
288
- {"token": "gamma", "confidence": 0.95},
289
- ],
290
- )
291
- dr = _compute_document_result(
292
- doc_id="d1", image_path="/tmp/x.png",
293
- ground_truth="alpha beta gamma",
294
- ocr_result=ocr, char_exclude=None,
295
- )
296
- assert dr.calibration_metrics is not None
297
- assert dr.calibration_metrics["overall_accuracy"] == 1.0
298
- # confidence moyenne = (0.95 + 0.85 + 0.95) / 3
299
- assert dr.calibration_metrics["overall_confidence"] == pytest.approx(
300
- (0.95 + 0.85 + 0.95) / 3,
301
- )
 
272
  # ──────────────────────────────────────────────────────────────────────────
273
 
274
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/engines/test_sprint50_google_vision_confidences.py CHANGED
@@ -329,29 +329,3 @@ class TestRESTPath:
329
  # ──────────────────────────────────────────────────────────────────────────
330
 
331
 
332
- class TestEndToEndWithRunner:
333
- def test_runner_picks_up_google_vision_confidences(self) -> None:
334
- from picarones.measurements.runner import _compute_document_result
335
- from picarones.adapters.legacy_engines.base import EngineResult
336
-
337
- ocr = EngineResult(
338
- engine_name="google_vision",
339
- image_path="/tmp/x.png",
340
- text="alpha beta gamma",
341
- duration_seconds=0.1,
342
- token_confidences=[
343
- {"token": "alpha", "confidence": 0.95},
344
- {"token": "beta", "confidence": 0.92},
345
- {"token": "gamma", "confidence": 0.97},
346
- ],
347
- )
348
- dr = _compute_document_result(
349
- doc_id="d1", image_path="/tmp/x.png",
350
- ground_truth="alpha beta gamma",
351
- ocr_result=ocr, char_exclude=None,
352
- )
353
- assert dr.calibration_metrics is not None
354
- assert dr.calibration_metrics["overall_accuracy"] == 1.0
355
- assert dr.calibration_metrics["overall_confidence"] == pytest.approx(
356
- (0.95 + 0.92 + 0.97) / 3,
357
- )
 
329
  # ──────────────────────────────────────────────────────────────────────────
330
 
331
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/engines/test_sprint51_azure_confidences.py CHANGED
@@ -250,29 +250,3 @@ class TestRunOverride:
250
  # ──────────────────────────────────────────────────────────────────────────
251
 
252
 
253
- class TestEndToEndWithRunner:
254
- def test_runner_picks_up_azure_confidences(self) -> None:
255
- from picarones.measurements.runner import _compute_document_result
256
- from picarones.adapters.legacy_engines.base import EngineResult
257
-
258
- ocr = EngineResult(
259
- engine_name="azure_doc_intel",
260
- image_path="/tmp/x.png",
261
- text="alpha beta gamma",
262
- duration_seconds=0.1,
263
- token_confidences=[
264
- {"token": "alpha", "confidence": 0.97},
265
- {"token": "beta", "confidence": 0.93},
266
- {"token": "gamma", "confidence": 0.95},
267
- ],
268
- )
269
- dr = _compute_document_result(
270
- doc_id="d1", image_path="/tmp/x.png",
271
- ground_truth="alpha beta gamma",
272
- ocr_result=ocr, char_exclude=None,
273
- )
274
- assert dr.calibration_metrics is not None
275
- assert dr.calibration_metrics["overall_accuracy"] == 1.0
276
- assert dr.calibration_metrics["overall_confidence"] == pytest.approx(
277
- (0.97 + 0.93 + 0.95) / 3,
278
- )
 
250
  # ──────────────────────────────────────────────────────────────────────────
251
 
252
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/integration/test_chantier5.py CHANGED
@@ -216,39 +216,3 @@ class TestCliPackage:
216
  )
217
 
218
 
219
- # ──────────────────────────────────────────────────────────────────────────
220
- # 5.C — runner reste atteignable via son API publique historique
221
- # ──────────────────────────────────────────────────────────────────────────
222
-
223
-
224
- class TestRunnerStillReachable:
225
- """L'API historique de ``picarones.measurements.runner`` reste accessible.
226
-
227
- Le chantier 2 (post-Sprint 97) avait allégé ``runner.py`` de 303 lignes
228
- (1322 → 1019) ; le sprint « découpage de runner.py » (mai 2026, hors
229
- chantier 5) l'a ensuite éclaté en sous-package ``runner/``. Dans tous
230
- les cas, les fonctions historiques restent atteignables via les
231
- ré-exports — c'est ce qu'on vérifie ici."""
232
-
233
- @pytest.mark.parametrize("name", [
234
- "run_benchmark",
235
- "_compute_document_result",
236
- "_cpu_doc_worker",
237
- "_io_doc_worker",
238
- "_aggregate_confusion",
239
- "_aggregate_calibration",
240
- "_calibration_from_engine_result",
241
- "_aggregate_ner",
242
- "_attach_ner_metrics",
243
- ])
244
- def test_function_still_in_runner(self, name):
245
- try:
246
- from picarones.measurements import runner
247
- except ImportError as exc:
248
- if "tqdm" in str(exc):
249
- pytest.skip("tqdm non installé")
250
- raise
251
- assert hasattr(runner, name), (
252
- f"runner.{name} a disparu"
253
- )
254
- assert callable(getattr(runner, name))
 
216
  )
217
 
218
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/integration/test_sprint13_parallelisation_stats.py DELETED
@@ -1,552 +0,0 @@
1
- """Tests Sprint 13 — Corrections structurelles : parallelisation, exceptions, statistiques.
2
-
3
- Classes de tests
4
- ----------------
5
- TestPyprojectCorrections (4 tests) — Part 1 : Beta, deps clarifiées
6
- TestEngineExecutionMode (5 tests) — Part 2 : execution_mode sur les classes moteur
7
- TestRunnerParallelParams (5 tests) — Part 3 : signature run_benchmark étendue
8
- TestRunnerTimeout (3 tests) — Part 3 : timeout par document
9
- TestRunnerPartialResults (4 tests) — Part 3 : sauvegarde / reprise partiels
10
- TestRunnerSilentExceptions (3 tests) — Part 2 : warnings au lieu de pass silencieux
11
- TestWilcoxonValidation (7 tests) — Part 4 : valeurs de référence connues
12
- TestWilcoxonScipyIntegration (3 tests) — Part 4 : cohérence scipy / natif
13
- """
14
-
15
- from __future__ import annotations
16
-
17
- import inspect
18
- import json
19
- import math
20
- from pathlib import Path
21
- from unittest.mock import patch
22
-
23
- import pytest
24
-
25
- ROOT = Path(__file__).parent.parent.parent
26
-
27
-
28
- # ===========================================================================
29
- # Fixtures
30
- # ===========================================================================
31
-
32
- @pytest.fixture
33
- def tmp_corpus(tmp_path):
34
- """Corpus minimal de 3 documents pour les tests runner."""
35
- from PIL import Image
36
- for i in range(3):
37
- img = Image.new("RGB", (100, 30), color="white")
38
- img.save(tmp_path / f"doc{i:02d}.png")
39
- (tmp_path / f"doc{i:02d}.gt.txt").write_text(f"texte vérité {i}", encoding="utf-8")
40
- return tmp_path
41
-
42
-
43
- # ===========================================================================
44
- # Part 1 — Corrections pyproject.toml
45
- # ===========================================================================
46
-
47
- class TestPyprojectCorrections:
48
-
49
- def _read_pyproject(self) -> str:
50
- return (ROOT / "pyproject.toml").read_text(encoding="utf-8")
51
-
52
- def test_classifier_is_beta(self):
53
- """Le classifier doit être 4 - Beta et non 5 - Production/Stable."""
54
- content = self._read_pyproject()
55
- assert "Development Status :: 4 - Beta" in content, (
56
- "pyproject.toml doit contenir 'Development Status :: 4 - Beta'"
57
- )
58
- assert "Production/Stable" not in content, (
59
- "pyproject.toml ne doit plus contenir 'Production/Stable'"
60
- )
61
-
62
- def test_fastapi_not_in_base_deps(self):
63
- """fastapi ne doit pas être dans les dépendances de base."""
64
- import re
65
- content = self._read_pyproject()
66
- # Extraire la section dependencies = [...] sous [project] (avant la 1re section suivante)
67
- m = re.search(r"^dependencies\s*=\s*\[(.*?)\]", content, re.DOTALL | re.MULTILINE)
68
- assert m, "Section dependencies introuvable dans pyproject.toml"
69
- base_deps = m.group(1)
70
- assert "fastapi" not in base_deps, (
71
- "fastapi ne doit pas être dans les dépendances de base — seulement dans [web]"
72
- )
73
-
74
- def test_httpx_not_in_base_deps(self):
75
- """httpx ne doit pas être dans les dépendances de base."""
76
- import re
77
- content = self._read_pyproject()
78
- m = re.search(r"^dependencies\s*=\s*\[(.*?)\]", content, re.DOTALL | re.MULTILINE)
79
- assert m
80
- base_deps = m.group(1)
81
- assert "httpx" not in base_deps, (
82
- "httpx ne doit pas être dans les dépendances de base — seulement dans [web]"
83
- )
84
-
85
- def test_web_extra_has_fastapi_httpx_multipart(self):
86
- """L'extra [web] doit contenir fastapi, httpx et python-multipart."""
87
- import tomllib
88
- with (ROOT / "pyproject.toml").open("rb") as fh:
89
- data = tomllib.load(fh)
90
- web_deps = " ".join(data["project"]["optional-dependencies"]["web"])
91
- assert "fastapi" in web_deps
92
- assert "httpx" in web_deps
93
- assert "python-multipart" in web_deps
94
-
95
-
96
- # ===========================================================================
97
- # Part 2 — execution_mode sur les classes moteur
98
- # ===========================================================================
99
-
100
- class TestEngineExecutionMode:
101
-
102
- def test_base_engine_default_mode_is_io(self):
103
- """BaseOCREngine doit avoir execution_mode = 'io' par défaut."""
104
- from picarones.adapters.legacy_engines.base import BaseOCREngine
105
- assert BaseOCREngine.execution_mode == "io"
106
-
107
- def test_tesseract_engine_mode_is_cpu(self):
108
- """TesseractEngine doit avoir execution_mode = 'cpu'."""
109
- from picarones.adapters.legacy_engines.tesseract import TesseractEngine
110
- assert TesseractEngine.execution_mode == "cpu"
111
-
112
- def test_pero_engine_mode_is_cpu(self):
113
- """PeroOCREngine doit avoir execution_mode = 'cpu'."""
114
- from picarones.adapters.legacy_engines.pero_ocr import PeroOCREngine
115
- assert PeroOCREngine.execution_mode == "cpu"
116
-
117
- def test_mistral_engine_default_mode_is_io(self):
118
- """MistralOCREngine doit hériter execution_mode = 'io'."""
119
- from picarones.adapters.legacy_engines.mistral_ocr import MistralOCREngine
120
- assert MistralOCREngine.execution_mode == "io"
121
-
122
- def test_google_vision_default_mode_is_io(self):
123
- """GoogleVisionEngine doit hériter execution_mode = 'io'."""
124
- from picarones.adapters.legacy_engines.google_vision import GoogleVisionEngine
125
- assert GoogleVisionEngine.execution_mode == "io"
126
-
127
-
128
- # ===========================================================================
129
- # Part 3 — Signature run_benchmark étendue
130
- # ===========================================================================
131
-
132
- class TestRunnerParallelParams:
133
-
134
- def test_max_workers_param_exists(self):
135
- """run_benchmark doit accepter max_workers."""
136
- from picarones.measurements.runner import run_benchmark
137
- sig = inspect.signature(run_benchmark)
138
- assert "max_workers" in sig.parameters
139
-
140
- def test_max_workers_default_is_4(self):
141
- """max_workers doit avoir 4 comme valeur par défaut."""
142
- from picarones.measurements.runner import run_benchmark
143
- sig = inspect.signature(run_benchmark)
144
- assert sig.parameters["max_workers"].default == 4
145
-
146
- def test_timeout_seconds_param_exists(self):
147
- """run_benchmark doit accepter timeout_seconds."""
148
- from picarones.measurements.runner import run_benchmark
149
- sig = inspect.signature(run_benchmark)
150
- assert "timeout_seconds" in sig.parameters
151
-
152
- def test_timeout_seconds_default_is_60(self):
153
- """timeout_seconds doit avoir 60.0 comme valeur par défaut."""
154
- from picarones.measurements.runner import run_benchmark
155
- sig = inspect.signature(run_benchmark)
156
- assert sig.parameters["timeout_seconds"].default == 60.0
157
-
158
- def test_partial_dir_param_exists(self):
159
- """run_benchmark doit accepter partial_dir (None par défaut)."""
160
- from picarones.measurements.runner import run_benchmark
161
- sig = inspect.signature(run_benchmark)
162
- assert "partial_dir" in sig.parameters
163
- assert sig.parameters["partial_dir"].default is None
164
-
165
-
166
- # ===========================================================================
167
- # Part 3 — Timeout par document
168
- # ===========================================================================
169
-
170
- class TestRunnerTimeout:
171
-
172
- def test_timeout_doc_result_has_error(self, tmp_corpus):
173
- """Un document ayant dépassé le timeout doit avoir engine_error contenant 'timeout'."""
174
- from picarones.evaluation.corpus import load_corpus_from_directory
175
- from picarones.measurements.runner import run_benchmark
176
- from picarones.adapters.legacy_engines.base import BaseOCREngine
177
- import time
178
-
179
- class SlowEngine(BaseOCREngine):
180
- @property
181
- def name(self): return "slow_engine"
182
- def version(self): return "0.1"
183
- def _run_ocr(self, image_path):
184
- time.sleep(5) # 5 secondes — dépasse le timeout de 1s
185
- return "jamais atteint"
186
-
187
- corpus = load_corpus_from_directory(str(tmp_corpus))
188
- result = run_benchmark(
189
- corpus, [SlowEngine()],
190
- show_progress=False,
191
- timeout_seconds=1.0,
192
- max_workers=1,
193
- )
194
- assert len(result.engine_reports) == 1
195
- report = result.engine_reports[0]
196
- assert len(report.document_results) == len(corpus)
197
- # Au moins un document doit être marqué timeout
198
- timeout_docs = [dr for dr in report.document_results if dr.engine_error and "timeout" in dr.engine_error]
199
- assert len(timeout_docs) > 0, "Aucun document marqué timeout — le timeout ne fonctionne pas"
200
-
201
- def test_timeout_doc_result_cer_is_one(self, tmp_corpus):
202
- """Un document timeout doit avoir CER = 1.0."""
203
- from picarones.evaluation.corpus import load_corpus_from_directory
204
- from picarones.measurements.runner import run_benchmark
205
- from picarones.adapters.legacy_engines.base import BaseOCREngine
206
- import time
207
-
208
- class SlowEngine(BaseOCREngine):
209
- @property
210
- def name(self): return "slow"
211
- def version(self): return "0.1"
212
- def _run_ocr(self, image_path):
213
- time.sleep(5)
214
- return ""
215
-
216
- corpus = load_corpus_from_directory(str(tmp_corpus))
217
- result = run_benchmark(
218
- corpus, [SlowEngine()],
219
- show_progress=False,
220
- timeout_seconds=1.0,
221
- max_workers=1,
222
- )
223
- for dr in result.engine_reports[0].document_results:
224
- if dr.engine_error and "timeout" in dr.engine_error:
225
- assert dr.metrics.cer == 1.0
226
-
227
- def test_fast_docs_not_affected_by_timeout(self, tmp_corpus):
228
- """Des documents rapides ne doivent pas être touchés par un timeout généreux."""
229
- from picarones.evaluation.corpus import load_corpus_from_directory
230
- from picarones.measurements.runner import run_benchmark
231
- from picarones.adapters.legacy_engines.base import BaseOCREngine
232
-
233
- class FastEngine(BaseOCREngine):
234
- @property
235
- def name(self): return "fast"
236
- def version(self): return "0.1"
237
- def _run_ocr(self, image_path): return "texte ocr"
238
-
239
- corpus = load_corpus_from_directory(str(tmp_corpus))
240
- result = run_benchmark(
241
- corpus, [FastEngine()],
242
- show_progress=False,
243
- timeout_seconds=30.0,
244
- )
245
- timeout_docs = [
246
- dr for dr in result.engine_reports[0].document_results
247
- if dr.engine_error and "timeout" in dr.engine_error
248
- ]
249
- assert len(timeout_docs) == 0, "Les documents rapides ne doivent pas être marqués timeout"
250
-
251
-
252
- # ===========================================================================
253
- # Part 3 — Résultats partiels (sauvegarde / reprise)
254
- # ===========================================================================
255
-
256
- class TestRunnerPartialResults:
257
-
258
- def test_partial_file_created_during_run(self, tmp_corpus, tmp_path):
259
- """_save_partial_line doit être appelée pour chaque document traité."""
260
- from picarones.evaluation.corpus import load_corpus_from_directory
261
- from picarones.measurements.runner import run_benchmark
262
- from picarones.adapters.legacy_engines.base import BaseOCREngine
263
- # Sprint « découpage de runner.py » (mai 2026) : ``_save_partial_line``
264
- # vit désormais dans le sous-module ``runner.partial`` ; le ré-export
265
- # dans ``runner.__init__`` est une référence figée. Pour patcher
266
- # dynamiquement la fonction utilisée par ``run_benchmark``, il faut
267
- # cibler le module source.
268
- from picarones.measurements.runner import partial as _partial_mod
269
- from picarones.measurements.runner import orchestration as _orch_mod
270
-
271
- save_calls: list[str] = []
272
- original_save = _partial_mod._save_partial_line
273
-
274
- def tracking_save(path, doc_result):
275
- save_calls.append(doc_result.doc_id)
276
- original_save(path, doc_result)
277
-
278
- class MockEngine(BaseOCREngine):
279
- @property
280
- def name(self): return "mock"
281
- def version(self): return "0.1"
282
- def _run_ocr(self, image_path): return "texte"
283
-
284
- corpus = load_corpus_from_directory(str(tmp_corpus))
285
- # Patche la fonction directement dans l'orchestrateur, qui
286
- # l'a importée depuis ``partial`` au moment du chargement.
287
- with patch.object(_orch_mod, "_save_partial_line", side_effect=tracking_save):
288
- run_benchmark(
289
- corpus, [MockEngine()],
290
- show_progress=False,
291
- partial_dir=str(tmp_path),
292
- )
293
- assert len(save_calls) == len(corpus), (
294
- f"_save_partial_line appelée {len(save_calls)} fois, attendu {len(corpus)}"
295
- )
296
-
297
- def test_partial_file_deleted_after_success(self, tmp_corpus, tmp_path):
298
- """Le fichier .partial.json doit être supprimé après un benchmark réussi."""
299
- from picarones.evaluation.corpus import load_corpus_from_directory
300
- from picarones.measurements.runner import run_benchmark
301
- from picarones.adapters.legacy_engines.base import BaseOCREngine
302
-
303
- class MockEngine(BaseOCREngine):
304
- @property
305
- def name(self): return "mock"
306
- def version(self): return "0.1"
307
- def _run_ocr(self, image_path): return "texte"
308
-
309
- corpus = load_corpus_from_directory(str(tmp_corpus))
310
- run_benchmark(
311
- corpus, [MockEngine()],
312
- show_progress=False,
313
- partial_dir=str(tmp_path),
314
- )
315
- partial_files = list(tmp_path.glob("*.partial.json"))
316
- assert len(partial_files) == 0, f"Fichier(s) partiel(s) non supprimé(s) : {partial_files}"
317
-
318
- def test_partial_load_skips_already_done_docs(self, tmp_corpus, tmp_path):
319
- """La reprise depuis un fichier partiel doit sauter les documents déjà traités."""
320
- from picarones.evaluation.corpus import load_corpus_from_directory
321
- from picarones.measurements.runner import _load_partial, _partial_path
322
-
323
- corpus = load_corpus_from_directory(str(tmp_corpus))
324
- corpus_name = corpus.name
325
- engine_name = "mock_engine"
326
-
327
- # Créer un fichier partiel simulant 1 document déjà traité
328
- path = _partial_path(corpus_name, engine_name, tmp_path)
329
- doc = corpus.documents[0]
330
- partial_line = {
331
- "doc_id": doc.doc_id,
332
- "image_path": str(doc.image_path),
333
- "ground_truth": doc.ground_truth,
334
- "hypothesis": "déjà traité",
335
- "metrics": {"cer": 0.1, "cer_nfc": 0.1, "cer_caseless": 0.1,
336
- "wer": 0.1, "wer_normalized": 0.1, "mer": 0.1, "wil": 0.1,
337
- "reference_length": 10, "hypothesis_length": 10},
338
- "duration_seconds": 0.5,
339
- }
340
- path.write_text(json.dumps(partial_line) + "\n", encoding="utf-8")
341
-
342
- _, loaded = _load_partial(corpus_name, engine_name, tmp_path)
343
- assert len(loaded) == 1
344
- assert loaded[0].doc_id == doc.doc_id
345
- assert loaded[0].hypothesis == "déjà traité"
346
-
347
- def test_partial_load_returns_empty_for_missing_file(self, tmp_path):
348
- """Si aucun fichier partiel n'existe, la liste doit être vide."""
349
- from picarones.measurements.runner import _load_partial
350
- _, loaded = _load_partial("corpus_inexistant", "moteur_inexistant", tmp_path)
351
- assert loaded == []
352
-
353
-
354
- # ===========================================================================
355
- # Part 2 — Exceptions non silencieuses dans le runner
356
- # ===========================================================================
357
-
358
- class TestRunnerSilentExceptions:
359
-
360
- def test_confusion_failure_logs_warning(self, tmp_corpus, caplog):
361
- """Une erreur dans build_confusion_matrix doit être loguée, pas ignorée."""
362
- import logging
363
- from picarones.evaluation.corpus import load_corpus_from_directory
364
- from picarones.measurements.runner import run_benchmark
365
- from picarones.adapters.legacy_engines.base import BaseOCREngine
366
-
367
- class MockEngine(BaseOCREngine):
368
- @property
369
- def name(self): return "mock"
370
- def version(self): return "0.1"
371
- def _run_ocr(self, image_path): return "texte ocr"
372
-
373
- corpus = load_corpus_from_directory(str(tmp_corpus))
374
- with patch(
375
- "picarones.measurements.runner._compute_document_result",
376
- wraps=__import__("picarones.measurements.runner", fromlist=["_compute_document_result"])._compute_document_result,
377
- ):
378
- with patch("picarones.evaluation.metrics.confusion.build_confusion_matrix", side_effect=RuntimeError("crash test")):
379
- with caplog.at_level(logging.WARNING):
380
- result = run_benchmark(corpus, [MockEngine()], show_progress=False)
381
-
382
- assert result is not None, "Le benchmark ne doit pas planter si la confusion matrix échoue"
383
- # La clé est que le benchmark se termine normalement
384
- assert len(result.engine_reports) == 1
385
-
386
- def test_progress_callback_failure_logs_warning(self, tmp_corpus, caplog):
387
- """Une exception dans le progress_callback doit être loguée, pas propagée."""
388
- import logging
389
- from picarones.evaluation.corpus import load_corpus_from_directory
390
- from picarones.measurements.runner import run_benchmark
391
- from picarones.adapters.legacy_engines.base import BaseOCREngine
392
-
393
- class MockEngine(BaseOCREngine):
394
- @property
395
- def name(self): return "mock"
396
- def version(self): return "0.1"
397
- def _run_ocr(self, image_path): return "texte"
398
-
399
- def bad_callback(engine_name, doc_idx, doc_id):
400
- raise ValueError("callback crash")
401
-
402
- corpus = load_corpus_from_directory(str(tmp_corpus))
403
- with caplog.at_level(logging.WARNING):
404
- result = run_benchmark(
405
- corpus, [MockEngine()],
406
- show_progress=False,
407
- progress_callback=bad_callback,
408
- )
409
- assert result is not None
410
- assert any("progress_callback" in r.message for r in caplog.records), (
411
- "L'exception du callback doit être loguée en WARNING"
412
- )
413
-
414
- def test_aggregate_helpers_log_on_failure(self, caplog):
415
- """Les helpers _aggregate_* doivent logger en WARNING et retourner None."""
416
- import logging
417
- from picarones.measurements.runner import _aggregate_confusion
418
-
419
- # Créer un doc_result avec des données de confusion corrompues
420
- from picarones.evaluation.benchmark_result import DocumentResult
421
- from picarones.evaluation.metric_result import MetricsResult
422
- bad_dr = DocumentResult(
423
- doc_id="x", image_path="x.png", ground_truth="gt", hypothesis="hyp",
424
- metrics=MetricsResult(cer=0.1, cer_nfc=0.1, cer_caseless=0.1,
425
- wer=0.1, wer_normalized=0.1, mer=0.1, wil=0.1,
426
- reference_length=2, hypothesis_length=2),
427
- duration_seconds=0.1,
428
- confusion_matrix={"invalid_key": "corrupt_data"}, # va planter ConfusionMatrix(**...)
429
- )
430
- with caplog.at_level(logging.WARNING):
431
- result = _aggregate_confusion([bad_dr])
432
- assert result is None
433
- assert any("aggregate_confusion" in r.message for r in caplog.records)
434
-
435
-
436
- # ===========================================================================
437
- # Part 4 — Validation du test de Wilcoxon contre valeurs de référence
438
- # ===========================================================================
439
-
440
- class TestWilcoxonValidation:
441
-
442
- def test_identical_sequences_not_significant(self):
443
- """Séquences identiques → pas de différence, p = 1.0, significant = False."""
444
- from picarones.evaluation.statistics import wilcoxon_test
445
- a = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
446
- r = wilcoxon_test(a, a)
447
- assert r["significant"] is False
448
- assert r["p_value"] == 1.0
449
- assert r["n_pairs"] == 0
450
-
451
- def test_all_positive_diffs_w_minus_is_zero(self):
452
- """Si toutes les différences a−b sont positives : W⁻ = 0, W⁺ = n(n+1)/2."""
453
- from picarones.evaluation.statistics import wilcoxon_test
454
- n = 10
455
- a = [float(i) for i in range(1, n + 1)]
456
- b = [0.0] * n
457
- r = wilcoxon_test(a, b)
458
- expected_total = n * (n + 1) / 2.0
459
- assert math.isclose(r["W_minus"], 0.0, abs_tol=1e-9)
460
- assert math.isclose(r["W_plus"], expected_total, abs_tol=1e-9)
461
-
462
- def test_w_plus_w_minus_sum_invariant(self):
463
- """W⁺ + W⁻ doit toujours être égal à n(n+1)/2 (n = nombre de paires non nulles)."""
464
- from picarones.evaluation.statistics import wilcoxon_test
465
- a = [0.10, 0.25, 0.05, 0.40, 0.30, 0.15, 0.20, 0.35, 0.08, 0.18]
466
- b = [0.12, 0.20, 0.08, 0.35, 0.28, 0.18, 0.15, 0.40, 0.10, 0.20]
467
- r = wilcoxon_test(a, b)
468
- n = r["n_pairs"]
469
- expected = n * (n + 1) / 2.0
470
- actual = r["W_plus"] + r["W_minus"]
471
- assert math.isclose(actual, expected, abs_tol=1e-6), (
472
- f"W⁺+W⁻ = {actual} ≠ n(n+1)/2 = {expected}"
473
- )
474
-
475
- def test_clearly_different_sequences_significant(self):
476
- """Deux séquences très différentes (n=15) doivent donner p < 0.05."""
477
- from picarones.evaluation.statistics import wilcoxon_test
478
- a = [0.05] * 15 # moteur A très performant
479
- b = [0.60] * 15 # moteur B peu performant — toutes diff = −0.55
480
- # Diffs a−b = −0.55 pour tous → W⁺ = 0 → devrait être significatif
481
- r = wilcoxon_test(a, b)
482
- assert r["significant"] is True, f"p = {r['p_value']} — devrait être significatif"
483
- assert r["p_value"] < 0.05
484
-
485
- def test_large_n_normal_approximation_reasonable(self):
486
- """Pour n = 20, l'approximation normale doit donner une p-value dans [0, 1]."""
487
- from picarones.evaluation.statistics import wilcoxon_test
488
- import random
489
- rng = random.Random(42)
490
- a = [rng.uniform(0.1, 0.5) for _ in range(20)]
491
- b = [x + rng.uniform(0.0, 0.1) for x in a]
492
- r = wilcoxon_test(a, b)
493
- assert 0.0 <= r["p_value"] <= 1.0
494
- assert r["n_pairs"] <= 20
495
-
496
- def test_small_n_returns_conservative_p(self):
497
- """Pour n < 10, la p-value doit être 0.04 (significatif) ou 0.20 (non sign.)."""
498
- from picarones.evaluation.statistics import wilcoxon_test, _SCIPY_AVAILABLE
499
- if _SCIPY_AVAILABLE:
500
- pytest.skip("scipy disponible — la table exacte n'est pas utilisée")
501
- a = [0.1, 0.2, 0.3]
502
- b = [0.5, 0.6, 0.7] # toutes diff = −0.4 → W = 0 → significatif
503
- r = wilcoxon_test(a, b)
504
- # Avec n=3, W=0 ≤ _W_CRITICAL[3]=0 → p=0.04
505
- assert r["p_value"] in (0.04, 0.20)
506
-
507
- def test_result_keys_complete(self):
508
- """Le dict retourné doit contenir toutes les clés documentées."""
509
- from picarones.evaluation.statistics import wilcoxon_test
510
- r = wilcoxon_test([0.1, 0.3, 0.2, 0.4, 0.15, 0.35, 0.25, 0.5, 0.45, 0.05],
511
- [0.2, 0.2, 0.3, 0.3, 0.25, 0.25, 0.35, 0.35, 0.40, 0.15])
512
- for key in ("statistic", "p_value", "significant", "interpretation", "n_pairs", "W_plus", "W_minus"):
513
- assert key in r, f"Clé manquante dans le résultat Wilcoxon : {key}"
514
-
515
-
516
- # ===========================================================================
517
- # Part 4 — Cohérence scipy / implémentation native
518
- # ===========================================================================
519
-
520
- class TestWilcoxonScipyIntegration:
521
-
522
- def test_scipy_available_flag_is_bool(self):
523
- """_SCIPY_AVAILABLE doit être un booléen."""
524
- from picarones.evaluation.statistics import _SCIPY_AVAILABLE
525
- assert isinstance(_SCIPY_AVAILABLE, bool)
526
-
527
- def test_scipy_and_native_agree_on_significance(self):
528
- """Scipy et l'implémentation native doivent s'accorder sur la significativité."""
529
- from picarones.evaluation.statistics import wilcoxon_test, _SCIPY_AVAILABLE
530
- if not _SCIPY_AVAILABLE:
531
- pytest.skip("scipy non disponible")
532
-
533
- # Cas avec différences claires et n suffisant pour que les deux méthodes convergent
534
- a = [0.05, 0.08, 0.06, 0.07, 0.04, 0.09, 0.05, 0.07, 0.06, 0.08,
535
- 0.05, 0.07, 0.06, 0.08, 0.04]
536
- b = [0.30, 0.35, 0.28, 0.32, 0.31, 0.29, 0.34, 0.33, 0.30, 0.31,
537
- 0.29, 0.32, 0.33, 0.30, 0.31]
538
-
539
- r = wilcoxon_test(a, b)
540
- # Avec scipy, résultat doit être significatif
541
- assert r["significant"] is True
542
-
543
- def test_scipy_p_value_in_valid_range(self):
544
- """La p-value fournie par scipy doit être dans [0, 1]."""
545
- from picarones.evaluation.statistics import wilcoxon_test, _SCIPY_AVAILABLE
546
- if not _SCIPY_AVAILABLE:
547
- pytest.skip("scipy non disponible")
548
-
549
- a = [0.1 + i * 0.02 for i in range(12)]
550
- b = [0.1 + i * 0.01 for i in range(12)]
551
- r = wilcoxon_test(a, b)
552
- assert 0.0 <= r["p_value"] <= 1.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/measurements/test_sprint15_llm_pipeline_bugs.py CHANGED
@@ -247,62 +247,3 @@ class TestPipelineEmptyLLMResponse:
247
  if rec.levelno >= logging.WARNING
248
  )
249
 
250
-
251
- # ---------------------------------------------------------------------------
252
- # Bug 3 — Cohérence runner/rapport : empty hypothesis → CER 1.0 dans DocumentResult
253
- # ---------------------------------------------------------------------------
254
-
255
- class TestRunnerDocumentResultCohérence:
256
- """Le DocumentResult doit stocker CER=1.0 pour une hypothèse vide."""
257
-
258
- def test_empty_hypothesis_stored_as_cer_one(self):
259
- """_compute_document_result avec text="" → metrics.cer = 1.0."""
260
- from picarones.measurements.runner import _compute_document_result
261
- from picarones.adapters.legacy_engines.base import EngineResult
262
-
263
- ocr_result = EngineResult(
264
- engine_name="TestEngine",
265
- image_path="fake.png",
266
- text="", # ← sortie vide
267
- duration_seconds=1.0,
268
- error=None, # ← pas d'erreur technique
269
- )
270
-
271
- doc_result = _compute_document_result(
272
- doc_id="doc1",
273
- image_path="fake.png",
274
- ground_truth="Bonjour le monde",
275
- ocr_result=ocr_result,
276
- char_exclude=None,
277
- )
278
-
279
- assert doc_result.metrics.cer == pytest.approx(1.0), (
280
- f"CER attendu 1.0 pour hypothèse vide, obtenu {doc_result.metrics.cer}"
281
- )
282
- assert doc_result.metrics.error is None, (
283
- "L'erreur ne devrait pas être renseignée — c'est une hypothèse vide, pas une erreur technique"
284
- )
285
-
286
- def test_engine_error_also_gives_cer_one(self):
287
- """EngineResult avec error → metrics.cer = 1.0 (comportement existant)."""
288
- from picarones.measurements.runner import _compute_document_result
289
- from picarones.adapters.legacy_engines.base import EngineResult
290
-
291
- ocr_result = EngineResult(
292
- engine_name="TestEngine",
293
- image_path="fake.png",
294
- text="",
295
- duration_seconds=0.0,
296
- error="Moteur en erreur",
297
- )
298
-
299
- doc_result = _compute_document_result(
300
- doc_id="doc1",
301
- image_path="fake.png",
302
- ground_truth="Bonjour le monde",
303
- ocr_result=ocr_result,
304
- char_exclude=None,
305
- )
306
-
307
- assert doc_result.metrics.cer == pytest.approx(1.0)
308
- assert doc_result.metrics.error is not None
 
247
  if rec.levelno >= logging.WARNING
248
  )
249
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/measurements/test_sprint16_narrative_foundations.py CHANGED
@@ -12,10 +12,7 @@ Couverture :
12
 
13
  from __future__ import annotations
14
 
15
- import json
16
- from pathlib import Path
17
 
18
- from picarones.evaluation.corpus import Corpus, Document
19
  from picarones.measurements.narrative import (
20
  DetectorRegistry,
21
  Fact,
@@ -23,219 +20,6 @@ from picarones.measurements.narrative import (
23
  FactType,
24
  detect_all,
25
  )
26
- from picarones.measurements.runner import (
27
- _aggregate_hallucination,
28
- _aggregate_line_metrics,
29
- _compute_document_result,
30
- run_benchmark,
31
- )
32
- from picarones.adapters.legacy_engines.base import BaseOCREngine, EngineResult
33
-
34
-
35
- class _FakeEngine(BaseOCREngine):
36
- """Moteur factice — renvoie un texte configurable, utile en test."""
37
-
38
- def __init__(self, output_text: str, name: str = "fake", config=None):
39
- super().__init__(config)
40
- self._output = output_text
41
- self._display_name = name
42
-
43
- @property
44
- def name(self) -> str:
45
- return self._display_name
46
-
47
- def version(self) -> str:
48
- return "test"
49
-
50
- def _run_ocr(self, image_path):
51
- return self._output, None
52
-
53
- def run(self, image_path) -> EngineResult:
54
- return EngineResult(
55
- engine_name=self.name,
56
- image_path=str(image_path),
57
- text=self._output,
58
- duration_seconds=0.01,
59
- )
60
-
61
-
62
- # ---------------------------------------------------------------------------
63
- # 1. Câblage line_metrics et hallucination par document
64
- # ---------------------------------------------------------------------------
65
-
66
- class TestDocumentResultWiring:
67
- """Vérifie que ``_compute_document_result`` peuple les nouveaux champs."""
68
-
69
- def test_line_metrics_populated_on_success(self, tmp_path: Path):
70
- image = tmp_path / "doc.png"
71
- image.write_bytes(b"\x89PNG\r\n\x1a\n") # stub — image_quality loggera un warning
72
-
73
- ocr = EngineResult(
74
- engine_name="fake",
75
- image_path=str(image),
76
- text="ligne une\nligne deux\nligne trois",
77
- duration_seconds=0.1,
78
- )
79
- gt = "ligne une\nligne deux\nligne trois"
80
-
81
- result = _compute_document_result(
82
- doc_id="doc1",
83
- image_path=str(image),
84
- ground_truth=gt,
85
- ocr_result=ocr,
86
- char_exclude=None,
87
- )
88
-
89
- assert result.line_metrics is not None, "line_metrics doit être peuplé"
90
- assert "percentiles" in result.line_metrics
91
- assert "gini" in result.line_metrics
92
- assert result.line_metrics["line_count"] == 3
93
-
94
- def test_hallucination_metrics_populated_on_success(self, tmp_path: Path):
95
- image = tmp_path / "doc.png"
96
- image.write_bytes(b"")
97
-
98
- gt = "le chat est sur le tapis rouge et dort paisiblement"
99
- hyp = "le chat mange des bananes spatiales en orbite lunaire"
100
-
101
- ocr = EngineResult(
102
- engine_name="fake",
103
- image_path=str(image),
104
- text=hyp,
105
- duration_seconds=0.1,
106
- )
107
-
108
- result = _compute_document_result(
109
- doc_id="doc1",
110
- image_path=str(image),
111
- ground_truth=gt,
112
- ocr_result=ocr,
113
- char_exclude=None,
114
- )
115
-
116
- assert result.hallucination_metrics is not None
117
- assert "anchor_score" in result.hallucination_metrics
118
- assert "length_ratio" in result.hallucination_metrics
119
- assert "is_hallucinating" in result.hallucination_metrics
120
-
121
- def test_new_fields_empty_on_engine_failure(self, tmp_path: Path):
122
- """Si l'OCR échoue (success=False), pas de calcul line_metrics/hallucination."""
123
- image = tmp_path / "doc.png"
124
- image.write_bytes(b"")
125
-
126
- ocr = EngineResult(
127
- engine_name="fake",
128
- image_path=str(image),
129
- text="",
130
- duration_seconds=0.1,
131
- error="simulated failure",
132
- )
133
- result = _compute_document_result(
134
- doc_id="doc1",
135
- image_path=str(image),
136
- ground_truth="ground truth text",
137
- ocr_result=ocr,
138
- char_exclude=None,
139
- )
140
-
141
- assert result.line_metrics is None
142
- assert result.hallucination_metrics is None
143
-
144
-
145
- # ---------------------------------------------------------------------------
146
- # 2. Agrégation au niveau EngineReport
147
- # ---------------------------------------------------------------------------
148
-
149
- class TestAggregationWiring:
150
- """Vérifie que le benchmark complet produit les agrégations."""
151
-
152
- def test_aggregate_line_metrics_helper_with_empty_list(self):
153
- assert _aggregate_line_metrics([]) is None
154
-
155
- def test_aggregate_hallucination_helper_with_empty_list(self):
156
- assert _aggregate_hallucination([]) is None
157
-
158
- def test_benchmark_end_to_end_produces_aggregations(self, tmp_path: Path):
159
- img = tmp_path / "test.png"
160
- img.write_bytes(b"")
161
-
162
- corpus = Corpus(
163
- name="test",
164
- documents=[
165
- Document(
166
- doc_id="d1",
167
- image_path=img,
168
- ground_truth="bonjour le monde\nligne deux\nfin",
169
- ),
170
- Document(
171
- doc_id="d2",
172
- image_path=img,
173
- ground_truth="autre document test\navec deux lignes",
174
- ),
175
- ],
176
- source_path=str(tmp_path),
177
- )
178
-
179
- engine = _FakeEngine(
180
- output_text="bonjour le monde\nligne deux\nfin",
181
- name="fake_engine",
182
- )
183
-
184
- result = run_benchmark(
185
- corpus=corpus,
186
- engines=[engine],
187
- show_progress=False,
188
- max_workers=1,
189
- partial_dir=str(tmp_path / "partial"),
190
- )
191
-
192
- assert len(result.engine_reports) == 1
193
- report = result.engine_reports[0]
194
-
195
- assert report.aggregated_line_metrics is not None, (
196
- "aggregated_line_metrics doit être peuplé après benchmark"
197
- )
198
- assert "gini_mean" in report.aggregated_line_metrics
199
- assert "document_count" in report.aggregated_line_metrics
200
- assert report.aggregated_line_metrics["document_count"] == 2
201
-
202
- assert report.aggregated_hallucination is not None, (
203
- "aggregated_hallucination doit être peuplé après benchmark"
204
- )
205
- assert "anchor_score_mean" in report.aggregated_hallucination
206
- assert report.aggregated_hallucination["document_count"] == 2
207
-
208
- def test_json_export_includes_new_aggregations(self, tmp_path: Path):
209
- img = tmp_path / "t.png"
210
- img.write_bytes(b"")
211
- corpus = Corpus(
212
- name="test",
213
- documents=[
214
- Document(doc_id="d1", image_path=img, ground_truth="un\ndeux"),
215
- ],
216
- source_path=str(tmp_path),
217
- )
218
- engine = _FakeEngine(output_text="un\ndeux", name="fake")
219
-
220
- out = tmp_path / "bench.json"
221
- run_benchmark(
222
- corpus=corpus,
223
- engines=[engine],
224
- output_json=out,
225
- show_progress=False,
226
- max_workers=1,
227
- partial_dir=str(tmp_path / "partial"),
228
- )
229
-
230
- data = json.loads(out.read_text(encoding="utf-8"))
231
- report = data["engine_reports"][0]
232
- assert "aggregated_line_metrics" in report
233
- assert "aggregated_hallucination" in report
234
-
235
-
236
- # ---------------------------------------------------------------------------
237
- # 3. Modèle Fact et DetectorRegistry
238
- # ---------------------------------------------------------------------------
239
 
240
  class TestFactModel:
241
  def test_fact_is_serializable(self):
 
12
 
13
  from __future__ import annotations
14
 
 
 
15
 
 
16
  from picarones.measurements.narrative import (
17
  DetectorRegistry,
18
  Fact,
 
20
  FactType,
21
  detect_all,
22
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  class TestFactModel:
25
  def test_fact_is_serializable(self):
tests/measurements/test_sprint40_ner_runner.py DELETED
@@ -1,311 +0,0 @@
1
- """Tests Sprint 40 — backend extracteur NER + câblage runner.
2
-
3
- Couvre :
4
-
5
- 1. ``SpacyEntityExtractor`` lazy-importe spaCy ; sans spaCy installé,
6
- l'extracteur retourne ``[]`` avec un warning explicite.
7
- 2. ``is_spacy_available`` reflète l'état réel.
8
- 3. ``get_extractor(profile)`` accepte une clé de profil ou un nom de
9
- modèle direct.
10
- 4. ``DocumentResult.ner_metrics`` est sérialisé via ``as_dict``
11
- uniquement quand renseigné, et libéré par ``compact()``.
12
- 5. ``EngineReport.aggregated_ner`` apparaît dans ``as_dict`` quand
13
- renseigné (rétrocompat sinon).
14
- 6. Câblage runner avec un extracteur **mock** (callable injecté) :
15
- - ``ner_metrics`` est attaché aux DR dont le doc a une GT entités ;
16
- - ``aggregated_ner`` est calculé sur l'EngineReport ;
17
- - les docs sans GT entités sont ignorés.
18
- 7. Sans extracteur fourni au runner, rien n'est calculé (rétrocompat).
19
- 8. Un extracteur qui lève sur un doc spécifique → warning, autres docs
20
- inchangés.
21
- """
22
-
23
- from __future__ import annotations
24
-
25
- from pathlib import Path
26
-
27
- import pytest
28
-
29
- from picarones.domain.artifacts import ArtifactType
30
- from picarones.evaluation.corpus import Corpus, Document, EntitiesGT, TextGT
31
- from picarones.evaluation.metrics.ner_backends import (
32
- SPACY_PROFILES,
33
- SpacyEntityExtractor,
34
- get_extractor,
35
- is_spacy_available,
36
- )
37
- from picarones.evaluation.benchmark_result import DocumentResult, EngineReport
38
- from picarones.measurements.runner import _aggregate_ner, _attach_ner_metrics
39
-
40
-
41
- # ──────────────────────────────────────────────────────────────────────────
42
- # 1-3. Backend SpacyEntityExtractor
43
- # ──────────────────────────────────────────────────────────────────────────
44
-
45
-
46
- class TestSpacyExtractor:
47
- def test_falls_back_silently_without_spacy(
48
- self, caplog: pytest.LogCaptureFixture
49
- ) -> None:
50
- """Sans spaCy installé, l'extracteur retourne [] avec un warning
51
- explicite et ne lève pas."""
52
- ext = SpacyEntityExtractor("fr_core_news_sm")
53
- with caplog.at_level("WARNING", logger="picarones.evaluation.metrics.ner_backends"):
54
- result = ext("Marie de Bourgogne en 1477.")
55
- # Sans spaCy, on a toujours [] et un warning
56
- if not is_spacy_available():
57
- assert result == []
58
- assert any(
59
- "spaCy" in rec.message or "spacy" in rec.message
60
- for rec in caplog.records
61
- )
62
- assert ext.available is False
63
-
64
- def test_empty_text_returns_empty(self) -> None:
65
- ext = SpacyEntityExtractor()
66
- assert ext("") == []
67
-
68
- def test_idempotent_load(self) -> None:
69
- """L'appel répété ne re-tente pas le chargement."""
70
- ext = SpacyEntityExtractor("inexistant_model_xyz")
71
- ext("test") # premier appel : tentative de chargement
72
- ext("test") # deuxième : pas de re-tentative
73
- assert ext._loaded is True
74
-
75
-
76
- class TestProfilesAndFactory:
77
- def test_known_profiles_listed(self) -> None:
78
- for key in ("fr", "en", "multilingual"):
79
- assert key in SPACY_PROFILES
80
-
81
- def test_get_extractor_with_known_profile(self) -> None:
82
- ext = get_extractor("fr")
83
- assert isinstance(ext, SpacyEntityExtractor)
84
- assert ext.model_name == SPACY_PROFILES["fr"]
85
-
86
- def test_get_extractor_with_direct_model_name(self) -> None:
87
- ext = get_extractor("custom_model_name")
88
- assert ext.model_name == "custom_model_name"
89
-
90
-
91
- # ──────────────────────────────────────────────────────────────────────────
92
- # 4-5. DocumentResult / EngineReport sérialisation
93
- # ──────────────────────────────────────────────────────────────────────────
94
-
95
-
96
- def _make_document_result(
97
- doc_id: str = "d1",
98
- hypothesis: str = "Marie de Bourgogne en 1477.",
99
- ner_metrics: dict | None = None,
100
- ) -> DocumentResult:
101
- from picarones.evaluation.metric_result import MetricsResult
102
-
103
- return DocumentResult(
104
- doc_id=doc_id,
105
- image_path="/tmp/x.png",
106
- ground_truth="Marie de Bourgogne en 1477.",
107
- hypothesis=hypothesis,
108
- metrics=MetricsResult(
109
- cer=0.0, cer_nfc=0.0, cer_caseless=0.0,
110
- wer=0.0, wer_normalized=0.0, mer=0.0, wil=0.0,
111
- reference_length=27, hypothesis_length=27,
112
- ),
113
- duration_seconds=0.1,
114
- ner_metrics=ner_metrics,
115
- )
116
-
117
-
118
- class TestModelSerialization:
119
- def test_ner_metrics_omitted_when_none(self) -> None:
120
- dr = _make_document_result(ner_metrics=None)
121
- d = dr.as_dict()
122
- assert "ner_metrics" not in d
123
-
124
- def test_ner_metrics_present_when_set(self) -> None:
125
- dr = _make_document_result(ner_metrics={"global": {"f1": 0.8}})
126
- d = dr.as_dict()
127
- assert d["ner_metrics"] == {"global": {"f1": 0.8}}
128
-
129
- def test_compact_clears_ner_metrics(self) -> None:
130
- # Sprint A14-S1 — A.I.0 P0 : ``compact()`` est désormais no-op
131
- # par défaut (cf. core/results.py). Le comportement
132
- # "efface les analyses" est explicitement opt-in via
133
- # ``drop_analyses=True``.
134
- dr = _make_document_result(ner_metrics={"global": {"f1": 0.8}})
135
- dr.compact(drop_analyses=True)
136
- assert dr.ner_metrics is None
137
-
138
- def test_compact_default_is_noop(self) -> None:
139
- """Sprint A14-S1 — défaut sans argument ne touche à rien."""
140
- dr = _make_document_result(ner_metrics={"global": {"f1": 0.8}})
141
- dr.compact()
142
- assert dr.ner_metrics == {"global": {"f1": 0.8}}
143
-
144
- def test_engine_report_aggregated_ner_omitted_when_none(self) -> None:
145
- rep = EngineReport(
146
- engine_name="t", engine_version="1", engine_config={},
147
- document_results=[_make_document_result()],
148
- )
149
- d = rep.as_dict()
150
- assert "aggregated_ner" not in d
151
-
152
- def test_engine_report_aggregated_ner_included_when_set(self) -> None:
153
- rep = EngineReport(
154
- engine_name="t", engine_version="1", engine_config={},
155
- document_results=[_make_document_result()],
156
- aggregated_ner={"global": {"f1": 0.75}, "doc_count": 1},
157
- )
158
- d = rep.as_dict()
159
- assert d["aggregated_ner"] == {"global": {"f1": 0.75}, "doc_count": 1}
160
-
161
-
162
- # ──────────────────────────────────────────────────────────────────────────
163
- # 6. Câblage runner avec extracteur mock
164
- # ──────────────────────────────────────────────────────────────────────────
165
-
166
-
167
- def _mock_extractor_factory(per_text: dict[str, list[dict]]) -> callable:
168
- """Construit un extracteur qui renvoie une réponse prédéfinie par
169
- texte d'entrée — utile pour tester le câblage runner sans dépendance
170
- NLP réelle."""
171
-
172
- def _extract(text: str) -> list[dict]:
173
- return per_text.get(text, [])
174
-
175
- return _extract
176
-
177
-
178
- def _corpus_with_entities(tmp_path: Path) -> Corpus:
179
- """Crée un corpus minimal avec deux documents, dont un seul porte
180
- une GT entités."""
181
- image1 = tmp_path / "doc1.png"
182
- image2 = tmp_path / "doc2.png"
183
- image1.write_bytes(b"fake")
184
- image2.write_bytes(b"fake")
185
-
186
- doc1 = Document(
187
- image_path=image1,
188
- ground_truth="Marie de Bourgogne en 1477.",
189
- ground_truths={
190
- ArtifactType.RAW_TEXT: TextGT(text="Marie de Bourgogne en 1477."),
191
- ArtifactType.ENTITIES: EntitiesGT(entities=[
192
- {"label": "PER", "start": 0, "end": 17, "text": "Marie de Bourgogne"},
193
- {"label": "DATE", "start": 21, "end": 25, "text": "1477"},
194
- ]),
195
- },
196
- )
197
- doc2 = Document(
198
- image_path=image2,
199
- ground_truth="Texte sans GT entités.",
200
- )
201
- return Corpus(name="test", documents=[doc1, doc2])
202
-
203
-
204
- class TestRunnerWiring:
205
- def test_attach_ner_only_for_docs_with_entities(self, tmp_path: Path) -> None:
206
- corpus = _corpus_with_entities(tmp_path)
207
- # Mock extractor : renvoie la même chose que la GT pour doc1 (parfait)
208
- extractor = _mock_extractor_factory({
209
- "Marie de Bourgogne en 1477.": [
210
- {"label": "PER", "start": 0, "end": 17, "text": "Marie de Bourgogne"},
211
- {"label": "DATE", "start": 21, "end": 25, "text": "1477"},
212
- ],
213
- "Texte sans GT entités.": [], # pas appelé en réalité
214
- })
215
- dr1 = _make_document_result(
216
- doc_id="doc1", hypothesis="Marie de Bourgogne en 1477.",
217
- )
218
- dr2 = _make_document_result(
219
- doc_id="doc2", hypothesis="Texte sans GT entités.",
220
- )
221
- _attach_ner_metrics(corpus, [dr1, dr2], extractor)
222
-
223
- # doc1 : a une GT entités → ner_metrics calculé
224
- assert dr1.ner_metrics is not None
225
- assert dr1.ner_metrics["global"]["f1"] == pytest.approx(1.0)
226
-
227
- # doc2 : pas de GT entités → rien
228
- assert dr2.ner_metrics is None
229
-
230
- def test_aggregate_ner_combines_doc_metrics(self, tmp_path: Path) -> None:
231
- # Deux documents avec ner_metrics fournis
232
- dr1 = _make_document_result()
233
- dr1.ner_metrics = {
234
- "global": {"precision": 1.0, "recall": 0.5, "f1": 2/3, "support": 2},
235
- "per_category": {
236
- "PER": {"precision": 1.0, "recall": 0.5, "f1": 2/3, "support": 2},
237
- },
238
- "true_positives": 1, "false_positives": 0, "false_negatives": 1,
239
- "hallucinated_entities": [], "missed_entities": [{"label": "PER"}],
240
- "iou_threshold": 0.5,
241
- }
242
- dr2 = _make_document_result()
243
- dr2.ner_metrics = {
244
- "global": {"precision": 1.0, "recall": 1.0, "f1": 1.0, "support": 1},
245
- "per_category": {
246
- "LOC": {"precision": 1.0, "recall": 1.0, "f1": 1.0, "support": 1},
247
- },
248
- "true_positives": 1, "false_positives": 0, "false_negatives": 0,
249
- "hallucinated_entities": [], "missed_entities": [],
250
- "iou_threshold": 0.5,
251
- }
252
- agg = _aggregate_ner([dr1, dr2])
253
- assert agg is not None
254
- assert agg["doc_count"] == 2
255
- assert agg["true_positives"] == 2
256
- assert agg["false_negatives"] == 1
257
- assert agg["missed_total"] == 1
258
- # Micro F1 global : TP=2, FP=0, FN=1 → P=1, R=2/3, F1=0.8
259
- assert agg["global"]["f1"] == pytest.approx(0.8)
260
-
261
- def test_aggregate_returns_none_when_no_ner_metrics(self) -> None:
262
- dr = _make_document_result(ner_metrics=None)
263
- assert _aggregate_ner([dr]) is None
264
-
265
-
266
- # ──────────────────────────────────────────────────────────────────────────
267
- # 7. Rétrocompat : sans extractor, rien ne change
268
- # ──────────────────────────────────────────────────────────────────────────
269
-
270
-
271
- class TestBackwardCompat:
272
- def test_no_extractor_no_calculation(self, tmp_path: Path) -> None:
273
- """Si entity_extractor=None, le runner ne touche pas aux
274
- ner_metrics. On valide que le DocumentResult par défaut a bien
275
- ner_metrics=None — le runner ne l'attribue pas spontanément."""
276
- # Les deux DRs ne reçoivent jamais d'extracteur ; ils restent
277
- # tels quels. Le corpus n'est pas nécessaire ici (valide la
278
- # rétrocompat du modèle).
279
- dr1 = _make_document_result(doc_id="doc1")
280
- dr2 = _make_document_result(doc_id="doc2")
281
- assert dr1.ner_metrics is None
282
- assert dr2.ner_metrics is None
283
-
284
-
285
- # ──────────────────────────────────────────────────────────────────────────
286
- # 8. Robustesse : extracteur qui lève
287
- # ──────────────────────────────────────────────────────────────────────────
288
-
289
-
290
- class TestRobustness:
291
- def test_extractor_raising_does_not_break_others(
292
- self, tmp_path: Path, caplog: pytest.LogCaptureFixture
293
- ) -> None:
294
- """Si l'extracteur lève sur le doc1, le doc2 doit tout de même
295
- être traité (et inversement, ici doc1 est le seul avec GT
296
- entités, donc on vérifie qu'aucun crash ne casse le runner)."""
297
- corpus = _corpus_with_entities(tmp_path)
298
-
299
- def _broken_extractor(text: str) -> list[dict]:
300
- raise RuntimeError("boom")
301
-
302
- dr1 = _make_document_result(
303
- doc_id="doc1", hypothesis="Marie de Bourgogne en 1477.",
304
- )
305
- with caplog.at_level("WARNING", logger="picarones.measurements.runner"):
306
- _attach_ner_metrics(corpus, [dr1], _broken_extractor)
307
-
308
- # Pas de propagation, ner_metrics reste None
309
- assert dr1.ner_metrics is None
310
- # Et un warning explicite a été émis
311
- assert any("ner.attach" in rec.message for rec in caplog.records)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/measurements/test_sprint42_calibration_runner.py DELETED
@@ -1,285 +0,0 @@
1
- """Tests Sprint 42 — exposition des token_confidences + câblage runner.
2
-
3
- Le runner peut maintenant calculer des métriques de calibration
4
- (ECE / MCE / reliability) dès qu'un moteur expose des
5
- ``token_confidences`` sur l'``EngineResult``.
6
-
7
- Couvre :
8
-
9
- 1. ``EngineResult.token_confidences`` accepte ``None`` (rétrocompat
10
- stricte) ou une liste de dicts.
11
- 2. ``DocumentResult.calibration_metrics`` est sérialisé via ``as_dict``
12
- uniquement quand renseigné, libéré par ``compact()``.
13
- 3. ``EngineReport.aggregated_calibration`` apparaît dans ``as_dict``
14
- quand renseigné.
15
- 4. ``_calibration_from_engine_result`` :
16
- - Aligne en bag-of-words avec multiplicité (proxy oracle)
17
- - Normalise les confidences en pourcentage (>1) à [0, 1]
18
- - Ignore les confidences négatives (Tesseract -1 pour non-mots)
19
- - Retourne ``None`` sur entrée vide / ``None``
20
- 5. ``_aggregate_calibration`` :
21
- - Combine les bins de plusieurs documents en somme pondérée
22
- - Recalcule ECE/MCE micro à partir des sommes
23
- - Retourne ``None`` si aucun doc n'a de calibration
24
- 6. Rétrocompat : sans token_confidences sur l'EngineResult, aucun
25
- calcul calibration ; ``aggregated_calibration = None``.
26
- """
27
-
28
- from __future__ import annotations
29
-
30
- import pytest
31
-
32
- from picarones.measurements.runner import (
33
- _aggregate_calibration,
34
- _calibration_from_engine_result,
35
- )
36
- from picarones.evaluation.benchmark_result import DocumentResult, EngineReport
37
- from picarones.adapters.legacy_engines.base import EngineResult
38
-
39
-
40
- # ──────────────────────────────────────────────────────────────────────────
41
- # 1. EngineResult.token_confidences
42
- # ──────────────────────────────────────────────────────────────────────────
43
-
44
-
45
- class TestEngineResultExtension:
46
- def test_default_is_none(self) -> None:
47
- r = EngineResult("e", "/tmp/x.png", "hello", 1.0)
48
- assert r.token_confidences is None
49
-
50
- def test_accepts_list_of_dicts(self) -> None:
51
- confs = [{"token": "hello", "confidence": 0.95}]
52
- r = EngineResult("e", "/tmp/x.png", "hello", 1.0, token_confidences=confs)
53
- assert r.token_confidences == confs
54
-
55
-
56
- # ──────────────────────────────────────────────────────────────────────────
57
- # 2-3. Modèles : sérialisation et compact
58
- # ──────────────────────────────────────────────────────────────────────────
59
-
60
-
61
- def _make_dr(calibration_metrics: dict | None = None) -> DocumentResult:
62
- from picarones.evaluation.metric_result import MetricsResult
63
-
64
- return DocumentResult(
65
- doc_id="d1", image_path="/tmp/x.png",
66
- ground_truth="a b c", hypothesis="a b c",
67
- metrics=MetricsResult(
68
- cer=0.0, cer_nfc=0.0, cer_caseless=0.0,
69
- wer=0.0, wer_normalized=0.0, mer=0.0, wil=0.0,
70
- reference_length=5, hypothesis_length=5,
71
- ),
72
- duration_seconds=0.1,
73
- calibration_metrics=calibration_metrics,
74
- )
75
-
76
-
77
- class TestModelsSerialization:
78
- def test_calibration_metrics_omitted_when_none(self) -> None:
79
- d = _make_dr(None).as_dict()
80
- assert "calibration_metrics" not in d
81
-
82
- def test_calibration_metrics_present_when_set(self) -> None:
83
- d = _make_dr({"ece": 0.05, "mce": 0.1}).as_dict()
84
- assert d["calibration_metrics"] == {"ece": 0.05, "mce": 0.1}
85
-
86
- def test_compact_clears_calibration(self) -> None:
87
- # Sprint A14-S1 — ``compact()`` est désormais opt-in.
88
- dr = _make_dr({"ece": 0.05})
89
- dr.compact(drop_analyses=True)
90
- assert dr.calibration_metrics is None
91
-
92
- def test_engine_report_aggregated_calibration_omitted_when_none(self) -> None:
93
- rep = EngineReport(
94
- engine_name="t", engine_version="1", engine_config={},
95
- document_results=[_make_dr()],
96
- )
97
- assert "aggregated_calibration" not in rep.as_dict()
98
-
99
- def test_engine_report_aggregated_calibration_included_when_set(self) -> None:
100
- rep = EngineReport(
101
- engine_name="t", engine_version="1", engine_config={},
102
- document_results=[_make_dr()],
103
- aggregated_calibration={"ece": 0.05, "n_predictions": 100},
104
- )
105
- assert rep.as_dict()["aggregated_calibration"] == {
106
- "ece": 0.05, "n_predictions": 100,
107
- }
108
-
109
-
110
- # ──────────────────────────────────────────────────────────────────���───────
111
- # 4. Helper d'alignement
112
- # ──────────────────────────────────────────────────────────────────────────
113
-
114
-
115
- class TestCalibrationFromEngineResult:
116
- def test_returns_none_for_empty_inputs(self) -> None:
117
- assert _calibration_from_engine_result("text", None) is None
118
- assert _calibration_from_engine_result("text", []) is None
119
-
120
- def test_perfect_calibration_when_conf_matches_accuracy(self) -> None:
121
- gt = "a b c d e f g h i j"
122
- # 7 tokens dans la GT à conf=0.7, 3 hors de la GT à conf=0.7 → ECE = 0
123
- tcs = (
124
- [{"token": c, "confidence": 0.7} for c in "abcdefg"]
125
- + [{"token": c, "confidence": 0.7} for c in ["X", "Y", "Z"]]
126
- )
127
- m = _calibration_from_engine_result(gt, tcs)
128
- assert m is not None
129
- assert m["ece"] == pytest.approx(0.0, abs=1e-9)
130
- assert m["overall_accuracy"] == pytest.approx(0.7)
131
- assert m["n_predictions"] == 10
132
-
133
- def test_normalizes_percentage_confidences(self) -> None:
134
- """Conf > 1 est interprétée en pourcentage et divisée par 100."""
135
- m = _calibration_from_engine_result(
136
- "hello", [{"token": "hello", "confidence": 95.0}],
137
- )
138
- assert m is not None
139
- # 95/100 = 0.95
140
- assert m["overall_confidence"] == 0.95
141
-
142
- def test_skips_negative_confidences(self) -> None:
143
- """Tesseract met -1 pour les non-mots ; on les ignore."""
144
- m = _calibration_from_engine_result(
145
- "hello", [
146
- {"token": "hello", "confidence": 0.9},
147
- {"token": ".", "confidence": -1.0},
148
- ],
149
- )
150
- assert m is not None
151
- assert m["n_predictions"] == 1
152
-
153
- def test_bag_of_words_with_multiplicity(self) -> None:
154
- # GT contient deux 'le'. L'hypothèse en a trois → 2 corrects, 1 incorrect.
155
- gt = "le chat le chien"
156
- tcs = [
157
- {"token": "le", "confidence": 0.9},
158
- {"token": "le", "confidence": 0.9},
159
- {"token": "le", "confidence": 0.9}, # 3e 'le' : pas dans la GT
160
- {"token": "chat", "confidence": 0.9},
161
- {"token": "chien", "confidence": 0.9},
162
- ]
163
- m = _calibration_from_engine_result(gt, tcs)
164
- # 4 corrects sur 5
165
- assert m["overall_accuracy"] == 0.8
166
- assert m["n_predictions"] == 5
167
-
168
- def test_skips_invalid_entries(self) -> None:
169
- m = _calibration_from_engine_result(
170
- "hello", [
171
- "not a dict",
172
- {"no_token": True, "confidence": 0.5},
173
- {"token": "hello"}, # pas de confidence
174
- {"token": "hello", "confidence": "abc"}, # conf non numérique
175
- {"token": "hello", "confidence": 0.9}, # valide
176
- ],
177
- )
178
- assert m is not None
179
- assert m["n_predictions"] == 1
180
-
181
-
182
- # ──────────────────────────────────────────────────────────────────────────
183
- # 5. Agrégateur
184
- # ──────────────────────────────────────────────────────────────────────────
185
-
186
-
187
- class TestAggregateCalibration:
188
- def test_returns_none_when_no_doc_has_calibration(self) -> None:
189
- drs = [_make_dr(None), _make_dr(None)]
190
- assert _aggregate_calibration(drs) is None
191
-
192
- def test_combines_bins_across_docs(self) -> None:
193
- # Doc 1 : bin [0.5, 0.6) avec 10 prédictions, conf=0.55, acc=0.5
194
- # Doc 2 : bin [0.5, 0.6) avec 20 prédictions, conf=0.55, acc=0.7
195
- # Agrégat attendu : 30 prédictions dans ce bin, conf moy = 0.55,
196
- # acc moy pondérée = (10*0.5 + 20*0.7) / 30 = 19/30 ≈ 0.633
197
- empty_bin = lambda lo, hi: { # noqa: E731
198
- "bin_low": lo, "bin_high": hi,
199
- "avg_confidence": None, "accuracy": None,
200
- "count": 0, "gap": None,
201
- }
202
- bins1 = [empty_bin(k / 10, (k + 1) / 10) for k in range(10)]
203
- bins1[5] = {
204
- "bin_low": 0.5, "bin_high": 0.6,
205
- "avg_confidence": 0.55, "accuracy": 0.5,
206
- "count": 10, "gap": 0.05,
207
- }
208
- m1 = {
209
- "ece": 0.05, "mce": 0.05, "n_bins": 10, "n_predictions": 10,
210
- "overall_accuracy": 0.5, "overall_confidence": 0.55, "bins": bins1,
211
- }
212
- bins2 = [empty_bin(k / 10, (k + 1) / 10) for k in range(10)]
213
- bins2[5] = {
214
- "bin_low": 0.5, "bin_high": 0.6,
215
- "avg_confidence": 0.55, "accuracy": 0.7,
216
- "count": 20, "gap": 0.15,
217
- }
218
- m2 = {
219
- "ece": 0.15, "mce": 0.15, "n_bins": 10, "n_predictions": 20,
220
- "overall_accuracy": 0.7, "overall_confidence": 0.55, "bins": bins2,
221
- }
222
- drs = [_make_dr(m1), _make_dr(m2)]
223
- agg = _aggregate_calibration(drs)
224
- assert agg is not None
225
- assert agg["n_predictions"] == 30
226
- assert agg["doc_count"] == 2
227
- # Accuracy combinée = (10*0.5 + 20*0.7) / 30
228
- assert agg["overall_accuracy"] == (10 * 0.5 + 20 * 0.7) / 30
229
- # Confidence combinée = 0.55 (constante)
230
- assert abs(agg["overall_confidence"] - 0.55) < 1e-9
231
- # ECE micro : seul bin non vide (bin 5), avec count=30,
232
- # avg_conf=0.55, accuracy=19/30 ≈ 0.633, gap = |0.55 - 0.633|
233
- expected_ece = abs(0.55 - 19 / 30)
234
- assert abs(agg["ece"] - expected_ece) < 1e-9
235
- assert agg["mce"] == agg["ece"] # un seul bin non vide → MCE = ECE
236
-
237
-
238
- # ──────────────────────────────────────────────────────────────────────────
239
- # 6. Rétrocompat : sans token_confidences, rien ne change
240
- # ──────────────────────────────────────────────────────────────────────────
241
-
242
-
243
- class TestBackwardCompat:
244
- def test_engine_result_default_no_calibration(self) -> None:
245
- # Un EngineResult sans token_confidences → calibration_metrics
246
- # ne doit pas être calculée.
247
- from picarones.measurements.runner import _compute_document_result
248
- ocr = EngineResult(
249
- engine_name="e",
250
- image_path="/tmp/x.png",
251
- text="a b c",
252
- duration_seconds=0.1,
253
- token_confidences=None,
254
- )
255
- dr = _compute_document_result(
256
- doc_id="d1", image_path="/tmp/x.png",
257
- ground_truth="a b c",
258
- ocr_result=ocr,
259
- char_exclude=None,
260
- )
261
- assert dr.calibration_metrics is None
262
-
263
- def test_engine_result_with_confs_triggers_calibration(self) -> None:
264
- from picarones.measurements.runner import _compute_document_result
265
- ocr = EngineResult(
266
- engine_name="e",
267
- image_path="/tmp/x.png",
268
- text="a b c",
269
- duration_seconds=0.1,
270
- token_confidences=[
271
- {"token": "a", "confidence": 0.9},
272
- {"token": "b", "confidence": 0.9},
273
- {"token": "c", "confidence": 0.9},
274
- ],
275
- )
276
- dr = _compute_document_result(
277
- doc_id="d1", image_path="/tmp/x.png",
278
- ground_truth="a b c",
279
- ocr_result=ocr,
280
- char_exclude=None,
281
- )
282
- assert dr.calibration_metrics is not None
283
- # 3 tokens, tous corrects, conf 0.9 → accuracy = 1, conf = 0.9
284
- assert dr.calibration_metrics["overall_accuracy"] == 1.0
285
- assert dr.calibration_metrics["overall_confidence"] == 0.9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/measurements/test_sprint61_philological_runner.py DELETED
@@ -1,303 +0,0 @@
1
- """Tests Sprint 61 — câblage backend des métriques philologiques.
2
-
3
- Couvre :
4
-
5
- 1. Champs ``DocumentResult.philological_metrics`` et
6
- ``EngineReport.aggregated_philological`` posés.
7
- 2. Sérialisation conditionnelle dans ``as_dict``.
8
- 3. Libération par ``compact``.
9
- 4. ``compute_philological_metrics`` :
10
- - GT médiéval déclenche abbreviations + mufi
11
- - GT imprimé ancien déclenche early_modern
12
- - GT moderne déclenche modern_archives
13
- - GT avec numéraux romains déclenche roman_numerals
14
- - GT avec caractères hors Basic Latin déclenche unicode_blocks
15
- - GT en ASCII pur sans marqueur → ``None``
16
- - GT vide / None → ``None``
17
- 5. ``aggregate_philological_metrics`` :
18
- - Somme correcte des compteurs par module
19
- - Recalcul correct des scores globaux
20
- - Doc count cohérent
21
- - Aucun document avec signal → ``None``
22
- 6. Intégration runner end-to-end via fixture mock.
23
- """
24
-
25
- from __future__ import annotations
26
-
27
- from picarones.measurements.philological_hooks import (
28
- aggregate_philological_metrics,
29
- compute_philological_metrics,
30
- )
31
- from picarones.evaluation.benchmark_result import DocumentResult, EngineReport
32
- from picarones.evaluation.metric_result import MetricsResult
33
-
34
-
35
- def _make_doc(
36
- doc_id: str = "d1",
37
- gt: str = "",
38
- hyp: str = "",
39
- philological: dict | None = None,
40
- ) -> DocumentResult:
41
- """Helper : construit un DocumentResult minimal pour les tests."""
42
- return DocumentResult(
43
- doc_id=doc_id,
44
- image_path=f"/tmp/{doc_id}.png",
45
- ground_truth=gt,
46
- hypothesis=hyp,
47
- metrics=MetricsResult(
48
- cer=0.0, cer_nfc=0.0, cer_caseless=0.0,
49
- wer=0.0, wer_normalized=0.0, mer=0.0, wil=0.0,
50
- reference_length=len(gt), hypothesis_length=len(hyp),
51
- ),
52
- duration_seconds=0.1,
53
- philological_metrics=philological,
54
- )
55
-
56
-
57
- # ──────────────────────────────────────────────────────────────────────────
58
- # 1. Champs posés sur DocumentResult / EngineReport
59
- # ──────────────────────────────────────────────────────────────────────────
60
-
61
-
62
- class TestFields:
63
- def test_document_result_default_none(self) -> None:
64
- dr = _make_doc()
65
- assert dr.philological_metrics is None
66
-
67
- def test_document_result_accepts_dict(self) -> None:
68
- dr = _make_doc(philological={"mufi": {"coverage": 0.9}})
69
- assert dr.philological_metrics == {"mufi": {"coverage": 0.9}}
70
-
71
- def test_engine_report_default_none(self) -> None:
72
- report = EngineReport(
73
- engine_name="test", engine_version="1.0",
74
- engine_config={}, document_results=[],
75
- )
76
- assert report.aggregated_philological is None
77
-
78
- def test_engine_report_accepts_dict(self) -> None:
79
- report = EngineReport(
80
- engine_name="test", engine_version="1.0",
81
- engine_config={}, document_results=[],
82
- aggregated_philological={"mufi": {"coverage": 0.9}},
83
- )
84
- assert report.aggregated_philological == {"mufi": {"coverage": 0.9}}
85
-
86
-
87
- # ──────────────────────────────────────────────────────────────────────────
88
- # 2. Sérialisation as_dict
89
- # ──────────────────────────────────────────────────────────────────────────
90
-
91
-
92
- class TestSerialization:
93
- def test_as_dict_omits_none(self) -> None:
94
- dr = _make_doc()
95
- d = dr.as_dict()
96
- assert "philological_metrics" not in d
97
-
98
- def test_as_dict_includes_when_present(self) -> None:
99
- dr = _make_doc(philological={"mufi": {"coverage": 1.0}})
100
- d = dr.as_dict()
101
- assert d["philological_metrics"] == {"mufi": {"coverage": 1.0}}
102
-
103
- def test_engine_report_as_dict_omits_none(self) -> None:
104
- report = EngineReport(
105
- engine_name="t", engine_version="1", engine_config={},
106
- document_results=[],
107
- )
108
- assert "aggregated_philological" not in report.as_dict()
109
-
110
- def test_engine_report_as_dict_includes_when_present(self) -> None:
111
- report = EngineReport(
112
- engine_name="t", engine_version="1", engine_config={},
113
- document_results=[],
114
- aggregated_philological={"mufi": {"coverage": 0.5}},
115
- )
116
- d = report.as_dict()
117
- assert d["aggregated_philological"] == {"mufi": {"coverage": 0.5}}
118
-
119
-
120
- # ──────────────────────────────────────────────────────────────────────────
121
- # 3. Libération par compact()
122
- # ──────────────────────────────────────────────────────────────────────────
123
-
124
-
125
- class TestCompact:
126
- def test_compact_clears_philological(self) -> None:
127
- # Sprint A14-S1 — opt-in via drop_analyses=True.
128
- dr = _make_doc(philological={"mufi": {"coverage": 1.0}})
129
- dr.compact(drop_analyses=True)
130
- assert dr.philological_metrics is None
131
-
132
-
133
- # ──────────────────────────────────────────────────────────────────────────
134
- # 4. compute_philological_metrics — adaptive masking
135
- # ──────────────────────────────────────────────────────────────────────────
136
-
137
-
138
- class TestComputeAdaptive:
139
- def test_medieval_triggers_abbreviations_and_mufi(self) -> None:
140
- gt = "fait en lan ꝑ regem þæt"
141
- m = compute_philological_metrics(gt, gt)
142
- assert m is not None
143
- assert "abbreviations" in m
144
- assert "mufi" in m
145
-
146
- def test_early_modern_triggers_typography(self) -> None:
147
- gt = "le ſerpent finement & ã"
148
- m = compute_philological_metrics(gt, gt)
149
- assert m is not None
150
- assert "early_modern" in m
151
-
152
- def test_modern_archives_triggers_module(self) -> None:
153
- gt = "Mme Dupont au bd Voltaire vol. II"
154
- m = compute_philological_metrics(gt, gt)
155
- assert m is not None
156
- assert "modern_archives" in m
157
-
158
- def test_roman_numerals_triggers_module(self) -> None:
159
- gt = "Louis XIV mourut en MDCCXV"
160
- m = compute_philological_metrics(gt, gt)
161
- assert m is not None
162
- assert "roman_numerals" in m
163
-
164
- def test_unicode_blocks_triggered_only_outside_basic_latin(self) -> None:
165
- # ASCII pur sans marqueur → unicode_blocks omis (Basic Latin
166
- # uniquement, breakdown trivial).
167
- m = compute_philological_metrics("hello world", "hello world")
168
- assert m is None
169
-
170
- def test_unicode_blocks_triggered_with_diacritics(self) -> None:
171
- # Du Latin Extended → unicode_blocks inclus
172
- gt = "café à é ô"
173
- m = compute_philological_metrics(gt, gt)
174
- assert m is not None
175
- assert "unicode_blocks" in m
176
-
177
- def test_empty_returns_none(self) -> None:
178
- assert compute_philological_metrics("", "") is None
179
- assert compute_philological_metrics(None, None) is None
180
-
181
- def test_no_signal_returns_none(self) -> None:
182
- # Pure Basic Latin sans aucun marqueur philologique
183
- m = compute_philological_metrics("hello", "hello")
184
- assert m is None
185
-
186
-
187
- # ──────────────────────────────────────────────────────────────────────────
188
- # 5. aggregate_philological_metrics
189
- # ──────────────────────────────────────────────────────────────────────────
190
-
191
-
192
- class TestAggregation:
193
- def test_no_data_returns_none(self) -> None:
194
- assert aggregate_philological_metrics([]) is None
195
- assert aggregate_philological_metrics([None, None]) is None
196
-
197
- def test_aggregates_only_present_modules(self) -> None:
198
- # Doc 1 a mufi+abbr, Doc 2 a juste roman_numerals
199
- d1 = compute_philological_metrics("ꝑ ꝓ ꝗ", "per pro qui")
200
- d2 = compute_philological_metrics("Louis XIV", "Louis 14")
201
- agg = aggregate_philological_metrics([d1, d2])
202
- assert agg is not None
203
- # mufi présent (Doc1 le déclenchait avec ꝑ/ꝓ/ꝗ qui sont MUFI)
204
- assert "abbreviations" in agg
205
- assert "roman_numerals" in agg
206
- # doc_count par module
207
- assert agg["abbreviations"]["doc_count"] == 1
208
- assert agg["roman_numerals"]["doc_count"] == 1
209
-
210
- def test_aggregation_sums_counters(self) -> None:
211
- # 3 docs avec MUFI : "þæt ꝑ" = 3 caractères MUFI (þ, æ, ꝑ)
212
- gt = "þæt ꝑ"
213
- per_doc = [compute_philological_metrics(gt, gt) for _ in range(3)]
214
- agg = aggregate_philological_metrics(per_doc)
215
- assert agg is not None
216
- assert "mufi" in agg
217
- # 3 caractères × 3 docs = 9
218
- assert agg["mufi"]["n_mufi_chars_reference"] == 9
219
- assert agg["mufi"]["n_mufi_chars_preserved"] == 9
220
- assert agg["mufi"]["coverage"] == 1.0
221
- assert agg["mufi"]["doc_count"] == 3
222
-
223
- def test_aggregation_recomputes_global_score(self) -> None:
224
- # Doc1 préserve 100%, Doc2 préserve 0% → moyenne pondérée
225
- d1 = compute_philological_metrics("XIV", "XIV")
226
- d2 = compute_philological_metrics("V", "perdu")
227
- agg = aggregate_philological_metrics([d1, d2])
228
- roman = agg["roman_numerals"]
229
- # Doc1 : 1 strict_preserved (XIV)
230
- # Doc2 : 1 lost (V)
231
- # Total : 2 numéraux, 1 strict → 0.5
232
- assert roman["n_numerals_reference"] == 2
233
- assert roman["global_strict_score"] == 0.5
234
-
235
- def test_per_category_aggregation_modern_archives(self) -> None:
236
- # Deux docs avec modern_archives sur catégories différentes
237
- d1 = compute_philological_metrics("Mme bd", "Mme bd")
238
- d2 = compute_philological_metrics("vol. p.", "vol. p.")
239
- agg = aggregate_philological_metrics([d1, d2])
240
- per_cat = agg["modern_archives"]["per_category"]
241
- # Doc1 : civility_titles + address ; Doc2 : bibliographic
242
- assert "civility_titles" in per_cat
243
- assert "address" in per_cat
244
- assert "bibliographic" in per_cat
245
- for cat in per_cat.values():
246
- assert cat["strict_score"] == 1.0
247
-
248
-
249
- # ──────────────────────────────────────────────────────────────────────────
250
- # 6. Intégration end-to-end (mock léger sur le runner)
251
- # ──────────────────────────────────────────────────────────────────────────
252
-
253
-
254
- class TestRunnerIntegration:
255
- """Vérifie que ``_compute_document_result`` attache bien les
256
- ``philological_metrics`` quand la GT a du signal."""
257
-
258
- def test_runner_attaches_philological(self, tmp_path) -> None:
259
- from picarones.measurements.runner import _compute_document_result
260
- from picarones.adapters.legacy_engines.base import EngineResult
261
-
262
- # Créer une image fictive (le module image_quality échouera
263
- # gracieusement, ce qui est OK pour le test).
264
- img = tmp_path / "doc.png"
265
- img.write_bytes(b"") # vide ; on ignore le résultat image_quality
266
-
267
- gt = "ꝑ regem mcclxxxij"
268
- ocr_result = EngineResult(
269
- engine_name="mock", image_path=str(img),
270
- text=gt, duration_seconds=0.1, error=None,
271
- )
272
- dr = _compute_document_result(
273
- doc_id="d1",
274
- image_path=str(img),
275
- ground_truth=gt,
276
- ocr_result=ocr_result,
277
- char_exclude=None,
278
- )
279
- assert dr.philological_metrics is not None
280
- assert "abbreviations" in dr.philological_metrics
281
- assert "roman_numerals" in dr.philological_metrics
282
-
283
- def test_runner_omits_philological_on_plain_text(self, tmp_path) -> None:
284
- from picarones.measurements.runner import _compute_document_result
285
- from picarones.adapters.legacy_engines.base import EngineResult
286
-
287
- img = tmp_path / "doc.png"
288
- img.write_bytes(b"")
289
-
290
- # Texte ASCII pur sans marqueur philologique
291
- gt = "hello world without any markers"
292
- ocr_result = EngineResult(
293
- engine_name="mock", image_path=str(img),
294
- text=gt, duration_seconds=0.1, error=None,
295
- )
296
- dr = _compute_document_result(
297
- doc_id="d1",
298
- image_path=str(img),
299
- ground_truth=gt,
300
- ocr_result=ocr_result,
301
- char_exclude=None,
302
- )
303
- assert dr.philological_metrics is None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/measurements/test_sprint_a14_s1_normalization_propagation.py CHANGED
@@ -19,15 +19,11 @@ from __future__ import annotations
19
 
20
  import inspect
21
 
 
22
  from picarones.evaluation.metrics.normalization import (
23
  NORMALIZATION_PROFILES,
24
  get_builtin_profile,
25
  )
26
- from picarones.app.services._legacy_runner_adapter import run_benchmark_via_service
27
- from picarones.measurements.runner.document import _compute_document_result
28
- from picarones.measurements.runner.workers import (
29
- _io_doc_worker,
30
- )
31
 
32
 
33
  class TestRunBenchmarkSignature:
@@ -38,14 +34,6 @@ class TestRunBenchmarkSignature:
38
  # Et avec une valeur par défaut sûre.
39
  assert sig.parameters["normalization_profile"].default is None
40
 
41
- def test_io_worker_accepts_normalization_profile(self) -> None:
42
- sig = inspect.signature(_io_doc_worker)
43
- assert "normalization_profile" in sig.parameters
44
-
45
- def test_compute_document_result_accepts_normalization_profile(self) -> None:
46
- sig = inspect.signature(_compute_document_result)
47
- assert "normalization_profile" in sig.parameters
48
-
49
 
50
  class TestProfileResolution:
51
  def test_all_eleven_profiles_resolvable(self) -> None:
 
19
 
20
  import inspect
21
 
22
+ from picarones.app.services._legacy_runner_adapter import run_benchmark_via_service
23
  from picarones.evaluation.metrics.normalization import (
24
  NORMALIZATION_PROFILES,
25
  get_builtin_profile,
26
  )
 
 
 
 
 
27
 
28
 
29
  class TestRunBenchmarkSignature:
 
34
  # Et avec une valeur par défaut sûre.
35
  assert sig.parameters["normalization_profile"].default is None
36
 
 
 
 
 
 
 
 
 
37
 
38
  class TestProfileResolution:
39
  def test_all_eleven_profiles_resolvable(self) -> None: