Spaces:
Running
sprint61: câblage runner des 6 modules philologiques (Sprints 55-60)
Browse filesLes six modules philologiques sont désormais calculés automatiquement
par le runner pour chaque document et agrégés par moteur, sans option
à activer.
- Nouveau module picarones/core/philological_runner.py :
- compute_philological_metrics(reference, hypothesis) : calcul
des 6 modules avec adaptive masking (un module n'apparaît que
si la GT a du signal exploitable).
- aggregate_philological_metrics(per_doc) : agrégation des
compteurs bruts + recalcul des scores globaux + préservation
des structures per_block/per_abbreviation/per_char/per_category/
per_status agrégées.
- Nouveaux champs DocumentResult.philological_metrics et
EngineReport.aggregated_philological (Optional[dict], sérialisés
conditionnellement, libérés par compact).
- Câblage runner : calcul inconditionnel (coût O(N), négligeable),
erreur d'un module n'arrête pas les autres + warning explicite.
- Rétrocompat stricte : aucun paramètre ajouté, comportement
existant inchangé sur les corpus sans signal philologique.
- +24 tests dans test_sprint61_philological_runner.py.
Tests : 2316 passed, 2 skipped, 0 failed.
https://claude.ai/code/session_01RusTQYcSfXqTsbFNvwmCV7
- CHANGELOG.md +49 -0
- CLAUDE.md +2 -1
- picarones/core/philological_runner.py +363 -0
- picarones/core/results.py +35 -0
- picarones/core/runner.py +22 -0
- tests/test_sprint61_philological_runner.py +303 -0
|
@@ -16,6 +16,55 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
- **Sprint 60 — Numéraux romains transversaux : couche de calcul
|
| 20 |
(clôture extension philologique par période).** Suite directe
|
| 21 |
Sprints 56-59. Les numéraux romains traversent les trois
|
|
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
| 19 |
+
- **Sprint 61 — Câblage backend des métriques philologiques au
|
| 20 |
+
runner (Sprints 55-60).** Suite directe Sprints 55-60 — les six
|
| 21 |
+
modules philologiques (unicode_blocks, abbreviations, mufi,
|
| 22 |
+
early_modern, modern_archives, roman_numerals) sont désormais
|
| 23 |
+
calculés automatiquement par le runner pour chaque document et
|
| 24 |
+
agrégés par moteur, **sans aucune option à activer**.
|
| 25 |
+
- Nouveau module `picarones/core/philological_runner.py` :
|
| 26 |
+
- ``compute_philological_metrics(reference, hypothesis)``
|
| 27 |
+
calcule les six modules et retourne un dict avec une clé par
|
| 28 |
+
module ayant du **signal exploitable** dans la GT
|
| 29 |
+
(``n_markers_reference > 0``, ``n_mufi_chars_reference > 0``,
|
| 30 |
+
au moins un caractère hors Basic Latin pour unicode_blocks,
|
| 31 |
+
etc.). Retourne ``None`` si aucun module n'a de signal.
|
| 32 |
+
- ``aggregate_philological_metrics(per_doc_list)`` agrège les
|
| 33 |
+
compteurs bruts par module (somme), recalcule les scores
|
| 34 |
+
globaux à partir des sommes (accuracy, coverage, strict,
|
| 35 |
+
expansion, value, preservation), et préserve les structures
|
| 36 |
+
``per_block`` / ``per_abbreviation`` / ``per_char`` /
|
| 37 |
+
``per_category`` / ``per_status`` agrégées.
|
| 38 |
+
- **Adaptive masking** : un module n'apparaît dans le résultat
|
| 39 |
+
que si au moins un document a eu du signal pour lui — les
|
| 40 |
+
rapports restent lisibles sur les corpus sans marqueur
|
| 41 |
+
philologique pertinent (typique des fonds XXIᵉ propres).
|
| 42 |
+
- Nouveaux champs sur ``DocumentResult.philological_metrics`` et
|
| 43 |
+
``EngineReport.aggregated_philological`` (``Optional[dict]``,
|
| 44 |
+
``None`` par défaut, sérialisés conditionnellement par
|
| 45 |
+
``as_dict``, libérés par ``compact``).
|
| 46 |
+
- Câblage dans ``runner._compute_document_result`` : le calcul
|
| 47 |
+
est inconditionnel (coût O(N) sur le texte, négligeable face à
|
| 48 |
+
l'OCR) et l'erreur d'un module individuel ne propage pas — on
|
| 49 |
+
omet le module et on logue un warning explicite (jamais
|
| 50 |
+
``except: pass`` selon les règles CLAUDE.md).
|
| 51 |
+
- Câblage dans ``run_benchmark`` : agrégation par moteur
|
| 52 |
+
appelée juste après les autres agrégations Sprint 5/10/40/42.
|
| 53 |
+
- **Rétrocompat stricte** : aucun paramètre ajouté, aucun
|
| 54 |
+
comportement existant modifié ; un benchmark sans signal
|
| 55 |
+
philologique voit ses ``philological_metrics`` à ``None`` (pas
|
| 56 |
+
de champ dans le JSON de sortie).
|
| 57 |
+
- +24 tests dans `test_sprint61_philological_runner.py` (champs
|
| 58 |
+
par défaut, sérialisation conditionnelle, libération par
|
| 59 |
+
compact, calcul adaptive sur 6 cas de figure — médiéval,
|
| 60 |
+
imprimé ancien, moderne, numéral romain, diacritiques,
|
| 61 |
+
ASCII pur —, agrégation : sommes correctes, recalcul des scores
|
| 62 |
+
globaux, per_category modern_archives, intégration runner
|
| 63 |
+
end-to-end avec mock ``EngineResult``).
|
| 64 |
+
- **Verrou levé** : les six modules philologiques sont désormais
|
| 65 |
+
visibles dans le pipeline standard de bench ; il manque
|
| 66 |
+
uniquement la vue HTML dédiée (Sprint 62 à venir).
|
| 67 |
+
|
| 68 |
- **Sprint 60 — Numéraux romains transversaux : couche de calcul
|
| 69 |
(clôture extension philologique par période).** Suite directe
|
| 70 |
Sprints 56-59. Les numéraux romains traversent les trois
|
|
@@ -207,6 +207,7 @@ AZURE_DOC_INTEL_KEY=...
|
|
| 207 |
| 33 | **Sprint 2 du plan d'évolution 2026 — Phase 0.2 : interface module générique**. Nouveau module `picarones/core/modules.py` avec l'enum `ArtifactType` (IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER) et la classe abstraite `BaseModule` qui déclare `input_types`/`output_types`, `execution_mode` (`"io"`/`"cpu"`), une méthode `process(dict[ArtifactType, Any]) → dict[ArtifactType, Any]`, et des helpers `validate_inputs`/`validate_outputs`. `BaseOCREngine` (`picarones/engines/base.py`) hérite désormais de `BaseModule` avec `input_types=(IMAGE,)` et `output_types=(TEXT,)` ; sa nouvelle méthode `process` wrappe l'API historique `run()`. Aucun adaptateur OCR existant n'est touché — `test_engines.py` passe à 20/20 sans modification. +23 tests dans `test_sprint33_module_interface.py` (contrat, validation, MockModule TEXT→ALTO démonstratif comme demandé par le plan, délégation `BaseOCREngine.process → run`, cohérence ArtifactType/GTLevel). **Verrou levé** : un même runner peut maintenant exécuter un OCR (image→texte), un mappeur VLM→ALTO, un rewriter ALTO→ALTO, un module NER (texte→entités), etc. — fondation directe pour l'axe B du plan. |
|
| 208 |
| 34 | **Sprint 3 du plan d'évolution 2026 — Phase 0.3 : registre typé de métriques (clôture Phase 0)**. Nouveaux modules `picarones/core/metric_registry.py` (`MetricSpec`, `@register_metric`, `select_metrics`, `compute_at_junction`) et `picarones/core/builtin_metrics.py` qui enregistre `cer`, `wer`, `mer`, `wil` sur `(TEXT, TEXT)` plus un stub `text_preservation_after_reconstruction` sur `(TEXT, ALTO)` comme preuve de concept de jonction hétérogène. **Approche strictement additive** : ni `metrics.py` ni `compute_metrics` ne sont modifiés, le rapport HTML reste identique octet par octet. La sélection par signature de types est exacte (pas de coercion). +21 tests dans `test_sprint34_metric_registry.py`, dont une parité numérique CER/WER/MER/WIL avec `compute_metrics` legacy à 1e-9 près sur 4 paires de textes. **Verrou levé** : le runner d'une pipeline composée peut maintenant calculer automatiquement la métrique adéquate à chaque jonction de son DAG selon les types d'artefacts produits/attendus — fondation directe pour la métrique d'absorption d'erreur (acte B.3) et toutes les métriques structurelles à venir (Layout F1, reading order F1, NER). |
|
| 209 |
| 35 | **Sprint 4 du plan d'évolution 2026 — Étape 2 / axe A : métriques inter-moteurs (couche de calcul)**. Nouveau module `picarones/core/inter_engine.py` qui répond à deux questions distinctes mais liées : *(a) à quel point les moteurs font-ils des erreurs de natures différentes ?* via `kl_divergence`, `jensen_shannon_divergence` (symétrique, bornée `[0, 1]`), et `taxonomy_divergence_matrix` qui construit la matrice triangulaire inter-moteurs ; *(b) quel CER serait atteignable si on combinait les moteurs ?* via `oracle_token_recall` (proxy bag-of-words, borne supérieure du recall atteignable), `complementarity_gap` (oracle vs meilleur moteur seul, gap absolu/relatif), et `pairwise_disagreement_rate`. Fonctions pures, sans I/O ni intégration runner — la couche de calcul est livrée indépendamment, le câblage narratif (`ENSEMBLE_OPPORTUNITY`) et HTML (matrice de divergence, badge oracle) suit au Sprint 36. +27 tests couvrant les invariants mathématiques (KL ≥ 0, KL(p,p) = 0, JS symétrique et bornée, oracle ≥ best_single, multiplicité respectée), les cas concrets (deux moteurs spécialisés sortent comme candidats ensemble, complémentarité parfaite atteint oracle = 1), et les garde-fous (référence vide, hypothèses vides, métrique inconnue). |
|
|
|
|
| 210 |
| 60 | **Sprint 29 du plan d'évolution 2026 — Étape 3 / extension philologique transversale : numéraux romains (couche de calcul, clôture extension par période)**. Suite directe Sprints 56-59. Les numéraux romains traversent les trois périodes patrimoniales — médiéval (minuscules + j final `mcclxxxij`=1282), imprimé ancien (`Tome IV`), moderne (`Louis XIV`, `MCMXIV`). Module `picarones/core/roman_numerals.py` : `roman_to_int` parsing tolérant casse + j médiéval avec validation stricte des paires soustractives canoniques (IV, IX, XL, XC, CD, CM seulement — rejette `ICI`, `IL`, `VV`, `IIIII`), forme additive médiévale `IIII` acceptée, `int_to_roman` canonique, `detect_roman_numerals(text, min_length=1)` avec filtre paramétrable contre les single-letter ambigus (`I` pronom). `compute_roman_numeral_metrics` classifie chaque numéral GT en **5 statuts ordonnés par priorité** : `strict_preserved` (forme exacte), `case_changed` (valeur OK casse différente), `j_dropped` (j médiéval normalisé en i), `converted_to_arabic` (XIV→14), `lost`. Retourne `per_status`, `per_numeral`, `lost_numerals`, `global_strict_score`, `global_value_score` (toute forme préservant la valeur). `roman_numeral_strict_score` et `roman_numeral_value_score` enregistrés dans le registre typé Sprint 34 pour `(TEXT, TEXT)`. **Choix éditorial assumé identique aux Sprints 58-59** : pas de classification automatique — le chercheur lit `per_status` et juge la convention. +93 tests (parsing paramétrée standard + minuscules + j médiéval, formes invalides rejetées, aller-retour, détection avec min_length et frontière de mot anti-`VIVE`, **rejet du faux positif `ICI`**, 5 statuts individuellement, priorité strict>arabic, **3 cas réalistes par période** — charte médiévale, imprimé ancien, souverain moderne —, comptage exhaustif somme des per_status = total, dégénérés, raccourcis, intégration registre). **Verrou levé** : l'extension philologique transversale est intégralement livrée — un benchmark sur n'importe quel fonds patrimonial européen peut désormais classer les moteurs sur leur traitement des numéraux romains, indépendamment de la période. |
|
| 211 |
| 59 | **Sprint 28 du plan d'évolution 2026 — Étape 3 / extension philologique aux périodes contemporaines : marqueurs et abréviations des archives modernes XIXᵉ-XXᵉ (couche de calcul)**. Suite directe Sprints 56-58. Sur les fonds modernes BnF (état civil, recensements, presse, monographies, archives militaires, annuaires) la typographie historique a disparu mais subsiste un riche système d'abréviations contemporaines. Module `picarones/core/modern_archives.py` avec **9 catégories** : `civility_titles` (Mme, Mlle, Mgr, Dr, Pr, Me, M., R.P., S.M., S.A.R., S.E., S.S.), `ordinals` (1ᵉʳ, 1ʳᵉ, 2ᵈ, 2ᵉ, Vᵉ, XIᵉ-XXᵉ avec exposants Unicode), `currency` (₶, ₣, ƒ, £ + l./s./d. d'Ancien Régime), `administrative` (arr., dép., cant., com., reg., prov.), `civil_status` (°, †, ✶, ⚭, ép., vve), `typographic_punctuation` (« », —, –, …, ’, ‘), `latin_abbr_modern` (e.g., i.e., etc., cf., ibid., op. cit., ad lib., N.B.), `bibliographic` (vol., t., p., pp., n°, fasc., éd., ms., f., r°, v°), `address` (bd, av., r., pl., imp., fbg). `get_category`, `get_expansions`, `detect_modern_markers` avec **stratégie greedy plus-long-gagne** (S.A.R. avant S.A.) et **frontières de mot adaptées** au type de marqueur (espace/ponctuation pour `M.`/`arr.`, `\b` standard pour `Mme`/`bd`, match littéral pour les Unicode `₶`/`†`/`«`). `compute_modern_archives_metrics` retourne deux scores par catégorie (pattern Sprint 56) : `strict_score` (forme abrégée préservée) et `expansion_score` (abrégée OU développée présente, casse-insensible) ; `missed_markers` distingue **pertes pures** (`expansion_preserved=False`) et **modernisations** (`expansion_preserved=True`). `modern_archives_strict_score` et `modern_archives_expansion_score` enregistrés dans le registre typé Sprint 34 pour `(TEXT, TEXT)`. **Choix éditorial assumé** : pas de classification automatique « diplomatique »/« modernisant » — c'est un outil de recherche, le chercheur lit les chiffres bruts et conclut lui-même. +75 tests (catégorisation 33 marqueurs ×9 catégories, détection par catégorie ×9, greedy plus-long-gagne, frontière de mot anti-faux-positifs, scénarios standards diplo/mod/erreur, breakdown per_category, **5 cas réalistes** clé — citation biblio, état civil, adresse, protocole royal, monnaie Ancien Régime, ponctuation typo —, dégénérés, comptage exhaustif, sanité tables, raccourcis, intégration registre). **Verrou levé** : l'extension philologique couvre désormais **trois périodes principales** des fonds patrimoniaux européens — médiéval (Sprints 56-57), imprimé ancien XVIᵉ-XVIIIᵉ (Sprint 58), archives modernes XIXᵉ-XXᵉ (ce sprint). |
|
| 212 |
| 58 | **Sprint 27 du plan d'évolution 2026 — Étape 3 / extension philologique : marqueurs typographiques de l'imprimé ancien XVIᵉ-XVIIIᵉ (couche de calcul)**. Première extension du volet philologique aux périodes post-médiévales. Les Sprints 56-57 sont orientés médiéval scribal ; ce sprint cible les **éditeurs d'imprimés anciens** pour qui les marqueurs caractéristiques sont **typographiques** (composition imprimée) et non scribaux. Module `picarones/core/early_modern_typography.py` : 5 catégories de marqueurs (`ligatures` ff fi fl ffi ffl ſt st, `long_s` ſ, `dotless_i` ı, `ampersand` &, `nasal_tildes` ã Ã ñ Ñ õ Õ ũ Ũ ẽ Ẽ ĩ Ĩ pré-composés + séquences `voyelle + U+0303`). `get_category(char)` classe en catégorie ou None ; `detect_markers(text)` retourne `[(index, marker, category)]` reconnaissant à la fois les caractères pré-composés et les séquences combinantes ; `compute_early_modern_metrics(ref, hyp)` aligne via `difflib.SequenceMatcher` et retourne `global_preservation` + `per_category[name]={total,preserved,preservation}` + `missed_markers`. `early_modern_preservation` enregistré dans le registre typé Sprint 34 pour `(TEXT, TEXT)`. **Le breakdown par catégorie discrimine la convention typographique** : un moteur diplomatique préserve toutes les catégories ; un moteur modernisant ſ→s, fi→fi, ı→i, ã→a préserve typiquement uniquement & ; un moteur mixte panache. +38 tests dans `test_sprint58_early_modern.py` (catégorisation paramétrée 18 caractères, détection 5 catégories + tilde combinant + ordre, **trois scénarios standards** discriminés à 1.0 / 0.2 / 0.4, dégénérés, missed_markers, preserved+missed=total, sets disjoints, raccourci, intégration registre). **Verrou levé** : un benchmark sur des imprimés anciens peut désormais classer les moteurs sur leur convention typographique éditoriale — symétrique à ce que le Sprint 56 fait pour les manuscrits médiévaux. |
|
|
@@ -278,7 +279,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
|
|
| 278 |
## Contexte développement
|
| 279 |
|
| 280 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 281 |
-
- **Tests** :
|
| 282 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 283 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 284 |
- **Transcript de la conversation de développement** :
|
|
|
|
| 207 |
| 33 | **Sprint 2 du plan d'évolution 2026 — Phase 0.2 : interface module générique**. Nouveau module `picarones/core/modules.py` avec l'enum `ArtifactType` (IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER) et la classe abstraite `BaseModule` qui déclare `input_types`/`output_types`, `execution_mode` (`"io"`/`"cpu"`), une méthode `process(dict[ArtifactType, Any]) → dict[ArtifactType, Any]`, et des helpers `validate_inputs`/`validate_outputs`. `BaseOCREngine` (`picarones/engines/base.py`) hérite désormais de `BaseModule` avec `input_types=(IMAGE,)` et `output_types=(TEXT,)` ; sa nouvelle méthode `process` wrappe l'API historique `run()`. Aucun adaptateur OCR existant n'est touché — `test_engines.py` passe à 20/20 sans modification. +23 tests dans `test_sprint33_module_interface.py` (contrat, validation, MockModule TEXT→ALTO démonstratif comme demandé par le plan, délégation `BaseOCREngine.process → run`, cohérence ArtifactType/GTLevel). **Verrou levé** : un même runner peut maintenant exécuter un OCR (image→texte), un mappeur VLM→ALTO, un rewriter ALTO→ALTO, un module NER (texte→entités), etc. — fondation directe pour l'axe B du plan. |
|
| 208 |
| 34 | **Sprint 3 du plan d'évolution 2026 — Phase 0.3 : registre typé de métriques (clôture Phase 0)**. Nouveaux modules `picarones/core/metric_registry.py` (`MetricSpec`, `@register_metric`, `select_metrics`, `compute_at_junction`) et `picarones/core/builtin_metrics.py` qui enregistre `cer`, `wer`, `mer`, `wil` sur `(TEXT, TEXT)` plus un stub `text_preservation_after_reconstruction` sur `(TEXT, ALTO)` comme preuve de concept de jonction hétérogène. **Approche strictement additive** : ni `metrics.py` ni `compute_metrics` ne sont modifiés, le rapport HTML reste identique octet par octet. La sélection par signature de types est exacte (pas de coercion). +21 tests dans `test_sprint34_metric_registry.py`, dont une parité numérique CER/WER/MER/WIL avec `compute_metrics` legacy à 1e-9 près sur 4 paires de textes. **Verrou levé** : le runner d'une pipeline composée peut maintenant calculer automatiquement la métrique adéquate à chaque jonction de son DAG selon les types d'artefacts produits/attendus — fondation directe pour la métrique d'absorption d'erreur (acte B.3) et toutes les métriques structurelles à venir (Layout F1, reading order F1, NER). |
|
| 209 |
| 35 | **Sprint 4 du plan d'évolution 2026 — Étape 2 / axe A : métriques inter-moteurs (couche de calcul)**. Nouveau module `picarones/core/inter_engine.py` qui répond à deux questions distinctes mais liées : *(a) à quel point les moteurs font-ils des erreurs de natures différentes ?* via `kl_divergence`, `jensen_shannon_divergence` (symétrique, bornée `[0, 1]`), et `taxonomy_divergence_matrix` qui construit la matrice triangulaire inter-moteurs ; *(b) quel CER serait atteignable si on combinait les moteurs ?* via `oracle_token_recall` (proxy bag-of-words, borne supérieure du recall atteignable), `complementarity_gap` (oracle vs meilleur moteur seul, gap absolu/relatif), et `pairwise_disagreement_rate`. Fonctions pures, sans I/O ni intégration runner — la couche de calcul est livrée indépendamment, le câblage narratif (`ENSEMBLE_OPPORTUNITY`) et HTML (matrice de divergence, badge oracle) suit au Sprint 36. +27 tests couvrant les invariants mathématiques (KL ≥ 0, KL(p,p) = 0, JS symétrique et bornée, oracle ≥ best_single, multiplicité respectée), les cas concrets (deux moteurs spécialisés sortent comme candidats ensemble, complémentarité parfaite atteint oracle = 1), et les garde-fous (référence vide, hypothèses vides, métrique inconnue). |
|
| 210 |
+
| 61 | **Sprint 30 du plan d'évolution 2026 — Étape 3 / câblage backend des métriques philologiques au runner (Sprints 55-60)**. Suite directe Sprints 55-60. Les six modules philologiques sont désormais calculés automatiquement par le runner pour chaque document et agrégés par moteur, sans aucune option à activer. Nouveau module `picarones/core/philological_runner.py` : `compute_philological_metrics(reference, hypothesis)` calcule les six modules avec **adaptive masking** (un module n'apparaît que si la GT a du signal exploitable : `n_markers_reference > 0`, `n_mufi_chars_reference > 0`, au moins un caractère hors Basic Latin pour unicode_blocks…) ; `aggregate_philological_metrics(per_doc_list)` agrège les compteurs bruts par module (somme), recalcule les scores globaux, et préserve les structures `per_block`/`per_abbreviation`/`per_char`/`per_category`/`per_status` agrégées. Nouveaux champs `DocumentResult.philological_metrics` et `EngineReport.aggregated_philological` (`Optional[dict]`, sérialisés conditionnellement, libérés par `compact`). Câblage runner : calcul inconditionnel (coût O(N) sur texte, négligeable face à l'OCR), erreur d'un module individuel n'arrête pas les autres + warning explicite. Rétrocompat stricte : aucun paramètre ajouté, comportement existant inchangé, un benchmark sans signal philologique n'a aucun champ ajouté au JSON. +24 tests dans `test_sprint61_philological_runner.py` (champs, sérialisation/compact, calcul adaptive sur 6 cas — médiéval/imprimé/moderne/romain/diacritiques/ASCII pur, agrégation des compteurs et recalcul des scores globaux, intégration runner end-to-end avec mock). **Verrou levé** : les six modules philologiques sont désormais visibles dans le pipeline standard de bench, il manque la vue HTML dédiée (Sprint 62). |
|
| 211 |
| 60 | **Sprint 29 du plan d'évolution 2026 — Étape 3 / extension philologique transversale : numéraux romains (couche de calcul, clôture extension par période)**. Suite directe Sprints 56-59. Les numéraux romains traversent les trois périodes patrimoniales — médiéval (minuscules + j final `mcclxxxij`=1282), imprimé ancien (`Tome IV`), moderne (`Louis XIV`, `MCMXIV`). Module `picarones/core/roman_numerals.py` : `roman_to_int` parsing tolérant casse + j médiéval avec validation stricte des paires soustractives canoniques (IV, IX, XL, XC, CD, CM seulement — rejette `ICI`, `IL`, `VV`, `IIIII`), forme additive médiévale `IIII` acceptée, `int_to_roman` canonique, `detect_roman_numerals(text, min_length=1)` avec filtre paramétrable contre les single-letter ambigus (`I` pronom). `compute_roman_numeral_metrics` classifie chaque numéral GT en **5 statuts ordonnés par priorité** : `strict_preserved` (forme exacte), `case_changed` (valeur OK casse différente), `j_dropped` (j médiéval normalisé en i), `converted_to_arabic` (XIV→14), `lost`. Retourne `per_status`, `per_numeral`, `lost_numerals`, `global_strict_score`, `global_value_score` (toute forme préservant la valeur). `roman_numeral_strict_score` et `roman_numeral_value_score` enregistrés dans le registre typé Sprint 34 pour `(TEXT, TEXT)`. **Choix éditorial assumé identique aux Sprints 58-59** : pas de classification automatique — le chercheur lit `per_status` et juge la convention. +93 tests (parsing paramétrée standard + minuscules + j médiéval, formes invalides rejetées, aller-retour, détection avec min_length et frontière de mot anti-`VIVE`, **rejet du faux positif `ICI`**, 5 statuts individuellement, priorité strict>arabic, **3 cas réalistes par période** — charte médiévale, imprimé ancien, souverain moderne —, comptage exhaustif somme des per_status = total, dégénérés, raccourcis, intégration registre). **Verrou levé** : l'extension philologique transversale est intégralement livrée — un benchmark sur n'importe quel fonds patrimonial européen peut désormais classer les moteurs sur leur traitement des numéraux romains, indépendamment de la période. |
|
| 212 |
| 59 | **Sprint 28 du plan d'évolution 2026 — Étape 3 / extension philologique aux périodes contemporaines : marqueurs et abréviations des archives modernes XIXᵉ-XXᵉ (couche de calcul)**. Suite directe Sprints 56-58. Sur les fonds modernes BnF (état civil, recensements, presse, monographies, archives militaires, annuaires) la typographie historique a disparu mais subsiste un riche système d'abréviations contemporaines. Module `picarones/core/modern_archives.py` avec **9 catégories** : `civility_titles` (Mme, Mlle, Mgr, Dr, Pr, Me, M., R.P., S.M., S.A.R., S.E., S.S.), `ordinals` (1ᵉʳ, 1ʳᵉ, 2ᵈ, 2ᵉ, Vᵉ, XIᵉ-XXᵉ avec exposants Unicode), `currency` (₶, ₣, ƒ, £ + l./s./d. d'Ancien Régime), `administrative` (arr., dép., cant., com., reg., prov.), `civil_status` (°, †, ✶, ⚭, ép., vve), `typographic_punctuation` (« », —, –, …, ’, ‘), `latin_abbr_modern` (e.g., i.e., etc., cf., ibid., op. cit., ad lib., N.B.), `bibliographic` (vol., t., p., pp., n°, fasc., éd., ms., f., r°, v°), `address` (bd, av., r., pl., imp., fbg). `get_category`, `get_expansions`, `detect_modern_markers` avec **stratégie greedy plus-long-gagne** (S.A.R. avant S.A.) et **frontières de mot adaptées** au type de marqueur (espace/ponctuation pour `M.`/`arr.`, `\b` standard pour `Mme`/`bd`, match littéral pour les Unicode `₶`/`†`/`«`). `compute_modern_archives_metrics` retourne deux scores par catégorie (pattern Sprint 56) : `strict_score` (forme abrégée préservée) et `expansion_score` (abrégée OU développée présente, casse-insensible) ; `missed_markers` distingue **pertes pures** (`expansion_preserved=False`) et **modernisations** (`expansion_preserved=True`). `modern_archives_strict_score` et `modern_archives_expansion_score` enregistrés dans le registre typé Sprint 34 pour `(TEXT, TEXT)`. **Choix éditorial assumé** : pas de classification automatique « diplomatique »/« modernisant » — c'est un outil de recherche, le chercheur lit les chiffres bruts et conclut lui-même. +75 tests (catégorisation 33 marqueurs ×9 catégories, détection par catégorie ×9, greedy plus-long-gagne, frontière de mot anti-faux-positifs, scénarios standards diplo/mod/erreur, breakdown per_category, **5 cas réalistes** clé — citation biblio, état civil, adresse, protocole royal, monnaie Ancien Régime, ponctuation typo —, dégénérés, comptage exhaustif, sanité tables, raccourcis, intégration registre). **Verrou levé** : l'extension philologique couvre désormais **trois périodes principales** des fonds patrimoniaux européens — médiéval (Sprints 56-57), imprimé ancien XVIᵉ-XVIIIᵉ (Sprint 58), archives modernes XIXᵉ-XXᵉ (ce sprint). |
|
| 213 |
| 58 | **Sprint 27 du plan d'évolution 2026 — Étape 3 / extension philologique : marqueurs typographiques de l'imprimé ancien XVIᵉ-XVIIIᵉ (couche de calcul)**. Première extension du volet philologique aux périodes post-médiévales. Les Sprints 56-57 sont orientés médiéval scribal ; ce sprint cible les **éditeurs d'imprimés anciens** pour qui les marqueurs caractéristiques sont **typographiques** (composition imprimée) et non scribaux. Module `picarones/core/early_modern_typography.py` : 5 catégories de marqueurs (`ligatures` ff fi fl ffi ffl ſt st, `long_s` ſ, `dotless_i` ı, `ampersand` &, `nasal_tildes` ã Ã ñ Ñ õ Õ ũ Ũ ẽ Ẽ ĩ Ĩ pré-composés + séquences `voyelle + U+0303`). `get_category(char)` classe en catégorie ou None ; `detect_markers(text)` retourne `[(index, marker, category)]` reconnaissant à la fois les caractères pré-composés et les séquences combinantes ; `compute_early_modern_metrics(ref, hyp)` aligne via `difflib.SequenceMatcher` et retourne `global_preservation` + `per_category[name]={total,preserved,preservation}` + `missed_markers`. `early_modern_preservation` enregistré dans le registre typé Sprint 34 pour `(TEXT, TEXT)`. **Le breakdown par catégorie discrimine la convention typographique** : un moteur diplomatique préserve toutes les catégories ; un moteur modernisant ſ→s, fi→fi, ı→i, ã→a préserve typiquement uniquement & ; un moteur mixte panache. +38 tests dans `test_sprint58_early_modern.py` (catégorisation paramétrée 18 caractères, détection 5 catégories + tilde combinant + ordre, **trois scénarios standards** discriminés à 1.0 / 0.2 / 0.4, dégénérés, missed_markers, preserved+missed=total, sets disjoints, raccourci, intégration registre). **Verrou levé** : un benchmark sur des imprimés anciens peut désormais classer les moteurs sur leur convention typographique éditoriale — symétrique à ce que le Sprint 56 fait pour les manuscrits médiévaux. |
|
|
|
|
| 279 |
## Contexte développement
|
| 280 |
|
| 281 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 282 |
+
- **Tests** : 2316 passed, 2 skipped (Sprints 32-34 = Phase 0 close ; Sprints 35-37 = inter-moteurs livrés bout-en-bout ; Sprints 38+40+41 = NER livré bout-en-bout ; Sprints 39+42+43 = calibration livrée bout-en-bout côté rapport ; Sprint 44 = médiane par défaut ; Sprints 45+46 = stratification A.III livrée bout-en-bout ; Sprints 47-51 = les 5 adapters OCR exposent leurs confidences natives ; **Étape 2 close** ; Sprints 52-54 = axe A.II.2 (métriques structurelles) couches de calcul intégralement livrées ; Sprints 55-60 = extension philologique sur trois périodes + numéraux romains transversaux côté calcul ; Sprint 61 = câblage backend des 6 modules philologiques au runner avec adaptive masking)
|
| 283 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 284 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 285 |
- **Transcript de la conversation de développement** :
|
|
@@ -0,0 +1,363 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Helpers de câblage des métriques philologiques (Sprints 55-60) au runner.
|
| 2 |
+
|
| 3 |
+
Sprint 61 — câblage backend des 6 modules philologiques :
|
| 4 |
+
|
| 5 |
+
- ``unicode_blocks`` (Sprint 55)
|
| 6 |
+
- ``abbreviations`` (Sprint 56)
|
| 7 |
+
- ``mufi`` (Sprint 57)
|
| 8 |
+
- ``early_modern`` (Sprint 58)
|
| 9 |
+
- ``modern_archives`` (Sprint 59)
|
| 10 |
+
- ``roman_numerals`` (Sprint 60)
|
| 11 |
+
|
| 12 |
+
Principe « adaptive »
|
| 13 |
+
----------------------
|
| 14 |
+
Un module n'est inclus dans le résultat que si la **GT contient du
|
| 15 |
+
signal exploitable** pour ce module. Cette logique évite de polluer
|
| 16 |
+
les rapports sur les corpus sans marqueurs philologiques (typique
|
| 17 |
+
sur des données XXIᵉ ou des transcriptions modernes propres).
|
| 18 |
+
|
| 19 |
+
Coût
|
| 20 |
+
----
|
| 21 |
+
Les 6 calculs sont O(N) sur la longueur du texte ; le surcoût total
|
| 22 |
+
par document est négligeable face à un appel OCR. L'activation est
|
| 23 |
+
donc **automatique** (pas d'opt-in), contrairement aux backends NER
|
| 24 |
+
ou calibration qui exigent une dépendance externe ou des données
|
| 25 |
+
spécifiques.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
import logging
|
| 31 |
+
from typing import Optional
|
| 32 |
+
|
| 33 |
+
from picarones.core.abbreviations import compute_abbreviation_metrics
|
| 34 |
+
from picarones.core.early_modern_typography import compute_early_modern_metrics
|
| 35 |
+
from picarones.core.modern_archives import compute_modern_archives_metrics
|
| 36 |
+
from picarones.core.mufi import compute_mufi_coverage
|
| 37 |
+
from picarones.core.roman_numerals import compute_roman_numeral_metrics
|
| 38 |
+
from picarones.core.unicode_blocks import compute_unicode_block_accuracy
|
| 39 |
+
|
| 40 |
+
logger = logging.getLogger(__name__)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 44 |
+
# Critères « le module a-t-il du signal sur ce document ? »
|
| 45 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 46 |
+
#
|
| 47 |
+
# Pour chaque module, on définit un prédicat sur le résultat : si vrai,
|
| 48 |
+
# le module est inclus ; sinon, il est omis pour ne pas alourdir le
|
| 49 |
+
# rapport.
|
| 50 |
+
|
| 51 |
+
def _has_unicode_signal(result: dict) -> bool:
|
| 52 |
+
# Le module retourne toujours du signal dès que GT non-vide ; on
|
| 53 |
+
# n'inclut que si la GT a au moins un caractère **hors Basic
|
| 54 |
+
# Latin** (sinon le breakdown se réduit à 100 % Basic Latin et
|
| 55 |
+
# n'apporte rien au lecteur).
|
| 56 |
+
per_block = result.get("per_block", {})
|
| 57 |
+
for block, stats in per_block.items():
|
| 58 |
+
if block == "Basic Latin":
|
| 59 |
+
continue
|
| 60 |
+
if stats.get("total", 0) > 0:
|
| 61 |
+
return True
|
| 62 |
+
return False
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _has_abbreviation_signal(result: dict) -> bool:
|
| 66 |
+
return result.get("n_abbreviations_in_reference", 0) > 0
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _has_mufi_signal(result: dict) -> bool:
|
| 70 |
+
return result.get("n_mufi_chars_reference", 0) > 0
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _has_early_modern_signal(result: dict) -> bool:
|
| 74 |
+
return result.get("n_markers_reference", 0) > 0
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _has_modern_archives_signal(result: dict) -> bool:
|
| 78 |
+
return result.get("n_markers_reference", 0) > 0
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _has_roman_numeral_signal(result: dict) -> bool:
|
| 82 |
+
return result.get("n_numerals_reference", 0) > 0
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# Ordre fixé pour la reproductibilité des sorties.
|
| 86 |
+
_PHILOLOGICAL_MODULES: tuple[
|
| 87 |
+
tuple[str, callable, callable], ...
|
| 88 |
+
] = (
|
| 89 |
+
("unicode_blocks", compute_unicode_block_accuracy, _has_unicode_signal),
|
| 90 |
+
("abbreviations", compute_abbreviation_metrics, _has_abbreviation_signal),
|
| 91 |
+
("mufi", compute_mufi_coverage, _has_mufi_signal),
|
| 92 |
+
("early_modern", compute_early_modern_metrics, _has_early_modern_signal),
|
| 93 |
+
("modern_archives", compute_modern_archives_metrics, _has_modern_archives_signal),
|
| 94 |
+
("roman_numerals", compute_roman_numeral_metrics, _has_roman_numeral_signal),
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 99 |
+
# Calcul par document
|
| 100 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def compute_philological_metrics(
|
| 104 |
+
reference: Optional[str],
|
| 105 |
+
hypothesis: Optional[str],
|
| 106 |
+
) -> Optional[dict]:
|
| 107 |
+
"""Calcule les 6 métriques philologiques pour un document.
|
| 108 |
+
|
| 109 |
+
Retourne un dict avec une clé par module ayant du signal, ou
|
| 110 |
+
``None`` si aucun module n'en a (corpus sans marqueur
|
| 111 |
+
philologique pertinent).
|
| 112 |
+
|
| 113 |
+
En cas d'erreur dans un module individuel, le module est
|
| 114 |
+
silencieusement omis et un warning est émis (les autres modules
|
| 115 |
+
restent calculés).
|
| 116 |
+
"""
|
| 117 |
+
ref = reference or ""
|
| 118 |
+
if not ref:
|
| 119 |
+
return None
|
| 120 |
+
out: dict = {}
|
| 121 |
+
for name, compute_fn, has_signal_fn in _PHILOLOGICAL_MODULES:
|
| 122 |
+
try:
|
| 123 |
+
result = compute_fn(ref, hypothesis or "")
|
| 124 |
+
except Exception as exc: # pragma: no cover — défense en profondeur
|
| 125 |
+
logger.warning(
|
| 126 |
+
"[philological_runner] module %s a échoué : %s", name, exc,
|
| 127 |
+
)
|
| 128 |
+
continue
|
| 129 |
+
if has_signal_fn(result):
|
| 130 |
+
out[name] = result
|
| 131 |
+
return out if out else None
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 135 |
+
# Agrégation corpus-wide par moteur
|
| 136 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _aggregate_unicode(per_doc: list[dict]) -> dict:
|
| 140 |
+
total_correct = 0
|
| 141 |
+
total_chars = 0
|
| 142 |
+
per_block: dict[str, dict[str, int]] = {}
|
| 143 |
+
for d in per_doc:
|
| 144 |
+
for block, stats in d.get("per_block", {}).items():
|
| 145 |
+
slot = per_block.setdefault(block, {"correct": 0, "total": 0})
|
| 146 |
+
slot["correct"] += stats.get("correct", 0)
|
| 147 |
+
slot["total"] += stats.get("total", 0)
|
| 148 |
+
total_correct += stats.get("correct", 0)
|
| 149 |
+
total_chars += stats.get("total", 0)
|
| 150 |
+
out_per_block = {
|
| 151 |
+
block: {
|
| 152 |
+
"correct": slot["correct"],
|
| 153 |
+
"total": slot["total"],
|
| 154 |
+
"accuracy": (
|
| 155 |
+
slot["correct"] / slot["total"] if slot["total"] > 0 else 0.0
|
| 156 |
+
),
|
| 157 |
+
}
|
| 158 |
+
for block, slot in sorted(per_block.items())
|
| 159 |
+
}
|
| 160 |
+
return {
|
| 161 |
+
"global_accuracy": total_correct / total_chars if total_chars > 0 else 0.0,
|
| 162 |
+
"n_chars_total": total_chars,
|
| 163 |
+
"n_chars_correct": total_correct,
|
| 164 |
+
"per_block": out_per_block,
|
| 165 |
+
"doc_count": len(per_doc),
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _aggregate_abbreviations(per_doc: list[dict]) -> dict:
|
| 170 |
+
n_total = 0
|
| 171 |
+
n_strict = 0
|
| 172 |
+
n_expansion = 0
|
| 173 |
+
per_abbr: dict[str, dict[str, int]] = {}
|
| 174 |
+
for d in per_doc:
|
| 175 |
+
n_total += d.get("n_abbreviations_in_reference", 0)
|
| 176 |
+
n_strict += d.get("n_strict_preserved", 0)
|
| 177 |
+
n_expansion += d.get("n_expansion_preserved", 0)
|
| 178 |
+
for entry in d.get("per_abbreviation", []):
|
| 179 |
+
slot = per_abbr.setdefault(
|
| 180 |
+
entry["abbr"],
|
| 181 |
+
{"total": 0, "strict": 0, "expansion": 0},
|
| 182 |
+
)
|
| 183 |
+
slot["total"] += 1
|
| 184 |
+
if entry.get("strict_preserved"):
|
| 185 |
+
slot["strict"] += 1
|
| 186 |
+
if entry.get("expansion_preserved"):
|
| 187 |
+
slot["expansion"] += 1
|
| 188 |
+
return {
|
| 189 |
+
"n_abbreviations_in_reference": n_total,
|
| 190 |
+
"n_strict_preserved": n_strict,
|
| 191 |
+
"n_expansion_preserved": n_expansion,
|
| 192 |
+
"global_strict_score": n_strict / n_total if n_total > 0 else 0.0,
|
| 193 |
+
"global_expansion_score": n_expansion / n_total if n_total > 0 else 0.0,
|
| 194 |
+
"per_abbreviation": {
|
| 195 |
+
abbr: {
|
| 196 |
+
"n_total": slot["total"],
|
| 197 |
+
"n_strict": slot["strict"],
|
| 198 |
+
"n_expansion": slot["expansion"],
|
| 199 |
+
"strict_score": slot["strict"] / slot["total"],
|
| 200 |
+
"expansion_score": slot["expansion"] / slot["total"],
|
| 201 |
+
}
|
| 202 |
+
for abbr, slot in sorted(per_abbr.items())
|
| 203 |
+
},
|
| 204 |
+
"doc_count": len(per_doc),
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _aggregate_mufi(per_doc: list[dict]) -> dict:
|
| 209 |
+
n_total = 0
|
| 210 |
+
n_preserved = 0
|
| 211 |
+
per_char: dict[str, dict[str, int]] = {}
|
| 212 |
+
for d in per_doc:
|
| 213 |
+
n_total += d.get("n_mufi_chars_reference", 0)
|
| 214 |
+
n_preserved += d.get("n_mufi_chars_preserved", 0)
|
| 215 |
+
for ch, stats in d.get("per_char", {}).items():
|
| 216 |
+
slot = per_char.setdefault(ch, {"total": 0, "preserved": 0})
|
| 217 |
+
slot["total"] += stats.get("total", 0)
|
| 218 |
+
slot["preserved"] += stats.get("preserved", 0)
|
| 219 |
+
return {
|
| 220 |
+
"n_mufi_chars_reference": n_total,
|
| 221 |
+
"n_mufi_chars_preserved": n_preserved,
|
| 222 |
+
"coverage": n_preserved / n_total if n_total > 0 else 0.0,
|
| 223 |
+
"per_char": {
|
| 224 |
+
ch: {
|
| 225 |
+
"total": slot["total"],
|
| 226 |
+
"preserved": slot["preserved"],
|
| 227 |
+
"coverage": slot["preserved"] / slot["total"],
|
| 228 |
+
}
|
| 229 |
+
for ch, slot in sorted(per_char.items())
|
| 230 |
+
},
|
| 231 |
+
"doc_count": len(per_doc),
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _aggregate_early_modern(per_doc: list[dict]) -> dict:
|
| 236 |
+
n_total = 0
|
| 237 |
+
n_preserved = 0
|
| 238 |
+
per_cat: dict[str, dict[str, int]] = {}
|
| 239 |
+
for d in per_doc:
|
| 240 |
+
n_total += d.get("n_markers_reference", 0)
|
| 241 |
+
n_preserved += d.get("n_markers_preserved", 0)
|
| 242 |
+
for cat, stats in d.get("per_category", {}).items():
|
| 243 |
+
slot = per_cat.setdefault(cat, {"total": 0, "preserved": 0})
|
| 244 |
+
slot["total"] += stats.get("total", 0)
|
| 245 |
+
slot["preserved"] += stats.get("preserved", 0)
|
| 246 |
+
return {
|
| 247 |
+
"n_markers_reference": n_total,
|
| 248 |
+
"n_markers_preserved": n_preserved,
|
| 249 |
+
"global_preservation": n_preserved / n_total if n_total > 0 else 0.0,
|
| 250 |
+
"per_category": {
|
| 251 |
+
cat: {
|
| 252 |
+
"total": slot["total"],
|
| 253 |
+
"preserved": slot["preserved"],
|
| 254 |
+
"preservation": slot["preserved"] / slot["total"],
|
| 255 |
+
}
|
| 256 |
+
for cat, slot in sorted(per_cat.items())
|
| 257 |
+
},
|
| 258 |
+
"doc_count": len(per_doc),
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _aggregate_modern_archives(per_doc: list[dict]) -> dict:
|
| 263 |
+
n_total = 0
|
| 264 |
+
n_strict = 0
|
| 265 |
+
n_expansion = 0
|
| 266 |
+
per_cat: dict[str, dict[str, int]] = {}
|
| 267 |
+
for d in per_doc:
|
| 268 |
+
n_total += d.get("n_markers_reference", 0)
|
| 269 |
+
n_strict += d.get("n_strict_preserved", 0)
|
| 270 |
+
n_expansion += d.get("n_expansion_preserved", 0)
|
| 271 |
+
for cat, stats in d.get("per_category", {}).items():
|
| 272 |
+
slot = per_cat.setdefault(
|
| 273 |
+
cat, {"total": 0, "strict": 0, "expansion": 0},
|
| 274 |
+
)
|
| 275 |
+
slot["total"] += stats.get("n_total", 0)
|
| 276 |
+
slot["strict"] += stats.get("n_strict_preserved", 0)
|
| 277 |
+
slot["expansion"] += stats.get("n_expansion_preserved", 0)
|
| 278 |
+
return {
|
| 279 |
+
"n_markers_reference": n_total,
|
| 280 |
+
"n_strict_preserved": n_strict,
|
| 281 |
+
"n_expansion_preserved": n_expansion,
|
| 282 |
+
"global_strict_score": n_strict / n_total if n_total > 0 else 0.0,
|
| 283 |
+
"global_expansion_score": n_expansion / n_total if n_total > 0 else 0.0,
|
| 284 |
+
"per_category": {
|
| 285 |
+
cat: {
|
| 286 |
+
"n_total": slot["total"],
|
| 287 |
+
"n_strict_preserved": slot["strict"],
|
| 288 |
+
"n_expansion_preserved": slot["expansion"],
|
| 289 |
+
"strict_score": slot["strict"] / slot["total"],
|
| 290 |
+
"expansion_score": slot["expansion"] / slot["total"],
|
| 291 |
+
}
|
| 292 |
+
for cat, slot in sorted(per_cat.items())
|
| 293 |
+
},
|
| 294 |
+
"doc_count": len(per_doc),
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def _aggregate_roman_numerals(per_doc: list[dict]) -> dict:
|
| 299 |
+
from picarones.core.roman_numerals import ALL_STATUSES, VALUE_PRESERVING_STATUSES
|
| 300 |
+
|
| 301 |
+
n_total = 0
|
| 302 |
+
per_status: dict[str, int] = {s: 0 for s in ALL_STATUSES}
|
| 303 |
+
for d in per_doc:
|
| 304 |
+
n_total += d.get("n_numerals_reference", 0)
|
| 305 |
+
for status, count in d.get("per_status", {}).items():
|
| 306 |
+
per_status[status] = per_status.get(status, 0) + count
|
| 307 |
+
n_strict = per_status.get("strict_preserved", 0)
|
| 308 |
+
n_value = sum(per_status.get(s, 0) for s in VALUE_PRESERVING_STATUSES)
|
| 309 |
+
return {
|
| 310 |
+
"n_numerals_reference": n_total,
|
| 311 |
+
"n_strict_preserved": n_strict,
|
| 312 |
+
"n_value_preserved": n_value,
|
| 313 |
+
"global_strict_score": n_strict / n_total if n_total > 0 else 0.0,
|
| 314 |
+
"global_value_score": n_value / n_total if n_total > 0 else 0.0,
|
| 315 |
+
"per_status": per_status,
|
| 316 |
+
"doc_count": len(per_doc),
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
_AGGREGATORS = {
|
| 321 |
+
"unicode_blocks": _aggregate_unicode,
|
| 322 |
+
"abbreviations": _aggregate_abbreviations,
|
| 323 |
+
"mufi": _aggregate_mufi,
|
| 324 |
+
"early_modern": _aggregate_early_modern,
|
| 325 |
+
"modern_archives": _aggregate_modern_archives,
|
| 326 |
+
"roman_numerals": _aggregate_roman_numerals,
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def aggregate_philological_metrics(
|
| 331 |
+
doc_metrics: list[Optional[dict]],
|
| 332 |
+
) -> Optional[dict]:
|
| 333 |
+
"""Agrège les ``philological_metrics`` per-document en un dict
|
| 334 |
+
corpus-wide par module.
|
| 335 |
+
|
| 336 |
+
Pour chaque module, on agrège uniquement les documents qui ont
|
| 337 |
+
eu du signal pour ce module. Si aucun module n'a été calculé
|
| 338 |
+
sur aucun document, retourne ``None``.
|
| 339 |
+
"""
|
| 340 |
+
by_module: dict[str, list[dict]] = {}
|
| 341 |
+
for doc in doc_metrics:
|
| 342 |
+
if not doc:
|
| 343 |
+
continue
|
| 344 |
+
for module, payload in doc.items():
|
| 345 |
+
by_module.setdefault(module, []).append(payload)
|
| 346 |
+
if not by_module:
|
| 347 |
+
return None
|
| 348 |
+
out: dict = {}
|
| 349 |
+
for module, payloads in by_module.items():
|
| 350 |
+
aggregator = _AGGREGATORS.get(module)
|
| 351 |
+
if aggregator is None: # pragma: no cover
|
| 352 |
+
logger.warning(
|
| 353 |
+
"[philological_runner] aucun agrégateur pour %s", module,
|
| 354 |
+
)
|
| 355 |
+
continue
|
| 356 |
+
out[module] = aggregator(payloads)
|
| 357 |
+
return out if out else None
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
__all__ = [
|
| 361 |
+
"compute_philological_metrics",
|
| 362 |
+
"aggregate_philological_metrics",
|
| 363 |
+
]
|
|
@@ -70,6 +70,26 @@ class DocumentResult:
|
|
| 70 |
Présent uniquement si le moteur a fourni des ``token_confidences``
|
| 71 |
sur l'``EngineResult``.
|
| 72 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
def as_dict(self) -> dict:
|
| 75 |
d = {
|
|
@@ -103,6 +123,8 @@ class DocumentResult:
|
|
| 103 |
d["ner_metrics"] = self.ner_metrics
|
| 104 |
if self.calibration_metrics is not None:
|
| 105 |
d["calibration_metrics"] = self.calibration_metrics
|
|
|
|
|
|
|
| 106 |
return d
|
| 107 |
|
| 108 |
def compact(self) -> None:
|
|
@@ -130,6 +152,7 @@ class DocumentResult:
|
|
| 130 |
self.hallucination_metrics = None
|
| 131 |
self.ner_metrics = None
|
| 132 |
self.calibration_metrics = None
|
|
|
|
| 133 |
|
| 134 |
|
| 135 |
@dataclass
|
|
@@ -173,6 +196,16 @@ class EngineReport:
|
|
| 173 |
micro recalculé à partir des sommes par bin. ``None`` si aucun
|
| 174 |
document n'avait de ``calibration_metrics`` (cas par défaut tant que
|
| 175 |
les engines n'exposent pas ``token_confidences``)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
def __post_init__(self) -> None:
|
| 178 |
if not self.aggregated_metrics and self.document_results:
|
|
@@ -249,6 +282,8 @@ class EngineReport:
|
|
| 249 |
d["aggregated_ner"] = self.aggregated_ner
|
| 250 |
if self.aggregated_calibration is not None:
|
| 251 |
d["aggregated_calibration"] = self.aggregated_calibration
|
|
|
|
|
|
|
| 252 |
return d
|
| 253 |
|
| 254 |
|
|
|
|
| 70 |
Présent uniquement si le moteur a fourni des ``token_confidences``
|
| 71 |
sur l'``EngineResult``.
|
| 72 |
"""
|
| 73 |
+
# Sprint 61 — métriques philologiques (Sprints 55-60) calculées
|
| 74 |
+
# automatiquement. Présent uniquement si au moins un module a
|
| 75 |
+
# détecté du signal dans la GT.
|
| 76 |
+
philological_metrics: Optional[dict] = None
|
| 77 |
+
"""Métriques philologiques (Sprints 55-60).
|
| 78 |
+
|
| 79 |
+
Dict avec une clé par module en présence de signal :
|
| 80 |
+
|
| 81 |
+
- ``unicode_blocks`` : Sprint 55, retour de ``compute_unicode_block_accuracy``
|
| 82 |
+
- ``abbreviations`` : Sprint 56, retour de ``compute_abbreviation_metrics``
|
| 83 |
+
- ``mufi`` : Sprint 57, retour de ``compute_mufi_coverage``
|
| 84 |
+
- ``early_modern`` : Sprint 58, retour de ``compute_early_modern_metrics``
|
| 85 |
+
- ``modern_archives`` : Sprint 59, retour de ``compute_modern_archives_metrics``
|
| 86 |
+
- ``roman_numerals`` : Sprint 60, retour de ``compute_roman_numeral_metrics``
|
| 87 |
+
|
| 88 |
+
Un module n'est inclus que si la GT contient du signal exploitable
|
| 89 |
+
(n_markers_reference > 0, n_mufi_chars_reference > 0, etc.).
|
| 90 |
+
Cette logique adaptative permet de garder les rapports lisibles
|
| 91 |
+
sur les corpus sans marqueurs philologiques.
|
| 92 |
+
"""
|
| 93 |
|
| 94 |
def as_dict(self) -> dict:
|
| 95 |
d = {
|
|
|
|
| 123 |
d["ner_metrics"] = self.ner_metrics
|
| 124 |
if self.calibration_metrics is not None:
|
| 125 |
d["calibration_metrics"] = self.calibration_metrics
|
| 126 |
+
if self.philological_metrics is not None:
|
| 127 |
+
d["philological_metrics"] = self.philological_metrics
|
| 128 |
return d
|
| 129 |
|
| 130 |
def compact(self) -> None:
|
|
|
|
| 152 |
self.hallucination_metrics = None
|
| 153 |
self.ner_metrics = None
|
| 154 |
self.calibration_metrics = None
|
| 155 |
+
self.philological_metrics = None
|
| 156 |
|
| 157 |
|
| 158 |
@dataclass
|
|
|
|
| 196 |
micro recalculé à partir des sommes par bin. ``None`` si aucun
|
| 197 |
document n'avait de ``calibration_metrics`` (cas par défaut tant que
|
| 198 |
les engines n'exposent pas ``token_confidences``)."""
|
| 199 |
+
# Sprint 61
|
| 200 |
+
aggregated_philological: Optional[dict] = None
|
| 201 |
+
"""Métriques philologiques agrégées sur le corpus (Sprints 55-60).
|
| 202 |
+
|
| 203 |
+
Dict avec une clé par module ayant du signal sur au moins un
|
| 204 |
+
document. Pour chaque module, l'agrégation somme les compteurs
|
| 205 |
+
bruts (n_total, n_preserved, etc.) et recalcule les scores
|
| 206 |
+
globaux ; les structures per_category/per_block/per_status sont
|
| 207 |
+
également agrégées. ``None`` si aucun document n'a porté de
|
| 208 |
+
``philological_metrics``."""
|
| 209 |
|
| 210 |
def __post_init__(self) -> None:
|
| 211 |
if not self.aggregated_metrics and self.document_results:
|
|
|
|
| 282 |
d["aggregated_ner"] = self.aggregated_ner
|
| 283 |
if self.aggregated_calibration is not None:
|
| 284 |
d["aggregated_calibration"] = self.aggregated_calibration
|
| 285 |
+
if self.aggregated_philological is not None:
|
| 286 |
+
d["aggregated_philological"] = self.aggregated_philological
|
| 287 |
return d
|
| 288 |
|
| 289 |
|
|
@@ -285,6 +285,19 @@ def _compute_document_result(
|
|
| 285 |
except Exception as e:
|
| 286 |
_logger.warning("[image_quality] fonctionnalité dégradée : %s", e)
|
| 287 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
return DocumentResult(
|
| 289 |
doc_id=doc_id,
|
| 290 |
image_path=image_path,
|
|
@@ -303,6 +316,7 @@ def _compute_document_result(
|
|
| 303 |
line_metrics=line_metrics_data,
|
| 304 |
hallucination_metrics=hallucination_data,
|
| 305 |
calibration_metrics=calibration_data,
|
|
|
|
| 306 |
)
|
| 307 |
|
| 308 |
|
|
@@ -714,6 +728,13 @@ def run_benchmark(
|
|
| 714 |
agg_line_metrics = _aggregate_line_metrics(document_results)
|
| 715 |
agg_hallucination = _aggregate_hallucination(document_results)
|
| 716 |
agg_calibration = _aggregate_calibration(document_results)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 717 |
|
| 718 |
report = EngineReport(
|
| 719 |
engine_name=engine.name,
|
|
@@ -729,6 +750,7 @@ def run_benchmark(
|
|
| 729 |
aggregated_line_metrics=agg_line_metrics,
|
| 730 |
aggregated_hallucination=agg_hallucination,
|
| 731 |
aggregated_calibration=agg_calibration,
|
|
|
|
| 732 |
)
|
| 733 |
engine_reports.append(report)
|
| 734 |
logger.info(
|
|
|
|
| 285 |
except Exception as e:
|
| 286 |
_logger.warning("[image_quality] fonctionnalité dégradée : %s", e)
|
| 287 |
|
| 288 |
+
# Sprint 61 — métriques philologiques (Sprints 55-60). Calcul
|
| 289 |
+
# automatique : O(N) sur le texte, coût négligeable. Le helper
|
| 290 |
+
# gère lui-même l'« adaptive masking » : un module n'est inclus
|
| 291 |
+
# que si la GT a du signal pour lui.
|
| 292 |
+
philological_data: Optional[dict] = None
|
| 293 |
+
try:
|
| 294 |
+
from picarones.core.philological_runner import compute_philological_metrics
|
| 295 |
+
philological_data = compute_philological_metrics(
|
| 296 |
+
ground_truth, ocr_result.text,
|
| 297 |
+
)
|
| 298 |
+
except Exception as e:
|
| 299 |
+
_logger.warning("[philological] fonctionnalité dégradée : %s", e)
|
| 300 |
+
|
| 301 |
return DocumentResult(
|
| 302 |
doc_id=doc_id,
|
| 303 |
image_path=image_path,
|
|
|
|
| 316 |
line_metrics=line_metrics_data,
|
| 317 |
hallucination_metrics=hallucination_data,
|
| 318 |
calibration_metrics=calibration_data,
|
| 319 |
+
philological_metrics=philological_data,
|
| 320 |
)
|
| 321 |
|
| 322 |
|
|
|
|
| 728 |
agg_line_metrics = _aggregate_line_metrics(document_results)
|
| 729 |
agg_hallucination = _aggregate_hallucination(document_results)
|
| 730 |
agg_calibration = _aggregate_calibration(document_results)
|
| 731 |
+
# Sprint 61 — agrégation philologique (modules Sprints 55-60).
|
| 732 |
+
from picarones.core.philological_runner import (
|
| 733 |
+
aggregate_philological_metrics,
|
| 734 |
+
)
|
| 735 |
+
agg_philological = aggregate_philological_metrics(
|
| 736 |
+
[dr.philological_metrics for dr in document_results],
|
| 737 |
+
)
|
| 738 |
|
| 739 |
report = EngineReport(
|
| 740 |
engine_name=engine.name,
|
|
|
|
| 750 |
aggregated_line_metrics=agg_line_metrics,
|
| 751 |
aggregated_hallucination=agg_hallucination,
|
| 752 |
aggregated_calibration=agg_calibration,
|
| 753 |
+
aggregated_philological=agg_philological,
|
| 754 |
)
|
| 755 |
engine_reports.append(report)
|
| 756 |
logger.info(
|
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.core.philological_runner import (
|
| 28 |
+
aggregate_philological_metrics,
|
| 29 |
+
compute_philological_metrics,
|
| 30 |
+
)
|
| 31 |
+
from picarones.core.results import DocumentResult, EngineReport
|
| 32 |
+
from picarones.core.metrics 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 |
+
dr = _make_doc(philological={"mufi": {"coverage": 1.0}})
|
| 128 |
+
dr.compact()
|
| 129 |
+
assert dr.philological_metrics is None
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 133 |
+
# 4. compute_philological_metrics — adaptive masking
|
| 134 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class TestComputeAdaptive:
|
| 138 |
+
def test_medieval_triggers_abbreviations_and_mufi(self) -> None:
|
| 139 |
+
gt = "fait en lan ꝑ regem þæt"
|
| 140 |
+
m = compute_philological_metrics(gt, gt)
|
| 141 |
+
assert m is not None
|
| 142 |
+
assert "abbreviations" in m
|
| 143 |
+
assert "mufi" in m
|
| 144 |
+
|
| 145 |
+
def test_early_modern_triggers_typography(self) -> None:
|
| 146 |
+
gt = "le ſerpent finement & ã"
|
| 147 |
+
m = compute_philological_metrics(gt, gt)
|
| 148 |
+
assert m is not None
|
| 149 |
+
assert "early_modern" in m
|
| 150 |
+
|
| 151 |
+
def test_modern_archives_triggers_module(self) -> None:
|
| 152 |
+
gt = "Mme Dupont au bd Voltaire vol. II"
|
| 153 |
+
m = compute_philological_metrics(gt, gt)
|
| 154 |
+
assert m is not None
|
| 155 |
+
assert "modern_archives" in m
|
| 156 |
+
|
| 157 |
+
def test_roman_numerals_triggers_module(self) -> None:
|
| 158 |
+
gt = "Louis XIV mourut en MDCCXV"
|
| 159 |
+
m = compute_philological_metrics(gt, gt)
|
| 160 |
+
assert m is not None
|
| 161 |
+
assert "roman_numerals" in m
|
| 162 |
+
|
| 163 |
+
def test_unicode_blocks_triggered_only_outside_basic_latin(self) -> None:
|
| 164 |
+
# ASCII pur sans marqueur → unicode_blocks omis (Basic Latin
|
| 165 |
+
# uniquement, breakdown trivial).
|
| 166 |
+
m = compute_philological_metrics("hello world", "hello world")
|
| 167 |
+
assert m is None
|
| 168 |
+
|
| 169 |
+
def test_unicode_blocks_triggered_with_diacritics(self) -> None:
|
| 170 |
+
# Du Latin Extended → unicode_blocks inclus
|
| 171 |
+
gt = "café à é ô"
|
| 172 |
+
m = compute_philological_metrics(gt, gt)
|
| 173 |
+
assert m is not None
|
| 174 |
+
assert "unicode_blocks" in m
|
| 175 |
+
|
| 176 |
+
def test_empty_returns_none(self) -> None:
|
| 177 |
+
assert compute_philological_metrics("", "") is None
|
| 178 |
+
assert compute_philological_metrics(None, None) is None
|
| 179 |
+
|
| 180 |
+
def test_no_signal_returns_none(self) -> None:
|
| 181 |
+
# Pure Basic Latin sans aucun marqueur philologique
|
| 182 |
+
m = compute_philological_metrics("hello", "hello")
|
| 183 |
+
assert m is None
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 187 |
+
# 5. aggregate_philological_metrics
|
| 188 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
class TestAggregation:
|
| 192 |
+
def test_no_data_returns_none(self) -> None:
|
| 193 |
+
assert aggregate_philological_metrics([]) is None
|
| 194 |
+
assert aggregate_philological_metrics([None, None]) is None
|
| 195 |
+
|
| 196 |
+
def test_aggregates_only_present_modules(self) -> None:
|
| 197 |
+
# Doc 1 a mufi+abbr, Doc 2 a juste roman_numerals
|
| 198 |
+
d1 = compute_philological_metrics("ꝑ ꝓ ꝗ", "per pro qui")
|
| 199 |
+
d2 = compute_philological_metrics("Louis XIV", "Louis 14")
|
| 200 |
+
agg = aggregate_philological_metrics([d1, d2])
|
| 201 |
+
assert agg is not None
|
| 202 |
+
# mufi présent (Doc1 le déclenchait avec ꝑ/ꝓ/ꝗ qui sont MUFI)
|
| 203 |
+
assert "abbreviations" in agg
|
| 204 |
+
assert "roman_numerals" in agg
|
| 205 |
+
# doc_count par module
|
| 206 |
+
assert agg["abbreviations"]["doc_count"] == 1
|
| 207 |
+
assert agg["roman_numerals"]["doc_count"] == 1
|
| 208 |
+
|
| 209 |
+
def test_aggregation_sums_counters(self) -> None:
|
| 210 |
+
# 3 docs avec MUFI : "þæt ꝑ" = 3 caractères MUFI (þ, æ, ꝑ)
|
| 211 |
+
gt = "þæt ꝑ"
|
| 212 |
+
per_doc = [compute_philological_metrics(gt, gt) for _ in range(3)]
|
| 213 |
+
agg = aggregate_philological_metrics(per_doc)
|
| 214 |
+
assert agg is not None
|
| 215 |
+
assert "mufi" in agg
|
| 216 |
+
# 3 caractères × 3 docs = 9
|
| 217 |
+
assert agg["mufi"]["n_mufi_chars_reference"] == 9
|
| 218 |
+
assert agg["mufi"]["n_mufi_chars_preserved"] == 9
|
| 219 |
+
assert agg["mufi"]["coverage"] == 1.0
|
| 220 |
+
assert agg["mufi"]["doc_count"] == 3
|
| 221 |
+
|
| 222 |
+
def test_aggregation_recomputes_global_score(self) -> None:
|
| 223 |
+
# Doc1 préserve 100%, Doc2 préserve 0% → moyenne pondérée
|
| 224 |
+
d1 = compute_philological_metrics("XIV", "XIV")
|
| 225 |
+
d2 = compute_philological_metrics("V", "perdu")
|
| 226 |
+
agg = aggregate_philological_metrics([d1, d2])
|
| 227 |
+
roman = agg["roman_numerals"]
|
| 228 |
+
# Doc1 : 1 strict_preserved (XIV)
|
| 229 |
+
# Doc2 : 1 lost (V)
|
| 230 |
+
# Total : 2 numéraux, 1 strict → 0.5
|
| 231 |
+
assert roman["n_numerals_reference"] == 2
|
| 232 |
+
assert roman["global_strict_score"] == 0.5
|
| 233 |
+
|
| 234 |
+
def test_per_category_aggregation_modern_archives(self) -> None:
|
| 235 |
+
# Deux docs avec modern_archives sur catégories différentes
|
| 236 |
+
d1 = compute_philological_metrics("Mme bd", "Mme bd")
|
| 237 |
+
d2 = compute_philological_metrics("vol. p.", "vol. p.")
|
| 238 |
+
agg = aggregate_philological_metrics([d1, d2])
|
| 239 |
+
per_cat = agg["modern_archives"]["per_category"]
|
| 240 |
+
# Doc1 : civility_titles + address ; Doc2 : bibliographic
|
| 241 |
+
assert "civility_titles" in per_cat
|
| 242 |
+
assert "address" in per_cat
|
| 243 |
+
assert "bibliographic" in per_cat
|
| 244 |
+
for cat in per_cat.values():
|
| 245 |
+
assert cat["strict_score"] == 1.0
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 249 |
+
# 6. Intégration end-to-end (mock léger sur le runner)
|
| 250 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
class TestRunnerIntegration:
|
| 254 |
+
"""Vérifie que ``_compute_document_result`` attache bien les
|
| 255 |
+
``philological_metrics`` quand la GT a du signal."""
|
| 256 |
+
|
| 257 |
+
def test_runner_attaches_philological(self, tmp_path) -> None:
|
| 258 |
+
from picarones.core.runner import _compute_document_result
|
| 259 |
+
from picarones.engines.base import EngineResult
|
| 260 |
+
|
| 261 |
+
# Créer une image fictive (le module image_quality échouera
|
| 262 |
+
# gracieusement, ce qui est OK pour le test).
|
| 263 |
+
img = tmp_path / "doc.png"
|
| 264 |
+
img.write_bytes(b"") # vide ; on ignore le résultat image_quality
|
| 265 |
+
|
| 266 |
+
gt = "ꝑ regem mcclxxxij"
|
| 267 |
+
ocr_result = EngineResult(
|
| 268 |
+
engine_name="mock", image_path=str(img),
|
| 269 |
+
text=gt, duration_seconds=0.1, error=None,
|
| 270 |
+
)
|
| 271 |
+
dr = _compute_document_result(
|
| 272 |
+
doc_id="d1",
|
| 273 |
+
image_path=str(img),
|
| 274 |
+
ground_truth=gt,
|
| 275 |
+
ocr_result=ocr_result,
|
| 276 |
+
char_exclude=None,
|
| 277 |
+
)
|
| 278 |
+
assert dr.philological_metrics is not None
|
| 279 |
+
assert "abbreviations" in dr.philological_metrics
|
| 280 |
+
assert "roman_numerals" in dr.philological_metrics
|
| 281 |
+
|
| 282 |
+
def test_runner_omits_philological_on_plain_text(self, tmp_path) -> None:
|
| 283 |
+
from picarones.core.runner import _compute_document_result
|
| 284 |
+
from picarones.engines.base import EngineResult
|
| 285 |
+
|
| 286 |
+
img = tmp_path / "doc.png"
|
| 287 |
+
img.write_bytes(b"")
|
| 288 |
+
|
| 289 |
+
# Texte ASCII pur sans marqueur philologique
|
| 290 |
+
gt = "hello world without any markers"
|
| 291 |
+
ocr_result = EngineResult(
|
| 292 |
+
engine_name="mock", image_path=str(img),
|
| 293 |
+
text=gt, duration_seconds=0.1, error=None,
|
| 294 |
+
)
|
| 295 |
+
dr = _compute_document_result(
|
| 296 |
+
doc_id="d1",
|
| 297 |
+
image_path=str(img),
|
| 298 |
+
ground_truth=gt,
|
| 299 |
+
ocr_result=ocr_result,
|
| 300 |
+
char_exclude=None,
|
| 301 |
+
)
|
| 302 |
+
assert dr.philological_metrics is None
|
| 303 |
+
|