Claude commited on
Commit
f6dc855
·
unverified ·
1 Parent(s): fe4b9ac

sprint61: câblage runner des 6 modules philologiques (Sprints 55-60)

Browse files

Les 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 CHANGED
@@ -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
CLAUDE.md CHANGED
@@ -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** : 2292 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-57 = axe A.II.3 (philologique) médiéval intégralement livré côté calcul ; Sprint 58 = imprimé ancien XVIᵉ-XVIIIᵉ ; Sprint 59 = archives modernes XIXᵉ-XXᵉ ; Sprint 60 = numéraux romains transversaux — **extension philologique livrée pour les trois périodes principales et la dimension transversale numérale**)
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** :
picarones/core/philological_runner.py ADDED
@@ -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
+ ]
picarones/core/results.py CHANGED
@@ -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
 
picarones/core/runner.py CHANGED
@@ -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(
tests/test_sprint61_philological_runner.py ADDED
@@ -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
+